本節信息量很大,咱們要從總體上把握 LevelDB 這座大廈的結構。當咱們熟悉了總體的結構,接下來就能夠各個擊破來細緻瞭解它的各類微妙的細節了。算法
LevelDB 有點相似於建築,分爲地基和地面兩部分,也就是磁盤和內存,而地基又比如地殼結構分了不少層級,不一樣層級的數據還會按期從上往下移動 —— 沉積做用。若是磁盤底層的冷數據被修改了,它又會再次進入內存,一段時間後又會被持久化刷回到磁盤文件的淺層,而後再慢慢往下移動到底層,周而復始就比如地球水循環。數據庫
LevelDB 的內存中維護了 2 個跳躍列表,一個是隻讀的 rtable,一個是可修改的 wtable。跳躍列表在個人另外一本書《Redis 深度歷險》中有詳細講解,這裏就再也不細緻重複說明。簡單理解,跳躍列表就是一個 Key 有序的 Set 集合,排序規則由全局的「比較器」決定,默認是字典序。跳躍列表的查找和更新操做時間複雜度都是 Log(n)。數組
跳躍列表是由多個層次的鏈表構成,其中最底層的鏈表存儲了全部的 Key,它們是有序的。普通鏈表並不支持快速二分查找,可是跳躍鏈表的特殊結構可讓最底層的鏈表以近似二分查找算法的效率定位到指定節點。簡單理解就是跳躍列表同時具有了有序數組的快速定位能力和鏈表的高效增刪能力。可是它會付出必定的代價,在實現上有必定的複雜度。bash
若是跳躍列表只存 Key,那 Value 存哪裏呢?答案是 Value 也存在跳躍列表的 Key 中。跳躍列表中存儲的 Key 比較特殊,它是一個複合結構字符串,它同時包含了鍵值對的 Key 和 Value。 多線程
其中 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 整數編碼。 優化
若是跳躍列表中同一個 key 存在多個修改操做,也就是說有多個「複合 Key」,那麼這幾個「複合 Key」 確定會挨在一塊兒按照 sequence 值排序的。當 Get 操做到來時,它會在跳躍列表中定位到 key 所在的位置,選擇這幾個一樣的 key 中 seq 最大的「複合 Key」,提取出其中的 value 值返回。待 Put 和 Delete 操做日誌寫到日誌文件後,其鍵值對合併成「複合 Key」插入到 wtable 的指定位置中。 ui
待 wtable 的大小達到一個閾值,LevelDB 將它凝固成只讀的 rtable,同時生成一個新的 wtable 繼續接受寫操做。rtable 將會被異步線程刷到磁盤中。Get 操做會優先查詢 wtable,若是找不到就去 rtable 中去找,rtable 若是還找不到,再去磁盤文件裏去找。由於 wtable 要支持多線程讀寫,因此訪問它是須要加鎖控制。而 rtable 是隻讀的,它就不須要,可是它的存在時間很短,rtable 一旦生成,很快就會被異步線程序列化到磁盤上,而後就會被置空。可是異步線程序列化也須要耗費必定的時間,若是 wtable 增加過快,很快就被寫滿了,這時候 rtable 尚未完成序列化,而wtable 急需變身怎麼辦?這時寫線程就會阻塞等待異步線程序列化完成,這是 LevelDB 的卡頓點之一,也是將來 RocksDB 的優化點。編碼
圖中還有個日誌文件,記錄了近期的寫操做日誌。若是 LevelDB 遇到突發停機事故,沒有持久化的 wtable 和 rtable 數據就會丟失。這時就必須經過重放日誌文件中的指令數據來恢復丟失的數據。注意到日誌文件也是有兩份的,它和內存的跳躍列表正好對應起來。當 wtable 要變身時,日誌文件也會跟着變身。待 rtable 落盤成功以後,只讀日誌文件就能夠被刪除了。spa
LevelDB 在磁盤上存儲了不少 sst 文件,sst 表示 Sorted String Table,文件裏全部的 Key 都會有序的。每一個文件都會對應一個層級,每一個層級都會有多個文件。底層的文件內容來源於上一層,最終它們都會來源於 0 層文件,而 0 層的文件又來源於內存裏的 rtable 序列化。一個 rtable 會被序列化爲一個完整的 0 層文件。這就是咱們前面所說的「下沉做用」。
從內存的 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 的相關信息。
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 只有 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 內部究竟長什麼樣