LevelDB在以太坊中的研究筆記

LevelDB基本介紹

LevelDB is a fast key-value storage library written at Google that provides an ordered mapping from string keys to string values.

Authors: Sanjay Ghemawat ([email protected]) and Jeff Dean ([email protected])

Features

  • Keys and values are arbitrary byte arrays.
  • Data is stored sorted by key.
  • Callers can provide a custom comparison function to override the sort order.
  • The basic operations are Put(key,value)Get(key)Delete(key).
  • Multiple changes can be made in one atomic batch.
  • Users can create a transient snapshot to get a consistent view of data.
  • Forward and backward iteration is supported over the data.
  • Data is automatically compressed using the Snappy compression library.
  • External activity (file system operations etc.) is relayed through a virtual interface so users can customize the operating system interactions.

Documentation

LevelDB library documentation is online and bundled with the source code.

Limitations

  • This is not a SQL database. It does not have a relational data model, it does not support SQL queries, and it has no support for indexes.
  • Only a single process (possibly multi-threaded) can access a particular database at a time.
  • There is no client-server support builtin to the library. An application that needs such support will have to wrap their own server around the library.

源代碼地址:https://github.com/google/leveldb

LevelDB是Google開源的持久化KV單機數據庫,具有很高的隨機寫,順序讀/寫性能,但是隨機讀的性能很一般,也就是說,LevelDB很適合應用在查詢較少,而寫很多的場景。

LevelDB是一個功能上類Redis的key/value存儲引擎。Redis是一個基於純內存的存儲系統,而LevelDB是基於內存 + SSD的架構,內存存儲最新的修改和熱數據(可理解爲緩存),SSD作爲全量數據的持久化存儲,所以LevelDB具備比redis更高的存儲量,且具備良好的寫入性能,讀性能就略差了,主要原因是由於冷數據需要進行磁盤IO。Facebook在levelDB的基礎上優化了 RocksDB。

LevelDB一般採用 proxy + 多機主備 的形式搭建集羣,常見的兼容Redis協議,可通過Redis客戶端訪問。

LevelDB應用了LSM (Log Structured Merge) 策略,lsm_tree對索引變更進行延遲及批量處理,並通過一種類似於歸併排序的方式高效地將更新遷移到磁盤,降低索引插入開銷。

LevelDB的架構

LevelDB 有點類似於建築,分爲地基和地面兩部分,也就是磁盤內存,而地基又好比地殼結構分了很多層級,不同層級的數據還會定期從上往下移動 —— 沉積作用。如果磁盤底層的冷數據被修改了,它又會再次進入內存,一段時間後又會被持久化刷回到磁盤文件的淺層,然後再慢慢往下移動到底層。

內存結構

LevelDB 的內存中維護了 2 個跳躍列表,一個是隻讀的 rtable,一個是可修改的 wtable。簡單理解,跳躍列表就是一個 Key 有序的 Set 集合,key通過分層連接的方式,提高鏈表的查找速率。跳躍列表的查找和更新操作時間複雜度都是 Log(n)。

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

rtable和wtable的數據結構

其中 sequence 爲全局自增序列號,LevelDB 遇到一個修改操作,全局序列號自動加一。LevelDB 中的 Key 存儲了多個版本的 Value。LevelDB 使用序列號來標記鍵值對的版本,序列號越大,對應的鍵值對越新。

type 爲數據類型,標記是 Put 還是 Delete 操作,只有兩個取值,0 表示 Delete,1 表示 Put。

如果是刪除操作,後面的 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 的指定位置中。

待 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 層文件。這就是我們前面所說的「下沉作用」。

從內存的 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 就是一個比較消耗資源的操作。

LevelDB的整體架構

LevelDb本質上是一套存儲系統以及在這套存儲系統上提供的一些操作接口。爲了便於理解整個系統及其處理流程,我們可以從兩個不同的角度來看待LevleDb:靜態角度和動態角度。從靜態角度,可以假想整個系統正在運行過程中(不斷插入刪除讀取數據),此時我們給LevelDb照相,從照片可以看到之前系統的數據在內存和磁盤中是如何分佈的,處於什麼狀態等;從動態的角度,主要是瞭解系統是如何寫入一條記錄,讀出一條記錄,刪除一條記錄的,同時也包括除了這些接口操作外的內部操作比如compaction,系統運行時崩潰後如何恢復系統等等方面。

LevelDb作爲存儲系統,數據記錄的存儲介質包括內存以及磁盤文件,如果像上面說的,當LevelDb運行了一段時間,此時我們給LevelDb進行透視拍照,那麼您會看到如下一番景象:

leveldb包含六個部分:內存中的MemTable和Immutalbe MemTable及磁盤上的Current文件, Manifest文件, log文件, SSTable文件。

