【譯】BerkeleyDB設計經驗

wenliang.zhang發表於2016-10-10

英文原文連結:http://www.aosabook.org/en/bdb.html 
中文翻譯連線:http://www.ituring.com.cn/article/details/8111 

作者:Margo Seltzer 和 Keith Bostic

康威法則(Conway’s law)說明了設計反映了產生它的組織的結構。展開來說,我們也許會預見一款由兩個人設計和完成最初製作的軟體不僅會在一定程度上反映組織的結構,還會反映每一位帶來的內在偏見和哲學理念。我們中的一位(Seltzer)在檔案系統和資料庫管理系統的世界中度過她的職業生涯。如果被問及於此,她會辯解說此二者基本上是等同物,進一步地,作業系統和資料庫管理系統實質上都既是資源管理器又是便利抽象層的提供者。它們的區別“僅僅”在於實現的細節。另一位(Bostic)則信仰軟體工程中基於工具的方法和基於簡單構造塊的元件構建方法,因為這樣的系統在各種重要“能力”方面總是優於單體式體系結構:可理解性、可擴充套件性、可維護性、可測試性和靈活性。

當把這兩種理念結合起來,你就不會奇怪我們花了過去二十年間的大部分時光共事於Berkeley DB(一個提供高速、靈活、可靠和可擴充套件的資料管理的軟體庫)了。Berkeley DB提供了人們所期待的傳統系統(例如關係型資料庫)中的大多數的同樣功能,但是打包方式不同。例如,Berkeley DB提供了按鍵值的和按順序的兩種快速資料訪問,同時還有事務支援和故障恢復。但是,它以庫的形式提供這些特性,與需要這些服務的應用程式連結到一起,而不是作為一個獨立的伺服器應用提供服務。

在本章中,我們將要更深入地觀察Berkeley DB,看到它由一組模組組成,每個模組都體現了Unix的“把一件事做好”的哲學。嵌入了Berkeley DB的應用程式能夠直接使用這些模組或者通過更加熟悉的操作獲取、存放和刪除資料項來間接使用它們。我們將集中關注體系結構——我們是如何開始的,我們設計了什麼,我們在哪結束了以及為什麼。設計能夠(而且一定將要)被強迫去適應和改變——重要的是隨時間的推移而維護原則和一致的願景。我們也將簡要的談及長期軟體專案的程式碼演進。Berkeley DB有超過20年的持續開發,這難免會給好的設計造成負面影響。

4.1 開端

Berkeley DB起源於Unix作業系統還專屬於AT&T的時代。那時有幾百種實用工具和函式庫的血統還帶有嚴格的許可限制。Margo Seltzer那時是加州大學伯克利分校的研究生,Keith Bostic是伯克利計算機系統研究組的一員。當時Keith正在從伯克利軟體發行版(BSD)中刪除AT&T的專屬軟體。

Berkeley DB專案開始於一個適度的目標——用一個新的、改進的、可同時支援記憶體和磁碟操作的雜湊實現來替代記憶體雜湊軟體包hsearch和磁碟雜湊軟體包dbm/ndbm,以及允許不帶專有許可的自由分發。Margo Seltzer寫的雜湊庫 SY91 基於Litwin的可擴充套件線性雜湊研究成果。它宣稱採用了一種聰明的方法來達到雜湊值和頁面地址之間的常量時間對映,以及處理較大資料的能力——大於底層的雜湊桶或檔案系統頁大小的項,通常是4到8KB。

如果雜湊表很好,那麼B樹加上雜湊表將會更好。Mike Olson,也是加州大學伯克利分校的研究生,曾寫過一些B樹的實現,同意再寫一個。我們三個人把Margo的雜湊軟體和Mike的B樹軟體轉換成了一套和存取方法無關的API,應用程式通過資料庫控制程式碼來引用雜湊表或B樹,控制程式碼帶有讀取或修改資料的處理方法。

基於這兩種存取方法,Mike Olson和Marge Seltzer寫了一篇關於LIBTP(一個執行於應用程式地址空間的可程式設計事務函式庫)的研究論文 SO92

這套雜湊和B樹函式庫以Berkeley DB 1.85的名稱被整合到了最終的4BSD發行版中。從技術上看,該B樹存取方法實現的是B+ link樹,不過在本章的後續部分我們將採用B樹一詞,因為它是存取方法的名稱。Berkeley DB 1.85的結構和API對用過Linux或BSD衍生系統的人而言很可能比較熟悉。

Berkeley DB 1.85沉寂了一些年,直到1996年Netscape與Margo Seltzer和Keith Bostic簽約來實現LIBTP論文中描述的全部事務設計並且實現一個生產質量級的版本。這項工作產生了Berkeley DB的第一個事務性版本,版本2.0。

Berkeley DB的後續歷史就是一個更簡單、傳統的大事年表了:Berkeley DB 2.0(1997)引入了事務;Berkeley DB 3.0 (1999)是一個重新設計的版本,增加了更多級別的抽象和間接性以支援不斷增長的功能;Berkeley DB 4.0 (2001)引入了複製和高可用;Oracle Berkeley DB 5.0 (2010)增加了SQL支援。

在寫作本文的時候,Berkeley DB 是世界上使用最廣泛的資料庫工具集,有幾億份部署的拷貝執行在從路由器、瀏覽器、郵件系統到作業系統的各種系統中。雖然已經有超過20年的歷史了,Berkeley DB 基於工具和麵向物件的設計方法使得它可以增量改進和重構以滿足使用它的軟體的需求。

設計教訓1

對任何複雜的軟體包的測試和維護來說,將其設計和構建成帶有良好定義的API邊界的、一組互相協作的模組至關重要。在有需求時,這些邊界能夠(而且必須!)移動,但是邊界總得存在。這些邊界的存在可以防止軟體變成一堆不可維護的義大利麵條。Butler Lampson曾說過,電腦科學中的所有問題都可以通過新增一個間接層來解決。更確切的是,當被問及物件導向的東西是什麼意思時,Lampson說這意味著能夠在一套API之後有多種實現。Berkeley DB的設計和實現體現了這種同一套介面之後允許多種實現的方法,提供了物件導向的觀感,雖然函式庫是用C實現的。

4.2 體系結構概述

本節我們將從LIBTP開始回顧Berkeley DB的體系結構,強調它演進中的關鍵問題。

圖4.1摘自Seltzer和Olson的原始論文,說明了原先的LIBTP體系結構;而圖4.2則展現了Berkeley DB 2.0設計時的體系結構。

圖4.1:LIBTP原型系統的體系結構 圖4.1:LIBTP原型系統的體系結構

圖4.2:Berkeley DB 2.0預期的體系結構 圖4.2:Berkeley DB 2.0預期的體系結構

