從 RocksDB 看 LSM-Tree 演算法設計

Richard_Yi發表於2021-12-29
原創不易,轉載請註明出處

前言

目前筆者本人正在基於 Pulsar 搭建公司內部的訊息平臺,自然也對其底層儲存做了一些研究。Pulsar 使用 BookKeeper 作為儲存層,BookKeeper 底層使用到了 RocksDB 來儲存 Entry (BookKeeper 中的資料儲存單元) 對應的位置索引。RocksDB 是我一直關注的儲存引擎技術,因為之前在調研持久型 KV 儲存的時候,發現主流開源的 pika/kvrocks,以及最終選用的雲廠商的持久型 KV 儲存服務,底層都是基於 RocksDB。還有大名鼎鼎的 TiDB,其儲存引擎也是 RocksDB。

懷著好奇,我開始了對於 RocksDB 的學習,由於 RocksDB 一般用於底層開發,如果不是開發資料儲存中介軟體,日常很難接觸到,所以我決定先去學習 RocksDB 的資料結構設計:LSM 樹。

本文先是介紹了 RocksDB 對於LSM 樹的實現,再總結了 LSM 樹的設計思想,也類比了 Elasticsearch Lucene 的儲存設計思想,最後將 LSM 樹和常見的 B+ 樹做了對比。

LSM 樹 簡介

LSM 樹,全稱 Log-Structured-Merge Tree。初看名字你可能認為它會是一個樹,但其實不是,LSM 樹實際上是一個複雜的演算法設計。這個演算法設計源自 Google 的 Bigtable 論文 (引入了術語 SSTable 和 memtable )。

基於 LSM 樹演算法設計實現的儲存引擎,我們稱之為 LSM 儲存引擎。在 LevelDB、RocksDB、Cassandra、HBase 都基於 LSM 樹演算法實現了對應的儲存引擎。

下面我們通過 RocksDB 的 LSM 樹實現,來詳細瞭解一下 LSM 樹的設計思想。如果只想看 LSM 樹的設計思想總結,可以跳轉到最後的總結部分,私以為總結的還是不錯的。

RocksDB LSM 樹 實現

1. 核心組成

首先,先看看 RocksDB 的三種基本檔案格式 memtable & WAL & SSTable。

下圖描述了 RocksDB LSM 樹的核心組成和關鍵流程步驟(讀 & 寫 & flush & compaction)。

img

1.1 memtable (active & immutable)

memtable 是 RocksDB 記憶體中的資料結構,同時服務於讀和寫;資料在寫入時總會將資料寫入 active memtable,執行查詢的時候總是要先查詢 memtable,因為 memtable 中的資料總是更新的;memtable 實現方式是 skiplist,適合範圍查詢和插入;

memtable 生命週期

當一個 active memtable 被寫滿,會被置為只讀狀態,變成 immutable memtable。然後會建立一塊新的 active memtable 來提供寫入。

immutable memtable 會在記憶體中保留,等待後臺執行緒對其進行 flush 操作。flush 的觸發條件是 immutable memtable 數量超過 min_write_buffer_number_to_merge 。flush 會一次把 immutable memtable 合併壓縮後寫入磁碟的 L0 sst 中。 flush 之後對應的 memtable 會被銷燬。

相關引數:

  • write_buffer_size:一塊 memtable 的 容量
  • max_write_buffer_number:memtable 的最大存在數
  • min_write_buffer_number_to_merge:設定刷入 sst 之前,最小可合併的 memtable 數(如果設定成 1,代表著 flush 過程中沒有合併壓縮操作)

1.2 WAL (write-ahead log)

WAL 大家應該都很熟悉,一種有利於順序寫的持久化日誌檔案。很多儲存系統中都有類似的設計(如 MySQL 的 redo log/undo log、ZK 的 WAL);

RocksDB 每次寫資料都會先寫入 WAL 再寫入 memtable,在發生故障時,通過重放 WAL 恢復記憶體中的資料,保證資料一致性。

這種設計有什麼好處呢?這樣 LSM 樹就可以將有易失性(volatile)的記憶體看做是持久型儲存,並且信任記憶體上的資料。

至於 WAL 的建立刪除時機,每次 flush 一個 CF(列族資料,下文會提) 後,都會新建一個 WAL。這並不意味著舊的 WAL 會被刪除,因為別的 CF 資料可能還沒有落盤,只有所有的 CF 資料都被 flush 且所有的 WAL 有關的 data 都落盤,相關的 WAL 才會被刪除

1.3 SSTable (sorted string table)

SSTable,全稱是 Sorted String Table,存在於磁碟,是一個持久化的、有序的、不可更改的 Map 結構,Key 和 Value 都是任意的 Byte 串。上文提到記憶體中的 memtable 在滿足條件的情況下會執行 flush 操作

