鴻篇鉅製 —— LevelDB 的整體架構

碼洞發表於2019-01-10

本節資訊量很大,我們要從整體上把握 LevelDB 這座大廈的結構。當我們熟悉了整體的結構,接下來就可以各個擊破來細緻瞭解它的各種微妙的細節了。

一個比喻

LevelDB 有點類似於建築,分為地基和地面兩部分,也就是磁碟和記憶體,而地基又好比地殼結構分了很多層級,不同層級的資料還會定期從上往下移動 —— 沉積作用。如果磁碟底層的冷資料被修改了,它又會再次進入記憶體,一段時間後又會被持久化刷回到磁碟檔案的淺層,然後再慢慢往下移動到底層,周而復始就好比地球水迴圈。

鴻篇鉅製 —— LevelDB 的整體架構

記憶體結構

LevelDB 的記憶體中維護了 2 個跳躍列表,一個是隻讀的 rtable,一個是可修改的 wtable。跳躍列表在我的另一本書《Redis 深度歷險》中有詳細講解,這裡就不再細緻重複說明。簡單理解,跳躍列表就是一個 Key 有序的 Set 集合,排序規則由全域性的「比較器」決定,預設是字典序。跳躍列表的查詢和更新操作時間複雜度都是 Log(n)。

鴻篇鉅製 —— LevelDB 的整體架構

跳躍列表是由多個層次的連結串列構成,其中最底層的連結串列儲存了所有的 Key,它們是有序的。普通連結串列並不支援快速二分查詢,但是跳躍連結串列的特殊結構可以讓最底層的連結串列以近似二分查詢演算法的效率定位到指定節點。簡單理解就是跳躍列表同時具備了有序陣列的快速定位能力和連結串列的高效增刪能力。但是它會付出一定的代價,在實現上有一定的複雜度。

如果跳躍列表只存 Key,那 Value 存哪裡呢?答案是 Value 也存在跳躍列表的 Key 中。跳躍列表中儲存的 Key 比較特殊,它是一個複合結構字串,它同時包含了鍵值對的 Key 和 Value。

鴻篇鉅製 —— LevelDB 的整體架構
其中 sequence 為全域性自增序列號,LevelDB 遇到一個修改操作,全域性序列號自動加一。LevelDB 中的 Key 儲存了多個版本的 Value。LevelDB 使用序列號來標記鍵值對的版本,序列號越大,對應的鍵值對越新。

type 為資料型別,標記是 Put 還是 Delete 操作,只有兩個取值,0 表示 Delete,1 表示 Put。

internal_key = key + sequence + type
Key = internal_key_size + internal_key + value_size + value
複製程式碼

如果是刪除操作,後面的 value_size 欄位值 為 0,value 欄位值是空的。我們要將 Delete 操作等價看成 Put 操作。同時為了節省儲存空間,internal_key_size 和 value_size 都要採用 varint 整數編碼。

鴻篇鉅製 —— LevelDB 的整體架構
如果跳躍列表中同一個 key 存在多個修改操作,也就是說有多個「複合 Key」,那麼這幾個「複合 Key」 肯定會挨在一起按照 sequence 值排序的。當 Get 操作到來時,它會在跳躍列表中定位到 key 所在的位置,選擇這幾個同樣的 key 中 seq 最大的「複合 Key」,提取出其中的 value 值返回。

待 Put 和 Delete 操作日誌寫到日誌檔案後,其鍵值對合併成「複合 Key」插入到 wtable 的指定位置中。

鴻篇鉅製 —— LevelDB 的整體架構
待 wtable 的大小達到一個閾值,LevelDB 將它凝固成只讀的 rtable,同時生成一個新的 wtable 繼續接受寫操作。rtable 將會被非同步執行緒刷到磁碟中。Get 操作會優先查詢 wtable,如果找不到就去 rtable 中去找,rtable 如果還找不到,再去磁碟檔案裡去找。