LIBTP實現和Berkeley DB 2.0設計之間唯一顯著的區別是刪除了程式管理器(process manager)。LIBTP要求每個執行緒註冊到庫中,然後同步各個執行緒/程式,而不是提供子系統級的同步。正如4.4節中討論的那樣,原先的設計可能更好。

圖4.3:實際的Berkeley DB 2.0.6體系結構 圖4.3:實際的Berkeley DB 2.0.6體系結構

設計和實際釋出的Berkeley DB 2.0.6(見圖4.3)在體系結構上的區別體現在後者實現了一個強壯的恢復管理器(recovery manager)。恢復子系統在圖中用灰色表示。恢復既包括用recovery框表示的驅動結構,也包括用於恢復存取方法所執行操作的重做(redo)和撤銷(undo)例程的集合。這些在圖中用“access method recovery routines”標註的橢圓形表示。與LIBTP中針對特定存取方法編寫日誌和恢復例程不同,Berkeley DB 2.0中對恢復的處理是一種一致的設計。這個通用的設計也產生了不同模組間更豐富的介面。

圖4.4展現了Berkeley DB 5.0.21的體系結構。圖中的數字表示表4.1中列出的API。雖然仍可以看出原始的體系結構的樣子,當前的體系結構體現了新模組的增加,舊模組的分解(例如log變成了log和dbreg),以及模組間API的顯著增加。

經過十年多的演進,幾十個商業釋出,以及幾百個新特性的增加之後,我們看到體系結構明顯比以前更復雜了。值得注意的關鍵點是:首先,複製(replication)在系統中增加了全新的一層,不過做得很清晰,就像前期的程式碼一樣通過同樣的API與系統的其他部分互動。其次,log模組被分成了log和dbreg(database registration)。在4.8節對此有更詳細的描述。第三,我們把所有模組間的呼叫放到了一個以下劃線打頭的名稱空間內,這樣應用軟體就不會與我們的函式名衝突了。我們在設計教訓6中對此進一步討論。

第四,日誌子系統的API現在是基於遊標的了(API log_get不復存在,代之以API log_cursor)。過去,Berkeley DB中在同一時刻讀寫日誌的執行緒從來就沒有多於一個,因此函式庫中只有一個日誌的當前掃描指標。這從來都不是一個好的抽象(但還可以工作),但有了複製之後它變得不可用了。就像應用層API用遊標實現迴圈一樣,日誌現在也通過遊標來支援迴圈了。第五,存取方法中的fileop模組提供了事務保護的資料庫建立、刪除和重新命名操作。我們嘗試了多次以使得實現使人滿意(它仍然不是我們期望的那樣清晰),在許多次改造之後,我們把它抽成一個獨立的模組。

設計教訓2

軟體設計絕對是迫使你自己在試圖解決問題前通盤考慮整個問題的幾種方法之一。有經驗的程式設計師採用不同的技術來達到這個目的:有些先寫第一版然後扔掉,有些寫出大量的手冊或設計文件,其他的則設計出程式碼模板並識別出每個需求,分派到一個具體的函式或一段註釋。例如,在Berkeley DB中,我們在寫程式碼之前為存取方法和底層模組建立了一份完整的Unix風格的手冊。不管採用的具體技術如何,在程式碼除錯開始後都很難想清楚程式的體系結構,更不要說大的體系結構變化通常會浪費前期的除錯努力。軟體體系結構設計需要一種與程式碼除錯不同的思維方式,當你開始除錯時的軟體體系結構通常就是你在該版本中將會交付的結構。

圖4.4:Berkeley DB 5.0.21的體系結構 圖4.4:Berkeley DB 5.0.21的體系結構

應用程式API

1. DBP控制程式碼處理操作 2. DB_ENV恢復 3. 事務API
open open(… DB_RECOVER …) DB_ENV->txn_begin
get   DB_TXN->abort
put   DB_TXN->commit
del   DB_TXN->prepare
cursor    

存取方法用到的API

4. Into Lock 5. Into Mpool 6. Into Log 7. Into Dbreg
__lock_downgrade __memp_nameop __log_print_record __dbreg_setup  
__lock_vec __memp_fget   __dbreg_net_id  
__lock_get __memp_fput   __dbreg_revoke  
__lock_put __memp_fset   __dbreg_teardown  
  __memp_fsync   __dbreg_close_id  
  __memp_fopen   __dbreg_log_id  
  __memp_fclose      
  __memp_ftruncate      
  __memp_extend_freelist      

恢復的API

8. Into Lock 9. Into Mpool 10. Into Log 11. Into Dbreg 12. Into Txn
__lock_getlocker __memp_fget __log_compare __dbreg_close_files __txn_getckp
__lock_get_list __memp_fput __log_open __dbreg_mark_restored __txn_checkpoint
  __memp_fset __log_earliest __dbreg_init_recover __txn_reset
  __memp_nameop __log_backup   __txn_recycle_id
    __log_cursor   __txn_findlastckp
    __log_vtruncate   __txn_ckp_read

事務模組用到的API

13. Into Lock 14. Into Mpool 15. Into Log 16. Into Dbreg  
__lock_vec __memp_sync __log_cursor __dbreg_invalidate_files  
__lock_downgrade __memp_nameop __log_current_lsn __dbreg_close_files  
      __dbreg_log_files  

複製子系統的API

    17. From Log   18. From Txn
    __rep_send_message   __rep_lease_check
    __rep_bulk_message   __rep_txn_applied
        __rep_send_message

複製子系統用到的API

19. Into Lock 20. Into Mpool 21. Into Log 22. Into Dbreg 23. Into Txn
__lock_vec __memp_fclose __log_get_stable_lsn __dbreg_mark_restored __txn_recycle_id
__lock_get __memp_fget __log_cursor __dbreg_invalidate_files __txn_begin
__lock_id __memp_fput __log_newfile __dbreg_close_files __txn_recover
  __memp_fsync __log_flush   __txn_getckp
    __log_rep_put   __txn_updateckp
    __log_zero    
    __log_vtruncate    

表4.1:Berkeley DB 5.0.21的API

為什麼把事務函式庫設計成多個模組而不是為單一用途優化?針對這個問題有三個答案。首先,它促使一個更嚴謹的設計。其次,程式碼中若沒有明顯的邊界,複雜的軟體包必然會惡化成為一堆不可維護的東西。第三,你不可能預見使用者使用你的軟體的所有方式,如果你授權使用者訪問軟體的內部模組,他們將會用你從未想到過的方式來使用這些模組。

在隨後的章節中,我們會討論Berkeley DB中的每個元件,理解它做了什麼以及它在整個系統中的位置。

4.3 存取方法:B樹、雜湊、記錄號和佇列

Berkeley DB的存取方法同時提供了基於變長和定長位元組串的按鍵值查詢和迴圈。B樹和雜湊支援變長的鍵/值對。記錄號和佇列支援記錄號/值對(其中記錄號支援變長值而佇列僅支援定長值)。

Notes:Btree、Hash、Recno、Queue在這裡屬於專用名詞,保留英文似乎更好。

B樹和雜湊存取方法之間的主要區別在於B樹提供了鍵值引用的區域性性,而雜湊則沒有。這意味著對幾乎所有的資料集B樹都是合適的存取方法,而雜湊存取方法則適合於大到連B樹索引都在記憶體中放不下的資料集。此時,把記憶體用來存放資料比存放索引要更好。1990年那時的記憶體比今天要小很多,這種權衡顯得更有道理。

記錄號和佇列之間的差別在於佇列以只支援定長值為代價來支援記錄級鎖定;記錄號支援變長物件,但和B樹以及雜湊一樣,僅支援頁級鎖定。

我們最初把Berkeley DB設計成CRUD功能(建立、讀取、更新和刪除)是基於鍵的,而且是給應用的主要介面。後來我們增加了遊標以支援迴圈。這個需求導致了函式庫中大量的重複程式碼,造成了混亂和資源浪費。隨著時間的推移,這變得不可維護,我們把所有基於鍵的操作都轉換成了遊標操作(現在,基於鍵的操作會分配一個快取的遊標,執行操作,然後將遊標返回到遊標池)。這是軟體開發中不斷重複的規則之一的應用:在你知道必須去做之前,不要以任何方式優化一條減少清晰度和簡潔性的程式碼路徑。

設計教訓3

軟體體系結構不會優雅地老化。軟體體系結構的退化與軟體本身的改動數量成正比:缺陷修復會腐蝕軟體的層次,新特性會使設計產生應力。確定什麼時候軟體體系結構退化到該重新設計或重寫一個模組是一個很難的決定。一方面,在設計退化時,維護和開發變得更困難,最終變成一個老化的軟體。它的每次釋出只能靠一群暴力測試者來維持。因為沒有人知道該軟體內部是怎麼工作的。另一方面,使用者會強烈抱怨根本性改動帶來的不穩定和不相容。作為一個軟體架構師,你唯一的保證是無論選擇那條路,總有人對你不滿。

我們略去了對Berkeley DB存取方法內部的詳細討論。他們實現了眾所周知的B樹和雜湊演算法(記錄號是B樹程式碼之上的一層;佇列是一個檔案塊查詢功能,儘管它被記錄級鎖定弄複雜了。)

4.4 函式庫的介面層

隨著時間的推移,我們增加了更多的功能,發現應用程式和內部程式碼都需要相同的上層功能(例如內部的表連線操作要用到多個遊標來遍歷行,應用程式也會用遊標來遍歷同樣這些行。)

設計教訓4

你怎麼命名變數、方法和函式,採用什麼註釋或程式碼風格並不重要;也就是說有大量的格式和風格“足夠好”。重要和非常重要的是命名和風格保持一致。有經驗的程式設計師從程式碼格式和物件命名中得到大量資訊。你應當將命名和風格的不一致視為某些程式設計師將時間和精力花費來欺騙另外的程式設計師,反之亦然。不能遵循內部編碼規範是一種該被解僱的行為。

正因如此,我們把存取方法的API分拆為準確定義的層次。這些介面例程層處理所有必要的通用錯誤檢查,函式特有的錯誤檢查,介面追蹤以及其他如自動事務管理等任務。當應用程式呼叫進Berkeley DB時,它們呼叫的是基於物件控制程式碼內的方法的第一層介面例程(例如Berkeley DB遊標的“put”方法就是呼叫__dbc_put_pp介面來更新資料項的)。我們用字尾“_pp”來標識所有可以被應用程式呼叫的函式。

Berkeley DB的介面層處理的任務之一是追蹤哪些執行緒正在Berkeley DB庫內執行。這是必要的,因為有些內部的Berkeley DB操作只可以在庫內沒有執行緒執行時被執行。Berkeley DB通過在每個庫API開始時標記執行緒在庫內執行,在API呼叫返回時清除標記來追蹤執行緒。這些進入/退出檢查總是在介面層進行檢查,與此類似的是檢查呼叫是否在複製環境中執行。

很明顯的一個問題是“為什麼不傳遞一個執行緒識別符號到函式庫,這難道不是更簡單嗎?”答案是肯定的,那將容易很多,我們當然希望已經那麼做了。可是,這種變化將導致每個Berkeley DB應用程式,以及每個應用程式中對Berkeley DB的大部分呼叫,這在大部分情況下需要應用程式的重構。

設計教訓5

軟體架構師必須慎重選擇升級路徑:使用者會接受小的改動來升級到新的版本(如果你保證升級期間只出現編譯時錯誤也就是明顯的錯誤;升級的變化絕不應該導致難以理解的失敗。)但是要做出真正根本性的變化,你必須承認這是一個新的基礎程式碼,所以需要現有使用者的移植。顯然,新的基礎程式碼和應用移植在時間或資源上算都不便宜,但是二者都不會像告訴他們一個大改動實際是一次小升級那樣惹惱你的使用者群。

介面層負責的另一個任務是事務的產生。Berkeley DB支援一種每個操作都隱含一個事務的模式(這可以省去應用程式顯式建立和提交事務的麻煩)。支援這種模式需要在應用程式未指定自己的事務呼叫API時,自動為其建立一個事務。

最後,所有的Berkeley DB API都需要引數檢查。在Berkeley DB中有兩種型別的錯誤檢查——判斷資料庫是否在前一個操作中被破壞了的通用性檢查,以及我們是否正在一個複製狀態變化的中間(例如,改變哪個副本以允許寫入)。也有針對具體API的檢查:標記的正確使用,引數的正確使用,選項的正確組合,以及其他可以在真正執行請求的操作前檢查的錯誤。

API相關的檢查都被封裝在以“_arg”為字尾的函式中。因此,與遊標的put方法相關的錯誤檢查就位於__dbc_put_arg中,它被函式__dbc_put_pp呼叫。

最後,當所有引數檢驗和事務產生完成時,我們呼叫真正執行操作的輔助方法(在上述例子中是__dbc_put),這也是我們內部呼叫遊標put功能時用的函式。

這種模組拆分在一段開發密集期間逐漸形成,那時我們正在決策需要採取哪些行動以支援複製環境。在基礎程式碼中迭代開發不少次後,我們把前面所說的所有檢查都抽出來以使得以後發現問題時更容易修改。

4.5 底層模組

在存取方法之下有四個模組:緩衝區管理器、鎖管理器、日誌管理器和事務管理器。我們將分別討論每個模組,不過它們有一些共同的體系結構特性。

首先,所有的這些子系統都有自己的API,而且最初每個子系統都有自己的物件控制程式碼,子系統的所有方法都基於該控制程式碼。例如,你可以用Berkeley DB的鎖管理器來處理你自己的鎖或者寫自己的遠端鎖管理器。你也可以用Berkeley DB的記憶體管理器來處理自己的共享記憶體中的檔案頁。隨著時間的推移,這些子系統特性的控制程式碼被從API中刪除了以簡化Berkeley DB應用程式。雖然這些子系統仍然是可以被獨立於其他子系統使用的獨立模組,它們現在共享一個通用的物件控制程式碼,也就是DB_ENV“環境”控制程式碼。這個體系結構的特性強化了分層和通用性。雖然層不時在移動,而且還有些地方一個子系統跨越到另一個子系統,讓程式設計師把系統的不同部分理解為各自獨立的軟體產品是一個不錯的原則。

其次,所有的這些子系統(實際上,所有的Berkeley DB函式)都給上層返回錯誤碼。作為一個函式庫,Berkeley DB不能通過定義全域性變數侵入應用程式的名字空間。更何況強制錯誤從呼叫棧通過單一路徑返回強化了好的程式設計師紀律。

設計經驗6

在函式庫的設計中,重視名字空間是至關重要的。用你的函式庫的程式設計師應該不需要去記住幾十個函式、常量、結構、全域性變數的保留名字以避免應用和函式庫的命名衝突。

最後,所有這些子系統都支援共享記憶體。因為Berkeley DB支援在多個執行的程式之間共享資料庫,所有共享資料結構都必須放在共享記憶體中。這個選擇的最明顯的結果是記憶體資料結構都必須採用一對基地址和偏移量而不是指標,以使得基於指標的資料結構都可以在多程式的環境下工作。換句話說,不通過指標做間接轉換,Berkeley DB函式庫必須通過基地址(共享記憶體段被對映到程式中的記憶體地址)加上一個偏移量(給定資料結構在對映的記憶體段中的偏移位置)來建立指標。為了支援這個特性,我們寫了一個BSD版本的queue軟體包,它實現了各種各樣的連結串列。

設計教訓7

在我們寫共享記憶體的連結串列軟體包之前,Berkeley DB的工程師們手工編寫了共享記憶體中的各式不同的資料結構,而且這些實現容易出錯和很難除錯。共享記憶體連結串列軟體包,仿照BSD連結串列軟體包(queue.h)實現,代替了所有這些努力。在它一旦除錯通過後,我們再也不需要去除錯共享記憶體連結串列問題了。這體現了三個重要的設計原則:第一,如果一個功能出現了多次,那就寫出共享的函式並使用它們,因為對於任何特定功能而言,兩份拷貝的存在一定說明其中一份實現得不正確。其次,當你開發一系列通用的例程時,給這些例程寫一個測試集,這樣你就可以分開除錯它們。第三,程式碼越難以書寫,單獨書寫並維護它就越重要。因為基本上不可能防止外圍程式碼感染和侵蝕一份程式碼。

4.6 緩衝區管理器:Mpool

Berkeley DB的Mpool子系統是檔案頁面的記憶體緩衝池,它隱藏了這樣一個事實:記憶體是一種有限資源,當處理超過記憶體大小的資料庫時,需要函式庫在磁碟和記憶體間來回移動資料庫頁。將資料庫頁快取在記憶體中使得原先的雜湊庫大大優於先前的hsearch和hdbm實現。

雖然Berkeley DB的B樹存取方法是一個相當傳統的B+樹實現,樹節點之間的指標用頁面號而不是記憶體指標表示,因為函式也把磁碟頁格式用作記憶體頁格式。這種表示的優勢在於頁面可以不需要格式轉換就能被從快取刷出到磁碟,劣勢在於遍歷索引結構時需要(代價稍高的)重複的緩衝池查詢而不是(代價稍低的)記憶體操作。

底層假設Berkeley DB索引的記憶體表示實際上是磁碟上持久資料的快取,這還有其他的一些對效能的影響。例如,每當Berkeley DB訪問一個快取的頁面時,首先要pin住記憶體中的頁面。Pin操作防止任何其他的執行緒或程式將該頁從記憶體池中換出。即便整個索引結構都可以在緩衝中放下,並且從不需要被重新整理到磁碟,Berkeley DB仍然在每個操作時要獲取和釋放這些pin,因為Mpool底層的模型是一個快取而不是一個持久儲存。

4.6.1 Mpool的檔案抽象

Mpool假設它位於檔案系統之上,通過其API暴露檔案抽象。例如,DB_MPOOLFILE控制程式碼表示一個磁碟檔案,提供了從檔案中獲取頁面和寫頁面到檔案的方法。雖然Berkeley DB也支援臨時的和純粹的記憶體資料庫,這二者也是通過DB_MPOOLFILE控制程式碼引用的,因為底層都是Mpool抽象層。Get和put方法是主要的Mpool API:get確保頁面在快取中,獲得頁面上的一個pin並返回指向頁面的指標。當函式庫用完頁面時,put呼叫unpin頁面並允許頁面被換出。Berkeley DB的早期版本不區分讀訪問的pin頁面和寫訪問的pin頁面。然而,為了增加併發性,我們擴充套件了Mpool的API以允許呼叫者指示更新頁面的意圖。區分讀訪問和寫訪問的能力對多版本併發控制的實現至為重要。為讀訪問pin住的髒頁面是可以被寫入磁碟的,而為寫訪問pin住的髒頁面就不能,因為後者可能在任何時刻都處於不一致的狀態。

4.6.2 先寫日誌

Berkeley DB採用先寫日誌(WAL)實現故障恢復的事務機制。術語先寫日誌定義了一個策略,要求任何修改所對應的日誌記錄都要先於它實際的資料更新被寫到磁碟。Berkeley DB採用WAL作為其事務機制對Mpool有重要的影響,Mpool必須在通用的快取機制以及支援WAL協議的需要之間找到設計的平衡點。

Berkeley DB將日誌順序號(LSN)寫到所有資料頁上,以記錄每個特定頁的最近更新所對應的日誌記錄。實施WAL需要Mpool在寫頁面到磁碟前驗證頁面上的LSN對應的日誌記錄已經安全地記錄到磁碟了。設計的挑戰在於提供該功能而不要求所有Mpool的客戶採用和Berkeley DB完全一致的頁面格式。Mpool通過提供一系列的set(和get)方法指引其行為來解決這個挑戰。DB_MPOOLFILE的方法set_lsn_offset提供了頁面內的位元組偏移,告訴Mpool到哪兒去找LSN以實現WAL。如果這個方法從未被調過,Mpool就不實現WAL。類似的,set_clearlen方法告訴Mpool頁內有多少位元組表示後設資料,在快取中建立一個頁面前需要顯式的清除掉這些位元組。這些API允許Mpool提供了支援Berkeley DB事務所必要的功能,而不是迫使Mpool的所有使用者去自己實現。

