LevelDB 學習筆記2:合併

路過的摸魚俠發表於2022-04-17

LevelDB 學習筆記2:合併

部分圖片來自 RocksDB 文件

LevelDB 中會發生兩種不同的合併行為,分別稱為 minor compaction 和 major compaction

Minor Compaction

將記憶體資料庫刷到硬碟的過程稱為 minor compaction

  • 產出的 L0 層的 sstable
    • 事實上,LevelDB 不一定會將 minor compaction 產生的 sstable 放到 L0 裡
  • L0 層的 sstable 可能存在 overlap
  • 如果上一次產生的 imm memtable 還沒能刷盤,而新的 memtable 已寫滿,寫入執行緒必須等待到 minor compaction 完成才能繼續寫入
    • 只允許同時存在一個 imm memtable

Minor Compaction 的流程

主要流程在 CompactMemTable()

  • 藉助工具類 TableBuilder 構建 sstable 檔案
    • BuildTable()
  • 選擇將這個產生的 sstable 檔案放到哪一層去
    • PickLevelForMemTableOutput()
    • 如果某個 sstable 檔案和 L0 層沒有重疊部分,就可以考慮將它扔到後面的層級裡
    • 如果滿足
      • 和 level + 1 層不重疊
      • 且不要和 level+ 2 有太多的重疊部分
    • 我們就可以將它扔到 level + 1 層去
    • 我們希望它能放到第二層去
      • 這樣可以避免 0 -> 1 層合併的巨大 I/O 開銷
    • 但我們不希望它直接扔到最後一層,這樣可能帶來帶來的問題是
      • 如果某個 key 被重複改寫,可能帶來磁碟空間的浪費
      • 比如你寫到 L7 中,然後再改寫它時可能又在 L6 裡寫了一份副本,以此類推,可能每一層裡都有這個 key 的副本
    • 最高可以放到 config::kMaxMemCompactLevel(預設為 2)層裡去
  • 提交版本修改
    • 增加新的 sstable 檔案
  • 刪除 imm memtable 的日誌檔案

Major Compaction

  • L0 層的記錄有 overlap,搜尋的時候可能要遍歷所有的 L0 級檔案
    • 當 L0 層檔案數量到達閾值(kL0_CompactionTrigger,預設值為 4)時,會被合併到 L1 層中去
    • 在沒有 overlap 的層裡搜尋時,只需要找到 key 在哪個檔案裡,然後遍歷這個檔案就行了
    • 所以針對 L0 層的 major compaction 可以提高資料檢索效率

  • major compaction 過程會消耗大量時間,為了防止使用者寫入速度太快,L0 級檔案數量不斷增長,LevelDB 設定了兩個閾值
    • kL0_SlowdownWritesTrigger,預設值為 8
      • 放緩寫入,每個合併寫操作都會被延遲 1ms
    • kL0_StopWritesTrigger,預設值為 12
      • 寫入暫停,直到後臺合併執行緒工作完成

除了 L0 層以外,其他層級內 sstable 檔案的 key 是有序且不重疊的

  • LevelDB 的寫入都是 Append 的,也就是不管是修改還是刪除,都是新增新的記錄,因此資料庫裡可能存在 key 相同的多條記錄
    • major compaction 也起到合併相同 key 的記錄、減小空間開銷的作用
  • 而且如果 L1 層檔案積累的太多,L0 層檔案做 major compaction 的時候,需要和大量的 L1 層檔案做合併,導致 compaction 的 I/O 開銷很大
    • 所以合併操作也能降低 compaction 的 I/O 開銷
  • 當 Li(i > 0)層檔案大小超過 \(10^i\) MB 時,也會觸發 major compaction,選擇至少一個 Li 層檔案和 Li+1 層檔案合併
    • 下面這個圖來自 RocksDB 文件,所以閾值跟 LevelDB 不一樣

? major compaction 的作用:

  • 提高資料檢索效率
  • 合併相同 key 的記錄、減小空間開銷的作用
  • 降低 compaction 的 I/O 開銷
  • 可能發生的一種情況是,L0 合併完成後,L1 也觸發合併閾值,需要合併,導致遞迴的合併
    • 最壞的情況是每次合併都會引起下一層觸發合併

Trivial Move

  • LevelDB 做的一種優化是當滿足下列條件的情況下
    • level 層的檔案個數只有一個
    • level 層檔案與 level+1 層檔案沒有重疊
    • level 層檔案與 level+2 層的檔案重疊部分的檔案大小不超過閾值
  • 直接將 level 層的檔案移動到 level+1 層去
  • 這種優化稱為 trivial move

Seek Compaction

如果某個檔案上,發生了多次無效檢索(搜尋某個 key,但沒找到),我們希望對該檔案做壓縮

LevelDB 假設

  • 檢索耗時 10ms
  • 讀寫 1MB 消耗 10ms(100MB/s)
  • 壓縮 1MB 檔案需要做 25MB 的 I/O
    • 從這次層讀 1MB 資料
    • 從下一層讀 10-12MB 資料
    • 寫 10-12MB 資料到下一層

因此,做 25 次檢索的代價等價於對 1MB 的資料做合併,也就是說,一次檢索的代價等價於對 40KB 資料做合併

LevelDB 最終的選擇比較保守,檔案裡每有 16KB 資料就允許對該檔案做一次無效檢索,當允許無效檢索的次數耗盡,就會觸發合併

