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