設計教訓8

先寫日誌是另一個提供封裝和分層的例子,即使是這個特性不會對其他的軟體有用:畢竟有多少程式會關心快取中的LSN?不管怎樣,這個原則是有用的,而且使得軟體容易維護、測試、除錯和擴充套件。

4.7 鎖管理器:Lock

像Mpool一樣,鎖管理器也被設計成一個通用模組:它被設計成支援物件層次的封鎖(例如獨立的資料項、資料項所在的頁面、甚至是一組檔案)的一個層次式鎖管理器(參看GLPT76)。在描述鎖管理器的特性時,也將同時解釋Berkeley DB是怎麼用它的。然而,就像Mpool一樣,其他的應用程式可以用完全不同的方式使用鎖管理器,不過那沒問題——它被設計得很靈活並支援很多不同的用法。

鎖管理器有三個關鍵的抽象:“封鎖者”標識鎖是代表誰獲取的,“封鎖物件”標識被鎖定的項,以及一個“衝突矩陣”。

封鎖者是32位無符號整數。Berkeley DB把這個32位的名字空間劃分為事務性封鎖者和非事務性封鎖者(雖然這種區分對鎖管理器而言是透明的)。當Berkeley DB使用鎖管理器時,它把範圍從0到0x7fffffff之間的ID分給非事務性封鎖者,把從0x80000000到0xffffffff的分給事務性封鎖者。例如,當應用程式開啟資料庫時,Berkeley DB獲取該資料庫上的一個長的讀鎖以保證它在被使用時沒有其他的執行緒刪除或重新命名它。因為這是一個長鎖,所以它不屬於任何一個事務,持有該鎖的封鎖者就是非事務性的。

任何使用鎖管理器的應用程式都需要分配封鎖者ID,所以鎖管理器的API同時提供了DB_ENV->lock_id和DB_ENV->lock_id_free呼叫用以分配和釋放封鎖者。因此應用程式不需要實現自己的封鎖者ID分配器,雖然他們也可以這麼做。

4.7.1 鎖物件

鎖物件是表示被封鎖物件的任意長度的不透明(opaque)位元組串。當兩個不同的封鎖者試圖鎖住一個特定物件時,他們採用同樣的不透明位元組串來引用該物件。也就是說,應用程式負責定義描述物件的不透明位元組串的約定。

例如,Berkeley DB採用一個DB_LOCK_ILOCK結構來描述其資料庫鎖。這個結構包含三個欄位:檔案識別符號、頁號和型別。

在幾乎所有情況下,Berkeley DB都只需要描述它想鎖定的特定檔案和頁面。Berkeley DB在資料庫建立時給每個庫分配一個唯一的32位數字,並把它寫到資料庫的後設資料頁中。以後就在Mpool、封鎖、日誌子系統中將它用作資料庫的唯一識別符號。這就是我們在DB_LOCK_ILOCK結構中引用的fileid欄位。不出所料,頁面號表示我們想要鎖定的特定資料庫中的某個頁。當我們引用頁面鎖時,我們將結構中的type欄位設定為DB_PAGE_LOCK。然而,我們我們也可以在需要時鎖定其他型別的物件。正如前面提到的,我們有時會鎖住資料庫控制程式碼,它就需要DB_HANDLE_LOCK型別。DB_RECORD_LOCK型別使我們可以處理佇列存取方法中的記錄級鎖定,而DB_DATABASE_LOCK型別則讓我們鎖定整個資料庫。

設計教訓9

Berkeley DB選擇採用頁面級別的鎖定是有足夠理由的,但是我們發現該選擇有時也是有問題的。當一個執行緒在修改資料庫頁面中的一條記錄時,頁級鎖定將不允許其他執行緒修改同一頁面中的其他記錄,這限制了應用程式的併發性。而只要兩個執行緒不在修改同一個記錄,記錄級鎖定就允許這樣的併發。頁級鎖定增強了穩定性,因為它限制了可能的恢復路徑(在恢復過程中,頁面總是在幾個狀態之一,而不是在允許多個記錄被同時在頁內增加或刪除時導致的無數的可能狀態)。因為Berkeley DB是為嵌入式系統使用的,一旦有破壞,不會有資料庫系統管理員來修復問題,我們選擇了穩定性而不是更好的併發。

4.7.2 衝突矩陣

我們將討論的封鎖子系統的最後一個抽象是衝突矩陣。衝突矩陣定義了系統中不同型別的鎖以及它們之間的互動。讓我們將持有鎖的實體稱為持有者,請求鎖的稱為請求者,並且假設持有者和請求者具有不同的封鎖者ID。衝突矩陣就是一個以[請求者][持有者]為下標的陣列,其中如果沒有衝突的格子為0,表明請求的鎖可以被授予,如果有衝突則為1,表明請求不能被授予。

鎖管理器含有一個預設的衝突矩陣,它碰巧正是Berkeley DB所需要的。然而,應用程式可以自由定義自己的封鎖模式和衝突矩陣以滿足它自己的需求。對衝突矩陣的唯一要求是它必須是方的(它有相同的行數和列數)並且應用程式用從0開始的整數描述其封鎖模式(例如讀、寫等)。表4.2列出了Berkeley DB的衝突矩陣。

  持有者              
請求者 No-Lock Read Write Wait iWrite iRead iRW uRead wasWrite
No-Lock                  
Read          
Write  
Wait                  
iWrite          
iRead              
iRW          
uRead            
iwasWrite      

表4.2:讀寫衝突矩陣

4.7.3 對層次封鎖的支援

在解釋Berkeley DB衝突矩陣中不同的封鎖模式之前,讓我們談談封鎖子系統是怎麼支援層次封鎖的。層次封鎖指的是一種鎖定同一層次結構中不同項的能力。例如檔案包含頁面,而頁面包含不同的元素。當在一個層次封鎖系統中修改一個頁面元素時,我們僅想鎖住該元素;如果我們要更新頁面中的每個元素,僅鎖定頁面將更有效,而如果我們要修改檔案中的每個頁面,最好的就是鎖定整個檔案。此外,層次封鎖必須理解容器的層次,因為鎖定一個頁面也意味著在某種程度上鎖定了檔案,檔案中有頁面在被修改時,你不能修改頁面所在的檔案。

