SQLite剖析(8):原子提交原理

1. 引言

像SQLITE這樣支持事務的數據庫的一個重要特性是「原子提交」。原子提交意味着,一個事務中的所有修改動作要麼全都發生,要麼一個都不發生。有了原子提交,對一個數據庫文件不同部分的多次寫操作,就會像瞬間同時完成了一樣。當然,現實中的存儲器硬件會把寫操作串行化,並且寫每個扇區都會花上那麼一小段時間,所以,絕對意義上的「瞬間同時完成」是不可能的。但SQLITE的原子提交邏輯還是讓整個過程看起來像那麼回事。

SQLITE保證,即使事務執行過程中發生了操作系統崩潰或掉電,整個事務也是原子的。本文描述了SQLITE實現原子提交時所採用的技術。

2. 對硬件的假設

雖然有的時候會使用閃存,但下文中,我們將把存儲設備稱爲「磁盤」。

我們假設對磁盤的寫操作是以「扇區」爲單位的,也就是說不可能直接對磁盤進行小於一個扇區的修改,要想進行這類修改,你必須把整個扇區讀進內存,進行所需的修改,然後再把整個扇區寫回去。

對真正「磁盤」來說,讀寫操作的最小單位都是一個扇區;但閃存有些不同,它們的最小讀單位一般遠小於最小寫單位。SQLITE只關心最小寫單位,所以,在本文中,我們說「扇區」的時候,指的是向存儲器中寫數據時的最小數據量。

3.3.14版之前,SQLITE在任何情況下都認爲一個扇區的大小是512字節,有一個編譯期選項能改變這個值,但從未有人用更大一些的值測試過相關代碼。直到不久以前,把這個值定爲512都是合理的,因爲所有的磁盤驅動器都在內部使用512字節的扇區。但最近,有人把磁盤扇區的大小提升到了4096字節,而且,閃存的扇區一般也是大於512字節的。由於這些原因,從3.3.14版開始,SQLITE的操作系統接口層提供了一種可以從文件系統獲取真實扇區大小的方法。不過,到目前爲止(3.5.0版),這一方法仍然只是返回一個硬編碼的512字節,因爲不論是win32系統還是unix系統,都沒有一個標準的機制來獲得實際的值。但這種方法給了嵌入式設備的提供商們根據實際情況進行調整的能力,也讓我們未來在win32和unix上給出一個更有意義的實現成了可能。

SQLITE並不假設對扇區的寫操作是原子的,它僅假設這種寫是「線性」的。所謂線性是指:寫一個扇區時,硬件總是從扇區一端開始,一個字節一個字節的寫到另一端結束,中間不會後退,硬件可以從頭向尾寫,也可以從尾向頭寫。如果掉電發生時只寫到了扇區的中間,則可能出現扇區一部分修改了而另一部分沒被修改的情況。SQLITE在這裏做的一個關鍵假設是:只要扇區被修改了,那麼它的第一個字節和最後一個字節中的至少一個會被修改,也就是說,硬件絕不會從中間開始向兩端寫。我們不清楚這個假設是否總是對的,但它看起來是合理的。

在上一段中,我們說「SQLITE沒有假設寫扇區是原子的」。默認情況下,這是正確的,但在3.5.0版中,我們增加了一個叫做「虛擬文件系統(VFS)」的接口,它是SQLITE和底層文件系統通訊的唯一路徑。代碼中包含了用於unix和windows的默認VFS實現,同時提供了一種在運行時創建新VFS實現的機制。在這個新的VFS接口中有一個稱爲「xDeviceCharacteristics」的方法,它通過詢問文件系統來判斷文件系統是否支持某些特性。如果文件系統支持某個特性,SQLITE就會試着利用這個特性進行某種優化。默認的xDeviceCharacteristics不會指出文件系統支持原子的寫扇區操作,所以與此相關的優化都是關閉的。

SQLITE假設操作系統會緩衝寫操作,並且寫操作會在數據被真正寫到磁盤上之前返回。SQLITE還假設寫操作會被操作系統記錄下來。因此,SQLITE會在關鍵點上執行「flush」或「fsync」,並假設「flush」和「fsync」會等所有正在進行的「寫操作」真正執行完畢後才返回。在某些版本的windows和unix上,「flush」和「fsync」原語會被打斷,這非常不幸,在這些系統上,如果提交的過程中發生了掉電,SQLITE的數據庫有可能崩潰掉,而SQLITE自己則對此無能爲力。SQLITE假設操作系統能像廣告宣傳的那樣完美,如果事實並非如此,你只好祈求老天保佑不要經常掉電了。

SQLITE假設文件增長時,新增加的部分最初包含的是垃圾數據,然後它們會被實際的數據覆蓋掉。換句話說,SQLITE假設文件大小的變化發生在文件內容變化之前。這是個悲觀的假設,爲了保證在從「文件大小改變」開始到「文件內容寫完」爲止的這段時間內,系統掉電不會導致數據庫崩潰,SQLITE要做一些額外的工作。VFS的xDeviceCharacteristics也可能會指出文件系統總是先寫數據後更新文件的大小,這種情況下,SQLITE可以跳過一些過於小心的數據庫保護操作,從而減少一次提交所需的磁盤I/O數量。但目前windows和unix上的VFS實現都沒有做這個假設。