當應用寫入一條Key:Value記錄的時候,LevelDb會先往log文件裏寫入,成功後將記錄插進Memtable中,這樣基本就算完成了寫入操作,因爲一次寫入操作只涉及一次磁盤順序寫和一次內存寫入,所以這是爲何說LevelDb寫入速度極快的主要原因。 
Log文件在系統中的作用主要是用於系統崩潰恢復而不丟失數據,假如沒有Log文件,因爲寫入的記錄剛開始是保存在內存中的,此時如果系統崩潰,內存中的數據還沒有來得及Dump到磁盤,所以會丟失數據(Redis就存在這個問題)。爲了避免這種情況,LevelDb在寫入內存前先將操作記錄到Log文件中,然後再記入內存中,這樣即使系統崩潰,也可以從Log文件中恢復內存中的Memtable,不會造成數據的丟失。

當Memtable插入的數據佔用內存到了一個界限後,需要將內存的記錄導出到外存文件中,LevleDb會生成新的Log文件和Memtable,原先的Memtable就成爲Immutable Memtable,顧名思義,就是說這個Memtable的內容是不可更改的,只能讀不能寫入或者刪除。新到來的數據被記入新的Log文件和Memtable,LevelDb後臺調度會將Immutable Memtable的數據導出到磁盤,形成一個新的SSTable文件。SSTable就是由內存中的數據不斷導出並進行Compaction操作後形成的,而且SSTable的所有文件是一種層級結構,第一層爲Level 0,第二層爲Level 1,依次類推,層級逐漸增高,這也是爲何稱之爲LevelDb的原因。

SSTable中的文件是Key有序的,就是說在文件中小key記錄排在大Key記錄之前,各個Level的SSTable都是如此,但是這裏需要注意的一點是:Level 0的SSTable文件(後綴爲.sst)和其它Level的文件相比有特殊性:這個層級內的.sst文件,兩個文件可能存在key重疊,比如有兩個level 0的sst文件,文件A和文件B,文件A的key範圍是:{bar, car},文件B的Key範圍是{blue,samecity},那麼很可能兩個文件都存在key=”blood”的記錄。對於其它Level的SSTable文件來說,則不會出現同一層級內.sst文件的key重疊現象,就是說Level L中任意兩個.sst文件,那麼可以保證它們的key值是不會重疊的。這點需要特別注意,後面您會看到很多操作的差異都是由於這個原因造成的。

SSTable中的某個文件屬於特定層級,而且其存儲的記錄是key有序的,那麼必然有文件中的最小key和最大key,這是非常重要的信息,LevelDb應該記下這些信息。Manifest就是幹這個的,它記載了SSTable各個文件的管理信息,比如屬於哪個Level,文件名稱叫啥,最小key和最大key各自是多少。下圖是Manifest所存儲內容的示意:

圖中只顯示了兩個文件(manifest會記載所有SSTable文件的這些信息),即Level 0的test.sst1和test.sst2文件,同時記載了這些文件各自對應的key範圍,比如test.sstt1的key範圍是“an”到 “banana”,而文件test.sst2的key範圍是“baby”到“samecity”,可以看出兩者的key範圍是有重疊的。

Current文件是幹什麼的呢?這個文件的內容只有一個信息,就是記載當前的manifest文件名。因爲在LevleDb的運行過程中,隨着Compaction的進行,SSTable文件會發生變化,會有新的文件產生,老的文件被廢棄,Manifest也會跟着反映這種變化,此時往往會新生成Manifest文件來記載這種變化,而Current則用來指出哪個Manifest文件纔是我們關心的那個Manifest文件。

以上介紹的內容就構成了LevelDb的整體靜態結構,在LevelDb日知錄接下來的內容中,我們會首先介紹重要文件或者內存數據的具體數據佈局與結構。

log文件

LevelDb對於一個log文件,會把它切割成以32K爲單位的物理Block,每次讀取的單位以一個Block作爲基本讀取單位,下圖展示的log文件由3個Block構成,所以從物理佈局來講,一個log文件就是由連續的32K大小Block構成的。

 

在應用的視野裏是看不到這些Block的,應用看到的是一系列的Key:Value對,在LevelDb內部,會將一個Key:Value對看做一條記錄的數據,另外在這個數據前增加一個記錄頭,用來記載一些管理信息,以方便內部處理,下圖顯示了一個記錄在LevelDb內部是如何表示的。

 

記錄頭包含三個字段,ChechSum是對“類型”和“數據”字段的校驗碼,爲了避免處理不完整或者是被破壞的數據,當LevelDb讀取記錄數據時候會對數據進行校驗,如果發現和存儲的CheckSum相同,說明數據完整無破壞,可以繼續後續流程。“記錄長度”記載了數據的大小,“數據”則是上面講的Key:Value數值對,“類型”字段則指出了每條記錄的邏輯結構和log文件物理分塊結構之間的關係,具體而言,主要有以下四種類型:FULL/FIRST/MIDDLE/LAST。