那麼問題在於怎麼允許不同的封鎖者在不同層級進行封鎖又不引起混亂。答案是一種叫做意向鎖的結構。封鎖者獲取容器上的一個意向鎖以說明將要鎖定容器內的東西的意向。於是,獲取頁面上的讀鎖隱含著獲取檔案上的一個意向讀鎖。類似的,要寫頁面中的一個元素,你必須同時獲取頁面和檔案上的意向寫鎖。在上面的衝突矩陣中,iRead、iWrite和iWR鎖都是意向鎖,它們分別表示讀的意向、寫的意向和同時讀寫的意向。

因此,在處理層次封鎖時,不是在某個東西上請求單一的一個鎖,可能有必要請求很多鎖:最終要操作的實體上的鎖以及所有包含該實體的實體的意向鎖。這個需求引入了Berkeley DB中的DB_ENV->lock_vec介面,它接受一個鎖請求的陣列然後原子性的授予(或拒絕)。

雖然Berkeley DB內部沒有采用層次封鎖,它利用了這個能力來指定不同的衝突矩陣,以及一次性指定多個鎖請求。在提供事務支援時,我們採用預設的衝突矩陣;但採用另一個衝突矩陣以支援不帶事務的簡單的併發存取和恢復支援。我們採用DB_ENV->lock_vec來處理鎖的耦合,這是一種增強B樹遍歷的併發性的技術Com79。在鎖耦合中,你只用持有鎖足夠的時間以獲取下一個鎖。也就是說,你只需要鎖住一個內部的B樹頁面足夠長的時間以讀到選擇和鎖定下一級頁面的資訊。

設計教訓10

Berkeley DB的通用設計在我們增加併發資料儲存功能時獲得了很好的回報。最初Berkeley DB只提供了兩種操作模式:要麼沒有寫併發性的執行,要麼支援全部事務支援。事務支援給開發人員帶來了一定程度的複雜性,我們發現有些應用程式想提高併發性又不想要全事務支援的額外代價。為了提供這個特性,我們增加了API級別的封鎖以允許併發性,同時保證沒有死鎖。這需要一個新的和不同的封鎖模式以支援遊標。與其在鎖管理器中增加特殊目的的程式碼,我們能夠建立另外一種鎖矩陣以支援API級鎖定只需要的封鎖模式。於是,僅僅通過將鎖管理器配置得不同,我們就能提供我們需要的封鎖支援。(不幸的是,修改存取方法就不那麼容易了,存取方法中還有相當大的一部分程式碼要處理這種併發存取的特殊模式)

4.8 日誌管理器:Log

日誌管理器提供了一個結構化的、僅限追加的檔案的抽象。與其他模組一樣,我們試圖設計出一個通用的日誌設施,然而日誌子系統可能是我們做的最不成功的一個模組。

設計教訓11

當你發現一個體繫結構上的問題而又不想立即修復時,你其實傾向於放過它。請記住被蠶食而死和被大象踩住都一定會要你的命。別太猶豫而不去修改整個框架來改進軟體結構,而且當你做出修改時,不要以為你以後會清理它而做出不完全的修改——一次做完並繼續向前進。就像經常說的,“如果你現在沒有時間去做,以後也不會有時間去做”。此外,在你修改框架時,同時也要寫測試結構。

日誌在概念上很簡單:它拿到不透明的位元組串並將它們順序地寫到檔案中,給每筆日誌一個稱作日誌順序號(LSN)的唯一標識。此外,日誌必須提供通過LSN的高效的正向和反向遍歷和檢索。這裡有兩個需要慎重處理的地方:第一,日誌必須要保證在任何可能的故障後處於一個一致的狀態(這裡“一致”指的是未損壞的日誌記錄的連續序列);其次,因為日誌記錄被寫到穩定儲存中以支援事務的提交,日誌的效能通常會限定事務性應用的效能。

因為日誌是一個僅限追加的資料結構,它可能會無限制增長。我們把日誌實現為一組順序編號的檔案,因此,日誌空間可以通過簡單的刪除舊日誌檔案來回收。在這種多檔案的日誌結構下,我們把LSN定義為檔案號和檔案內偏移組成的對。於是,給定一個LSN,日誌管理器定位日誌記錄就很簡單了:它移動到給定日誌檔案的給定偏移,並返回該位置的日誌記錄。但是日誌管理器怎麼知道從該位置返回多少位元組呢?

4.8.1 日誌記錄格式

日誌必須保留每個日誌記錄的後設資料以保證給定一個LSN,日誌管理器可以判斷待返回的記錄的大小。至少,它需要知道日誌記錄的長度。我們假定每個日誌記錄都有一個包含記錄長度的日誌記錄頭、前一個日誌記錄的偏移位置(以支援反向遍歷),以及一個日誌記錄的校驗和(以標識日誌的損壞和日誌檔案的結束)。這些後設資料足夠讓日誌管理器維護日誌記錄的順序了,但是這還不足以支援恢復的實現;該功能要靠日誌記錄中的內容以及Berkeley DB怎麼用這些日誌記錄來實現。

Berkeley DB通過日誌管理器在資料庫中更新資料項前寫下資料的前像和後像 HR83 。這些日誌記錄包含了重做或撤銷資料庫中操作的足夠資訊。Berkeley DB利用這些資訊處理事務撤銷(即,在事務撤銷時撤銷該事務的所有影響)和應用故障或系統故障後的恢復。

除了讀寫日誌記錄的API之外,日誌管理器還提供了一個強制將日誌記錄刷出到磁碟的API(DB_ENV->log_flush)。該API允許Berkeley DB實現WAL——在Mpool中回收頁面前,Berkeley DB檢查頁面的LSN並且要求日誌管理器保證該LSN已經在穩定儲存上了。只有這樣,Mpool才會將頁面寫到磁碟。

設計教訓12

Mpool和Log用內部的處理方法來處理WAL,在某些情況下,方法的宣告比本身的程式碼還要長,因為程式碼除了比較兩個整數之外什麼也不做。為什麼弄這些不太重要的方法僅僅去維護一致的層次呢?因為如果你的程式碼不是物件導向到了讓你牙疼的話,它還不夠物件導向。每段程式碼應該做少量的事情並且應該有個鼓勵程式設計師在小的功能塊之上構建新功能的上層設計。如果說我們在過去的幾十年中學到了什麼軟體開發的東西的話,那就是我們構建和維護大量軟體的能力是很弱的。構建和維護大量軟體是困難和容易出錯的,作為軟體架構師,你必須盡你所能、儘早、儘量頻繁的最大化軟體結構表達的資訊。