SQLITE假設文件刪除是原子的,至少從用戶程序的角度來看要是這樣。也就是說,如果SQLITE要刪除一個文件,並且刪除的過程中掉電了,那麼電力恢復後,文件要麼不能從文件系統中找到,要麼它的內容和刪除之前一模一樣。如果文件還能從文件系統中找到,但內容被修改或清空了,那麼數據庫極有可能會崩潰。

SQLITE假設檢測由宇宙射線、熱噪聲、驅動程序bug等引起的位錯誤(bit error)是操作系統和硬件的責任。SQLITE沒有在數據庫文件中增加任何冗餘信息來檢測或糾正這類問題。SQLITE假設它所讀的數據與它上次所寫的數據總是完全相同。

3. 單文件提交

我們先來從整體上看看SQLITE在一個單獨的數據庫文件上操作時,要保證事務提交的原子性需要哪些步驟。爲防止掉電時文件被破壞,文件格式在設計時也有相應考慮,相關細節和多數據庫提交技術將在後續章節討論。

3.1. 初始狀態

下圖給出了數據庫連接剛剛打開時計算機的狀態。圖的最右側是存儲在磁盤上的數據,每個小格代表一個扇區,藍色表示扇區存儲的是原始數據;圖的中間部分是操作系統的緩存,在當前的例子中,緩存是「冷」的,所以它的每個格都沒有着色;最左側是使用SQLITE的進程(譯註:本文的作者可能更喜歡unix,所以在windows上,原文中的部分「進程」用「線程」替換一下會更好,我沒有做這種替換,故需要您在閱讀過程中結合上下文判斷「進程」的具體含義)的內存,數據庫連接剛剛創建,還沒有讀任何數據,所以用戶的內存空間中什麼也沒有。

3.2. 獲取一個「讀鎖」

SQLITE寫數據庫之前,必須先讀,這樣它才能知道數據庫中已經有些什麼了。即使是單純的追加數據,SQLITE也要先從sqlite_master表中讀出數據庫的表結構,從而知道如何去解析INSERT語句,以及新數據應該保存到文件的哪個位置。

讀操作的第一步是獲取一個數據庫文件的「共享鎖」。這個共享鎖允許兩個或多個數據庫連接同時讀數據庫文件,但不許其他數據庫連接寫這個文件。這個鎖非常重要,因爲,如果在讀數據的過程中另一個連接寫了數據,我們就可能讀到一個新數據和舊數據的混合體,這會讓其他連接的寫操作失去原子性。

請注意,共享鎖是操作系統的磁盤緩存實現的,而不是磁盤本身。一般來說,文件鎖僅僅是操作系統內核中的一些標誌(細節取決於具體操作系統的接口層)。所以,當系統崩潰或掉電後,這個鎖就自動消失了。並且,通常情況下,創建這個鎖的進程退出後,鎖也會自動消失。

3.3. 從數據庫中讀數據

獲得共享鎖後,我們開始從數據庫文件中讀出數據。在這個例子中,由於我們假設最初的緩存是「冷」的,所以要先把數據從磁盤讀到操作系統的緩存,再把它們從緩存複製到用戶空間。後續的讀操作,由於部分或全部數據可能已經在緩存中了,或許就只需要從緩存複製到用戶空間這一步了。

一般情況下,我們不會需要數據庫文件的所有頁(譯註:頁是SQLITE對數據進行緩衝的最小單位,但本文中有時它和扇區是一個意思,請注意結合上下文區分),所以我們讀的只是它的一個子集。本例中,我們的數據庫文件有8個頁,而我們需要的是其中的3個。一個真實的數據庫可能有數千個頁,但每次查詢要訪問的一般只是其中很小的一部分。

3.4. 獲取一個預定(Reserved)鎖

在對數據庫做任何修改之前,SQLITE需要獲得一個預定鎖。預定鎖和共享鎖很像,它們都允許其他進程讀數據庫文件。並且,預定鎖也可以和多個共享鎖共存。但是,一個數據庫文件某一時刻只能有一個預定鎖,也就是隻允許一個進程有寫數據的意圖。

預定鎖的目的是告訴整個系統:有一個進程要在不久的將來修改數據庫文件了,但它目前還沒有任何實際行動。由於僅僅是個「意圖」,其他進程還可以繼續自己的讀操作,但是它們不能也有這個意圖了。

3.5. 創建回滾日誌(Journal)文件

在任何實質性的修改之前,SQLITE還需要創建一個獨立的回滾日誌文件,並把所有要被替換的數據庫頁的原始內容寫到這個文件中去。實際上,日誌文件將保存將數據庫文件恢復到原始狀態所需的全部信息。

日誌文件有一個不大的文件頭(圖中用綠色表示),它記錄了數據庫文件的原始大小。如果數據庫文件因爲修改變大了,我們仍然可以憑它來獲得文件的原始大小。數據庫頁和它們的對應的頁號會被放在一起寫到日誌文件中去。

創建新文件時,大多數操作系統(windows、linux、macOSX等)並不會立即向磁盤寫數據。新文件一開始只存在於操作系統的緩存中,直到操作系統有空閒的時候,它纔會真的去在磁盤上創建這個文件。這種方式讓用戶覺得文件創建非常快,起碼比真的去做磁盤I/O快多了。在下圖中,爲了表示這一情形,我們只在操作系統緩存中畫了這個日誌文件。