如果記錄類型是FULL,代表了當前記錄內容完整地存儲在一個物理Block裏,沒有被不同的物理Block切割開;如果記錄被相鄰的物理Block切割開,則類型會是其他三種類型中的一種。

假設目前存在三條記錄,Record A,Record B和Record C,其中Record A大小爲10K,Record B 大小爲80K,Record C大小爲12K,那麼其在log文件中的邏輯佈局會如上圖所示。Record A是圖中藍色區域所示,因爲大小爲10K<32K,能夠放在一個物理Block中,所以其類型爲FULL;Record B 大小爲80K,而Block 1因爲放入了Record A,所以還剩下22K,不足以放下Record B,所以在Block 1的剩餘部分放入Record B的開頭一部分,類型標識爲FIRST,代表了是一個記錄的起始部分;Record B還有58K沒有存儲,這些只能依次放在後續的物理Block裏面,因爲Block 2大小隻有32K,仍然放不下Record B的剩餘部分,所以Block 2全部用來放Record B,且標識類型爲MIDDLE,意思是這是Record B中間一段數據;Record B剩下的部分可以完全放在Block 3中,類型標識爲LAST,代表了這是Record B的末尾數據;圖中黃色的Record C因爲大小爲12K,Block 3剩下的空間足以全部放下它,所以其類型標識爲FULL。

從這個小例子可以看出邏輯記錄和物理Block之間的關係,LevelDb一次物理讀取爲一個Block,然後根據類型情況拼接出邏輯記錄,供後續流程處理。

SSTable文件

Log文件是物理分塊的,SSTable也一樣會將文件劃分爲固定大小的物理存儲塊,但是兩者邏輯佈局大不相同,根本原因是:Log文件中的記錄是Key無序的,即先後記錄的key大小沒有明確大小關係,而.sst文件內部則是根據記錄的Key由小到大排列的,從下面介紹的SSTable佈局可以體會到Key有序是爲何如此設計.sst文件結構的關鍵。 

 

上圖展示了一個.sst文件的物理劃分結構,同Log文件一樣,也是劃分爲固定大小的存儲塊,每個Block分爲三個部分,Block部分是數據存儲區, 藍色的Type區用於標識數據存儲區是否採用了數據壓縮算法(Snappy壓縮或者無壓縮兩種),CRC部分則是數據校驗碼,用於判別數據是否在生成和傳輸中出錯。

以上是.sst的物理佈局,下面介紹.sst文件的邏輯佈局,所謂邏輯佈局,就是說盡管大家都是物理塊,但是每一塊存儲什麼內容,內部又有什麼結構等。下圖展示了.sst文件的內部邏輯解釋。

可以看出,從大的方面,可以將.sst文件劃分爲數據存儲區和數據管理區,數據存儲區存放實際的Key:Value數據,數據管理區則提供一些索引指針等管理數據,目的是更快速便捷的查找相應的記錄。兩個區域都是在上述的分塊基礎上的,就是說文件的前面若干塊實際存儲KV數據,後面數據管理區存儲管理數據。管理數據又分爲四種不同類型:紫色的Meta Block,紅色的MetaBlock 索引和藍色的數據索引塊以及一個文件尾部塊。

LevelDb 1.2版對於Meta Block尚無實際使用,只是保留了一個接口,估計會在後續版本中加入內容,下面我們看看數據索引區和文件尾部Footer的內部結構。

上圖是數據索引的內部結構示意圖。再次強調一下,Data Block內的KV記錄是按照Key由小到大排列的,數據索引區的每條記錄是對某個Data Block建立的索引信息,每條索引信息包含三個內容,以上圖所示的數據塊i的索引Index i來說:紅色部分的第一個字段記載大於等於數據塊i中最大的Key值的那個Key,第二個字段指出數據塊i在.sst文件中的起始位置,第三個字段指出Data Block i的大小(有時候是有數據壓縮的)。後面兩個字段好理解,是用於定位數據塊在文件中的位置的,第一個字段需要詳細解釋一下,在索引裏保存的這個Key值未必一定是某條記錄的Key,以上圖的例子來說,假設數據塊i 的最小Key=“samecity”,最大Key=“the best”;數據塊i+1的最小Key=“the fox”,最大Key=“zoo”,那麼對於數據塊i的索引Index i來說,其第一個字段記載大於等於數據塊i的最大Key(“the best”)同時要小於數據塊i+1的最小Key(“the fox”),所以例子中Index i的第一個字段是:“the c”,這個是滿足要求的;而Index i+1的第一個字段則是“zoo”,即數據塊i+1的最大Key。

文件末尾Footer塊的內部結構見下圖,metaindex_handle指出了metaindex block的起始位置和大小;inex_handle指出了index Block的起始地址和大小;這兩個字段可以理解爲索引的索引,是爲了正確讀出索引值而設立的,後面跟着一個填充區和魔數。

