標籤:
在SQLite中,鎖和並發控制機制都是由pager.c模組負責處理的,用於實現ACID(Atomic, Consistent, Isolated和Durable)特性。在含有資料修改的事務中,該模組將確保或者所有的資料修改全部提交,或者全部復原。與此同時,該模組還提供了一些磁碟檔案的記憶體Cache功能。
事實上,pager模組並不關心資料庫儲存的細節,如B-Tree、編碼方式、索引等。它只是將其視為由統一大小(通常為1024位元組)的資料區塊構成的單一檔案,其中每個塊被稱為一個頁(page)。頁的起始編號為1,即資料庫的首個1024位元組稱為"page 1",其後的頁編號以此類推。pager通過OS介面模組(如os_unix.c, os_win.c)與作業系統通訊。
1、鎖
從單個進程的角度來看,一個資料庫檔案可以有五種不同的鎖狀態:
(1)UNLOCKED: 檔案沒有持有任何鎖,即當前資料庫不存在任何讀或寫的操作。其它的進程可以在該資料庫上執行任意的讀寫操作。此狀態為預設狀態。
(2)SHARED: 在此狀態下,該資料庫可以被讀取但是不能被寫入。在同一時刻可以有任意數量的進程在同一個資料庫上持有共用鎖定,因此讀操作是並發的。換句話說,只要有一個或多個共用鎖定處於活動狀態,就不再允許有資料庫檔案寫入的操作存在。
(3)RESERVED: 假如某個進程在將來的某一時刻打算在當前的資料庫中執行寫操作,然而此時只是從資料庫中讀取資料,那麼我們就可以簡單的理解為資料庫檔案此時已經擁有了保留鎖。當保留鎖處於活動狀態時,該資料庫只能有一個或多個共用鎖定存在,即同一資料庫的同一時刻只能存在一個保留鎖和多個共用鎖定。在Oracle中此類鎖被稱之為預寫鎖,不同的是Oracle中鎖的粒度可以細化到表甚至到行,因此該種鎖在Oracle中對並發的影響程度不像SQLite中這樣大。
(4)PENDING: 該鎖的意思是說,某個進程正打算在該資料庫上執行寫操作,然而此時該資料庫中卻存在很多共用鎖定(讀操作),那麼該寫操作就必須處於等待狀態,即等待所有共用鎖定消失為止,與此同時,新的讀操作將不再被允許,以防止寫鎖饑餓的現象發生。在此等待期間,該資料庫檔案的鎖狀態為PENDING,在等到所有共用鎖定消失以後,PENDING鎖狀態的資料庫檔案將在擷取獨佔鎖定之後進入EXCLUSIVE狀態。
(5)EXCLUSIVE: 在執行寫操作之前,該進程必須先擷取該資料庫的獨佔鎖定。然而一旦擁有了獨佔鎖定,任何其它鎖類型都不能與之共存。因此,為了最大化並發效率,SQLite將會最小化獨佔鎖定佔有的時間總量。
2、復原日誌
當一個進程要修改資料庫檔案的時候(並且不在WAL模式下),它首先將未改變之前的內容記錄到復原記錄檔中。復原日誌還要記錄資料庫的初始大小,以便以後進行復原操作。如果SQLite中的某一事務正在試圖修改多個資料庫中的資料(使用了ATTACH命令),那麼此時每一個資料庫都將產生一個屬於自己的復原記錄檔,用於分別記錄屬於自己的資料改變,與此同時還要產生一個用於協調多個資料庫操作的主要資料庫記錄檔,在主要資料庫記錄檔中並不包含要復原的頁資料,它只是包含各個資料庫復原記錄檔的檔案名稱。在每個復原記錄檔中也同樣包含了主要資料庫記錄檔的檔案名稱資訊。然而對於無需主要資料庫記錄檔的復原記錄檔,其中也會保留主要資料庫記錄檔的資訊,只是此時該資訊的值為空白。
我們可以將復原日誌視為"HOT"記錄檔,因為它的存在就是為了恢複資料庫的一致性狀態。當某一進程正在更新資料庫時,應用程式或OS突然崩潰,這樣更新操作就不能順利完成,於是產生HOT日誌。因此我們可以說HOT日誌只有在異常條件下才會產生,如果一切都非常順利的話,該檔案將永遠不會存在。
在沒有主要資料庫日誌情況下,如果一個日誌有非零頭部,並且相關的資料庫檔案沒有RESERVED鎖,則它是HOT的。在有主要資料庫日誌情況下,如果一個日誌的主要資料庫日誌存在,且在相關的資料庫檔案上沒有RESERVED鎖,則它也是HOT的。理解一個日誌什麼時候是HOT的非常重要,可以把前面的這些規則寫成下面形式:
* 一個日誌是HOT的,如果
* 它存在,且
* 它的空間大小大於512位元組,且
* 日誌頭部非零,結構良好,且
* 它的主要資料庫日誌存在,或者主要資料庫檔案名稱為空白字串,且
* 在相關的資料庫檔案沒有RESERVED鎖。
在讀資料庫之前,SQLite總是先檢查它是否有一個HOT日誌。如果有,則在讀資料庫之前先執行復原,以保證資料庫狀態是一致的。當一個進程想要讀取資料庫時,先要完成以下步驟:
(1)開啟資料庫檔案並擷取一個共用鎖定。如果不能擷取共用鎖定,則立刻失敗並返回SQLITE_BUSY。
(2)檢查資料庫檔案是否有HOT日誌,如果沒有,則工作完成,立刻返回。如果有,則這個日誌必鬚根據下面的演算法步驟進行復原。
(3)對資料庫檔案擷取等待鎖,再擷取獨佔鎖定(注意不要擷取保留鎖,因為這會讓其他進程認為日誌不再是HOT的了)。如果擷取失敗,意味著另外一個進程正嘗試做復原操作。這時只能釋放所有的鎖,關閉資料庫,返回SQLITE_BUSY。
(4)讀取記錄檔並且復原之前的修改。
(5)等待復原寫入到持久存放裝置,以恢複資料庫的完整性。
(6)刪除記錄檔(或者如果設定了PRAGMA journal_mode=TRUNCATE指令,則把日誌縮短成0位元組;如果設定了PRAGMA journal_mode=PERSIST指令,則把日誌頭部清零)。
(7)刪除主要資料庫日誌,如果這樣做安全的話。該步是可選的,只是為避免到期的主要資料庫記錄檔塞滿磁碟。
(8)釋放獨佔鎖定和等待鎖,但仍保持共用鎖定。
在這些演算法步驟成功完成後,就可以安全讀取資料庫了。一旦所有的讀取完成,釋放共用鎖定。
到期的主要資料庫日誌不再有任何用途,刪除它只是為了釋放磁碟空間。一個主要資料庫日誌是到期的,如果沒有單獨的記錄檔指向它。為了斷定一個主要資料庫日誌是否到期,SQLite首先讀取主要資料庫記錄檔以擷取所有記錄檔名。然後檢查這些記錄檔,看其中是否有主要資料庫記錄檔名欄位指向該主要資料庫日誌的,如果有則主要資料庫檔案不是到期的,否則主要資料庫檔案到期。
3、資料寫入
如果某一進程要想在資料庫上執行寫操作,那麼必須像前面描述一樣先擷取共用鎖定(如果有HOT日誌,則要復原未完成的更改),在共用鎖定擷取之後再擷取保留鎖。因為保留鎖預示著在將來某一時刻該進程將會執行寫操作,所以在同一時刻只有一個進程可以持有一把保留鎖,但是其它進程可以繼續持有共用鎖定以完成資料讀取的操作。如果要執行寫操作的進程不能擷取保留鎖,那說明另一進程已經擷取了保留鎖。在此種情況下,寫操作將失敗,並立即返回SQLITE_BUSY錯誤。在成功擷取保留鎖之後,該寫進程將建立復原日誌。日誌的頭部初始化為資料庫檔案的原有大小。日誌頭部中也有主要資料庫記錄檔名的欄位,初始時為空白字串。
在對任何資料做修改之前,寫進程會將待修改頁中的原有內容先行寫入復原記錄檔中,然而將要發生變化的頁起初並不會直接寫入磁碟檔案,而是先保留在記憶體中。這樣資料庫仍然是未修改的,其它進程就可以繼續讀取該資料庫中的資料。
或者是因為記憶體中的cache已滿,或者是應用程式已經提交了事務,最終,寫進程將資料更新到資料庫檔案中。然而在此之前,寫進程必須確保沒有其它的進程正在讀取資料庫,同時復原日誌中的資料確實被物理地寫入到磁碟檔案中(以便系統崩潰或斷電時能用它來進行復原)。其步驟如下:
(1)確保所有的復原日誌資料被物理地寫入磁碟檔案,以便在出現系統崩潰時可以將資料庫恢複到一致的狀態。
(2)對資料庫檔案擷取等待鎖,再擷取獨佔鎖定,如果此時其它的進程仍然持有共用鎖定,寫入線程將不得不被掛起並等待直到那些共用鎖定消失之後,才能進而得到獨佔鎖定。
(3)將記憶體中持有的修改頁寫入到原有的磁碟檔案中。
如果寫入到資料庫檔案的原因是因為cache已滿,那麼寫入進程將不會立刻提交,而是繼續對其它頁進行修改。但是在後續的修改被寫入到資料庫檔案之前,復原日誌必須被再一次重新整理到磁碟中。還要注意的是,寫入進程擷取的獨佔鎖定必須被一直持有,直到所有的更改被提交為止。這意味著從資料第一次被重新整理到磁碟檔案開始,直到事務被提交之前,其它的進程不能訪問該資料庫。
當寫入進程準備提交更改時,將執行以下步驟:
(4)擷取獨佔鎖定,同時通過上面的步驟1-3確保所有記憶體中的變化資料都被寫入到磁碟檔案中。
(5)將資料庫檔案的所有修改物理地寫入到磁碟中。
(6)刪除記錄檔(或者如果PRAGMA journal_mode為TRUNCATE或PERSIST,截短記錄檔或者對頭部清零)。如果在刪除之前出現系統故障,進程在下一次開啟該資料庫時仍將基於該HOT日誌進行恢複操作。因此只有在成功刪除記錄檔之後,我們才可以認為該事務成功完成。
(7)從資料庫檔案中刪除所有的獨佔鎖定和PENDING鎖。
一旦PENDING鎖被釋放,其它的進程就可以開始再次讀取資料庫了。在當前的實現中,保留鎖也會被釋放,但這不是必須的。將來的SQLite版本可能提供一個SQL命令"CHECKPOINT",用於提交當前事務所做的所有更改,但持有保留鎖,以便可以做更多的更改,而不給任何其他進程寫資料的機會。
如果一個事務中包含多個資料庫的修改,那麼它的提交邏輯將更為複雜,見如下步驟:
(4)確保每個資料庫檔案都已經持有了獨佔鎖定和一個有效記錄檔。
(5)建立主要資料庫記錄檔,其檔案名稱是隨機的。同時將每個資料庫的復原記錄檔的檔案名稱寫入該主要資料庫記錄檔,並重新整理到磁碟上。
(6)再將主要資料庫記錄檔的檔案名稱分別寫入到每個資料庫復原記錄檔的指定位置,並重新整理到磁碟。
(7)將所有的資料庫變化持久化到資料庫磁碟檔案中。
(8)刪除主記錄檔,如果在刪除之前出現系統故障,進程在下一次開啟該資料庫時仍將基於該HOT日誌進行恢複操作。因此只有在成功刪除主記錄檔之後,我們才可以認為該事務成功完成。
(9)刪除每個資料庫各自的記錄檔。
(10)從所有資料庫中刪除掉獨佔鎖定和PENDING鎖。
最後需要說明的是,在SQLite2中,如果多個進程正在從資料庫中讀取資料,也就是說該資料庫始終都有讀操作發生,即在每一時刻該資料庫都持有至少一把共用鎖定,這樣將會導致沒有任何進程可以執行寫操作,因為在資料庫持有讀鎖的時候是無法擷取寫鎖的,我們將這種情形稱為“寫饑餓”。在SQLite3中,通過使用PENDING鎖則有效避免了“寫饑餓”情形的發生。當某一進程持有PENDING鎖時,已經存在的讀操作可以繼續進行,直到其正常結束,但是新的讀操作將不會再被SQLite接受,所以在已有的讀操作全部結束後,持有PENDING鎖的進程就可以被啟用並試圖進一步擷取獨佔鎖定以完成資料的修改操作。
4、資料庫檔案是怎麼損壞的
pager模組是非常健壯的,但有時候也會被破壞。如果一個流氓進程開啟資料庫檔案或日誌,寫入無用的資料,則資料庫將損壞。對這種情況,無需更多討論。
在Unix上,SQLite使用POSIX建議的鎖來實現加鎖功能。在Windows上則使用LockFile(), LockFileEx()和UnlockFile()系統調用。SQLite假設這些系統調用能正確工作,否則資料庫也有可能損壞。有一點要注意,POSIX建議的鎖比較簡單,但甚至在許多NFS上都沒有實現(包括當前的Max OS X版本),有很多報告稱Windows下的網路檔案系統也有鎖的問題,因此你最好避免在網路檔案系統上使用SQLite。
Unix下SQLite使用fsync()系統調用來把資料重新整理到磁碟,Windows下則使用FlushFileBuffers()。重申一下,SQLite假設這些作業系統服務函數是正確工作的。但有報告稱fsync()和FlushFileBuffers()並不總是能正確地工作,特別是在廉價的IDE硬碟上。有一些IDE硬碟廠商的控制器晶片報告資料已經寫入到磁碟表面,但實際上資料還在硬碟驅動電路的易失性Cache中。也有報告稱Windows有時由於一些不確定的原因會忽略FlushFileBuffers()。如果這些報告屬實,那意味著因為斷電而導致資料庫損壞是有可能的。SQLite並不能防止硬體和OS的漏洞。
如果Linux ext3檔案系統在/etc/fstab中沒有"barrier=1"選項的情況下被掛載,且磁碟驅動的寫緩衝是啟用的,則當掉電或OS崩潰時檔案系統損壞就有可能發生,特別對於廉價消費級的硬碟。而帶有非易失性寫緩衝的企業級存放裝置發生檔案系統損失的可能性則小得多。據說有許多Linux發行版不使用barrier=1選項,並且不禁用寫緩衝,因此許多Linux發行版對這個問題是比較脆弱的。注意這是作業系統和硬體問題,SQLite無能為力,其他的資料庫引擎也有這個問題。
如果發生崩潰或斷電,則產生HOT日誌,但是這個HOT日誌被刪掉了。下一進程開啟資料庫時將不知道資料庫需要復原,資料庫處於不一致的狀態。有很多原因會導致復原日誌被刪除:
(1)系統管理員可能會在OS崩潰或系統掉電後做清理工作,看到記錄檔認為它是垃圾,刪除掉。
(2)有人(或者某個進程)可能會重新命名資料庫檔案,但卻沒有得命名相關的日誌。
(3)如果資料庫檔案有別名(永久連結或軟連結),且通過連結別名來開啟資料庫檔案,則產生的記錄檔將以連結名來命名,若下次開啟資料庫時使用另一個連結名,將找不到日誌。為了避免這個問題,你不應該對SQLite資料庫檔案建立連結。
(4)斷電導致的檔案系統損壞可能導致日誌被重新命名或被刪除。
當SQLite在Unix上建立一個記錄檔時,會開啟這個記錄檔所在的目錄,並且調用fsync(),試圖把目錄資訊寫入磁碟。但假設另外一個進程正在向該目錄添加或從該目錄中刪除不相關的檔案,這時突發斷電,就有可能導致記錄檔從該目錄中被刪除並移到"lost+found"。這是一個罕見的情境,但有可能發生。避免這種情況的最好方式是使用記錄檔系統。
對涉及多個資料庫和一個主要資料庫日誌的事務提交,如果這些資料庫位於不同的磁碟卷上,在事務提交時發生斷電,機器重新起來後磁碟可能用不同的名稱來掛載,或者一些磁碟根本就不掛載。這樣的情況下,各個記錄檔和主要資料庫記錄檔可能互相不能找到對方,最壞的結果是提交變得不再是原子性的了。一些資料庫可能復原,另一些則沒有復原。為了避免這樣的問題,我們應該把所有資料庫存放在一個磁碟卷上,並且斷電後使用同樣的名字來掛載硬碟。
5、SQL層級的事務控制
SQLite 3在實現上針對鎖和並發控製做了一些精細的變化,特別是對於事務這一SQL語言層級的特徵。在預設情況下,SQLite 3會將所有的SQL操作置於antocommit模式下,這樣所有針對資料庫的修改操作都會在SQL命令執行結束後被自動認可。在SQLite中,SQL命令"BEGIN TRANSACTION"(其中TRANSACTION關鍵字可選)用於顯式的聲明一個事務,禁用autocommit模式,即其後的SQL語句在執行後都不會自動認可,而是需要等到SQL命令"COMMIT"或"ROLLBACK"被執行時,才考慮提交還是復原。注意BEGIN命令並不獲得任何類型的鎖,在BEGIN之後,當執行第一個SELECT語句時才得到一個共用鎖定,當執行第一個DML語句(INSERT, UPDATE或DELETE)時才獲得一個保留鎖。至於排它鎖,只有在資料從記憶體寫入磁碟時開始,直到事務提交或復原之前才能持有排它鎖。
SQL命令COMMIT命令並不實際提交更改到磁碟,它只是重新開啟autocommit模式。然後,在命令結束時,正式的自動認可邏輯才實際提交更改到磁碟。SQL命令ROLLBACK也是開啟autocommit模式,但是它設定一標誌,以告訴自動認可邏輯執行復原,而不是提交。如果自動認可邏輯提交更改失敗,因為另外有進程持有共用鎖定,則autocommit模式會自動關閉。這允許使用者在共用鎖定釋放之後重新COMMIT。
如果多個SQL命令在同一個時刻同一個資料庫連接中被執行,autocommit將會被順延強制,直到最後一個命令完成。比如,如果一個SELECT語句正在被執行,在這個命令執行期間,需要返回所有檢索出來的行記錄,如果此時處理結果集的線程因為商務邏輯的需要被暫時掛起並處於等待狀態,而其它的線程此時或許正在該串連上對該資料庫執行INSERT、UPDATE或DELETE命令,那麼所有這些命令作出的資料修改都必須等到SELECT檢索結束後才能被提交。
SQLite剖析之鎖和並發控制