LSM樹(Log Structured Merged Tree)的名字往往給人一個錯誤的印象, 實際上LSM樹並沒有嚴格的樹狀結構。
LSM 樹的思想是使用順序寫代替隨機寫來提高寫效能,與此同時會略微降低讀效能。
LSM 的高速寫入能力與讀快取技術帶來的高速讀能力結合受到了需要處理大規模資料的開發者的青睞,成為了非常流行的儲存結構。
HBase、 Cassandra、 LevelDB、 RocksDB 以及 ClickHouse MergeTree 等流行的 NoSQL 資料庫均採用 LSM 儲存結構。
讀寫流程
具體來說 LSM 的資料更新是日誌式的,修改資料時直接追加一條新記錄(為被修改資料建立一個新版本),而使用 B/B+ 樹的資料庫則需要找到資料在磁碟上的位置並在原地進行修改。
這張經典圖片來自 Flink PMC 的 Stefan Richter 在Flink Forward 2018演講的PPT
寫入
在執行寫操作時,首先寫入 active memtable 和預寫日誌(Write Ahead Logging, WAL)。因為記憶體中 memtable 會斷電丟失資料,因此需要將記錄寫入磁碟中的 WAL 保證資料不會丟失。
顧名思義 MemTable是一個記憶體中的資料結構,它儲存了落盤之前的資料。SkipList 是最流行的 Memtable 實現方式,Hbase 和 RocksDB 均預設使用 SkipList 作為 MemTable。
當 Active MemTable 寫滿後會被轉換為不可修改的 Immutable MemTable,並建立一個新的空 Active MemTable。後臺執行緒會將 Immutable MemTable 寫入到磁碟中形成一個新的 SSTable 檔案,並隨後銷燬 Immutable MemTable。
SSTable (Sorted String Table) 是 LSM 樹在磁碟中持久化儲存的資料結構,它是一個有序的鍵值對檔案。
LSM 不會修改已存在的 SSTable, LSM 在修改資料時會直接在 MemTable 中寫入新版本的資料,並等待 MemTable 落盤形成新的 SSTable。因此,雖然在同一個 SSTable 中 key 不會重複,但是不同的 SSTable 中仍會存在相同的 Key。
讀取
因為最新的資料總是先寫入 MemTable,所以在讀取資料時首先要讀取 MemTable 然後從新到舊搜尋 SSTable,找到的第一個版本就是該 Key 的最新版本。
根據區域性性原理,剛寫入的資料很有可能被馬上讀取。因此, MemTable 在充當寫快取之外也是一個有效的讀快取。
為了提高讀取效率 SSTable 通常配有 BloomFilter 和索引來快速判斷其中是否包含某個 Key 以及快速定位它的位置。
因為讀取過程中需要查詢多個 SSTable 檔案,因此理論上 LSM 樹的讀取效率低於使用 B 樹的資料庫。為了提高讀取效率,RocksDB 中內建了塊快取(Block Cache)將頻繁訪問磁碟塊快取在記憶體中。而 LevelDB 內建了 Block Cache 和 Table Cache 來快取熱點 Block 和 SSTable。
Compact
隨著不斷的寫入 SSTable 數量會越來越多,資料庫持有的檔案控制程式碼(FD)會越來越多,讀取資料時需要搜尋的 SSTable 也會越來越多。另一方面對於某個 Key 而言只有最新版本的資料是有效的,其它記錄都是在白白浪費磁碟空間。因此對 SSTable 進行合併和壓縮(Compact)就十分重要。
在介紹 Compact 之前, 我們先來了解 3 個重要的概念:
- 讀放大:讀取資料時實際讀取的資料量大於真正的資料量。例如 LSM 讀取資料時需要掃描多個 SSTable.
- 寫放大:寫入資料時實際寫入的資料量大於真正的資料量。例如在 LSM 樹中寫入時可能觸發Compact操作,導致實際寫入的資料量遠大於該key的資料量。
- 空間放大:資料實際佔用的磁碟空間比資料的真正大小更多。例如上文提到的 SSTable 中儲存的舊版資料都是無效的。
Compact 策略需要在三種負面效應之間進行權衡以適應使用場景。
Size Tiered Compaction Strategy
Size Tiered Compaction Strategy (STCS) 策略保證每層 SSTable 的大小相近,同時限制每一層 SSTable 的數量。當某一層 SSTable 數量達到閾值後則將它們合併為一個大的 SSTable 放入下一層。
STCS 實現簡單且 SSTable 數量較少,缺點是當層數較深時容易出現巨大的 SSTable。此外,即使在壓縮後同一層的 SSTable 中仍然可能存在重複的 key,一方面存在較多無效資料即空間放大較嚴重,另一方面讀取時需要從舊到新掃描每一個 SSTable 讀放大嚴重。通常認為與下文介紹的 Leveled Compaction Strategy 相比, STCS 的寫放大較輕一些[1][2]。
STCS 是 Cassandra 的預設壓縮策略[3]。Cassandra 認為在插入較多的情況下 STCS 有更好的表現。
Tiered壓縮演算法在RocksDB的程式碼裡被命名為 Universal Compaction。
Leveled Compaction Strategy
Leveled Compaction Strategy (LCS)策略也是採用分層的思想,每一層限制總檔案的大小。
LCS 會將每一層切分成多個大小相近的SSTable, 且 SSTable 是在層內是有序的,一個key在每一層至多隻有1條記錄,不存在冗餘記錄。
LCS 層內不存在冗餘所以空間放大比較小。因為層內有序, 所以在讀取時每一層最多讀取一個 SSTable 所以讀放大較小。在讀取和更改較多的場景下 LCS 壓縮策略有著顯著優勢。
當某一層的總大小超過閾值之後,LCS 會從中選擇一個 SSTable 與下一層中所有和它有交集的 SSTable合併,並將合併後的 SSTable 放在下一層。請注意與所有有交集的 SSTable 合併保證了 compact 之後層內仍然是有序且無冗餘的。
LCS 下多個不相關的合併操作是可以併發執行的。
LCS 有一個變體稱為 Leveled-N 策略,它將每一層分為 N 個區塊,層內不再全域性有序只在區塊內保證有序。它是 LCS 與 STCS 的中間狀態,與 LCS 相比擁有更小的寫放大,與 STCS 相比擁有更小的讀放大與空間放大。
RocksDB 的壓縮策略
RocksDB 預設採用的是 Size Tiered 與 Leveled 混合的壓縮策略。在 RocksDB 中存在一個 Active MemTable 和多個 Immutable MemTable。
MemTable 被寫入磁碟後被稱為 Level-0 (L0)。L0 是無序的,也就是說 L0 的 SSTable 允許存在重疊。除 L0 外的層都是全域性有序的,且採用 Leveled 策略進行壓縮。
當 L0 中檔案個數超過閾值(level0_file_num_compaction_trigger)後會觸發壓縮操作,所有的 L0 檔案都將被合併進 L1。
因為 L0 是 MemTable 直接落盤後的結果,而熱點 key 的更新可能存在於多個 MemTable 中,所以 L0 的 SSTable 中極易因為熱點 key 而出現交集。
關於 RocksDB 壓縮的更多細節我們可以閱讀官方文件中的Compaction和 Leveled Compacton 兩篇文章。