上面主要介紹的是數據管理區的內部結構,下面我們看看數據區的一個Block的數據部分內部是如何佈局的.下圖是其內部佈局示意圖。

從圖中可以看出,其內部也分爲兩個部分,前面是一個個KV記錄,其順序是根據Key值由小到大排列的,在Block尾部則是一些“重啓點”(Restart Point),其實是一些指針,指出Block內容中的一些記錄位置。

“重啓點”是幹什麼的呢?我們一再強調,Block內容裏的KV記錄是按照Key大小有序的,這樣的話,相鄰的兩條記錄很可能Key部分存在重疊,比如key i=“the Car”,Key i+1=“the color”,那麼兩者存在重疊部分“the c”,爲了減少Key的存儲量,Key i+1可以只存儲和上一條Key不同的部分“olor”,兩者的共同部分從Key i中可以獲得。記錄的Key在Block內容部分就是這麼存儲的,主要目的是減少存儲開銷。“重啓點”的意思是:在這條記錄開始,不再採取只記載不同的Key部分,而是重新記錄所有的Key值,假設Key i+1是一個重啓點,那麼Key裏面會完整存儲“the color”,而不是採用簡略的“olor”方式。Block尾部就是指出哪些記錄是這些重啓點的。

在Block內容區,每個KV記錄的內部結構是怎樣的?上圖給出了其詳細結構,每個記錄包含5個字段:key共享長度,比如上面的“olor”記錄, 其key和上一條記錄共享的Key部分長度是“the c”的長度,即5;key非共享長度,對於“olor”來說,是4;value長度指出Key:Value中Value的長度,在後面的Value內容字段中存儲實際的Value值;而key非共享內容則實際存儲“olor”這個Key字符串。

MemTable詳解

內存中的數據結構Memtable,Memtable在整個體系中的重要地位也不言而喻。總體而言,所有KV數據都是存儲在Memtable,Immutable Memtable和SSTable中的,Immutable Memtable從結構上講和Memtable是完全一樣的,區別僅僅在於其是隻讀的,不允許寫入操作,而Memtable則是允許寫入和讀取的。當Memtable寫入的數據佔用內存到達指定數量,則自動轉換爲Immutable Memtable,等待Dump到磁盤中,系統會自動生成新的Memtable供寫操作寫入新數據,理解了Memtable,那麼Immutable Memtable自然不在話下。

LevelDb的MemTable提供了將KV數據寫入,刪除以及讀取KV記錄的操作接口,但是事實上Memtable並不存在真正的刪除操作,刪除某個Key的Value在Memtable內是作爲插入一條記錄實施的,但是會打上一個Key的刪除標記,真正的刪除操作是Lazy的,會在以後的Compaction過程中去掉這個KV。

需要注意的是,LevelDb的Memtable中KV對是根據Key大小有序存儲的,在系統插入新的KV時,LevelDb要把這個KV插到合適的位置上以保持這種Key有序性。其實,LevelDb的Memtable類只是一個接口類,真正的操作是通過背後的SkipList來做的,包括插入操作和讀取操作等,所以Memtable的核心數據結構是一個SkipList。  

SkipList是由William Pugh發明他在Communications of the ACM June 1990, 33(6) 668-676 發表了Skip lists: a probabilistic alternative to balanced trees在該論文中詳細解釋了SkipList的數據結構和插入刪除操作

SkipList是平衡樹的一種替代數據結構但是和紅黑樹不相同的是SkipList對於樹的平衡的實現是基於一種隨機化的算法的這樣也就是說SkipList的插入和刪除的工作是比較簡單的

關於SkipList的詳細介紹可以參考這篇文章http://www.cnblogs.com/xuqiang/archive/2011/05/22/2053516.html講述的很清楚LevelDbSkipList基本上是一個具體實現並無特殊之處

SkipList不僅是維護有序數據的一個簡單實現,而且相比較平衡樹來說,在插入數據的時候可以避免頻繁的樹節點調整操作,所以寫入效率是很高的,LevelDb整體而言是個高寫入系統,SkipList在其中應該也起到了很重要的作用。Redis爲了加快插入操作,也使用了SkipList來作爲內部實現數據結構。

LevelDB的讀寫數據操作

寫操作流程:

1、順序寫入磁盤log文件;
2、寫入內存memtable(採用skiplist結構實現);
3、寫入磁盤SST文件(sorted string table files),這步是數據歸檔的過程(永久化存儲)。

注意:

  • log文件的作用是是用於系統崩潰恢復而不丟失數據,假如沒有Log文件,因爲寫入的記錄剛開始是保存在內存中的,此時如果系統崩潰,內存中的數據還沒有來得及Dump到磁盤,所以會丟失數據;
  • 在寫memtable時,如果其達到check point(滿員)的話,會將其改成immutable memtable(只讀),然後等待dump到磁盤SST文件中,此時也會生成新的memtable供寫入新數據;
  • memtable和sst文件中的key都是有序的,log文件的key是無序的;
  • LevelDB刪除操作也是插入,只是標記Key爲刪除狀態,真正的刪除要到Compaction的時候纔去做真正的操作;
  • LevelDB沒有更新接口,如果需要更新某個Key的值,只需要插入一條新紀錄即可;或者先刪除舊記錄,再插入也可;

