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 可以提高資料檢索效率
- 當 L0 層檔案數量到達閾值(
- 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)
- L0 級檔案數量 / L0 級壓縮閾值(
- 其他層的計分演算法
- 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 的合併,開銷太大
- write buffer size 是指 memtable 轉換為 imm memtable 的大小閾值
- 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 的那個檔案
- 也就是
- 引起 seek compaction 的那個檔案
- size compaction
- 選擇所有參與合併的檔案
- 總的來說就是根據檔案的重疊部分不斷擴大參與合併的檔案範圍
- 先擴充 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 必須同時被合併
- 假設有兩個 block
- 總的來說就是根據檔案的重疊部分不斷擴大參與合併的檔案範圍
擴充邊界的示例:
執行合併
- 判斷是否滿足 [[#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 的資料項都會被拋棄