3.6. 在用戶空間中修改數據庫

數據庫頁的原始內容保存到日誌文件後,就可以在用戶空間中修改了。每個數據庫連接有一份私有的用戶空間拷貝,所以這些修改只會被當前的連接看到,其他連接看到的仍然是操作系統緩存中未被修改的內容。在這種情況下,雖然有一個進程正在對數據庫進行修改,其他進程仍然可以繼續讀數據庫的原始內容。

3.7. 把日誌文件「刷」到磁盤

下一步是把回滾日誌文件的內容刷到具有持久性的存儲器上。後面你會看到,這是讓數據庫能夠在掉電情況下存活的關鍵之一。它可能要花不少時間,因爲往持久性存儲器上寫東西一般是很慢的。

這一步通常比僅僅把回滾日誌刷到磁盤上覆雜的多。在大多數平臺上,你要刷(flush或fsync)兩次才行。第一次是日誌文件的基本內容。然後修改日誌文件的頭部,以反應日誌文件中實際的頁面數。接着刷第二次,把文件頭刷上去。至於爲什麼要修改文件頭並多刷一次,我們將在後續章節討論。

3.8. 獲取一個獨佔鎖

爲了對數據庫文件進行真正的修改,我們需要一個獨佔鎖。獲取這個鎖需要兩步,首先是獲取一個待決(Pending)鎖,然後再把它提升爲獨佔鎖。

待決鎖允許其他已經有了共享鎖的進程繼續讀數據庫文件,但它不允許創建新的共享鎖。設計它的目的是爲了避免一大堆讀進程把寫進程給餓到。系統中可能會有幾十甚至上百個進程想讀數據庫文件,每個這樣的進程都要經歷一個「獲得共享鎖、讀數據、釋放鎖」的過程。如果很多進程都想讀同一個數據庫文件,那麼一個極有可能現象是:新進程總是在已有的進程釋放共享鎖之前獲得一個新的共享鎖。這樣一來,數據庫文件就上就總有共享鎖了,要寫數據的進程可能會一直沒有機會得到自己的獨佔鎖。通過禁止創建新的共享鎖,待決鎖解決了這個問題,已有的共享鎖會逐漸被釋放,最終,當它們全部被釋放後,待決鎖就可以升級到獨佔鎖了。

3.9. 更新數據庫文件

一旦獲得獨佔鎖,就可以保證沒有其他進程在讀這個數據庫文件了,這時更新它就是安全的了。一般來說,這裏的更新只會影響到操作系統磁盤緩存這一層,而不會影響磁盤上的物理文件。

3.10. 把變化刷到存儲器

爲了把數據庫的變化寫到持久性存儲器,我們還要再刷一次。這也是保證數據庫在掉電情況下不崩潰的關鍵。當然,向磁盤或閃存寫數據實在是太慢了,這一步和3.7節中的刷日誌文件加在一起會消耗掉SQLITE一次事務提交的絕大部分時間。

3.11. 刪除日誌文件

把所有變化都安全的寫到存儲器上以後,回滾日誌文件就可以刪除了。這是提交事務的那個時間點。如果掉電或系統崩潰發生在這之前,後面將要介紹的恢復過程會讓數據庫文件回到修改之前的狀態,就好像什麼都沒發生過一樣。如果掉電或系統崩潰發生在日誌文件被刪除之後,那麼所有的修改都會生效。所以,SQLITE對數據庫的修改全部有效還是全部無效,實際上是取決於這個日誌文件是否存在。

刪除文件不一定真的是原子操作,但從用戶程序的角度來看,它卻好像總是原子的。進程總可以詢問操作系統「這個文件存在嗎?」並等到是或否的回答。如果事務提交過程中發生了掉電,SQLITE就會問操作系統是否存在回滾日誌文件,存在則事務是不完整的,需要回滾,不存在則說明事務確實成功提交了。

SQLITE事務的實現依賴於回滾日誌文件是否存在和用戶程序眼中的原子的文件刪除。所以,事務也是一個原子操作。

3.12. 釋放鎖

最後一步是釋放獨佔鎖,這樣其他進程就又能訪問數據庫文件了。

在下圖中,我們看到,用戶空間中的數據在鎖被釋放後就清除了。如果是較早版本的SQLITE,這是實際情況。但從最近幾版開始,SQLITE不這麼做了,因爲下個操作可能還會用到它們。比起從操作系統的緩存或磁盤中讀數據來,重用這些已經在本地內存中的數據的性能要高得多。再次使用它們之前,我們要先得到一個共享鎖,然後再檢查一下在我們沒有鎖的這段時間內是否有別的進程修改了數據庫文件。數據庫的第一頁有一個計數器,每次對數據庫進行修改時都會遞增它。檢查這個計數器,就能知道數據庫是否被別的進程修改過了。如果修改過,就必須清除用戶空間中的數據並把新數據讀進來。但更大的可能是沒有任何修改,這樣就可以重用原有的數據,從而大幅提高效率。

4. 回滾

原子提交看起來是瞬間完成的,但很明顯,前面介紹的過程需要一定的時間才能完成。如果在提交過程中電源被切斷,爲了讓整個過程看起來是瞬時的,我們必須回滾那些不完整的修改,並把數據庫恢復到事務開始之前的狀態。