讀操作流程:
1、在內存中依次查找memtable、immutable memtable;
2、如果配置了cache,查找cache;
3、根據mainfest索引文件,在磁盤中查找SST文件;

 

舉個例子:我們先往levelDb裏面插入一條數據 {key="www.samecity.com"  value="我們"},過了幾天,samecity網站改名爲:69同城,此時我們插入數據{key="www.samecity.com"  value="69同城"},同樣的key,不同的value;邏輯上理解好像levelDb中只有一個存儲記錄,即第二個記錄,但是在levelDb中很可能存在兩條記錄,即上面的兩個記錄都在levelDb中存儲了,此時如果用戶查詢key="www.samecity.com",我們當然希望找到最新的更新記錄,也就是第二個記錄返回,因此,查找的順序應該依照數據更新的新鮮度來,對於SSTable文件來說,如果同時在level L和Level L+1找到同一個key,level L的信息一定比level L+1的要新。

LevelDB在以太坊中的應用

初始化

在ethdb/database.go的NewLDBDataBase()函數中,

db, err := leveldb.OpenFile(file, &opt.Options{
		OpenFilesCacheCapacity: handles,
		BlockCacheCapacity:     cache / 2 * opt.MiB,
		WriteBuffer:            cache / 4 * opt.MiB, // Two of these are used internally
		Filter:                 filter.NewBloomFilter(10),
	})

file就是leveldb的路徑,以太坊的默認路徑是/Users/$Owner/Library/Ethereum/geth/chaindata

OpenFilesCacheCapacity:以太坊設置的是1024,作用應該是可打開的文件數吧,後續代碼中再確認一下

BlockCacheCapacity:設置的是cache的一半,是384M

WriteBuffer:設置的是cache的1/4,是192M,這個是memtable的size。爲什麼是1/4呢,因爲cache是設置的leveldb總共使用的大小,一半給了BlockCacheCapacity,另外一半是給memtable的。而leveldb寫數據的流程是先寫memtable,等寫滿了把這個memtable forzen,然後啓用minor compaction到level 0文件,同時new一個memtable供新寫入。所以cache的一半是給memtable和frozon memtable用的,單個memory的大小就是1/4

Filter:bloom filter,每個level文件會建filter,10的意思是每個key hash的次數。bloom的位數需要代碼確認下

OpenFile就會直接調用到leveldb的db.go文件中

經過一些列初始化,恢復log文件等,建立了若干個goroutine,看代碼

func openDB(s *session) (*DB, error) {
    ....
    // Doesn't need to be included in the wait group.
    go db.compactionError()  
    go db.mpoolDrain()

    if readOnly {
	db.SetReadOnly()
    } else {
	db.closeW.Add(2)
	go db.tCompaction()
	go db.mCompaction()
	// go db.jWriter()
    }
}

compactionError:看代碼是監聽一些channel做處理,暫未深究,後續補充

mpoolDrain:啓動一個30s的ticker讀取mempool chan,具體作用暫未深究,後續補充

mCompaction: minor compaction,就是把memory的內容寫入到level 0的文件

tCompaction:major compaction,就是合併不同層級的level文件。比如level 0滿了(已經有大於等於4個文件了),此goroutine監聽到了,就會將level 0的某個文件和level 1的某些文件合併成新的level 1文件

到這裏leveldb的初始化就成功了,新建幾個goroutine監聽是否compaction,基本流程大值如此了

讀寫數據

leveldb提供了一些接口來寫數據,以太坊做了包裝,具體看ethdb/interface.go

// Putter wraps the database write operation supported by both batches and regular databases.
type Putter interface {
	Put(key []byte, value []byte) error
}

// Database wraps all database operations. All methods are safe for concurrent use.
type Database interface {
	Putter
	Get(key []byte) ([]byte, error)
	Has(key []byte) (bool, error)
	Delete(key []byte) error
	Close()
	NewBatch() Batch
}

// Batch is a write-only database that commits changes to its host database
// when Write is called. Batch cannot be used concurrently.
type Batch interface {
	Putter
	ValueSize() int // amount of data in the batch
	Write() error
	// Reset resets the batch for reuse
	Reset()
}

定義了三個interface,Putter,Database和Batch與LevelDB讀寫交互

寫數據

寫數據又分爲寫新數據、更新數據和刪除數據

leveldb爲了效率考慮(如果刪除數據和更新數據用傳統的方式做的話,需要查找所有數據庫找到原始key,效率比較低),此三種情況統統使用插入數據的方式,刪除數據是寫一個刪除標誌,更新數據是寫一樣key帶不同的value