SSTable 的 檔案結構如下:

image-20211109130952811

既然對檔案結構進行了劃分,每塊區域肯定都有其作用:

  • 資料塊 Data Block,儲存有序鍵值對,是 SSTable 的資料實體;為了節省儲存空間,並不會為每一對 key-value 對都儲存完整的 key 值,而是儲存與上一個 key 非共享的部分,避免了 key 重複內容的儲存(這種通過 delta encode 的方式節省空間的方式在其他儲存中介軟體底層中也很常見)
  • Meta Block,儲存 Filter 相關資訊,用於加快 sst 中查詢資料的效率;Filter 通過 Bloom Filter 來過濾判斷指定的 data block 中是否存在要查詢的資料。
  • Meta Index Block,對 Meta Block 的索引,它只有一條記錄,key 是 meta index 的名字(也就是 Filter 的名字),value 為指向 meta index 的位置;
  • Index Block,index block 用來儲存所有 data block 的相關索引資訊。indexblock 包含若干條記錄,每一條記錄代表一個 data block 的索引資訊;
  • Footer,指向各個分割槽的位置和大小。Footer 是定長的,讀取 SSTable 檔案的時候,就是從檔案末尾,固定讀取位元組數,進而得到了 Footer 資訊。 Footer 中的資訊,指明瞭 MetaIndexBlock 和 IndexBlock 的位置,進而找到 MetaBlock 和 DataBlock。
可以看到,SSTable 檔案除了儲存實際資料外,還有索引結構 和 Filter,來加快 SST 的查詢效率,設計非常精妙。

2. 其他的一些名詞概念

Column Family (CF)

RocksDB 3.0 以後新增了一個 Column Family 的 feature,每個 kv 儲存之時都必須指定其所在的 CF。RocksDB 為了相容以往版本,預設建立一個 “default” 的 CF。儲存 kv 時如果不指定 CF,RocksDB 會把其存入 “default” CF 中。

RocksDB 允許使用者建立多個 Column Family ,這些 Column Family 各自擁有獨立的 memtable 以及 SST 檔案,但是共享同一個 WAL 檔案,這樣的好處是可以根據應用特點為不同的 Column Family 選擇不同的配置,但是又沒有增加對 WAL 的寫次數。

如果類比到關係型資料庫中,列族可以看做是表的概念。

3. 讀 & 寫

3.1 讀操作

  1. 在 active memtable 中查詢;
  2. 如果 active memtable 沒有,則在 immutable memtable 中查詢;
  3. 如果 immutable memtable 沒有,則在 L0 SSTable 中查詢(RocksDB 採用遍歷的方法查詢 L0 SSTable,為了提高查詢效率會控制 L0 檔案的個數);
  4. 如果找不到,則在剩餘的 SSTable 中查詢(對於 L1 層以及 L1 層以上層級的檔案,每個 SSTable 沒有交疊,可以使用二分查詢快速找到 key 所在的 Level 以及 SSTable)

每個 SSTable 在查詢之前通過 bloom filter 快速判斷資料是否存在於當前檔案,減少不必要的 IO。

RocksDB 為 SST 中訪問頻繁的 data blocks 設定了一個讀快取結構 Block cache,並提供了兩種開箱即用的實現 LRUCache 和 ClockCache 。

3.2 寫操作

image-20211109225855722

  1. 寫操作會先寫 WAL 檔案,保證資料不丟失;
  2. 完成 WAL 寫入後,將資料寫入到 記憶體中的 active memtable 中(為了保證有序性,RocksDB 使用跳錶資料結構實現 memtable);
  3. 然後等 memtable 資料達到一定規模時,會轉變成 immutable memtable,同時生成新的 memtable 提供服務;
  4. 在滿足落盤條件後,immutable memtable 會被合併刷入到硬碟的 SST 中;

順帶一提,預設情況下 RocksDB 中的寫磁碟行為都是非同步寫,僅僅把資料寫進了作業系統的快取區就返回了(pageCache),而這些資料被寫進磁碟是一個非同步的過程。非同步寫的吞吐率是同步寫的一千多倍。非同步寫的缺點是機器或者作業系統崩潰時可能丟掉最近一批寫請求發出的由作業系統快取的資料,但是 RocksDB 自身崩潰並不會導致資料丟失。而機器或者作業系統崩潰的概率比較低,所以大部分情況下可以認為非同步寫是安全的

4. Compaction

LSM 樹將離散的隨機寫請求都轉換成批量的順序寫請求(WAL + memtable),以此提高寫效能。但也帶來了一些問題:

  • 讀放大(Read Amplification)。按照上面【讀操作】的描述,讀操作有可能會訪問大量的檔案;
  • 空間放大(Space Amplification)。因為所有的寫入都是順序寫(append-only)的,不是在對資料進行直接更新(in-place update),所以過期資料不會馬上被清理掉。