Berkeley DB在日誌記錄上施以結構以減少恢復的難度。大部分Berkeley DB的日誌記錄描述的是事務性更新。也就是說,大部分日誌記錄對應於以事務身份所做的資料庫頁面更新。這個描述有助於我們識別哪些是Berkeley DB必須附加到每條日誌記錄的後設資料:資料庫、事務和記錄型別。事務標識和記錄型別欄位在每個記錄的同一位置出現。這使得恢復系統可以抽取出日誌型別並且將記錄分發到可以解釋和執行相關動作的合適的處理者。事務標識讓恢復過程識別日誌記錄屬於哪個事務,使得在恢復的不同階段中,它知道該記錄是否可以被忽略還是必須被處理。

4.8.2 打破抽象層

還有一些“特殊的”日誌記錄。檢查點記錄可能是這些特殊記錄中最熟悉的。做檢查點是使資料庫的磁碟狀態在某個時間點一致的過程。換句話說,Berkeley DB為了效能儘量將資料庫頁快取在Mpool中。然而,這些頁面最終必須被寫到磁碟,而我們越早做這個,在應用或系統故障時我們就能更快得恢復。這意味著需要在做檢查點的頻率和恢復時間長短之間權衡:系統做檢查點越頻繁,它就能更快得恢復。做檢查點是一個事務性功能,因為我們將在下一節介紹它的細節。就本節而言,我們將談談檢查點記錄以及日誌管理器如何在成為一個獨立的模組和一個專用的Berkeley DB元件之間掙扎的。

總之,日誌管理器本身沒有記錄型別的概念,因此在理論上,它不需要區分檢查點記錄和其他的記錄——它們都僅僅是需要日誌管理器寫到磁碟的不透明位元組串。實際上,日誌管理器維護了後設資料,說明了它確實理解一些記錄的內容。例如,在日誌啟動過程中,日誌管理器檢查所有它能找到的日誌檔案並且識別出最近寫過的日誌檔案。它假定所有該檔案之前的所有日誌檔案都是完整無缺的,然後開始檢查最近的日誌檔案並確定它含有哪些有效的記錄。它從日誌檔案的開頭開始讀,直到遇到一個不能正確校驗的日誌記錄頭才停下來,這意味著到了日誌尾或日誌檔案損壞了。這兩種情況都確定了日誌的邏輯結尾。

在讀取日誌以找到當前日誌尾的過程中,日誌管理器抽取Berkeley DB的記錄型別以尋找檢查點記錄。作為對事務系統的“幫忙”,它把找到的最後一個檢查地點記錄的位置保留在日誌管理器的後設資料中。也就是說,事務系統需要找到最後的檢查點,但是與其讓日誌管理器和事務管理器都去讀取整個日誌來幹這件事,事務管理器把該任務代理給了日誌管理器。這是一個違背抽象邊界而換來效能的典型例子。

這個權衡意味著什麼呢?假設Berkeley DB之外有個系統在使用日誌管理器。如果它碰巧寫了一個檢查點日誌型別對應的值到了Berkeley DB放置自己的記錄型別的同一個位置,那麼日誌管理器將把該記錄識別為一個檢查點記錄。然而,除非應用程式找日誌管理要這些資訊(通過直接讀取日誌後設資料中的cached_ckp_lsn欄位),這些資訊不會影響任何事情。簡而言之,這既不是一個有害的對分層的違背,也不是一個精明的效能優化。

檔案管理部分是日誌管理器與Berkeley DB其他模組間的分離比較模糊的另一個例子。就像前面提到的一樣,大部分Berkeley DB的日誌記錄需要標識一個資料庫。每條日誌記錄都可能包含資料庫的全名,但這樣在日誌空間的角度看將是很昂貴的,也比較難看,因為恢復將需要把這個名字對映到某種形式的控制程式碼以便能夠訪問資料庫(要麼是一個檔案描述符要麼是一個資料庫控制程式碼)。實際上,Berkeley DB在日誌中用一個整數標識資料庫,稱為一個日誌檔案ID,並實現了一系列的函式,統稱為dbreg(database registration的簡稱),來維護檔名和日誌檔案ID的對映。當資料庫被開啟時,這個對映的持久化版本(記錄型別為DBREG_REGISTER)被寫到日誌記錄中。然而,我們也需要這個對映的記憶體表示以支援事務的撤銷和恢復。哪個子系統應該負責維護這個對映呢?

理論上,檔案到日誌檔案ID的對映是一個高層的Berkeley DB函式;它不屬於任何一個子系統,子系統不應有全域性概念。在最初的設計中,這些資訊被留在日誌子系統的資料結構中,因為日誌系統看起來是最好的選擇。然而,在不斷地發現和修復實現中的缺陷時,這個對映支援被從日誌子系統程式碼中抽取出來形成了它自己小子系統,有了自己的物件導向的介面和私有的資料結構。(回過來看,這些資訊邏輯上本應該被放在Berkeley DB環境資訊本身中,在所有子系統之外。)

設計教訓13

極少存在“不重要的Bug”這樣的事情。確實,不時會有一些筆誤,但通常一個Bug意味著有人沒有完全理解他們在做的事情並實現錯了。當你修復Bug時,不要僅看現象,要看底層的原因。如果你願意的話,還應該看看產生誤解的原因,因為這樣可以更好的理解程式的體系結構並發現設計本身更本質的缺陷。

4.9 事務管理器:Txn

我們的最後一個模組是事務管理器,它把各個獨立的模組聯絡在一起以提供事務的ACID屬性(原子性、一致性、隔離性和永續性)。事務管理器負責事務的開始和結束(要麼提交,要麼撤銷),協調日誌管理器和緩衝區管理器做事務檢查點並組織恢復。我們將按順序逐一討論這些領域。

Jim Gray發明了ACID這個縮寫詞來描述事務提供的關鍵屬性 Gra81 。原子性的意思是一個事務中執行的所有操作在資料庫中表現為一個單一的單元——它們要麼都在資料庫中,要麼都不在。一致性的意思是事務把資料庫從一個邏輯一致的狀態轉移到另一個。例如,如果應用程式要求每個員工都必須被安排到一個已在資料庫中定義了的部門,那麼一致性屬性將會確保它(在事務正確書寫時)。隔離性的意思是從每個事務的角度看,它就像是在沒有任何其他併發事務在執行時順序執行的。最後,永續性的意思是一旦事務被提交,它就保持提交狀態——沒有故障可以使得已經提交的事務消失掉。

事務子系統在其他子系統的協助下確保ACID屬性。它採用傳統的事務開始、提交和撤銷操作來分隔事務的開始點和結束點。它也提供了一個prepare呼叫以實現兩階段提交,兩階段提交是在分佈事務之間提供事務屬性的技術,本章對此沒有描述。事務開始要分配一個新的事務識別符號並返回一個事務控制程式碼DB_TXN給應用程式。事務提交要寫一個提交日誌記錄然後強制刷出日誌到磁碟(除非應用程式表明它願意放棄永續性以換取更快的提交處理),保證即使在出現故障時,事務也會被提交。事務撤銷會反向讀取屬於對應事務的日誌記錄,撤銷該事務已經做的每個操作,將資料庫退回到該事務開始前的狀態。