那麼問題來了,如果更新或刪除數據,整個數據庫中有兩個或更多個相同的key,什麼時候合併,查找的時候怎麼確定哪個是正確的

答案:

(1)什麼時候合併

如果有兩個或多個相同的key(或者是刪除,key的v是刪除標誌),一直到major compaction的時候纔會執行合併動作或者刪除動作,這樣可以提升效率

(2)如何查找到正確的值

因爲leveldb的分層概念,讀數據的時候先查memory,然後再從level 0到level N逐層查詢,查詢到了就不再查詢,這裏有個新鮮度的概念,層級越低,新鮮度越高,memory中新鮮度最高。所以對於更新操作來說,即便是某個時刻數據庫中有兩個或者更過個相同key的kv,會以新鮮度高的爲準。如果查詢到了key爲刪除標誌,那麼直接返回not found即可

寫新數據
爲了減少leveldb的交互,寫數據的時候一般會以Batch進行,就是先往batch裏寫一堆數據,然後再統一把這個Batch寫到leveldb。

即便是單個kv的寫入,leveldb內部也是使用batch來寫入的,但是這個batch也會即時寫入memory和log

以太坊的core/blockchain.go中寫block的時候就是新建Batch,然後把Batch寫入leveldb

// WriteBlockWithState writes the block and all associated state to the database.
func (bc *BlockChain) WriteBlockWithState(block *types.Block, receipts ...) {
    ...
    // Write other block data using a batch.
    batch := bc.db.NewBatch()
    if err := WriteBlock(batch, block); err != nil {
	return NonStatTy, err
    }
    ....
    if err := batch.Write(); err != nil {
	return NonStatTy, err
    }
    ....
}

我們來看看batch.Write的實現,在leveldb的db_write.go代碼裏:

func (db *DB) Write(batch *Batch, wo *opt.WriteOptions) error {
	…
        <span style="color:#ff0000;">// 這段代碼的意思是當batch的內容長度大於memory table的長度(以太坊是192M),
        // 一次性寫入memory(當寫滿的時候會觸發minor compaction,然後接着寫memory直到把內容全部寫完)</span>
	if batch.internalLen > db.s.o.GetWriteBuffer() && !db.s.o.GetDisableLargeBatchTransaction() {
    		tr, err := db.OpenTransaction()
    		if err != nil {
    			return err
    		}
    		if err := tr.Write(batch, wo); err != nil {
			tr.Discard()
            		return err
    		}
    		return tr.Commit()
    	}
	…
	return db.writeLocked(batch, nil, merge, sync)

}

接着看writeLocked代碼:

func (db *DB) writeLocked(batch, ourBatch *Batch, merge, sync bool) error {
    // <span style="color:#ff0000;">flush的功能是看是否觸發minor compaction</span>
    mdb, mdbFree, err := db.flush(batch.internalLen)
    …
    // Write journal. <span style="color:#ff0000;">寫Log文件</span>
    if err := db.writeJournal(batches, seq, sync); err != nil {
        db.unlockWrite(overflow, merged, err)
        return err
    }
    
    // Put batches.  <span style="color:#ff0000;">寫batch數據到memory</span>
    for _, batch := range batches {
        if err := batch.putMem(seq, mdb.DB); err != nil {
            panic(err)
        }
        seq += uint64(batch.Len())
    }
    ….
    // Rotate memdb if it's reach the threshold.   
    <span style="color:#ff0000;">// 如果memory不夠寫batch的內容,調用rotateMem,就是把memory frezon觸發minor compaction
</span>    if batch.internalLen >= mdbFree {
        db.rotateMem(0, false)
    }
    db.unlockWrite(overflow, merged, nil)
    return nil
}

有點沒看懂爲什麼先batch.putMem然後判斷batch.internalLen與mdbFree比大小再rotateMem,理應是先判斷mdbFree...

還有個merge與一堆channel的交互沒看明白,後續接着看

再看rotateMem的實現

func (db *DB) rotateMem(n int, wait bool) (mem *memDB, err error) {
	retryLimit := 3
retry:
	// Wait for pending memdb compaction.
	err = db.compTriggerWait(db.mcompCmdC)
	if err != nil {
		return
	}
	retryLimit--

	// Create new memdb and journal. 
<span style="color:#ff0000;">        // 新建log文件和memory,同時把現在使用的memory指向爲frozenMem,minor compaction的時候寫入frozenMem到level 0文件</span>
	mem, err = db.newMem(n)
	if err != nil {
		if err == errHasFrozenMem {
			if retryLimit <= 0 {
				panic("BUG: still has frozen memdb")
			}
			goto retry
		}
		return
	}

	// Schedule memdb compaction.
        // <span style="color:#ff0000;">觸發minor compaction</span>
	if wait {
		err = db.compTriggerWait(db.mcompCmdC)
	} else {
		db.compTrigger(db.mcompCmdC)
	}
	return
}