4.1. 如果出了問題…

假設掉電發生在3.10節所講的那一步,也就是把數據庫變化刷到磁盤中去的時侯。電力恢復後,情況可能會像下圖所示的那樣。我們要修改三頁數據,但只成功完成了一頁,有一頁只寫了一部分,另一頁則一點都沒寫。

電力恢復後日志文件是完整的,這是個關鍵。3.7節中的操作就是爲了保證在對數據文件做任何改變之前回滾日誌的所有內容已經安全的寫到持久性存儲器中去了。

4.2. 「熱的」回滾日誌

任何進程第一次訪問數據庫文件之前,必須獲得一個3.2節中描述的共享鎖。然後,如果發現還有一個日誌文件,SQLITE就會檢查這個回滾日誌是不是「熱的」。我們必須回放熱日誌文件,從而把數據庫恢復到一致的狀態。只有在一個程序正在提交事務時發生掉電或崩潰的情況下,纔會出現熱日誌文件。

日誌文件在符合以下所有條件時纔是熱的:
● 日誌文件是存在的
● 日誌文件不是空文件
● 數據庫文件上沒有預定鎖
● 日誌文件頭中沒有主日誌文件的文件名,或者,如果有主日誌文件名的話,主日誌文件是存在的。

熱日誌文件告訴我們:之前有進程試圖提交一個事務,但由於某種原因,這個提交沒有完成。也就是說:數據庫處於一種不一致的狀態,使用之前必須修復(回滾)。

4.3. 獲取數據庫上的獨佔鎖

處理熱日誌的第一步是獲得數據庫文件上的獨佔鎖,這可以防止兩個或更多的進程同時回放一個熱日誌。

4.4. 回滾不完整的修改

獲得了獨佔鎖,進程就有權力修改數據庫文件了。它從日誌中讀出頁面的原有內容,然後把它們分別寫回到其在數據庫文件中的原始位置上去。前面說過,日誌文件的頭部記錄了數據庫文件在事務開始前的大小,如果修改讓數據庫文件變大了,SQLITE會使用這一信息把文件截斷到原始大小。這一步結束之後,數據庫文件就應該和事務開始前一樣大,並且包含和那時完全一樣的數據了。

4.5. 刪除熱日誌文件

日誌中的所有信息都回放到數據庫文件,並將數據庫文件刷到磁盤(回滾時可能會再次掉電)以後,就可以刪除熱日誌文件了。

4.6. 繼續前進,就像那個中斷了的事務根本沒發生過一樣

回滾的最後一步是把獨佔鎖降級爲共享鎖。此後,數據庫的狀態看起來就像那個中斷了的事務根本沒有開始過一樣了。由於整個回滾過程是完全自動、透明的,使用SQLITE的那個程序根本就不會知道有一個事務中斷並回滾了。

5. 多文件提交

通過ATTACHDATABASE命令,SQLITE允許一個數據庫連接使用多個數據庫文件。當在一個事務中修改多個文件時,所有文件都會被原子的更新。換句話說,或者所有文件都會被更新,或者一個也不會被更新。在多個文件上實現原子提交比在單個文件上實現更復雜,本章將解釋SQLITE是如何做到這一點的。

5.1. 每個數據庫一個日誌

當一個事務涉及了多個數據庫文件時,每個數據庫都有自己回滾日誌,並且對它們的鎖也是各自獨立的。下圖展示了三個數據庫文件在一個事務中被修改的情況,它所描述的狀態相當於單文件事務在第3.6節中的狀態。每個數據庫文件有各自的預定鎖,它們將要被修改的那些頁的原始內容已經寫進回滾日誌了,但還沒有刷到磁盤上。用戶內存中的數據已經被修改了,不過數據庫文件本身還沒有任何變化。
相比之前,下圖做了一些簡化。在這張圖上,藍色仍然代表原始數據,粉紅色仍然代表新數據。但上面沒有畫出回滾日誌和數據庫的頁,並且也沒有明確區分操作系統緩存中的數據和磁盤上的數據。所有這些在這張圖上仍然適用,不過即使把它們畫出來我們也學不到什麼新的東西,所以,爲了縮小圖幅,我們把它們省略掉了。

5.2. 主日誌文件

多文件提交中的下一步是創建一個「主日誌文件」。這個文件的名字是最初的數據庫文件名(也就是用sqlite3_open()打開的那個數據庫,而不是之後附加上來的那些)加上後綴「-mjHHHHHHHH」。其中HHHHHHHH是一個32位16進制隨機數,每次生成新的主日誌文件時,它都會不同。

(注意:上面一段中用來生成主日誌文件名的方法是3.5.0版中使用的方法。這個方法並沒有規範化,也不是SQLITE對外接口的一部分,在未來版本中,我們可能會修改它。)

主日誌中沒有與原始數據庫頁面內容相關的信息,它裏面保存的是所有參與到這個事務中的回滾日誌文件的完整路徑。

主日誌生成完畢後,會被立即刷到磁盤上,中間沒有任何別的操作。在unix系統上,主日誌所在的目錄,也會被同步一下,以確保掉電後它也會出現在這個目錄下。

5.3. 更新回滾日誌文件頭

下一步是把主日誌的路徑記錄到回滾日誌的文件頭中去,回滾日誌創建時在文件頭預留了相應的空間。

