SQLITE中原子提交的實現

來源:互聯網
上載者:User

轉自:http://blog.csdn.net/LocalVar/archive/2008/02/13/3620555.aspx

 

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實現都做了悲觀的假設。也許在與一些熟悉這些系統工作原理的專家交流之後,我們能放寬一些限制,讓它跑得更快些。特別的,我們猜測大部分現代檔案系統已經具有了“安全追加”和“原子扇區寫”這兩個特性,但在確認之前,我們仍會保守的做最壞假設。

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.