檔案的後設資料裡有一個 allowed_seeks 欄位,儲存的就是該檔案剩餘無效檢索的次數

  • allowed_seeks 的初始化方式
f->allowed_seeks = static_cast<int>((f->file_size / 16384U));
if (f->allowed_seeks < 100) f->allowed_seeks = 100;
  • 每次 Get() 呼叫,如果檢索了檔案,LevelDB 就會做判斷,是否檢索了一個以上的檔案,如果是,就減少這個檔案的 allowed_seeks
  • 當檔案的 allowed_seeks 減少為 0,就會觸發 seek compaction

壓縮計分

LevelDB 中採取計分機制來決定下一次壓縮應該在哪個層內進行

  • 每次版本變動都會更新壓縮計分
    • VersionSet::Finalize()
    • 計算每一層的計分,下次壓縮應該在計分最大的層裡進行
      • 計分最大層和最大計分會被存到當前版本的 compaction_level_compaction_score_
    • score >= 1 說明已經觸發了壓縮的條件,必須要做壓縮
  • L0 的計分演算法
    • L0 級檔案數量 / L0 級壓縮閾值(config::kL0_CompactionTrigger,預設為 4)
  • 其他層的計分演算法
    • Li 級檔案大小總和 / Li 級大小閾值
    • 大小閾值為 \(10^i\) MB

為什麼 L0 層要特殊處理

  • 使用更大的 write buffer 的情況下,這樣就不會做太多的 L0->L1 的合併
    • write buffer size 是指 memtable 轉換為 imm memtable 的大小閾值
      • options_.write_buffer_size
    • 比如設定 write buffer 為 10MB,且 L0 層的大小閾值為 10MB,每做一次 minor compaction 就需要做一次 L0->L1 的合併,開銷太大
  • L0 層檔案每次讀的時候都要做歸併(因為 key 是有重疊的),因此我們不希望 L0 層有太多檔案
    • 如果你設定一個很小的 write buffer,且使用大小閾值,就 L0 就可能堆積大量的檔案

Major Compaction 的流程

準備工作

  • 判斷合併型別
    • 如果 compaction_score_ > 1 做 size compaction
    • 如果是有檔案 allowed_seeks == 0 而引起的合併,做 seek compaction
  • 選擇合併初始檔案
    • size compaction
      • 輪轉
        • 初始檔案的最大 key 要大於該層上次合併時,所有參與合併檔案的最大 key
        • 每層上次合併的最大 key 記錄在 VersionSet 的 compact_pointer_ 欄位中
    • seek compaction
      • 引起 seek compaction 的那個檔案
        • 也就是 allowed_seeks 歸 0 的那個檔案
  • 選擇所有參與合併的檔案
    • 總的來說就是根據檔案的重疊部分不斷擴大參與合併的檔案範圍
      • 先擴充 Li 的邊界
      • 再擴充 Li+1 的邊界
      • 再反過來繼續擴充 Li 的邊界
        • 這次擴充不應該導致 Li+1 的邊界擴大(產生更多的重疊檔案),否則不做這次擴充
    • 具體過程在 PickCompaction()SetupOtherInputs()
    • 關鍵函式有兩個
      • GetOverlappingInputs()
        • 給定一個 key 的範圍,選擇 Li 中所有和該範圍有重疊的 sstable 檔案加入集合
      • AddBoundaryInputs()
        • 假設有兩個 block b1=(l1, u1)b2=(l2, u2)
        • 其中 b1 的上界和 b2 的下界的 user_key 相等
          • 也就是說這兩個塊是相鄰的
        • 如果只是合併 b1,也就是將它移動到下一層去
          • 那麼後續查這條 user_key 時,從 b2 中查到後,就不會再去下一層查詢
          • 如果 b2 中的資料比 b1 中的舊,那麼這樣查到的資料就是錯誤的
        • 因此 b1 和 b2 必須同時被合併

擴充邊界的示例:

執行合併

  • 判斷是否滿足 [[#Trivial Move]] 的條件
    • 滿足就做 trivial move,不再執行後續流程
  • 開始執行合併
    • 合併主要流程在 DoCompactionWork()
    • 用合併的輸入檔案構造 MergingIterator
    • 遍歷 MergingIterator
      • 這個過程就是對輸入檔案做歸併排序的過程
      • 如果遍歷過程中發現有 imm memtable 檔案存在,就會轉而先做 minor compaction
        • 並且會喚醒在 MakeRoomForWrite() 中等待 minor compaction 完成的執行緒
    • 藉助工具類 TableBuilder 構建 sstable 檔案
      • 將遍歷迭代器產生的 kv 對加入 builder
    • 如果當前檔案大小超過閾值或和 level+2 層有太多的重疊部分
      • 完成對該檔案的寫入,並開啟新的 TableBuilder
  • 提交版本更改
  • 呼叫 RemoveObsoleteFiles() 刪除不再需要的檔案

拋棄無用的資料項
  • 滿足以下條件的資料項會被拋棄,不會加入到合併後的檔案裡
    • 資料項的型別是刪除
    • 這個資料項比當前最老的 snapshot 還要老
    • level + 2 以上的層都不包含這個 user_key
      • 不然你把這項在合併階段刪掉了,使用者讀的時候就會讀到錯誤的資料
  • 比這些資料項更老的所有相同 user_key 的資料項都會被拋棄

相關文章