因為 wtable 要支援多執行緒讀寫,所以訪問它是需要加鎖控制。而 rtable 是隻讀的,它就不需要,但是它的存在時間很短,rtable 一旦生成,很快就會被非同步執行緒序列化到磁碟上,然後就會被置空。但是非同步執行緒序列化也需要耗費一定的時間,如果 wtable 增長過快,很快就被寫滿了,這時候 rtable 還沒有完成序列化,而wtable 急需變身怎麼辦?這時寫執行緒就會阻塞等待非同步執行緒序列化完成,這是 LevelDB 的卡頓點之一,也是未來 RocksDB 的優化點。

圖中還有個日誌檔案,記錄了近期的寫操作日誌。如果 LevelDB 遇到突發停機事故,沒有持久化的 wtable 和 rtable 資料就會丟失。這時就必須通過重放日誌檔案中的指令資料來恢復丟失的資料。注意到日誌檔案也是有兩份的,它和記憶體的跳躍列表正好對應起來。當 wtable 要變身時,日誌檔案也會跟著變身。待 rtable 落盤成功之後,只讀日誌檔案就可以被刪除了。

磁碟結構

LevelDB 在磁碟上儲存了很多 sst 檔案,sst 表示 Sorted String Table,檔案裡所有的 Key 都會有序的。每個檔案都會對應一個層級,每個層級都會有多個檔案。底層的檔案內容來源於上一層,最終它們都會來源於 0 層檔案,而 0 層的檔案又來源於記憶體裡的 rtable 序列化。一個 rtable 會被序列化為一個完整的 0 層檔案。這就是我們前面所說的「下沉作用」。

鴻篇鉅製 —— LevelDB 的整體架構

從記憶體的 rtable 序列化成 0 層 sst 檔案稱之為「Minor Compaction」,從 n 層 sst 檔案下沉到 n+1 層 sst 檔案稱之為「Major Compaction」。之所以這樣區分是因為 Minor 速度很快耗費資源少,將 rtable 完整地序列化為一個 sst 檔案就完事了。而 Major 會涉及到多個檔案之間的合併操作,耗費資源多,速度慢。層級越深的檔案總容量越大,在 LevelDB 原始碼裡有一個層級容量公式,容量和層級呈指數級關係。而通常每個 sst 檔案的大小都差不多,區別就成了每一層的檔案數量不一樣。

capacity = level > 0 && 10^(level+1) M
複製程式碼

每個檔案裡面的 Key 都是有序的,也就是說它內部的 Key 取值會有一個確定的範圍。0 層檔案和其它層檔案有一個明顯的區別那就是其它層內部的檔案之間範圍不會重疊,它們按照 Key 的順序嚴格做了切分。而 0 層檔案的內容是直接從記憶體 dump 下來的,所以 0 層的多個檔案的 Key 取值範圍會有重疊。

當記憶體出現讀 miss 要去磁碟搜尋時,會首先從 0 層搜尋,如果搜不到再去更深層次搜尋。

如果是其它層級,搜尋速度會很快,因為可以根據 Key 的範圍快速確定它可能會位於哪個檔案中。但是對於 0 層,因為檔案 Key 範圍會重疊,所以它可能存在於多個檔案中,那就需要對這多個檔案進行搜尋。正因如此,LevelDB 限制了 0 層檔案的數量,如果數量超出了預設的 4 個,就需要「下沉」到 1 層,這個「下沉」操作就是 Major Compaction。

所有檔案的 Key 取值範圍、層級和其它元資訊會儲存在資料庫目錄裡面的 MANIFEST 檔案中。資料庫開啟時,讀取一下這個檔案就知道了所有檔案的層級和 Key 取值範圍。

MANIFEST 檔案也有版本號,它的版本號體現在檔名上如 MANIFEST-000361。每一次重新開啟資料庫,都會生成一個新的 MANIFEST 檔案,具有不同的版本號,然後還需要將老的 MANIFEST 檔案刪除。

資料庫目錄中還有另外一個檔案 CURRENT,它裡面的內容很簡單,就是當前 MANIFEST 的檔名。LevelDB 首先讀取 CURRENT 檔案才知道哪個 MANIFEST 檔案是有效檔案。在遇到斷電時,會存在一個小概率中間狀態,新舊 MANIFEST 檔案共存於資料庫目錄中。

