LevelDB系統結構與設計思路分析

神手不痴發表於2018-07-24

LevelDB整體架構

LevelDB可以看作是Google BigTable的單機kv儲存引擎,它是一個kv型永續性儲存的C++程式庫。關於它的整體架構這裡不在詳述,可以參考文章LevelDB的設計與實現,文章主要講述了LevelDB的全域性結構,資料扭轉流程,系統中的資料佈局,以及一些重要的結構等,可以通過這篇文章從巨集觀上了解LevelDB。下面主要講述自己通過閱讀LevelDB原始碼後,對它的一些理解。

原始碼結構分析

原始碼結構

下圖為閱讀LevelDB原始碼後,對它的各個模組之間的關係的一個抽象化的理解
LevelDB原始碼框架圖
下面首先來了解各模組,然後來通過一些重要流程來把各個模組串聯起來

模組說明

DB 和 DBImpl

DB是leveldb對外的模組,它提供了對leveldb資料庫進行操作的介面。它提供的訪問資料庫的介面有:

Open: 開啟資料庫,獲取運算元據庫的物件指標。同時leveldb只能單程式訪問,程式間通過lock檔案來保證這一點,同時程式內通過記錄開啟的db name來保證這一點
Put: 將(key,value)寫入資料庫
Write: 提供原子的批量寫操作
Delete: 刪除某個key對應的entry,leveledb內部當作寫操作,用型別欄位區分正常寫入操作和刪除操作
Get: 獲取key對應的value
NewIterator: 資料庫的迭代器,可以用它進行scan操作

同時它還提供了一些其它操作介面,比較重要的有兩個:

GetSnapshot: 獲取當前DB狀態的快照,這樣使得讀操作能在DB當前狀態下進行,不受後續寫操作的影響
compactRange: 提供manual compact的介面,可以制定range去做compaction

DBImpl則提供了DB各個介面的具體實現,這裡就不在細述

Env

Env抽象化和作業系統相關的環境,這樣方便使用者定製。它主要提供了和檔案系統相關的介面,比如檔案建立,讀寫等。還提供了和執行緒相關的介面,主要用在使用background執行緒去做compaction的時候

