通常,如果你正在設計一個儲存系統,例如一個檔案系統或者資料庫,你主要問題之一是如何把資料儲存到磁碟上。你不僅要注意儲存索引資料,也要注意為儲存物件分配空間;你不僅要擔憂當你想擴大一個現有的模組(例如,附加到檔案)會發生什麼,注意新舊物件交替時候產生的儲存碎片。所有的這些增加了很多複雜度,解決方案往往或者有缺陷或者效率低。
日誌結構化儲存(Log structured storage)是一項可以解決所有這些問題的技術。它來源於20世紀80年代的日誌結構檔案系統(Log Structured File Systems),但是最近越來越多的把它當作構建資料庫引擎儲存的一種方法使用。在其原始的檔案系統應用程式中, 它受到一些缺點的影響而不能被廣泛使用,但我們將會看到,這些對於資料庫引擎來說都已經不是問題了,而且日誌結構化儲存除了簡單的儲存管理之外,還為資料庫引擎帶來了額外的好處。
顧名思義,日誌結構化儲存系統的基礎組織是一個日誌,即一個只可新增資料輸入的序列。每當你有新的資料要寫入的時候,你只需要簡單的新增它到日誌的末尾,而不需要在磁碟中為它尋找一個位置。索引資料可以通過對後設資料進行同樣處理得到:後設資料的更新同樣新增到日誌中。這看起來似乎效率不高,但是基於磁碟的索引結構(比如B-樹)通常非常廣泛,所以我們每次寫入是需要更新的索引節點數目通常非常小。讓我們看看一個簡單的例子。我們將從僅包含一個單項資料的日誌開始,並且有一個索引節點引用它:
到目前為止一切順利。現在,假設我們想增加第二個元素。我們把新的元素新增到日誌的末尾,然後我們更新索引項,並且也把更新後的版本新增到日誌:
最初的索引項(A)仍然在日誌檔案中,但不再使用了:它被新的項A’替換了,A’不僅引用新項Bar,還引用原始的未更改的Foo的副本。當某物想要讀取我們的檔案系統的時候,它需尋找索引節點的根節點,然後可以像其他任何使用基於磁碟的索引的系統中一樣使用它。
快速的尋找索引的根節點看來很必要。最簡單的方法是隻看日誌的最後一塊,因為我們最後寫的東西常常是索引的根節點。然而,這是不理想的,因為有可能當你試圖讀取索引的時候,另外一個程式中途新增到日誌中。我們可以通過一個單程式段(例如在日誌檔案的開頭)來避免這種情況的發生,這樣的單程式段包含一個當前根節點的指標。當我們更新日誌的時候,我們重寫第一項來保證它指向新的根節點。為了簡便起見,我們沒有在圖表中展示出來。
下面,讓我們看看更新元素時會發生什麼。例如我們修改Foo:
我們首先在日誌的尾部寫入一個Foo的全新副本。然後,我們再次更新索引節點(這裡只有A’),並且也把它們寫到日誌的末尾。再次,Foo的舊副本仍在日誌中,只是它不再被更新的索引引用了。
你可能已經意識到這個系統不會無限期地持續下去。在所有的舊資料佔據空間的情況下,我們將在某一時刻用完所有的儲存空間。在一個檔案系統中,是通過將磁碟看成一個環形緩衝器,覆蓋老的日誌資料,來解決這個問題的。當遇到這種情況,仍然有效的資料只是被重新新增到日誌中,就像是新寫入的一樣,這釋放了被覆蓋的老的副本。
在普通的檔案系統中,會有一個我在前面提到的缺點出現。隨著磁碟被佔滿,檔案系統需要花費越來越多的時間來做垃圾回收和將資料寫回日誌的開頭。當你達到80%的時候,你的檔案系統幾乎慢慢停下來。
然而,如果你使用的是日誌結構化儲存作為資料庫引擎,這不是一個問題!我們將會在一個普通的檔案系統上執行這個,所以我們可以用它來使得我們的生活更簡單。如果我們將資料庫分成一些定長塊,那麼當我們需要回收一些空間的時候,我們可以挑選一個塊,重寫任意仍然活躍的資料,並且刪除這個塊。上面例子的第一部分是開始看起來有一點稀疏,所以讓我們那樣做吧:
我們在這裡做的是取Bar的現存副本並且把它寫到日誌的末尾,然後像上面所說的那樣更新索引節點。既然我們已經做好了,第一個日誌段已經完全空了,可以刪除了。
這種方法和檔案系統的方法相比有幾個優點。一開始,我們並沒有侷限於最先刪除最老的部分:如果有一箇中間部分幾乎是空的,我們可以選擇垃圾回收這個,而不是最老的那個。這個對於那些有需要停留很長時間或者需要反覆重寫的資料的資料庫尤其有用:我們不想浪費太多時間在重寫相同的沒有被修改的資料上。我們在什麼時候垃圾回收上也有了更多的靈活性:我們通常可以等到一個部分幾乎沒用的時候再回收它,進一步縮小了額外的工作量。
不過,這種方法應用在資料庫上還有優點。為了保持事務一致性,資料庫通常使用一個“預寫日誌”( Write Ahead Log,WAL)。當一個資料庫想把一個事務更改持久化到磁碟上時,它首先將所有的改變寫到WAL上,重新整理這些到磁碟上,然後更新實際的資料庫檔案。這使得它可以通過重新同步記錄在WAL上的事務來從崩潰中恢復。不過,如果我們使用日誌結構化儲存,預寫日誌是資料庫檔案,所以我們只需要重寫一次資料。在恢復狀態,我們只要開啟資料庫,從最後記錄的索引標題開始,並且線性地向前搜尋,從資料中重建任意缺失的索引更新。
利用上面的恢復計劃,我們也可以進一步優化寫入。我們可以把它們快取到記憶體中,僅僅定期把它們寫到磁碟中,而不是在每次寫入的時候都寫入更新索引節點。只要我們提供一些方法從不完整的事務中辨別出完整事務,我們的恢復機制會注意重建崩潰後的東西。
利用這種方法,備份也會更簡單:我們可以不斷地逐步地副本每個新的日誌部分到備份媒介,因為它已經完成了,備份資料庫。為了恢復,我們僅需再次執行恢復過程。
這個系統最後一個重要的優勢涉及到資料庫中的併發性和事務語義。為了保證事務一致性,大多數資料庫使用複雜的系統鎖來控制哪個程式可以在什麼時候更新資料。根據所需要的一致性程度,這可能不但需要作者寫入時鎖定資料,而且需要讀者取出鎖來保證在他們閱讀的時候資料不會被修改,如果有足夠多的併發讀取發生,可能會導致明顯的效能下降,即使有相對較低的寫入率。
我們可以用多版本併發控制(Multiversion Concurrency Control, MVCC)來解決這個問題。當一個節點想從資料庫中讀取,它尋找當前的根索引節點,利用這個節點來處理其剩餘的事務。因為在一個基於日誌的儲存系統中,現存資料是不會被修改的,當現在的程式獲取到處理機會的時候擁有一個資料庫快照:一個併發性事務可以做的任何事情都不會影響它在資料庫中的檢視。就像那樣,我們可以無鎖閱讀了!
當涉及到寫回資料,我們可是使用樂觀鎖(Optimistic concurrency)。在一個典型的讀-修改-寫週期中,我們首先像上面說的那樣執行讀取操作。然後,為了寫入變換,我們取出資料庫的寫鎖,並且驗證在第一階段中我們讀入的資料都沒有被修改過。通過檢視索引,我們可以很快的做到這一點,而且可以檢查我們關心的地址和我們最後一次看到的是不是一樣的。如果是一樣的,沒有任何寫入發生,我們可以繼續進行修改。如果不同,發生了衝突的事務,我們只要在讀入階段回滾然後重新開始。
我這樣稱讚它,你可能會想知道什麼系統已經用了這個演算法。令人驚訝的是我知道的很少有用的,但是這裡有一些著名的例子:
- 儘管原始的Berkeley資料庫使用了相當標準的體系結構,Java埠,BDB-JE使用了我們剛剛提到的所有元件。
- CouchDB使用了上面提到的系統,不同之處在於,CouchDB當有足夠多的過期資料累積的時候就重寫整個資料庫,而不是把日誌分成若干部分然後分別回收。
- PostgreSQL使用MVCC,而且它的預寫日誌是結構化的,從而可以支援我們描述的增量備份方法。
- App Engine的資料儲存是基於Bigtable的,這和基於磁碟儲存是不同的方法,但是事務層使用了開放式併發。
如果你知道其他資料庫系統使用了這篇文章中描述的方法,請在評論中留言!