所以維護和減少 SST 檔案數量是很有必要的。RocksDB 會根據配置的不同 Compaction 演算法策略,進行 Compaction 操作。Compaction 操作會刪除過期 或者標記為刪除/重複的 key,對資料進行重新合併來提高查詢效率。

4.1 Level Style Compaction (default compaction style)

預設情況下,RocksDB 採用 Level Style Compaction 作為 LSM 樹的 Compaction 策略。

如果啟用 Level Style Compaction,L0 儲存著 RocksDB 最新的資料,Lmax 儲存著比較老的資料,L0 裡可能存著重複 keys,但是其他層檔案則不可能存在重複 key。每個 compaction 任務都會選擇 Ln 層的一個檔案以及與其相鄰的 Ln+1 層的多個檔案進行合併,刪除過期 或者 標記為刪除 或者 重複 的 key,然後把合併後的檔案放入 Ln+1 層。

compaction

Compaction 雖然減少了讀放大(減少 SST 檔案數量)和空間放大(清理無效資料),但也因此帶來了寫放大(Write Amplification)的問題(底層 I/O 被 Compaction 操作消耗,會大於上層請求速遞)

RocksDB 還有支援的其他的 Compaction 策略。

4.2 Universal Compaction

  • 只壓縮 L0 的所有檔案,合併後再放入 L0 層裡;
  • 目標是更低的寫放大,並且在讀放大和空間放大中 trade off;
具體演算法本篇不深究;

4.3 FIFO Compaction

  • FIFO 顧名思義就是先進先出,這種模式週期性地刪除舊資料。在 FIFO 模式下,所有檔案都在 L0,當 SST 檔案總大小超過 compaction_options_fifo.max_table_files_size,則刪除最老的 SST 檔案。對於 FIFO 來說,它的策略非常的簡單,所有的 SST 都在 Level 0,如果超過了閾值,就從最老的 SST 開始刪除。
  • 這套機制非常適合於儲存時序資料。

⭐ LSM 樹 設計思想總結

LSM 樹的設計思想很有意思。我這裡做下總結。

LSM 樹將對磁碟的隨機寫入轉化為了磁碟友好型的順序寫(無論機械磁碟還是 SSD,隨機讀寫都要遠遠慢於順序讀寫),從而大大提高了寫效能。

那麼是怎麼轉化的呢?核心就是在記憶體中維護一個有序的記憶體表(memtable),當記憶體表大於閾值的時候批量刷入磁碟,生成最新的 SSTable 檔案。因為本身 memtable 已經維護了按鍵排序的鍵值對,所以這個步驟可以高效地完成。

寫入記憶體表時先將資料寫入 WAL 日誌,用以在發生故障時,通過重放 WAL 恢復記憶體中的資料,保證資料庫的資料一致性。

在這種追加(append-only)寫入模式下,刪除資料變成了對資料新增刪除標記,更新資料變成了寫入新的 value,在同一個時間資料庫中會存在同個 key 的新值和舊值。這種影響被稱之為 空間放大(Space Amplification)

隨著資料的寫入,底層的 SSTable 檔案也會越來越多。

讀請求在這個模式下,變成了先在記憶體中尋找關鍵字,如果找不到則在磁碟中按照新-> 舊查詢 SSTable 檔案。為了優化這種訪問模式的讀效能,儲存引擎通常使用常見的針對讀的優化策略,比如使用額外的 Bloom Filter、讀 Cache

這種需要多次讀取的過程(或者說影響)被稱之為讀放大(Read Amplification)。很顯然,讀放大會影響 LSM 樹的讀效能。

為了優化讀效能(讀放大),同時優化儲存空間(空間放大),LSM 樹通過在執行合併和壓縮過程減少 SSTable 檔案數量,刪除無效(被刪除或者被覆蓋) 的舊值。這一過程被稱之為 compaction

但是 compaction 也會一些影響,在資料庫的生命週期中每次的資料寫入實際上會造成多次的磁碟寫入。這種影響被稱之為寫放大(Write Amplification)。在寫入繁重的應用程式中,效能瓶頸可能是資料庫可以寫入磁碟的速度。在這種情況下,寫放大會導致直接的效能代價:儲存引擎寫入磁碟的次數越多,可用磁碟頻寬內的每秒寫入次數越少。

這也是我認為 LSM 引擎儲存的一個缺點,就是壓縮過程有可能會干擾到正在進行的讀寫請求。儘管儲存引擎嘗試逐步執行壓縮而不影響併發訪問,但是磁碟資源有限,所以很容易發生請求需要等待磁碟完成昂貴的壓縮操作。對吞吐量和平均響應時間的影響通常很小,但是如果是高百分位情況下(如 P99 RT),有時就會出現查詢響應較長的情況。

