LevelDB 學習筆記2:合並


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 的數據項都會被拋棄


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM