先說下初衷吧,實(shí)際上我經(jīng)??吹接腥吮г筍QLite不支持多線程。而在iOS開發(fā)時,,為了不阻塞主線程,,數(shù)據(jù)庫訪問必須移到子線程中。為了解決這個矛盾,很有必要對此一探究竟,。 關(guān)于這個問題,,最權(quán)威的解答當(dāng)然是SQLite官網(wǎng)上的“Is SQLite threadsafe?”這個問答。 簡單來說,,從3.3.1版本開始,,它就是線程安全的了。而iOS的SQLite版本沒有低于這個版本的: 3.4.0 - iPhone OS 2.2.1 3.6.12 - iPhone OS 3.0 / 3.1 3.6.22 - iPhone OS 4.0 3.6.23.2 - iOS 4.1 / 4.2 3.7.2 - iOS 4.3 3.7.7 - iOS 5.0 當(dāng)然,,你也可以自己編譯最新版本,。只是我發(fā)現(xiàn)自己編譯出來的3.7.8居然比iOS 4.3.3內(nèi)置的3.7.2慢了一半,不知道蘋果做了什么優(yōu)化,。發(fā)現(xiàn)是我編譯成了debug版本,,改成release后性能比內(nèi)置版本高5%左右,不過構(gòu)建出來的app會大420k左右,。 不過這個線程安全仍然是有限制的,,在這篇《Is SQLite thread-safe?》里有詳細(xì)的解釋。 另一篇重要的文檔就是《SQLite And Multiple Threads》,。它指出SQLite支持3種線程模式: 單線程:禁用所有的mutex鎖,,并發(fā)使用時會出錯。當(dāng)SQLite編譯時加了SQLITE_THREADSAFE=0參數(shù),,或者在初始化SQLite前調(diào)用sqlite3_config(SQLITE_CONFIG_SINGLETHREAD)時啟用,。 多線程:只要一個數(shù)據(jù)庫連接不被多個線程同時使用就是安全的。源碼中是啟用bCoreMutex,,禁用bFullMutex,。實(shí)際上就是禁用數(shù)據(jù)庫連接和prepared statement(準(zhǔn)備好的語句)上的鎖,因此不能在多個線程中并發(fā)使用同一個數(shù)據(jù)庫連接或prepared statement,。當(dāng)SQLite編譯時加了SQLITE_THREADSAFE=2參數(shù)時默認(rèn)啟用,。若SQLITE_THREADSAFE不為0,可以在初始化SQLite前,,調(diào)用sqlite3_config(SQLITE_CONFIG_MULTITHREAD)啟用;或者在創(chuàng)建數(shù)據(jù)庫連接時,,設(shè)置SQLITE_OPEN_NOMUTEX flag。 串行:啟用所有的鎖,,包括bCoreMutex和bFullMutex,。因?yàn)閿?shù)據(jù)庫連接和prepared statement都已加鎖,所以多線程使用這些對象時沒法并發(fā),,也就變成串行了,。當(dāng)SQLite編譯時加了SQLITE_THREADSAFE=1參數(shù)時默認(rèn)啟用。若SQLITE_THREADSAFE不為0,,可以在初始化SQLite前,,調(diào)用sqlite3_config(SQLITE_CONFIG_SERIALIZED)啟用;或者在創(chuàng)建數(shù)據(jù)庫連接時,設(shè)置SQLITE_OPEN_FULLMUTEX flag。 而這里所說的初始化是指調(diào)用sqlite3_initialize()函數(shù),,這個函數(shù)在調(diào)用sqlite3_open()時會自動調(diào)用,,且只有第一次調(diào)用是有效的。 另一個要說明的是prepared statement,,它是由數(shù)據(jù)庫連接(的pager)來管理的,,使用它也可看成使用這個數(shù)據(jù)庫連接。因此在多線程模式下,,并發(fā)對同一個數(shù)據(jù)庫連接調(diào)用sqlite3_prepare_v2()來創(chuàng)建prepared statement,,或者對同一個數(shù)據(jù)庫連接的任何prepared statement并發(fā)調(diào)用sqlite3_bind_*()和sqlite3_step()等函數(shù)都會出錯(在iOS上,該線程會出現(xiàn)EXC_BAD_ACCESS而中止),。這種錯誤無關(guān)讀寫,,就是只讀也會出錯。文檔中給出的安全使用規(guī)則是:沒有事務(wù)正在等待執(zhí)行,,所有prepared statement都被finalized,。 順帶一提,調(diào)用sqlite3_threadsafe()可以獲得編譯期的SQLITE_THREADSAFE參數(shù),。標(biāo)準(zhǔn)發(fā)行版是1,,也就是串行模式;而iOS上是2,也就是多線程模式;Python的sqlite3模塊也默認(rèn)使用串行模式,,可以用sqlite3.threadsafety來配置,。但是默認(rèn)情況下,一個線程只能使用當(dāng)前線程打開的數(shù)據(jù)庫連接,,除非在連接時設(shè)置了check_same_thread=False參數(shù),。 現(xiàn)在3種模式都有所了解了,清楚SQLite并不是對多線程無能為力后,,接下來就了解下事務(wù)吧,。 數(shù)據(jù)庫只有在事務(wù)中才能被更改。所有更改數(shù)據(jù)庫的命令(除SELECT以外的所有SQL命令)都會自動開啟一個新事務(wù),,并且當(dāng)最后一個查詢完成時自動提交,。 而BEGIN命令可以手動開始事務(wù),并關(guān)閉自動提交,。當(dāng)下一條COMMIT命令執(zhí)行時,,自動提交再次打開,事務(wù)中所做的更改也被寫入數(shù)據(jù)庫,。當(dāng)COMMIT失敗時,,自動提交仍然關(guān)閉,以便讓用戶嘗試再次提交,。若執(zhí)行的是ROLLBACK命令,,則也打開自動提交,,但不保存事務(wù)中的更改。關(guān)閉數(shù)據(jù)庫或遇到錯誤時,,也會自動回滾事務(wù)。 經(jīng)常有人抱怨SQLite的插入太慢,,實(shí)際上它可以做到每秒插入幾萬次,,但是每秒只能提交幾十次事務(wù)。因此在插入大批數(shù)據(jù)時,,可以通過禁用自動提交來提速,。 事務(wù)在改寫數(shù)據(jù)庫文件時,會先生成一個rollback journal(回滾日志),,記錄初始狀態(tài)(其實(shí)就是備份),,所有改動都是在數(shù)據(jù)庫文件上進(jìn)行的。當(dāng)事務(wù)需要回滾時,,可以將備份文件的內(nèi)容還原到數(shù)據(jù)庫文件;提交成功時,,默認(rèn)的delete模式下會直接刪除這個日志。這個日志也可以幫助解決事務(wù)執(zhí)行過程中斷電,,導(dǎo)致數(shù)據(jù)庫文件損壞的問題,。但如果操作系統(tǒng)或文件系統(tǒng)有bug,或是磁盤損壞,,則仍有可能無法恢復(fù),。 而從3.7.0版本(對應(yīng)iOS 4.3)開始,SQLite還提供了Write-Ahead Logging模式,。與delete模式相比,,WAL模式在大部分情況下更快,并發(fā)性更好,,讀和寫之間互不阻塞;而其缺點(diǎn)對于iPhone這種嵌入式設(shè)備來說可以忽略,,只需注意不要以只讀方式打開WAL模式的數(shù)據(jù)庫即可。 使用WAL模式時,,改寫操是附加(append)到WAL文件,,而不改動數(shù)據(jù)庫文件,因此數(shù)據(jù)庫文件可以被同時讀取,。當(dāng)執(zhí)行checkpoint操作時,,WAL文件的內(nèi)容會被寫回數(shù)據(jù)庫文件。當(dāng)WAL文件達(dá)到SQLITE_DEFAULT_WAL_AUTOCHECKPOINT(默認(rèn)值是1000)頁(默認(rèn)大小是1KB)時,,會自動使用當(dāng)前COMMIT的線程來執(zhí)行checkpoint操作,。也可以關(guān)閉自動checkpoint,改為手動定期checkpoint,。 為了避免讀取的數(shù)據(jù)不一致,,查詢時也需要讀取WAL文件,,并記錄一個結(jié)尾標(biāo)記(end mark)。這樣的代價就是讀取會變得稍慢,,但是寫入會變快很多,。要提高查詢性能的話,可以減小WAL文件的大小,,但寫入性能也會降低,。 需要注意的是,低版本的SQLite不能讀取高版本的SQLite生成的WAL文件,,但是數(shù)據(jù)庫文件是通用的,。這種情況在用戶進(jìn)行iOS降級時可能會出現(xiàn),可以把模式改成delete,,再改回WAL來修復(fù),。 要對一個數(shù)據(jù)庫連接啟用WAL模式,需要執(zhí)行“PRAGMA journal_mode=WAL;”這條命令,,它的默認(rèn)值是“journal_mode=DELETE”,。執(zhí)行后會返回新的journal_mode字符串值,即成功時為"wal",,失敗時為之前的模式(例如"delete"),。一旦啟用WAL模式后,數(shù)據(jù)庫會保持這個模式,,這樣下次打開數(shù)據(jù)庫時仍然是WAL模式,。 要停止自動checkpoint,可以使用wal_autocheckpoint指令或sqlite3_wal_checkpoint()函數(shù),。手動執(zhí)行checkpoint可以使用wal_checkpoint指令或sqlite3_wal_checkpoint()函數(shù),。 還有一個很重要的知識點(diǎn)需要強(qiáng)調(diào):事務(wù)是和數(shù)據(jù)庫連接相關(guān)的,每個數(shù)據(jù)庫連接(使用pager來)維護(hù)自己的事務(wù),,且同時只能有一個事務(wù)(但是可以用SAVEPOINT來實(shí)現(xiàn)內(nèi)嵌事務(wù)),。 也就是說,事務(wù)與線程無關(guān),,一個線程里可以同時用多個數(shù)據(jù)庫連接來完成多個事務(wù),,而多個線程也可以同時(非并發(fā))使用一個數(shù)據(jù)庫連接來共同完成一個事務(wù)。 下面用Python來演示一下:
在這個例子中,,雖然是在子線程中執(zhí)行rollback,,但由于和主線程用的是同一個數(shù)據(jù)庫連接,所以主線程所做的更改也被回滾了,。 而如果是用不同的數(shù)據(jù)庫連接,,每個連接都不能讀取其他連接中未提交的數(shù)據(jù),除非使用read-uncommitted模式,。 而要實(shí)現(xiàn)事務(wù),,就不得不用到鎖,。 一個SQLite數(shù)據(jù)庫文件有5種鎖的狀態(tài): UNLOCKED:表示數(shù)據(jù)庫此時并未被讀寫。 SHARED:表示數(shù)據(jù)庫可以被讀取,。SHARED鎖可以同時被多個線程擁有,。一旦某個線程持有SHARED鎖,就沒有任何線程可以進(jìn)行寫操作,。 RESERVED:表示準(zhǔn)備寫入數(shù)據(jù)庫,。RESERVED鎖最多只能被一個線程擁有,此后它可以進(jìn)入PENDING狀態(tài),。 PENDING:表示即將寫入數(shù)據(jù)庫,正在等待其他讀線程釋放SHARED鎖,。一旦某個線程持有PENDING鎖,,其他線程就不能獲取SHARED鎖。這樣一來,,只要等所有讀線程完成,,釋放SHARED鎖后,它就可以進(jìn)入EXCLUSIVE狀態(tài)了,。 EXCLUSIVE:表示它可以寫入數(shù)據(jù)庫了,。進(jìn)入這個狀態(tài)后,其他任何線程都不能訪問數(shù)據(jù)庫文件,。因此為了并發(fā)性,,它的持有時間越短越好。 一個線程只有在擁有低級別的鎖的時候,,才能獲取更高一級的鎖,。SQLite就是靠這5種類型的鎖,巧妙地實(shí)現(xiàn)了讀寫線程的互斥,。同時也可看出,,寫操作必須進(jìn)入EXCLUSIVE狀態(tài),此時并發(fā)數(shù)被降到1,,這也是SQLite被認(rèn)為并發(fā)插入性能不好的原因,。 另外,read-uncommitted和WAL模式會影響這個鎖的機(jī)制,。在這2種模式下,,讀線程不會被寫線程阻塞,即使寫線程持有PENDING或EXCLUSIVE鎖,。 提到鎖就不得不說到死鎖的問題,,而SQLite也可能出現(xiàn)死鎖。 下面舉個例子: 連接1:BEGIN (UNLOCKED) 連接1:SELECT ... (SHARED) 連接1:INSERT ... (RESERVED) 連接2:BEGIN (UNLOCKED) 連接2:SELECT ... (SHARED) 連接1:COMMIT (PENDING,,嘗試獲取EXCLUSIVE鎖,,但還有SHARED鎖未釋放,,返回SQLITE_BUSY) 連接2:INSERT ... (嘗試獲取RESERVED鎖,但已有PENDING鎖未釋放,,返回SQLITE_BUSY) 現(xiàn)在2個連接都在等待對方釋放鎖,,于是就死鎖了。當(dāng)然,,實(shí)際情況并沒那么糟糕,,任何一方選擇不繼續(xù)等待,回滾事務(wù)就行了,。 不過要更好地解決這個問題,,就必須更深入地了解事務(wù)了。 實(shí)際上BEGIN語句可以有3種起始狀態(tài): DEFERRED:默認(rèn)值,,開始事務(wù)時不獲取任何鎖,。進(jìn)行第一次讀操作時獲取SHARED鎖,進(jìn)行第一次寫操作時獲取RESERVED鎖,。 IMMEDIATE:開始事務(wù)時獲取RESERVED鎖,。 EXCLUSIVE:開始事務(wù)時獲取EXCLUSIVE鎖。 現(xiàn)在考慮2個事務(wù)在開始時都使用IMMEDIATE方式: 連接1:BEGIN IMMEDIATE (RESERVED) 連接1:SELECT ... (RESERVED) 連接1:INSERT ... (RESERVED) 連接2:BEGIN IMMEDIATE (嘗試獲取RESERVED鎖,,但已有RESERVED鎖未釋放,,因此事務(wù)開始失敗,返回SQLITE_BUSY,,等待用戶重試) 連接1:COMMIT (EXCLUSIVE,,寫入完成后釋放) 連接2:BEGIN IMMEDIATE (RESERVED) 連接2:SELECT ... (RESERVED) 連接2:INSERT ... (RESERVED) 連接2:COMMIT (EXCLUSIVE,寫入完成后釋放) 這樣死鎖就被避免了,。 而EXCLUSIVE方式則更為嚴(yán)苛,,即使其他連接以DEFERRED方式開啟事務(wù)也不會死鎖: 連接1:BEGIN EXCLUSIVE (EXCLUSIVE) 連接1:SELECT ... (EXCLUSIVE) 連接1:INSERT ... (EXCLUSIVE) 連接2:BEGIN (UNLOCKED) 連接2:SELECT ... (嘗試獲取SHARED鎖,但已有EXCLUSIVE鎖未釋放,,返回SQLITE_BUSY,,等待用戶重試) 連接1:COMMIT (EXCLUSIVE,寫入完成后釋放) 連接2:SELECT ... (SHARED) 連接2:INSERT ... (RESERVED) 連接2:COMMIT (EXCLUSIVE,,寫入完成后釋放) 不過在并非很高的情況下,,直接獲取EXCLUSIVE鎖的難度比較大;而且為了避免EXCLUSIVE狀態(tài)長期阻塞其他請求,最好的方式還是讓所有寫事務(wù)都以IMMEDIATE方式開始,。 順帶一提,,要實(shí)現(xiàn)重試的話,可以使用sqlite3_busy_timeout()或sqlite3_busy_handler()函數(shù),。 由此可見,,要想保證線程安全的話,可以有這4種方式: SQLite使用單線程模式,,用一個專門的線程訪問數(shù)據(jù)庫,。 SQLite使用單線程模式,,用一個線程隊(duì)列來訪問數(shù)據(jù)庫,隊(duì)列一次只允許一個線程執(zhí)行,,隊(duì)列里的線程共用一個數(shù)據(jù)庫連接,。 SQLite使用多線程模式,每個線程創(chuàng)建自己的數(shù)據(jù)庫連接,。 SQLite使用串行模式,,所有線程共用全局的數(shù)據(jù)庫連接。 接下來就一一測試這幾種方式在iPhone 4(iOS 4.3.3,,SQLite 3.7.2)上的性能表現(xiàn),。 第一種方式太過麻煩,需要線程間通信,,這里我就忽略了,。 第二種方式可以用dispatch_queue_create()來創(chuàng)建一個serial queue,或者用一個maxConcurrentOperationCount為1的NSOperationQueue來實(shí)現(xiàn),。 這種方式的缺點(diǎn)就是事務(wù)必須在一個block或operation里完成,否則會亂序;而耗時較長的事務(wù)會阻塞隊(duì)列,。另外,,沒法利用多核CPU的優(yōu)勢。 先初始化數(shù)據(jù)庫:
再插入1000條測試數(shù)據(jù):
然后創(chuàng)建一個串行隊(duì)列:
再設(shè)置一個計數(shù)器,,每秒執(zhí)行一次:
這樣就可以開始測試select和update了:
這里是用dispatch_async()來異步地遞歸調(diào)用block,。 因?yàn)閎lock是在棧里生成的,異步執(zhí)行時已經(jīng)被銷毀,,所以需要copy到堆,。因?yàn)樾枰恢眻?zhí)行,所以我就沒release了,。 此外,,光copy的話還是無法正常執(zhí)行,但是把block本身的存儲類型設(shè)為__block后就正常了,,原因我也不清楚,。 測試結(jié)果為只讀時平均每秒165次,只寫時每秒68次,,同時讀寫時每秒各47次,。換成多線程或串行模式時,效率也差不多,。 接著試試WAL模式:
sqlite3_wal_checkpoint(database, NULL); // 每次測試前先checkpoint,,避免WAL文件過大而影響性能 測試結(jié)果為只讀時平均每秒166次,只寫時每秒244次,,同時讀寫時每秒各97次,。并發(fā)性增加了1倍有木有!更夸張的是寫入比讀取還快了,。 在自編譯的3.7.8版中,同時讀寫為每秒各102次,,加上SQLITE_THREADSAFE=0參數(shù)后為每秒各104次,,性能稍有提升。 第三種方式需要打開和關(guān)閉數(shù)據(jù)庫連接,,所以會額外消耗一些時間,。此外還要維持各個連接間的互斥,事務(wù)也比較容易沖突,,但能確保事務(wù)正確執(zhí)行,。 首先需要移除全局的database變量,并修改openDb()函數(shù):
再配置成多線程模式:
隊(duì)列改成可以亂序執(zhí)行的:
然后是訪問數(shù)據(jù)庫:
這里就無需遞歸調(diào)用了,,直接在子線程中循環(huán)即可,。 測試結(jié)果為只讀時平均每秒164次,只寫時每秒68次,,同時讀寫時分別為每秒14和30次(波動很大),。此外,這種方式因?yàn)樽畛鯁拥膸讉€線程持續(xù)訪問數(shù)據(jù)庫,,后加入的線程會滯后幾秒才啟動,,且很難打開數(shù)據(jù)庫連接或創(chuàng)建prepare statement。調(diào)試時發(fā)現(xiàn)只會啟用2個線程,,但是隨隊(duì)列中block數(shù)目的增加,,讀性能增高,寫性能降低,。讀寫各3個block時分別為每秒35和14次,。 WAL模式下甚至連初始時啟動2個線程都會被lock,因此只能改成不斷重試:
結(jié)果為只讀時平均每秒169次,,只寫時每秒246次,,同時讀寫時每秒分別為90和57次(波動較大)。并發(fā)效率有了顯著提升,,但仍不及第二種方式,。 第四種方式相當(dāng)于讓SQLite來維護(hù)隊(duì)列,只不過SQL的執(zhí)行是亂序的,,因此無法保證事務(wù)性,。 先恢復(fù)全局的database變量,然后配置成串行模式: sqlite3_config(SQLITE_CONFIG_SERIALIZED); 再是訪問數(shù)據(jù)庫:
測試結(jié)果為只讀時平均每秒164次,,只寫時每秒68次,,同時讀寫時每秒分別為57和43次。讀線程比寫線程的速率更高,而且新線程的加入不需要等待,。 WAL模式下,,只讀時平均每秒176次,只寫時每秒254次,,同時讀寫時每秒分別為109和85次,。 由此可見,要獲得最好的性能的話,,WAL模式是必須啟用的,,為此也有必要自己編譯SQLite 3.7.0以上的版本(除非不支持iOS 4.2及以下版本)。 而在測試過的后3種方式中:第3種是效率最低的,,不建議使用;第4種讀取性能更高,,適合無需使用事務(wù)的場合;第2種適用范圍更廣,效率也足夠優(yōu)秀,,一般應(yīng)采用這種方式,。 不過要注意的是,第2種方式在測試時的邏輯是完全與數(shù)據(jù)庫相關(guān)的,。實(shí)際中可能要做計算或IO訪問等工作,,在此期間其他線程都是被阻塞的,這樣就會大大降低效率了,。因此只建議把訪問數(shù)據(jù)庫的邏輯放入隊(duì)列,,其余工作在其他線程里完成。 剛才洗澡時我又想到一點(diǎn),,既然第2種方式不能并行,第4種方式不能保證事務(wù)性,,那么能否將各自的優(yōu)點(diǎn)結(jié)合起來呢? 于是一個新的實(shí)現(xiàn)方案又浮出水面了:使用2個串行隊(duì)列,,分別負(fù)責(zé)讀和寫,每個隊(duì)列各使用一個數(shù)據(jù)庫連接,,線程模式可以采用多線程或串行模式,。 代碼拿方式2稍做修改就行了,這里就不列出了,。測試結(jié)果波動比較大(估計是checkpoint的影響),,多線程模式下平均約為89和73次,串行模式下為91和86次,。 但在iPad 2這種雙核的機(jī)型上,,多線程明顯要比單隊(duì)列更具優(yōu)勢:方式2的成績是每秒各85次,方式3是94和124次(寫波動較大),,方式4是95和72次,,而新方案在多線程模式下是104和168次(寫波動很大,40~280之間),串行模式下為108和177次(寫波動很大),。 因此極端的優(yōu)化情況下,,可以根據(jù)CPU核心數(shù)來創(chuàng)建隊(duì)列數(shù),然后把數(shù)據(jù)庫訪問線程隨機(jī)分配到某個隊(duì)列中,。不過考慮到iOS設(shè)備這種嵌入式平臺并不需要密集地訪問數(shù)據(jù)庫,,而且除數(shù)據(jù)庫線程以外還有其他事要做,如果沒遇到瓶頸的話,,簡單的方案2其實(shí)也夠用了,。 【責(zé)任編輯:立方 TEL:(010)68476606】
|
|