至此數據寫完,如果memory空間夠,直接寫入memory

如果memory空間不夠,等待執行minor compaction(compTrigger內會等待compaction的結果)再寫入新建的memory db(是從mempool中拿的,應該是mempool中就兩塊兒memory,待寫入的memory和frozon memory)中

刪除數據/更新數據

先看插入新數據的接口,更新數據也是調用這個一樣的接口:

func (db *DB) Put(key, value []byte, wo *opt.WriteOptions) error {
	return db.putRec(keyTypeVal, key, value, wo)
}

插入數據是插入一個type爲keyTypeVal,key/value的數據

再看刪除數據的接口

func (db *DB) Delete(key []byte, wo *opt.WriteOptions) error {
	return db.putRec(keyTypeDel, key, nil, wo)
}

刪除數據的代碼其實就是插入一個type爲keyTypeDel,key/nil的數據,當做一個普通的數據插入到memory中

等後續做major compaction的時候找到原始的key再執行刪除動作(更新數據也是在major compaction的時候進行)

具體major compaction的代碼還未看明白,後續看明白了再貼上來

讀數據

讀數據是依次從memtable和各個level文件中查找數據,db.go的接口:

func (db *DB) Get(key []byte, ro *opt.ReadOptions) (value []byte, err error) {
	err = db.ok()
	if err != nil {
		return
	}
        <span style="color:#ff0000;">// 關於snapshot未做研究,後續有研究再貼一下</span>
	se := db.acquireSnapshot()
	defer db.releaseSnapshot(se)
	return db.get(nil, nil, key, se.seq, ro)
}
func (db *DB) get(auxm *memdb.DB, auxt tFiles, key []byte, seq uint64, ro *opt.ReadOptions) (value []byte, err error) {
	ikey := makeInternalKey(nil, key, seq, keyTypeSeek)

	if auxm != nil {
		if ok, mv, me := memGet(auxm, ikey, db.s.icmp); ok {
			return append([]byte{}, mv...), me
		}
	}
        <span style="color:#ff0000;">// 拿到memdb和frozon memdb依次查找</span>
	em, fm := db.getMems()
	for _, m := range [...]*memDB{em, fm} {
		if m == nil {
			continue
		}
		defer m.decref()

		if ok, mv, me := memGet(m.DB, ikey, db.s.icmp); ok {
			return append([]byte{}, mv...), me
		}
	}
        <span style="color:#ff0000;">// 拿到version後從version中各個level的文件中依次查找</span>
	v := db.s.version()
	value, cSched, err := v.get(auxt, ikey, ro, false)
	v.release()
	if cSched {
		// Trigger table compaction.
		db.compTrigger(db.tcompCmdC)
	}
	return
}

Compaction

compaction是把數據一級一級的往下寫,leveldb實現了minor compaction和major compaction

minor compaction,leveldb裏面的mCompaction goroutine做的事情,就是把memory中的數據寫入到level 0文件中

major compaction,leveldb裏面tCompaction goroutine做的事情,就是把低層的level文件合併寫入高層的level文件中

mCompaction

func (db *DB) mCompaction() {
	var x cCmd

	for {
		select {
		case x = <-db.mcompCmdC:
			switch x.(type) {
			case cAuto:
				db.memCompaction()
				x.ack(nil)
				x = nil
			default:
				panic("leveldb: unknown command")
			}
		case <-db.closeC:
			return
		}
	}
}

還記得寫數據的時候rotateMem中會寫channel mcompCmdC嗎,這個goroutine起來後一直在監聽該channel等待做compaction的事情,所以看memCompaction的實現