主日誌路徑寫到回滾日誌文件頭之前和之後,要分別把回滾日誌的內容往磁盤上刷一次。這可能有些效率損失,但非常重要,而且,幸運的是,刷第二次時一般只有一頁(最開始的那頁)數據有變化,所以整個操作可能並沒有想象的那麼慢。

這個操作大致相當於單文件提交時的第7步,也就是第3.7節中的內容。

5.4. 更新數據庫文件

把回滾日誌刷到磁盤上後,就可以安全的更新數據庫文件了。我們需要獲得所有數據庫文件上的獨佔鎖,然後寫數據,並把這些數據刷到磁盤上去。這一步相當於單文件提交時的第8、9和10步。

5.5. 刪除主日誌文件

下一步是刪除主日誌文件,這是多文件事務被實際提交的時間點。它相當於單文件提交時的第11步,也就是刪除日誌文件的那一步。
如果掉電或系統崩潰發生在這之後,重啓時,即使存在回滾日誌文件,事務也不會被回滾。這裏的區別在於回滾日誌的文件頭裏面有主日誌的路徑。SQLITE只認爲文件頭中沒有主日誌文件路徑的回滾日誌(單文件提交的情況)或主日誌文件仍然存在的回滾日誌是「熱的」,並且只會回放熱的回滾日誌。

5.6. 清理回滾日誌文件

最後是刪除所有的回滾日誌文件,釋放獨佔鎖以便其他進程發現數據的變化。這一步對應的是單文件提交時的第12步。

由於事務已經提交了,所以刪除這些文件在時間上並不是非常緊迫。當前的實現是刪除一個日誌文件,並釋放其對應的數據庫文件上的獨佔鎖,然後再接着處理下一個。今後,我們可能把它改成先刪除所有日誌文件,再釋放獨佔鎖。這裏,只要保證刪除日誌文件在前,釋放其對應的鎖在後就行,文件被刪除的順序或鎖被釋放的順序並不重要。

6. 提交中的更多細節

第3章從總體上介紹了SQLITE原子提交的實現方法,但漏掉了幾個重要的細節,本章將對它們進行一些補充說明。

6.1. 總是日誌中記錄整個扇區

在把數據庫頁面的原始內容寫進回滾日誌時,即使頁面比扇區小,SQLITE也會把完整的扇區寫進去。從前,SQLITE中的扇區大小是硬編碼的512字節,而最小頁面也是512字節,所以不會有什麼問題。但從3.3.14版開始,SQLITE也支持扇區大小超過512字節的存儲器了,所以,從這一版起,當某個扇區中的任何頁面被寫進日誌時,這個扇區中的其它頁面也會被一同寫進去。

掉電可能在寫扇區時發生,總是記錄整個扇區可以在這種情況下保證數據庫不被破壞。例如,我們假設每個扇區有四個頁面,現在2號頁面被修改了,爲了把變化寫入這個頁面,底層硬件,因爲它只能寫完整的扇區,也會把1、3、4號頁面重新寫一遍,如果寫操作被打斷,這三個頁面的數據可能就不對了。爲了避免這種情況,必須把扇區中的所有頁面寫到回滾日誌中去。

6.2. 日誌文件中的垃圾數據

向日志文件末尾追加數據時,SQLITE一般悲觀的假設文件系統會先用垃圾數據把文件撐大,再用正確的數據覆蓋這些垃圾。換句話說,SQLITE假設文件體積先變大,之後纔是寫入實際內容。如果掉電發生在文件已經變大但數據還未寫入時,回滾日誌中就會包含垃圾數據。電力恢復後,另一個SQLITE進程會發現這個日誌文件,並試圖恢復它,這就有可能把垃圾數據拷貝到數據庫文件,進而對其造成破壞。

爲對付這個問題,SQLITE建立了兩道防線。首先,SQLITE在回滾日誌的文件頭中記錄了實際的頁面數。這個數字一開始是0,所以,在回放一個不完整的回滾日誌時,SQLITE會發現文件中沒有包含任何頁面,也就不會對數據庫做任何修改。提交之前,回滾日誌會被刷到磁盤上,以保證其中沒有任何垃圾。之後,文件頭中的頁面數纔會被改成實際的數值。文件頭總是保存在一個單獨的扇區去,所以,如果在覆蓋它或把它刷到磁盤上時發生掉電,其它頁面是不會被破壞的。注意回滾日誌要往磁盤上刷兩次:第一次是寫頁面的原始內容,第二次是寫文件頭中的頁面數。

上一段描述的是同步選項設置爲「full」(PRAGMAsynchronous=FULL)時的情形,這也是默認的設置。不過,當同步選項低於「normal」時,SQLITE只會刷一次日誌文件,也就是修改完頁面數後的那一次。由於(大於0的)頁面數可能先於其它數據到達磁盤,這樣做有一定的風險。SQLITE假設文件系統會記錄寫請求,所以即使先寫數據後寫頁面數,頁面數也可能會先被磁盤記錄下來。所以,作爲第二道防線,SQLITE在日誌文件中爲每頁數據都記錄了一個32位的校驗碼。回滾日誌文件時,SQLITE會檢查這個校驗碼,一旦發現錯誤,就會放棄回滾操作。要注意的是,校驗碼無法完全保證頁面數據的正確性,數據有錯誤但校驗碼正確的概率雖然極小,卻不是零.。不過,校驗碼機制至少讓類似的事情看起來不那麼容易發生了。