上述是對 LSM 樹的個人總結,一些沒有提到實現細節的抽象描述(比如 memtable、compaction),在實際的儲存中都有對應的實現,細節可能不同,但是設計思想都是類似。

在本文具體提到的 RocksDB 實現中,寫放大、讀放大、空間放大,這三者就像 CAP 定理一樣,無法同時達到最優。為此 RocksDB 暴露了很多引數來讓使用者進行調優,以適應更多的應用場景。這其中很大一部分工作是在寫放大、讀放大和空間放大這三個放大因子之間做好 trade off。

Elasticsearch Lucene 中的類 LSM 設計思想

ES 底層搜尋引擎 Lucene 的 segment 設計思想和 LSM 樹非常相似。也運用到了WAL、記憶體 buffer、分段merge等思想。

一個文件被索引之後,就會被新增到記憶體緩衝區,並且 追加到了 translog。

img

隨著當前分片(shard)的 refresh 操作,這些在記憶體緩衝區的文件被 flush 到一個新的 segment 中,這個 segment 被開啟,使其可被搜尋,對應的記憶體緩衝區被清空。

img

隨著資料的寫入,還會觸發 commit 操作,做一次全量提交,然後對應的 Translog 會被刪除。(具體過程限於篇幅不贅述)。

segment 被 refresh 或 commit 之前,資料儲存在記憶體中,是不可被搜尋的,這也就是為什麼 Lucene 被稱為提供近實時而非實時查詢的原因。

Segment 中寫入的文件不可被修改,但可被刪除,刪除的方式也不是在檔案內部原地更改,而是會由另外一個檔案儲存需要被刪除的文件的 DocID,保證資料檔案不可被修改。Index的查詢需要對多個 Segment 進行查詢並對結果進行合併,還需要處理被刪除的文件,為了對查詢進行優化,Lucene會有策略對多個Segment進行合併,這點與 LSM 對 SSTable 的 compaction 類似。

這種機制避免了隨機寫,資料寫入都是 Batch 和 Append,能達到很高的吞吐量。同時為了提高寫入的效率,利用了檔案快取系統和記憶體來加速寫入時的效能,並使用日誌來防止資料的丟失。

⭐ LSM 樹 vs B+ 樹

講到這裡了,想想不如再和我們常見的 B+ 樹做一些對比。

設計理念不同

雖然像 LSM 樹一樣,B+ 樹保持按鍵排序的鍵值對(這允許高效的鍵值查詢和範圍查詢),但是兩者設計理念完全不同。

  • LSM 樹將資料庫分解為可變大小的段,通常是幾兆位元組或更大的大小,並且總是按順序編寫段。
  • 相比之下,B+ 樹將資料庫分解成固定大小的塊或頁面,傳統上大小為 4KB(有時會更大),並且一次只能讀取或寫入一個頁面。這種設計更接近於底層硬體,因為磁碟也被安排在固定大小的塊中。

資料的更新和刪除方面

  • B(+) 樹可以做到原地更新和刪除(in-place update),這種方式對資料庫事務支援更加友好,因為一個 key 只會出現一個 Page 頁裡面;
  • 但由於 LSM 樹只能追加寫(out-place update),並且在 L0 層的 SSTable 中會重疊,所以對事務支援較弱,只能在 compaction 的時候進行真正地更新和刪除。

效能方面

  • LSM 樹的優點是支援高吞吐的寫(可認為是 O(1)),這個特點在分散式系統上更為看重,當然針對讀取普通的 LSM 樹結構,讀取是 O(n) 的複雜度,在使用索引或者快取優化後的也可以達到 O(logN)的複雜度。
  • B+ 樹的優點是支援高效的讀(穩定的 O(logN)),但是在大規模的寫請求下(複雜度 O(LogN)),效率會變得比較低,因為隨著 insert 的操作,為了維護樹結構,節點會不斷的分裂和合並。操作磁碟的隨機讀寫概率會變大,故導致效能降低。

通常來說,我們會說,LSM 樹寫效能會優於 B 樹,而 B 樹的讀效能會優於 LSM 樹。但是請不要忽略 LSM 樹寫放大的影響,在進行效能判定是要更辯證的思考。

參考

  1. 《設計資料密集型應用》第三章:儲存與檢索(強烈推薦閱讀的一本書!!!)
  2. https://github.com/facebook/r...
  3. https://www.lxkaka.wang/rocks...
  4. http://alexstocks.github.io/h...
  5. https://cloud.tencent.com/dev...
  6. https://www.elastic.co/guide/...

相關文章