標籤:
1、非同步I/O模式
通常,當SQLite寫一個資料庫檔案時,會等待,直到寫操作完成,然後控制返回到調用程式。相比於CPU操作,寫檔案系統是非常耗時的,這是一個效能瓶頸。非同步I/O後端是SQLite的一個擴充模組,允許SQLite使用一個獨立的後台線程來執行所有的寫請求。雖然這並不會減少整個系統的資源消耗(CPU,磁碟頻寬等),但它允許SQLite在正在寫資料庫時立刻返回到調用者,從使用者角度看,無疑提高了前端的響應速度。對非同步I/O,寫請求在一個獨立的後台線程中被處理,這意味著啟動資料庫寫操作的線程不必等待磁碟I/O的發生。寫操作看起來似乎很快就發生了,但實際上速度跟通常是一樣的,只不過在後台進行。
非同步I/O似乎提供了更好的響應能力,但這是有代價的。你會失去ACID中的持久性(Durable)屬性。在SQLite的預設I/O後端中,一旦寫操作完成,你知道更改的資料已經安全地在磁碟上了。而非同步I/O卻不是這樣的情況。如果應用程式在資料寫操作之後,非同步寫線程完成之前發生崩潰或掉電,則資料庫更改可能根本沒有被寫到磁碟,下一次使用資料庫時就看不到更改。
非同步I/O失去了持久性,但仍然保持ACID的其他三個屬性:原子性(Atomic)、一致性(Consistent)和隔離性(Isolated)。很多應用程式沒有持久性也能很好地工作。
我們通過建立一個SQLite VFS對象並且用sqlite3_vfs_register()註冊它來使用非同步I/O模式。當用這個VFS開啟資料庫檔案並進行寫操作時(使用vfs的xWrite()方法),資料不會立刻寫到磁碟,而是放在由後台線程維護的寫隊列中。當用非同步VFS開啟資料庫檔案並進行讀操作時(使用vfs的xRead()方法),資料從磁碟讀出,而寫隊列從vfs讀進程的角度看,其xWrite()已經完成了。非同步I/O的虛擬檔案系統(VFS)通過sqlite3async_initialize()來註冊,通過sqlite3async_shutdown()來關閉。
為了積累經驗,非同步I/O的實現有意保持簡單。更多的功能會在將來的版本中添加。例如,在當前的實現中,如果寫操作正在一個穩定的流上發生,而這個流超過了後台寫線程的I/O能力,則掛起的寫操作隊列將會無限地增長,可能會耗盡主機系統的記憶體。複雜一點的模組則可以跟蹤掛起的寫運算元量,在超過一定數目後停止接收新的寫請求。
在單個進程中、使用非同步IO的多個串連可以並發地訪問單個資料庫。從使用者的角度看,如果所有串連都位於單個進程中,則正常SQLite和使用非同步IO的SQLite,其並發性並沒有什麼不同。如果檔案鎖是啟用的(預設是啟用的),來自多個進程的串連都要讀和寫資料庫檔案,則並發性在下面的情況下會減弱:
(1)當使用非同步IO的串連啟動一個資料庫事務時,資料庫會立刻被鎖住。然而鎖只有在寫隊列中的所有操作已經重新整理到磁碟後才能釋放。這意味著有時即使在一個"COMMIT"或"ROLLBACK"執行完後,資料庫可能仍然處於鎖住狀態。
(2)如果應用程式使用非同步IO連續地執行多個事務,其他資料庫使用者可能會因為資料庫一直被鎖住而不能使用資料庫。這是因為當一個BEGIN執行後,資料庫鎖會立刻建立起來。但當對應的COMMIT或ROLLBACK發生時,鎖不一定釋放了,要到後台寫隊列全部重新整理到磁碟後才能釋放。如果後台寫隊列還沒重新整理完,資料庫就一直處於鎖住狀態,其他進程不能訪問資料庫。
檔案鎖可以在運行時通過sqlite3async_control()函數禁用。對NFS這可以提高效能,因為可以避免對伺服器的來回非同步作業建立檔案鎖。但是如果多個串連嘗試訪問同一個資料庫,而檔案鎖被禁用了,則應用程式崩潰和資料庫損壞就可能發生。
非同步IO擴充模組由單個源檔案sqlite3async.c,和一個標頭檔sqlite3async.h組成,位於源碼樹的ext/async/子目錄下。應用程式可以用其中定義的C API來啟用和控制這個模組的功能。為了使用非同步IO擴充,把sqlite3async.c編譯成使用SQLite的應用程式的一部分,然後使用sqlite3async.h中定義的API來初始化和配置這個模組。這些API在sqlite3async.h的注釋中有詳細說明,使用這些API通常有以下步驟:
(1)調用sqlite3async_initialize()來給SQLite註冊非同步IO VFS(虛擬檔案系統)。
(2)建立一個後台線程來執行寫操作,並調用sqlite3async_run()。
(3)通過非同步IO VFS,使用正常的SQLite API來讀寫資料庫。
當前的非同步IO擴充相容win32系統和支援pthread介面的系統,包括Mac OS X, Linux和其他Unix變體。為了移植非同步IO擴充到其他的平台,使用者必須在新平台上實現互斥鎖和條件變數原語。當前並沒有外部可用介面來允許做這樣的控制,但是修改sqlite3async.c中的代碼以包含新平台的並發控制原語是相當容易的,更多細節可搜尋sqlite3async.c中的注釋串"PORTING FUNCTIONS"。然後實現下面這些函數的新版本:
static void async_mutex_enter(int eMutex);
static void async_mutex_leave(int eMutex);
static void async_cond_wait(int eCond, int eMutex);
static void async_cond_signal(int eCond);
static void async_sched_yield(void);
上面這些函數的功能在sqlite3async.c的注釋中有詳細描述。
2、共用快取模式
從3.3.0版開始,SQLite包含一個特別的“共用快取”模式(預設情況下禁用),主要用在嵌入式伺服器中。如果共用快取模式啟用,並且一個線程在同一個資料庫上建立多個串連,則這些串連共用一個資料和模式緩衝。這能夠顯著減少系統的記憶體和IO消耗。在3.5.0版中,共用快取模式被修改以便同一緩衝的共用可以跨越整個進程而不只是單個線程。在這個修改之前,線上程間傳遞資料連線是受限制的。從3.5.0版開始這個限制就消除了。
從另一個進程或線程的角度看,使用共用快取的兩個或多個資料庫連接看起來就像是一個串連。鎖協議用來在多個共用快取或資料庫使用者之間進行仲裁。
圖1 共用快取模式
圖1描述一個運行時配置的例子,有三個資料庫連接。串連1是一個正常的SQLite資料庫連接,串連2和3共用一個緩衝。正常的鎖協議用來在串連1和共用快取之間序列化資料庫訪問。而串連2和串連3對共用快取訪問的序列化則有專門的內部協議。見下面的描述。
有三個層級的共用快取加鎖模型,事務層級的加鎖,表層級的加鎖和模式層級的加鎖。
(1)事務層級的加鎖
SQLite串連可能開啟兩種類型的事務,讀事務和寫事務。這不是顯式完成的,一個事務隱式地含有一個讀事務,直到它首次寫一個資料庫檔案,這時成為一個寫事務。在任何時候共用快取上最多隻能有一個串連開啟一個寫事務,這個寫事務可以和任何數量的讀事務共存。這與非共用快取模式不同,非共用快取模式下有讀操作時不允許有寫操作。
(2)表層級的加鎖
當兩個或更多的串連使用一個共用快取,用鎖來序列化每個表格的並發訪問。表支援兩種類型的鎖,讀鎖和寫鎖。鎖被授予串連,任何時候每個資料庫連接上的每個表格可以有讀鎖、寫鎖或沒有鎖。一個表格上可以任何數量的讀鎖,但只能有一個寫鎖。讀資料庫表格時必須首先獲得一個讀鎖。寫表格時必須獲得一個寫鎖。如果不能擷取需要的鎖,查詢失敗並返回SQLITE_LOCKED給調用者。表層級的鎖在擷取之後,要到當前事務(讀或寫)結束時才釋放。
如果使用read_uncommitted pragma指令把事務隔離模式從串列(serialized,預設模式,即查詢資料時會加上共用瑣,阻塞其他事務修改真實資料)改成允許髒讀(read-uncommitted,即SELECT會讀取其他事務修改而還沒有提交的資料),則上面描述的行為會有稍許的變化。事務隔離模式還有另外兩種,無法重複讀read-comitted是同一個事務中兩次執行同樣的查詢語句,若在第一次與第二次查詢之間時間段,其他事務又剛好修改了其查詢的資料且提交了,則兩次讀到的資料不一致。可以重複讀read-repeatable是指同一個事務中兩次執行同樣的查詢語句,得到的資料始終都是一致的。
/* Set the value of the read-uncommitted flag: ** ** True -> Set the connection to read-uncommitted mode. ** False -> Set the connection to serialized (the default) mode. */ PRAGMA read_uncommitted = <boolean>; /* Retrieve the current value of the read-uncommitted flag */ PRAGMA read_uncommitted;
允許髒讀模式的資料庫連接在讀資料庫表時不會擷取讀鎖,如果這時另外一個資料庫連接修改了正在被讀的表資料,則可能導致查詢結果不一致,因為允許髒讀模式的讀事務不會被打斷。允許髒讀模式不會影響寫事務,它必須擷取寫鎖,因此資料庫寫操作可以被阻塞。允許髒讀模式也不會影響sqlite_master層級的鎖。
(3)模式(sqlite_master)層級的加鎖
sqlite_master表支援與其他資料庫表相同的共用快取讀鎖和寫鎖。還會使用下面的特殊規則:
* 在訪問任何資料庫表格或者擷取任何其他的讀鎖和寫鎖之前,串連必須先擷取一個sqlite_master表上的讀鎖。
* 在執行修改資料庫模式的語句(例如CREATE TABLE或DROP TABLE)之前,串連必須先擷取一個sqlite_master表上的寫鎖。
* 如果任何其他的串連持有關聯資料庫(包括預設的主要資料庫)的sqlite_master表上的寫鎖,則串連不可以編譯一個SQL語句。
在SQLite 3.3.0到3.4.2之間,資料庫連接只能被調用sqlite3_open()建立它的線程使用,一個串連只能與同一線程中的其他串連共用快取。從SQLite 3.5.0開始,這個限制消除了。在老版本的SQLite上,共用快取模式不能使用在虛擬表上,從SQLite 3.6.17開始,這個限制消除了。
共用快取模式在每個進程層級上啟用。C介面int sqlite3_enable_shared_cache(int)用來全域地啟用或禁用共用快取模式。每次調用sqlite3_enable_shared_cache()影響後續的使用sqlite3_open(), sqlite3_open16()或sqlite3_open_v2()建立的資料庫連接,已經存在的資料庫連接則不受影響。每次sqlite3_enable_shared_cache()的調用覆蓋進程上的前面各次調用。
使用sqlite3_open_v2()建立的單個資料庫連接,通過在第三個參數上使用SQLITE_OPEN_SHAREDCACHE或SQLITE_OPEN_PRIVATECACHE標誌,可能選擇參與或不參與共用快取模式。在該資料庫連接上這些標誌會覆蓋全域的sqlite3_enable_shared_cache()設定。如果同時使用這兩個標誌,則行為是未定義的。
當使用URI檔案名稱時,"cache"查詢參數可以用來指定串連是否使用共用快取模式。"cache=shared"啟用共用快取,"cache=private"禁用共用快取。例如:
ATTACH ‘file:aux.db?cache=shared‘ AS aux;
從SQLite 3.7.13開始,倘若資料庫使用URI檔案名稱建立,共用快取模式可以在記憶體資料庫上使用。為了向後相容,使用未修飾的":memory:"名稱開啟記憶體資料庫時預設是禁用共用快取的。而在SQLite 3.7.13之前,無論使用的記憶體資料庫名、當前系統的共用快取設定、以及查詢參數或標誌是什麼,記憶體資料庫上共用快取總是被禁用的。
在記憶體資料庫上啟用共用快取,會允許同一進程上的兩個或更多資料庫連接訪問同一段記憶體。當最後一個串連關閉時,記憶體資料庫會自動刪除,這段記憶體也會被重設。
3、解鎖通知
當多個串連在共用快取模式下訪問同一個資料庫時,單個表上的讀和寫鎖(即共用和獨佔鎖定)用來確保並發執行的事務是隔離的。如果串連不能擷取到需要的鎖,sqlite3_step()調用返回SQLITE_LOCKED。如果不能擷取到每個關聯資料庫的sqlite_master表上的讀鎖(雖然這種情況並不常見),sqlite3_prepare()或sqlite3_prepare_v2()調用也會返回SQLITE_LOCKED。
通過使用SQLite的sqlite3_unlock_notify()介面,我們可以讓sqlite3_step()或sqlite3_prepare_v2()調用阻塞直到獲得需要的鎖,而不是立刻返回SQLITE_LOCKED。下面的例子展示解鎖通知的使用。
/* 本例子使用pthreads API */ #include <pthread.h> /* ** 當註冊一個解鎖通知時,傳遞本結構執行個體的指標,以作為使用者上下文中的執行個體 */ typedef struct UnlockNotification UnlockNotification; struct UnlockNotification { int fired; /* 在解鎖事件發生後為True */ pthread_cond_t cond; /* 要等待的條件變數 */ pthread_mutex_t mutex; /* 保護本結構的互斥量 */ }; /* ** 解鎖通知回呼函數 */ static void unlock_notify_cb(void **apArg, int nArg){ int i; for(i=0; i<nArg; i++){ UnlockNotification *p = (UnlockNotification *)apArg[i]; pthread_mutex_lock(&p->mutex); /* 對臨界區加鎖 */ p->fired = 1; /* 觸發解鎖事件,本變數只能互斥訪問 */ pthread_cond_signal(&p->cond); pthread_mutex_unlock(&p->mutex); } } /* ** 本函數假設SQLite API調用(sqlite3_prepare_v2()或sqlite3_step())返回SQLITE_LOCKED。 ** 參數為關聯的資料庫連接。 ** 本函數調用sqlite3_unlock_notify()註冊一個解鎖通知回呼函數,然後阻塞直到 ** 回呼函數執行完並返回SQLITE_OK。調用者應該重試失敗的操作。 ** 或者,如果sqlite3_unlock_notify()指示阻塞將會導致系統死結,則本函數立刻 ** 返回SQLITE_LOCKED。調用者不應該重試失敗的操作,而是復原當前事務 */ static int wait_for_unlock_notify(sqlite3 *db){ int rc; UnlockNotification un; /* 初始化UnlockNotification結構 */ un.fired = 0; pthread_mutex_init(&un.mutex, 0); pthread_cond_init(&un.cond, 0); /* 註冊一個解鎖通知回呼函數 */ rc = sqlite3_unlock_notify(db, unlock_notify_cb, (void *)&un); assert( rc==SQLITE_LOCKED || rc==SQLITE_OK ); /* sqlite3_unlock_notify()調用總是返回SQLITE_LOCKED或SQLITE_OK。 ** 如果返回SQLITE_LOCKED,則系統死結。本函數需要返回SQLITE_LOCKED給調用者以 ** 便當前事務能夠復原。否則阻塞直到解鎖通知回呼函數執行,然後返回SQLITE_OK */ if( rc==SQLITE_OK ){ pthread_mutex_lock(&un.mutex); if( !un.fired ){ /* 如果解鎖事件沒有發生,則阻塞 */ pthread_cond_wait(&un.cond, &un.mutex); } pthread_mutex_unlock(&un.mutex); } /* 銷毀互斥量和條件變數 */ pthread_cond_destroy(&un.cond); pthread_mutex_destroy(&un.mutex); return rc; } /* ** 本函數是SQLite函數sqlite3_step()的封裝,它的工作方式與sqlite3_step()相同。 ** 但如果沒有獲得共用快取鎖,則本函數阻塞以等待鎖可用。 ** 如果本函數返回SQLITE_LOCKED,調用者應該復原當前事務,之後再嘗試。否則系統可能死結了 */ int sqlite3_blocking_step(sqlite3_stmt *pStmt){ int rc; while( SQLITE_LOCKED==(rc = sqlite3_step(pStmt)) ){ rc = wait_for_unlock_notify(sqlite3_db_handle(pStmt)); if( rc!=SQLITE_OK ) break; sqlite3_reset(pStmt); } return rc; } /* ** 本函數是SQLite函數sqlite3_prepare_v2()的封裝,它的工作方式與sqlite3_prepare_v2()相同。 ** 但如果沒有獲得共用快取鎖,則本函數阻塞以等待鎖可用。 ** 如果本函數返回SQLITE_LOCKED,調用者應該復原當前事務,之後再嘗試。否則系統可能死結了 */ int sqlite3_blocking_prepare_v2( sqlite3 *db, /* 資料庫控制代碼 */ const char *zSql, /* UTF-8編碼的SQL語句 */ int nSql, /* zSql的位元組數 */ sqlite3_stmt **ppStmt, /* OUT: 指向預先處理語句的指標 */ const char **pz /* OUT: 解析過的字串尾部位置 */ ){ int rc; while( SQLITE_LOCKED==(rc = sqlite3_prepare_v2(db, zSql, nSql, ppStmt, pz)) ){ rc = wait_for_unlock_notify(db); if( rc!=SQLITE_OK ) break; } return rc; }
如果例子中的sqlite3_blocking_step()或sqlite3_blocking_prepare_v2()函數返回SQLITE_LOCKED,則表明阻塞將導致系統死結。
只有在編譯時間定義預先處理宏SQLITE_ENABLE_UNLOCK_NOTIFY,才能使用sqlite3_unlock_notify()介面。該介面被設計成用在這樣的系統中:每個資料庫連接分配單獨的線程。如果在一個線程中運行多個資料庫連接,則不能使用該介面。sqlite3_unlock_notify()介面一次只在一個線程上工作,因此上面的鎖控制邏輯只能工作於一個線程的單個資料庫連接上。
上面的例子中,在sqlite3_step()或sqlite3_prepare_v2()返回SQLITE_LOCKED後,sqlite3_unlock_notify()被調用以註冊一個解鎖通知回呼函數。在資料庫連接持有表層級的鎖後,解鎖通知函數被執行以防止sqlite3_step()或sqlite3_prepare_v2()隨後完成事務並釋放所有鎖。例如,如果sqlite3_step()嘗試讀表格X,而其他某個串連Y正持有表格X的寫鎖,sqlite3_step()將返回SQLITE_LOCKED。如果隨後調用sqlite3_unlock_notify(),解鎖通知函數將在串連Y的事務結束後被調用。解鎖通知函數正在等待的串連(這裡的Y),被稱為“阻塞式串連”。
如果sqlite3_step()嘗試寫一個資料庫,但返回SQLITE_LOCKED,則可能有多個進程持有當前資料庫表格的讀鎖。這時SQLite隨意地選擇其中的一個串連,當這個串連的事務完成時執行解鎖通知函數。解鎖通知函數從sqlite3_step()(或sqlite3_close())裡執行,它關聯有一個阻塞式進程。解鎖通知函數裡面可以調用任何的sqlite3_XXX()函數,可以向其他等待線程發訊號,或者安排一些在以後要發生的行為。
sqlite3_blocking_step()函數使用的演算法描述如下:
(1)在指定的SQL語句對象上調用sqlite3_step(),如果返回除SQLITE_LOCKED之外的值,則直接返回這個值給調用者。如果返回SQLITE_LOCKED則繼續。
(2)調用sqlite3_unlock_notify()註冊一個解鎖通知回呼函數。如果sqlite3_unlock_notify()返回SQLITE_LOCKED,說明系統死結,返回這個值給調用者以便復原。否則繼續。
(3)阻塞,直到解鎖通知函數被另外一個線程執行。
(4)在SQL語句對象上調用sqlite3_reset()。因為SQLITE_LOCKED錯誤可能只發生在第一次調用sqlite3_step()時(不可能有sqlite3_step()先返回SQLITE_ROW而下一次卻返回SQLITE_LOCKED的情況)。這時SQL語句對象會被重設,從而不會影響查詢結果。如果不調用sqlite3_reset(),下一次調用sqlite3_step()將返回SQLITE_MISUSE。
(5)轉向步驟(1)。
sqlite3_blocking_prepare_v2()使用的演算法也類似,只不過第4步(重設SQL語句對象)忽略。
對於“寫饑餓”現象,SQLite能輔助應用程式避免出現寫饑餓的情況。當在一個表上擷取寫鎖的任何嘗試失敗後(因為有串連一直持有讀鎖),共用快取上啟動新事務的所有嘗試都會失敗,直到下面有一種情況變成True為止:
* 當前寫事務完成,或者
* 共用快取上開啟的讀事務數量減為0。
啟動新的讀事務失敗會返回SQLITE_LOCKED給調用者。如果調用者然後調用sqlite3_unlock_notify()註冊一個解鎖通知函數,阻塞式串連當前在共用快取上會有一個寫事務。這就避免了寫饑餓,因為沒有新的讀鎖可以開啟了。當所有存在的讀鎖完成時,寫操作最終能有機會獲得需要的寫鎖。
在wait_for_unlock_notify()調用sqlite3_unlock_notify()時,有可能阻塞式線程已經完成它的事務,這樣在sqlite3_unlock_notify()返回前解鎖通知函數會立刻被調用。解鎖通知函數也有可能被另一個線程調用,正好發生在sqlite3_unlock_notify()調用之後,而在這個線程開始等待非同步訊號之前。這樣的競爭條件怎麼處理,取決於應用程式使用的線程和同步原語。本例子中使用pthread,這是現代UNIX風格的系統(包括Linux)提供的介面。
pthread提供pthread_cond_wait()函數,它允許調用者同時釋放一個互斥量並開始等待一個非同步訊號。使用這個函數、一個"fired"標誌和一個互斥量,競爭狀態可以消除,如下:
當解鎖通知函數被調用時,這可能發生在調用sqlite3_unlock_notify()的線程開始等待一個非同步訊號之前,它做下面的工作:
(1)擷取互斥量。
(2)設定"fired"標誌為true。
(3)向等待線程發訊號。
(4)釋放互斥量。
當wait_for_unlock_notify()線程開始等待解鎖通知函數到達時,它:
(1)擷取互斥量。
(2)檢查"fired"標誌是否設定。如果已設定,解鎖通知函數已經被調用,直接釋放互斥量,然後繼續。
(3)如果沒設定,原子性地釋放互斥量,並開始等待非同步訊號。當訊號到達時,繼續。
通過這種方式,當wait_for_unlock_notify()開始阻塞時,解鎖通知函數不管是已經被調用,還是正在被調用,都沒有問題。
本文例子中的代碼至少在以下兩個方面可以改進:
* 能管理線程優先順序。
* 能處理SQLITE_LOCKED的特殊情形,這可能發生在刪除一個表或索引時。
雖然sqlite3_unlock_notify()只允許調用者指定單個的使用者上下文指標,但一個解鎖通知回調是傳給這種上下文指標數組的。這是因為當一個阻塞式線程完成它的事務時,如果有多個解鎖通知被註冊用於調用同一個C函數,則上下文指標就要排列成一個數組。如果每個線程分配一個優先順序,則高優先順序的線程就會比低優先順序的線程先得到訊號通知,而不是以任意的順序來通知線程。
如果執行一個"DROP TABLE"或"DROP INDEX"命令,而當前資料庫連接上有一個或多個正在執行的SELECT語句,則會返回SQLITE_LOCKED。如果調用了sqlite3_unlock_notify(),指定的回呼函數立刻會被調用。重新嘗試"DROP TABLE"或"DROP INDEX"將返回另外一個SQLITE_LOCKED錯誤。在上面的sqlite3_blocking_step()實現中,這會導致死迴圈。
調用者可以使用擴充錯誤碼來區別這種特殊的"DROP TABLE|INDEX"情形和其他情形。當它正常調用sqlite3_unlock_notify()時,擴充錯誤碼是SQLITE_LOCKED_SHAREDCACHE。在"DROP TABLE|INDEX"情形中,是普通的SQLITE_LOCKED。另外一種解決方案是限制重試單個查詢的次數(如100次)。雖然這會導致效率低一點,但我們這裡討論的情況並不是經常發生的。
SQLite剖析之非同步IO模式、共用快取模式和解鎖通知