在同步選項設置爲「full」時,就沒有必要用校驗碼了,我們只在同步選項低於「normal」時才需要它。然而,鑑於校驗碼是無害的,故不管同步選項如何設置,它們總是出現在回滾日誌中的。

6.3. 提交之前的緩存溢出

第三章描述的過程假設提交之前所有的數據庫變化都能保存在內存中。一般來說就是這樣的,但特殊情況也會出現。這時,數據庫變化會在事務提交之前用完用戶緩存,需要把緩存中的內容提前寫入數據庫才行。

操作之前,數據庫連接處於第3.6步時的狀態:原始頁面的內容已經保存到回滾日誌了,修改後的頁面位於用戶內存中。爲了回收緩存,SQLITE執行第3.7到3.9步,也就是把回滾日誌刷到磁盤上,獲取獨佔鎖,然後把變化寫入數據庫。但後續步驟在事務真正提交之前都有所不同。SQLITE會在日誌文件的最後追加一個文件頭(使用一個單獨的扇區),獨佔鎖繼續保留,而執行流程將跳到第3.6步。當事務提交或再次回收緩存時,將重複執行第3.7和3.9步(由於第一次回收緩存時獲得了獨佔鎖且一直沒有釋放,3.8步將被跳過)。

把預定鎖提升爲獨佔鎖將降低併發度,額外的刷磁盤操作也非常慢,所以回收緩存會嚴重影響系統效率。因此,只要有可能,SQLITE就不會使用它。

7. 優化

對程序的性能分析顯示,在絕大多數系統和絕大多數情況下,SQLITE把絕大部分時間消耗在了磁盤I/O上。所以,減少磁盤I/O的數量是最有可能大幅提升效率的方法。本章將介紹SQLITE在保證原子提交的前提下,爲減少磁盤I/O而使用的一些技術。

7.1. 在事務之間保持緩存數據

在3.12節中,我們說過當釋放共享鎖時會丟棄所有已經在用戶緩存中的數據庫信息。之所以這樣做,是因爲沒有共享鎖的時候其他進程能夠隨意修改數據庫文件的內容,從而導致已經緩存的數據過時。所以,每當一個新事務開始時,SQLITE都必須重新讀一次以前讀過的東西。這個操作並不像大家想象的那麼糟糕,因爲要重新讀的數據極有可能仍在操作系統的緩存中,所謂的「重讀」一般僅僅是把數據從內核空間拷貝到用戶空間而已。不過,即使如此,也是需要一些時間的。

從3.3.14版開始,我們在SQLITE中增加了一個機制來避免不必要的重讀。這些版本中,釋放共享鎖後,用戶緩存的頁面繼續保留。等到SQLITE啓動下一個事務並獲得共享鎖後,它會檢查是否有其他進程修改了數據庫文件。如果自上次釋放鎖後有修改,用戶緩存會被清空並重讀。但一般不會有任何修改,所以用戶緩存仍然有效,這樣很多不必要的讀操作就被避免了。

爲了判斷數據庫文件是否被修改,SQLITE在文件頭(第24到27字節)中使用了一個計數器,每個修改操作都會遞增它。釋放數據庫鎖之前,SQLITE會記下這個計數器的值,等到再次獲得鎖以後,它比較記錄的值和實際的值,相同則重用已有的緩存數據,不同則清空緩存並重讀。

7.2. 獨佔訪問模式

自3.3.14版開始,SQLITE中增加了「獨佔訪問模式」。在這種模式下,SQLITE會在事務提交後繼續保留獨佔鎖。這樣一來,其他進程就不能訪問數據庫了。不過,由於大多數的部署方案都只有一個進程訪問數據庫,所以一般不會有什麼問題。獨佔訪問模式讓以下三個減少磁盤I/O的方法成爲了可能:
1) 除了第一個事務,不必每次遞增數據庫文件頭中的計數器。這通常意味着在數據庫文件和回滾日誌中各自少刷一次1號頁面。
2) 因爲沒有別的進程能訪問數據庫,所以沒必要每次啓動事務時檢查計數器和清空用戶緩存。
3) 事務結束後可以截斷(譯註:把文件長度設置爲0字節)回滾日誌文件,而不是刪除它。在很多操作系統上,截斷比刪除快的多。

第三項優化,也就是用截斷代替刪除,並不要求一直擁有獨佔鎖。理論上說,總是實現它,而不是隻在獨佔訪問模式下實現它是可能的,也許我們會在未來版本中讓其成爲現實。不過,到目前爲止(3.5.0版),這項優化仍然只在獨佔訪問模式下有效。

7.3. 不記錄空閒頁面

從數據庫中刪除數據時,那些不再使用的頁面會被加到「空閒頁表」裏去。之後的插入操作將首先使用這些頁面,而不是擴大數據庫文件。一些空閒頁面中也有重要數據,比如說其他空閒頁面的位置等等。但大多數空閒頁面的內容沒有用,我們把這些頁面稱爲「葉頁」。修改葉頁的內容對數據庫沒有任何影響。