VersionSet, Version, VersionEdit

  • Version儲存當前磁碟以及記憶體中的檔案資訊,一般只有一個version為”current version”。同時還儲存了一系列的歷史version,這些version的存在是因為有讀操作還在引用(iterator和get,Compaction操作後會產生新的version作為current version
  • VersionSet就是一系列Version的集合
  • VersionEdit表示Version之間的變化,表示增加了多少檔案,刪除了多少檔案

Snapshot

  • 快照提供了一個當前KV儲存的一個可讀檢視,使得讀取操作不受寫操作影響,可以在讀操作過程中始終看到一致的資料
  • 一個快照對應當前儲存的最新資料版本號

MemTable, Immutable MemTable

  • MemTable是leveldb的記憶體快取。它也提供了資料的寫入,刪除,讀取等操作介面。它內部採用Skiplist作為資料組織結構,同時它使用自己實現的Arena作為記憶體分配器。
  • Immutable MemTable和MemTable結構是完全一樣的,只不過它是隻讀的,當MemTable中的資料量達到一定程度時會轉換成Immutable MemTable。

TableBuilder, BlockBuilder

TableBuilder: 將資料按照sst檔案的格式組織後,寫入sst檔案
BlockBuilder: 將資料按照Block的格式組織起來,被TableBuilder使用
BuildTable: 在將MemTable的資料寫入sst時呼叫,使用TableBuilder來實現

TableCache, BlockCache

TableCache和BlockCache底層都是用了LRUCache的資料結構

  • TableCache: 快取了Table相關的資訊,包括Table對應的File指標,以及Table物件的指標,Table物件包含了Table的後設資料,索引資訊等。可以防止過多的檔案open
  • BlockCache: 快取塊資料

重要流程分析

下面我們通過一些重要流程的分析,將上述模組串聯起來

Open

下圖為Open流程的簡要流程圖

LevelDB Open流程圖

它的主要流程為:

  1. New DBImpl: 建立DBImpl物件
  2. DBImpl::Recover: 恢復資料庫,主要流程就是通過manifest檔案來恢復出VersionSet,表示當前資料庫的檔案資訊;然後再恢復未記錄在manifest中的log檔案,將log檔案的資料重新寫入MemTable中。
  3. VersionSet::LogAndApply: 如果DBImpl在恢復過程中產生了新的檔案,那麼就會產生VersionEdit,需要將VersionEdit記錄在manifest檔案中,同時將VersionEdit Apply在VersionSet中,產生新的version。
  4. DBImpl::DeleteObsoleteFiles:將一些不需要的檔案刪除
  5. DBImpl::MaybeScheduleCompaction:嘗試是否需要進行Compaction

Put

下圖為Put流程的簡要流程圖

LevelDB Put流程圖

  1. Put操作會將(key,value)轉化成writebatch後,通過write介面來完成
  2. 在write之前需要通過MakeRoomForWrite來保證MemTable有空間來接受write請求,這個過程中可能阻塞寫請求,以及進行compaction。
  3. BuildBatchGroup就是儘可能的將多個writebatch合併在一起然後寫下去,能夠提升吞吐量
  4. AddRecord就是在寫入MemTable之前,現在操作寫入到log檔案中
  5. 最後WriteBatchInternal::InsertInto會將資料寫入到MemTable中

Get

下圖為Get流程的簡要流程圖

LevelDB Get流程圖

  1. 首先判斷options.snapshot是否為空,如果為不為空,快照值就取這個值,否則取最新資料的版本號
  2. 然後依次嘗試去MemTable, Immutable MemTable, VersionSet中去查詢。VersionSet中的查詢流程:

    1. 逐層查詢,確定key可能所在的檔案
    2. 然後根據檔案編號,在TableCache中查詢,如果未命中,會將Table資訊Load到cache中
    3. 根據Table資訊,確定key可能所在的Block
    4. 在BlockCache中查詢Block,如果未命中,會將Block load到Cache中。然後在Block內查詢key是否命中
  3. 更新讀資料的統計資訊,作為一根檔案是否應該進行Compaction的依據,後面講述Compaction時會說明
  4. 最後釋放對Memtable,Immutable MemTable,VersionSet的引用

Compaction

在leveldb中compaction主要包括Manual Compaction和Auto Compaction,在Auto Compaction中又包含了MemTable的Compaction和SSTable的Compaction。

Manual Compaction

leveldb中manual compaction是使用者指定需要做compaction的key range,呼叫介面CompactRange來實現,它的主要流程為:

  1. 計算和Range有重合的MaxLevel
  2. 從level 0 到 MaxLevel依次在每層對這個Range做Compaction
  3. 做Compaction時會限制選擇做Compaction檔案的大小,這樣可能每個level的CompactRange可能需要做多次Compaction才能完成
SSTable Compaction
  1. 啟動條件

    • 每個Level的檔案大小或檔案數超過了這個Level的限制(L0對比檔案個數,其它Level對比檔案大小。主要是因為L0檔案之間可能重疊,檔案過多影響讀訪問,而其它level檔案不重疊,限制檔案總大小,可以防止一次compaction IO過重)。
    • 含有被尋道次數超過一定閾值的檔案(這個是指讀請求查詢可能去讀多個檔案,如果最開始讀的那個檔案未查詢到,那麼這個檔案就被認為尋道一次,當檔案的尋道次數達到一定數量時,就認為這個檔案應該去做compaction)
    • 條件1的優先順序高於條件2
  2. 觸發條件

    • 任何改變了上面兩個條件的操作,都會觸發Compaction,即呼叫MaybeScheduleCompaction
    • 涉及到第一個條件改變,就是會改變某層檔案的檔案數目或大小,而只有Compaction操作之後才會改變這個條件
    • 涉及到第二個條件的改變,可能是讀操作和scan操作(scan操作是每1M資料取樣一次,獲得讀最後一個key所尋道的檔案,1M資料的cost大約為一次尋道)
  3. 檔案選取

    • 每個level都會記錄上一次Compaction選取的檔案所含Key的最大值,作為下次compaction選取檔案的起點
    • 對於根據啟動條件1所做的Compaction,選取檔案就從上次的點開始選取,這樣保證每層每個檔案都會選取到
    • 對於根據啟動條件2所做的Compaction,需要做compaction的檔案本身就已經確定了
    • Level + 1層檔案的選取,就是和level層選取的檔案有重合的檔案

    在leveldb中在L層會選取1個檔案,理論上這個檔案最多覆蓋的檔案數為12個(leveldb中預設一個檔案最大為2M,每層的最大資料量按照10倍增長。這樣L層的檔案在未對齊的情況下最多覆蓋L+1層的12個檔案),這樣可以控制一次Compaction的最大IO為(1+12)* 2M讀IO,總的IO不會超過52M

MemTable Compaction

MemTable Compaction最重要的是產出的檔案所在層次的選擇,它必須滿足如下條件: 假設最終選擇層次L,那麼檔案必須和[0, L-1]所有層的檔案都沒有重合,且對L+1層檔案的覆蓋不能超過一定的閾值(保證Compaction IO可控)

Compaction 檔案產出
  1. 什麼時候切換產出檔案

    • 檔案大小達到一定的閾值
    • 產出檔案對Level+2層有交集的所有檔案的大小超過一定閾值
  2. key丟棄的兩個條件

    • last_sequence_for_key <= smallest_snapshot (有一個更新的同樣的user_key比最小快照要小)
    • key_type == del && key <= smallest_snapshot && IsBaseLevelForKey(key的型別是刪除,且這個key的版本比最小快照要小,並且在更高Level沒有同樣的user_key)

總結與思考

  1. LSM儲存模型:犧牲讀效能,提高隨機寫效能
  2. Level儲存架構:儘可能地優化讀效能,同時減少對寫效能的影響。假設沒有Level的概念,每次讀請求都要去訪問多個檔案,於是才有Level的概念去做compaction,儘可能減少讀取的檔案數,同時又保證了每次Compaction IO的資料量,保證對正常的寫請求影響不會太大。
  3. LSM和Level之間是相互均衡的關係,它們決定了讀寫效能。在不同的應用場景下,我們需要在兩者間有所取捨。

參考文獻

[1]. https://github.com/google/leveldb/tree/master/doc.

[2]. https://dirtysalt.github.io/html/leveldb.html#orgheadline88.

[3]. https://blog.csdn.net/anderscloud/article/details/7182165.

[4]. https://www.atatech.org/articles/65485


相關文章