SQLite剖析之鎖和並發控制


    在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檢索結束后才能被提交。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2026 CODEPRJ.COM