由於葉頁的內容沒用,SQLITE不會把它們在提交過程的第3.5步中記錄到回滾日誌裏去。也就是說,修改葉頁,但不在回滾過程中恢復它們對數據庫無害。同樣的,一個新葉頁的內容既不會在第3.9步中寫入數據庫也不會在第3.3步中被讀出來。在數據庫文件有空閒空間時,這項優化大幅減少了磁盤I/O的數量。

7.4. 單頁更新和原子扇區寫

從3.5.0版開始,新的VFS接口包含了一個名叫xDeviceCharacteristics的方法,它可以報告底層存儲器是否支持一些特性。這些特性中,有一個是「原子扇區寫」。

我們前面說過,SQLITE假設寫扇區是線性的,而不是原子的。線性寫從扇區的一端開始,逐字節寫到另一端結束。如果在線性寫的中間發生掉電,則可能扇區的一端被修改了,另一端卻保持不變。但在原子寫的情況下,扇區或者被完全更新了,或者完全沒有變化。

我們相信大多數現在磁盤驅動器實現了原子扇區寫。掉電時,驅動器使用電容中的電能和(或)盤片旋轉的動能完成正在進行的操作。然而,在系統寫調用與磁盤電子元件之間存在太多的層次,所以我們在Unix和windows的默認VFS實現上做了一個保守的假設,認爲寫扇區不是原子的。另一方面,能對其使用的文件系統有更多發言權的設備廠商,如果它們的硬件確實支持原子扇區寫,也許會選擇打開xDeviceCharacteristics中的這個選項。

當寫扇區是原子的、數據庫頁面和扇區一樣大,而且數據庫的變化只涉及到一個頁面時,SQLITE會跳過整個記日誌和同步過程,直接把修改後的頁面寫到數據庫文件上。數據庫文件第一頁上的修改計數器也會獨立修改,因爲即使在更新它之前掉電也是無害的。
譯註:個人認爲,如果硬件不支持原子扇區寫,是無法在軟件層次上實現絕對意義上的原子提交的。

7.5. 支持安全追加的文件系統

3.5.0版加入的另一項優化措施是基於文件系統的「安全追加」功能的。SQLITE假設向文件(特別是回滾日誌文件)追加數據時,文件大小的改變早於文件內容增加。所以,如果掉電發生在文件變大之後,數據寫完之前,文件中就會包含垃圾數據。也可以通過VFS中的xDeviceCharacteristics方法指出文件系統支持「安全追加」功能,這意味着內容的增加早於大小的改變,所以掉電或系統崩潰不可能向日志文件中引入垃圾。

文件系統支持安全追加時,SQLITE總是在日誌文件頭的頁面數字段中填入-1,表示回滾時要處理的頁面數應該根據日誌文件的大小自動計算。這個-1不會被修改,所以提交時,我們可以不用單獨刷一次日誌文件的第一頁。而且,當回收緩存時,也沒有必要在日誌文件末尾再寫一個新的文件頭了,我們只要繼續在已有的日誌文件上追加新頁面即可。

8. 對原子提交的測試

我們作爲SQLITE的開發者,對其在掉電和系統崩潰時的健壯性充滿自信,因爲,我們的自動測試過程在模擬的掉電故障下,對它的恢復能力進行了非常多的檢測。我們把這種模擬的故障稱爲「崩潰測試」。

崩潰測試使用了一個修改過的VFS,以便模擬掉電或崩潰時可能出現的各種文件系統錯誤。它可以模擬出沒有完整寫入的扇區、因爲寫操作沒有完成而包含垃圾數據的頁面、順序錯誤的寫操作等,這些錯誤在測試場景的各個路徑點上都會出現。崩潰測試不停地執行事務,讓模擬的掉電或系統崩潰發生在各個不同的時刻,造成各種不同的數據損壞。在模擬的崩潰事件發生之後,測試程序重新打開數據庫,檢測事務是否完全完成或者(看起來)根本沒有啓動,也就是數據庫是否處於一個一致的狀態。

SQLITE的崩潰測試幫助我們發現了恢復機制中的很多小問題(現在都已經修復了)。其中的一部分非常隱晦,單單通過代碼檢查和分析可能是發現不了的。這些經驗讓SQLITE的開發者相信:那些沒有使用類似崩潰測試的數據庫系統,非常有可能包含在系統崩潰或掉電時導致數據庫損壞的BUG。

9. 可能發生的問題

雖然SQLITE的原子提交機制本身是健壯的,但它卻有可能被惡意的對手或不那麼完善的操作系統實現給打垮。本章將介紹幾個可能在掉電或系統崩潰時導致數據庫損壞的情形。

9.1. 有問題的鎖

SQLITE使用文件系統的鎖來保證某一時刻只有一個進程和數據庫連接可以修改數據庫。文件系統的鎖機制是在VFS層實現的,並且在每種操作系統上都有所不同。SQLITE自身的正確性依賴於這個實現的正確性。如果它出了問題,導致兩個或更多進程能同時修改一個數據庫文件,肯定會嚴重損壞數據庫。

有人向我們報告說windows的網絡文件系統和(Unix的,譯註)NFS的鎖都有些問題。我們驗證不了這些報告,但是考慮到在網絡文件系統上實現一個正確的鎖的難度,我們也無法否定它們。由於網絡文件系統的效率也很低,所以我們建議你最好是避免在其上使用SQLITE。如果一定要這麼做的話,請考慮使用一個附加的鎖機制來保證即使文件系統自身的鎖機制不起作用時,也不會出現多個進程同時寫一個數據庫文件的情況。