func (db *DB) memCompaction() {
        <span style="color:#ff0000;">// rotateMem的時候把當前使用的memory指向到frozonMem,這裏讀出來寫入level 0文件</span>
	mdb := db.getFrozenMem()
	
	// Pause table compaction. 
<span style="color:#ff0000;">        // 這裏的作用是minor compaction的時候要先暫停major compaction</span>
	resumeC := make(chan struct{})
	select {
	case db.tcompPauseC <- (chan<- struct{})(resumeC):
	case <-db.compPerErrC:
		close(resumeC)
		resumeC = nil
	case <-db.closeC:
		db.compactionExitTransact()
	}

	// Generate tables. <span style="color:#ff0000;">創建level 0文件然後寫memory到文件
</span>        // <span style="color:#ff0000;">flushMemdb是把memory內容寫到新建的level 0文件,然後把level 0文件加入到addedTables record中
</span>        // <span style="color:#ff0000;">代碼裏把level 0~N的文件叫做table</span>
	db.compactionTransactFunc("memdb@flush", func(cnt *compactionTransactCounter) (err error) {
		stats.startTimer()
		flushLevel, err = db.s.flushMemdb(rec, mdb.DB, db.memdbMaxLevel)
		stats.stopTimer()
		return
	}, func() error {
		for _, r := range rec.addedTables {
			db.logf("memdb@flush revert @%d", r.num)
			if err := db.s.stor.Remove(storage.FileDesc{Type: storage.TypeTable, Num: r.num}); err != nil {
				return err
			}
		}
		return nil
	})

	rec.setJournalNum(db.journalFd.Num)
	rec.setSeqNum(db.frozenSeq)

	// Commit. 
<span style="color:#ff0000;">        // 就是最終存儲tables,寫入到version記錄。。。後續深入看下</span>
	stats.startTimer()
	db.compactionCommit("memdb", rec)
	stats.stopTimer()

	db.logf("memdb@flush committed F·%d T·%v", len(rec.addedTables), stats.duration)

	for _, r := range rec.addedTables {
		stats.write += r.size
	}
	db.compStats.addStat(flushLevel, stats)

	// Drop frozen memdb. <span style="color:#ff0000;">    
        // minor compaction之後把指向frozon的memory重新放回mempool中</span>
	db.dropFrozenMem()

	// Resume table compaction.
        // <span style="color:#ff0000;">恢復major compaction</span>
	if resumeC != nil {
		select {
		case <-resumeC:
			close(resumeC)
		case <-db.closeC:
			db.compactionExitTransact()
		}
	}

	// Trigger table compaction. 
<span style="color:#ff0000;">        // tcompCmdC就是major compaction要監聽的channel,這裏寫數據到此channel</span>
	db.compTrigger(db.tcompCmdC)
}

後續需要繼續完善compactionCommit代碼,實現都在這裏

tCompaction

func (db *DB) tCompaction() {
	for {
		if db.tableNeedCompaction() {
			select {
			case x = <-db.tcompCmdC:
			case ch := <-db.tcompPauseC:
				db.pauseCompaction(ch)
				continue
			case <-db.closeC:
				return
			default:
			}
		} else {
			for i := range ackQ {
				ackQ[i].ack(nil)
				ackQ[i] = nil
			}
			ackQ = ackQ[:0]
			select {
			case x = <-db.tcompCmdC:
			case ch := <-db.tcompPauseC:
				db.pauseCompaction(ch)
				continue
			case <-db.closeC:
				return
			}
		}
		if x != nil {
			switch cmd := x.(type) {
			case cAuto:
				ackQ = append(ackQ, x)
			case cRange:
				x.ack(db.tableRangeCompaction(cmd.level, cmd.min, cmd.max))
			default:
				panic("leveldb: unknown command")
			}
			x = nil
		}
		db.tableAutoCompaction()
	}
}

計算是否要執行major compaction

func (v *version) computeCompaction() {
	for level, tables := range v.levels {
		var score float64
		size := tables.size()
		if level == 0 {
			// We treat level-0 specially by bounding the number of files
			// instead of number of bytes for two reasons:
			//
			// (1) With larger write-buffer sizes, it is nice not to do too
			// many level-0 compaction.
			//
			// (2) The files in level-0 are merged on every read and
			// therefore we wish to avoid too many files when the individual
			// file size is small (perhaps because of a small write-buffer
			// setting, or very high compression ratios, or lots of
			// overwrites/deletions).
			score = float64(len(tables)) / float64(v.s.o.GetCompactionL0Trigger())
		} else {
			score = float64(size) / float64(v.s.o.GetCompactionTotalSize(level))
		}

		if score > bestScore {
			bestLevel = level
			bestScore = score
		}

		statFiles[level] = len(tables)
		statSizes[level] = shortenb(int(size))
		statScore[level] = fmt.Sprintf("%.2f", score)
		statTotSize += size
	}

	v.cLevel = bestLevel
	v.cScore = bestScore
}

計算是否要compaction是邏輯是:計算一個分數,level 0是文件個數/4,level 0以上就是文件的總大小/預設的每個level的文件大小總量;最後找出算出的值最大的一個賦值到v.cScore,level賦值到v.cLevel

最終使用的時候是判斷這個cScore是否>=1來決定是否要進行compaction

func (v *version) needCompaction() bool {
	return v.cScore >= 1 || atomic.LoadPointer(&v.cSeek) != nil
}

還有一個判斷是v.cSeek是否爲空,這個是讀數據那邊用到的。

 

參考文獻

LevelDB源碼:https://github.com/google/leveldb
LevelDB 功能與架構:https://www.jianshu.com/p/223f0c73ddc2
LevelDB詳解:https://blog.csdn.net/linuxheik/article/details/52768223
LevelDB整體介紹:https://blog.csdn.net/charles1e/article/details/52966776
LevelDB介紹-隨筆:https://blog.csdn.net/csds319/article/details/80333187
以太坊之LevelDB源碼分析:https://blog.csdn.net/csds319/article/details/80361450

 

發佈了7 篇原創文章 · 獲贊 9 · 訪問量 1萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章