4.9.1 檢查點處理

事務管理器也負責做檢查點。在學術界有很多不同的技術來做檢查點 HR83 。Berkeley DB採用了模糊檢查點的一個變種。從根本上看,做檢查點需要需要將緩衝區從Mpool中寫到磁碟。這是一個很可能代價昂貴的操作,重要的是系統同時能繼續處理新的事務以避免長時間的服務中斷。在檢查點開始時,Berkeley DB檢查當前活動的事務集合以找到它們當中任何一個所寫的最小的LSN。該LSN就是檢查點LSN。事務管理器然後要求Mpool去重新整理快取中的髒頁到磁碟,寫這些緩衝可能會觸發日誌的刷出操作。在所有這些緩衝都被安全寫到磁碟後,事務管理器會寫一個包含檢查點LSN的檢查點記錄。該記錄表明在檢查點LSN之前的日誌記錄描述的所有操作現在都安全的存在磁碟上了。因此,在檢查點LSN之前的日誌記錄就不再需要用來恢復了。這有兩重意思:第一,系統可以回收檢查點LSN之前的任意日誌檔案。第二,恢復只需要處理檢查點LSN之後的日誌記錄。因為檢查點LSN之前的日誌記錄所描述的更新已經被反映在磁碟狀態中了。

注意在檢查點LSN和實際的檢查點記錄之間可能存在很多的日誌記錄。這沒什麼問題,因為那些記錄描述的操作邏輯上發生在檢查點之後,因此如果系統故障了還是需要做恢復的。

4.9.2 恢復

事務性難題的最後一個部分是恢復。恢復的目標是將磁碟資料庫從一個可能不一致的狀態轉到一個一致的狀態。Berkeley DB採用一個相當傳統的兩遍模式,大致對應於“相對於最後的檢查點LSN,撤銷所有沒有提交的事務並重做所有已經提交的事務”。後面將介紹更多的細節。

Berkeley DB需要重新構造從日誌檔案ID到實際的資料庫之間的對映以便它可以重做和撤銷資料庫中的操作。日誌中包含了DBREG_REGISTER日誌記錄的完整歷史,但是資料庫會長時間處於開啟狀態,我們不想保留整個資料庫開啟期間的日誌檔案,而需要一個更有效的方法來訪問這個對映。在寫檢查點記錄前,事務管理器寫下一組DBREG_REGISTER記錄來描述當前的從日誌檔案ID到資料庫的對映。在恢復期間,Berkeley DB使用這些日誌記錄去重新構造檔案對映。

當恢復開始時,事務管理器檢查日誌管理器的cached_ckp_lsn值來判斷最後一個檢查點記錄的位置。該記錄包含檢查點的LSN。Berkeley DB需要從該檢查點LSN開始恢復,但是為了做這件事,它需要重新構造在該檢查點LSN處的日誌檔案ID對映;這些資訊在該檢查點LSN之前的檢查點記錄中。因此,Berkeley DB必須查詢在該檢查點LSN之前的最近一個檢查點記錄。檢查點記錄不僅包含了檢查點LSN,還有前一個檢查點的LSN(以支援這個查詢過程)。恢復從最近的檢查點開始,採用每個檢查點記錄中的prev_lsn欄位去反向遍歷日誌直到它找到了一個出現在檢查點LSN之前的檢查點記錄。演算法如下:

ckp_record = read (cached_ckp_lsn)
ckp_lsn = ckp_record.checkpoint_lsn
cur_lsn = ckp_record.my_lsn
while (cur_lsn > ckp_lsn) {
    ckp_record = read (ckp_record.prev_ckp)
    cur_lsn = ckp_record.my_lsn
}

從前面的演算法找到的檢查點開始,恢復演算法順序讀取到日誌尾以重新構造日誌檔案ID對映。當它到達日誌尾時,對映應該準確的對應系統停止時存在的對映。也是在這個階段中,恢復演算法跟蹤遇到的每個事務提交記錄,記錄它們的事務標識。所有有日誌記錄但是其日誌標識未在事務提交記錄中出現的事務要麼是被回滾了,要麼是從未完成從而也應被視為回滾了。當恢復到日誌尾時,它調轉方向並開始反向讀取日誌。對於遇到的每個事務日誌記錄,它抽取出事務標識並構造出已經提交事務的列表,以決定該記錄是否該被回滾。如果它找到事務標識不屬於提交的事務,就抽取出記錄型別並且呼叫一個該日誌記錄的恢復例程,指導它去撤銷對應的操作。如果該記錄屬於一個已提交的事務,恢復在反向掃描時忽略它。反向掃描一直進行到檢查點LSN(Notes:這裡有個腳註)。最終,恢復再以正向方式最後讀一遍日誌,這次重做所有屬於已提交事務的日誌記錄。當這最後一個階段完成時,恢復做一個檢查點。此時,資料庫完全一致了,可以開始執行應用程式了。

總之,恢復可以被總結為:

  1. 找到最近檢查點記錄中檢查點LSN之前的那個檢查點
  2. 正向讀取日誌以恢復日誌檔案ID對映並構造出已提交事務的列表
  3. 反向讀取日誌到檢查點LSN,撤銷未提交事務的所有操作
  4. 正向讀取日誌,重做已提交事務的所有操作
  5. 做檢查點

理論上,最後一個檢查點是不必要的。實際上,它減少了未來的恢復的時間並使得資料庫處於一個一致的狀態。

設計教訓14

資料庫恢復是一個複雜的主題,很難寫,更難除錯,因為恢復根本不會頻繁的發生。在他的圖靈獎演講中,Edsger Dijkstra認為程式設計天生很難,承認我們不擅此道是智慧的開端。我們作為架構師和程式設計師的目的是使用我們所掌握的工具:設計、問題分解、評審、測試、命名和風格規範以及其它好的習慣來限制程式設計問題為我們能解決的問題。

4.10 結束語

Berkeley DB現在已經年滿20歲了。它可以說是第一個通用的事務性鍵/值儲存,也是NoSQL運動的鼻祖。Berkeley DB繼續作為幾百個商業產品和幾千個開源應用軟體(包括SQL、XML和NoSQL引擎)的底層儲存系統,並在全球有幾百萬個部署。我們在它的開發和維護過程中所學到的經驗教訓都體現在程式碼和上面總結的設計提示中了。我們分享並希望其他的軟體設計者和架構師發現它們有用。

腳註

請注意我們只需要返回到檢查點LSN,而不是它前面的檢查點記錄。


相關文章