蘋果Mac OSX計算機上預裝的SQLITE進行了一個擴展,可以在蘋果支持的所有網絡文件系統上使用一個替代的加鎖策略。只要所有進程使用統一的方式訪問數據庫文件,這個擴展就工作的很好。但不幸的是,這些加鎖機制是相互獨立的,如果一個進程用AFP鎖,另一個用點文件(dot-file)鎖,那這兩個進程就可能發生衝突,因爲AFP鎖並不能禁止點文件鎖,反之亦然。

9.2. 不完整的刷磁盤操作

在第3.7節和3.10節中你已經看到,SQLITE要把系統緩存刷到磁盤上。在unix系統上,這是用fsync()系統調用來完成的,windows上則是用FlushFileBuffers()。可是,我們收到的報告顯示,很多系統上的這些接口沒有廣告宣傳的那麼好。我們聽說,在一些windows版本上,通過修改註冊表,可以完全禁用FlushFileBuffers();而linux的某些歷史版本中的fsync僅僅是個什麼也不幹的空操作。我們還知道,即使是在FlushFileBuffers()或fsync()可以正常工作的系統上,IDE磁盤控制器也經常會在數據仍處在自己的緩存中時,撒謊說數據已經到達磁盤表面了。

在蘋果的系統上,如果你把fullsync選項打開(PRAGMAfullsync=ON),它可以保證數據確實刷到磁盤上了。Fullsync本身就很慢,而fullsync的實現還需要重置磁盤控制器,這會讓其他根本不相關的磁盤I/O也變慢,所以我們不建議你這樣做。

9.3. 文件刪除只完成了一半

SQLITE假設從用戶程序的角度看文件刪除是原子操作。如果刪除文件時掉電,電力恢復後,SQLITE期望這個文件或者不存在,或者是一個完整的、和刪除前一模一樣的文件。如果操作系統做不到這一點,事務就有可能不是原子的。

9.4. 文件中的垃圾

SQLITE的數據庫文件是普通的文件,其它用戶程序也可以打開它並任意的往裏面寫數據,一些流氓程序就可能這樣做。垃圾數據的來源也可能是操作系統或磁盤控制器的BUG,尤其是那些會在掉電時觸發的BUG。對此類問題,SQLITE無能爲力。

9.5. 刪除或重命名熱日誌文件

如果發生了掉電或崩潰,並且生成了熱日誌文件,那麼,在另一個SQLITE進程打開它和數據庫文件並完成回滾之前,這兩個文件的名字絕對不能改變。在第4.2步時,SQLITE會在打開的數據庫文件所在的目錄下,尋找熱日誌文件,這個文件的名字是從數據庫文件名派生而來的。所以,只要這兩個文件中的任何一個被移走或改名,就會找不到熱日誌,也就不會進行回滾。

我們認爲SQLITE恢復過程的失敗模式一般是這樣的:發生了掉電;電力恢復後,一位好心的用戶或者系統管理員開始清點損失;他們發現有一個名爲「important.data」的文件,他們可能很熟悉這個文件,所以沒有對其進行任何操作;但崩潰後,磁盤上還有一個名爲「important.data-journal」的熱日誌文件,用戶把它刪除了,因爲他們認爲這個文件是系統中的垃圾。防止此類事件的唯一方法可能就是加強用戶教育了。

如果有多個鏈接(硬鏈接或符號鏈接)指向一個數據庫文件,那麼生成的日誌文件會依據打開數據庫文件時使用鏈接名來命名。如果發生了崩潰,並且下次打開數據庫時使用了另一個鏈接,則也會因爲找不到熱日誌文件而不進行回滾。

某些時候,掉電會導致文件系統出錯,以致新更改的文件名無法記錄,這時,文件就會被移動到「/lost+found」目錄下。爲防止此類錯誤,SQLITE會在同步日誌文件的同時,打開並同步一下這個文件所在的目錄。但是,一些八竿子打不着的程序,在數據庫文件所在目錄下創建其他文件的操作,也可能會導致文件被移動到「/lost+found」裏去,這是SQLITE控制不了的,所以SQLITE對它也沒什麼辦法。如果你正在使用此類名字空間易被損壞的文件系統(我們相信大多數現代的日誌文件系統沒有此問題),我們建議你把SQLITE的數據庫文件放在單獨的子目錄中。

10. 總結和展望

不論是過去還是現在,總有人能發現一些SQLITE原子提交機制的失敗模式,開發者也不得不爲此做一些補丁。但這類事情發生的已經越來越少了,失敗模式也變得越來越隱晦。不過,如果藉此認爲SQLITE的原子提交邏輯已經無懈可擊了,肯定是相當愚蠢的。開發者們能承諾的只是儘量快速的修復新發現的BUG。

同時,我們也在尋找新的方法來優化這個提交機制。在Linux、MacOSX和windows上,當前的VFS實現都做了悲觀的假設。也許在與一些熟悉這些系統工作原理的專家交流之後,我們能放寬一些限制,讓它跑得更快些。特別的,我們猜測大部分現代文件系統已經具有了「安全追加」和「原子扇區寫」這兩個特性,但在確認之前,我們仍會保守的做最壞假設。