我們知道 LevelDB 的資料庫目錄不允許多程式同時訪問,那它是如何防止其它程式意外對這個目錄檔案進行讀寫操作呢?仔細觀察資料庫目錄,你還會發現一個名稱為 LOCK 的檔案,它就是控制多程式訪問資料庫的關鍵。當一個程式開啟了資料庫時,會在這個檔案上加上互斥檔案鎖,程式結束時,鎖就會自動釋放。

還有最後一個不那麼重要的操作日誌檔案 LOG,它記錄了資料庫的一系列關鍵性操作日誌,例如每一次 Minor 和 Major Compaction 的相關資訊。

鴻篇鉅製 —— LevelDB 的整體架構

多路歸併

Compaction 是比較耗費資源的操作,為了不影響線上的讀寫操作,LevelDB 將 Compaction 工作交給一個單一的非同步執行緒來完成。如果工作量巨大,這個單一的非同步執行緒也會有點吃不消。當非同步執行緒吃不消的時候,線上記憶體的讀寫操作也會收到影響。因為只有 rtable 沉到磁碟裡了,wtable 才可以變身。只有 wtable 變身了,才會有新的 wtable 被建立來容納後續更多的鍵值對。總之就是一環套一環,環環相扣。

下面我們來研究一下 Compaction 。Minor Compaction 很好理解,就是內容空間有限,所以需要將 rtable 中的資料 dump 到磁碟 0 層檔案。那為什麼需要從 0 層檔案 Compact 下沉到 1 層檔案呢?因為 0 層檔案如果過多,就會影響查詢效率。前面我們提到 0 層檔案之間的 Key 範圍會有重疊,所以單個 Key 可能存在於多個檔案中,IO 讀次數將會被檔案的數量放大。通過 Major Compaction 可以減少 0 層檔案的數量,提升讀效率。那是不是隻需要下沉到 1 層檔案就可以了呢?那 LevelDB 究竟是什麼原因需要這麼多層級呢?

鴻篇鉅製 —— LevelDB 的整體架構
假設 LevelDB 只有 2 層( 0 層和 1 層),那麼時間一長,1 層肯定會累計大量的檔案。當 0 層的檔案需要下沉時,也就是 Major Compaction 要來了,假設只下沉一個 0 層檔案,它不是簡簡單單地將檔案元資訊的層數從 0 改成 1 就可以了。它需要繼續保持 1 層檔案的有序性,每個檔案中的 Key 取值範圍要保持沒有重疊。它不能直接將 0 層檔案中的鍵值對分散插入或者追加到 1 層的所有檔案中,因為 sst 檔案是緊湊儲存的,插入操作肯定涉及到磁碟塊的移動。再說還有刪除操作,它需要幹掉 1 層檔案中的某些已刪除的鍵值對,避免它們持續佔用空間。

那 LevelDB 究竟是怎麼做的呢?它採用多路歸併演算法,將相關的 0 層檔案和 1 層 sst 檔案作為輸入,進行多路歸併,生成多個新的 1 層 sst 檔案,再將老的 sst 檔案幹掉,同時還會生成新的 MANIFEST 檔案。對於每個 0 層檔案,它會根據 Key 的取值範圍搜尋 1 層檔案中和它的範圍有重疊部分的 sst 檔案。如果 1 層檔案數量過多,每次多路歸併涉及到的檔案數量太多,歸併演算法就會非常耗費資源。所以 LevelDB 同樣也需要控制 1 層檔案的數量,當 1 層容量滿時,就會繼續下沉到 2 層、3 層、4 層等。

非 0 層的多路歸併資源消耗要少一些,因為單個檔案的 Key 取值範圍有限,能覆蓋到下一層的檔案數量有限,參與多路歸併的輸入檔案就少了很多。但是這個邏輯有個漏洞,那就是上下層的檔案數量有 10 倍的差距,按照平均範圍間隔來算,意味著上層平均一個檔案的取值範圍會覆蓋到下一層的 10 個檔案。所以說非 0 層的多路歸併資源消耗其實也不低,Major Compaction 就是一個比較消耗資源的操作。

下一節我們將深入磁碟檔案內部結構,看看每一個 sstable 內部究竟長什麼樣

鴻篇鉅製 —— LevelDB 的整體架構

相關文章