漫談RocksDB(三)基本操作——動如脱兔,靜若處子

語言: CN / TW / HK

本文已參與「新人創作禮」活動,一起開啟掘金創作之路。

前言

上一篇文章已經講述了RocksDB的基礎概念,本文趁熱打鐵,將上文留下的作業以及RocksDB的基本操作集中説明一下,既幫助大家鞏固下上文的概念,又為後文的高級操作以及特性打下基礎。

總體來説,RocksDB的基礎操作和LSM tree的基礎操作基本相同,但是RocksDB在標準的LSM tree方案的基礎上進行了定製化的優化,以支持自身的各種功能,下面就一起來看一下RocksDB的各種基本操作。

正文

寫流程:

rocksdb寫入時,直接以append方式寫到WAL文件以及memtable,隨即返回,因此非常快速。memtable達到一定閾值後切換為Immutable Memtable,只能讀不能寫。

Immutable memtable觸發閾值後,後台Flush線程負責按照時間順序將Immutable Memtable刷盤 生成Level 0 SST,Level 0 SST觸發閾值後,經合併操作(compaction)生成level 1 SST,level 1 SST 合併操作生成level 2 SST,以此類推,生成level n SST。流程如下:

讀流程:

按照 memtable --> Level 0 SST–> Level 1 SST --> … -> Level n SST的順序讀取數據。這和記錄的新舊順序是一的。因此只要在當前級別找到記錄,就可以返回。流程如下:

Flush流程

上一章節講述了Flush的定義,下面來講一下Flush的具體流程:

簡單來説在RocksDB中,每一個ColumnFamily都有自己的Memtable,當Memtable超過固定大小之後(或者WAL文件超過限制),它將會被設置為Immutable Memtable,然後會有後台的線程啟動來刷新這個Immutable Memtable到磁盤(SST)。

在下面這幾種條件下RocksDB會flush Memtable到磁盤:

  • 當某一個Memtable的大小超過write_buffer_size
  • 當所有的Memtable的大小超過db_write_buffer_size
  • 當WAL文件的大小超過max_total_wal_size,我們需要清理WAL,因此此時我們需要將此WAL對應的數據都刷新到磁盤,也是刷新Memtable

Compaction操作

上一章節講述了Compaction操作的定義,下面來講一下Compaction操作的具體作用:

  • 數據合併遷移:首先當上層level的sst文件數量或者大小達到閾值時,就會觸發指定的sst文件的合併並遷移到下一層。每一層都會檢查是否需要進行compaction,直到數據遷移到最下層
  • 數據真刪:LSM樹的特點決定數據的修改和刪除都不是即時的真刪,而是寫入一條新的數據進行衝抵。這些數據只會在compaction時才會真正刪除(HBase是在major compaction中才會刪除,minor compaction不會進行真刪),所以compaction能釋放空間,減少LSM樹的空間放大,但是代價就是寫放大,需要根據業務場景要求通過參數來調整兩者之間的關係,平衡業務與性能。
  • 冷熱數據管理:RocksDB的數據流向是從Memtable到level 0 最後到 level N,所以數據查詢時也是通過這個路徑進行的。由於業務上查詢大多是熱數據的查詢,所以數據的冷熱管理就顯得很必要,能很大程度上提高查詢效率;而且由於各個level的sst文件可以使用不同的壓縮算法,所以可以根據自己的業務需求進行壓縮算法的配置,達到最佳性能。

LSM tree相關的經典壓縮算法有下面幾種:經典Leveled,Tiered,Tiered+Leveled,Leveled-N,FIFO。除了這些,RocksDB還額外實現了Tiered+Leveled和termed Level,Tiered termed Universal,FIFO。這些算法各有各的特點,適用於不同的場景,不同的 compaction 算法,可以在空間放大、讀放大和寫放大之間這三個LSM tree的"副作用"中進行取捨,以適應特定的業務場景。大家可以按需選擇,如果沒有特殊需求,開箱即用即可。至於每一種壓縮算法的具體方式以及實現將會在高級特性篇進行講解。

Compression操作

上一章節講述了Compression操作的定義,下面來講一下Compression的具體操作:

使用options.compression來指定使用的壓縮方法。默認是Snappy。但是大多數情況下LZ4總是比Snappy好。之所以把Snappy作為默認的壓縮方法,是為了與舊版本保持兼容。LZ4/Snappy是輕量壓縮,所以在CPU使用率和存儲空間之間能取得一個較好的平衡。

如果你想要進一步減少存儲的使用並且你有一些空閒的CPU,你可以嘗試設置options.bottommost_compression來使用一個更加重量級的壓縮。最底層會使用這個方式進行壓縮。通常最底層會保存大部分的數據,所以用户通常會選擇偏向空間的設定,而不是花費cpu在各個層壓縮所有數據。我們推薦使用ZSTD。如果沒有,Zlib是第二選擇。

如果你有大量空閒CPU並且希望同時減少空間和寫放大,把options.compression設置為重量級的壓縮方法,所有層級都生效。推薦使用ZSTD,如果沒有就用Zlib

當然也可以通過一個已經過期的遺留選項options.compression_per_level,你可以有更好的控制每一層的壓縮方式。當這個選項被使用的時候,options.compression不會再生效,但是正常情況下這個是不必要的,除非你有極特殊的需求或者性能優化要求。

最後可以通過把BlockBasedTableOptions.enable_index_compression設置為false來關閉索引的壓縮。

Ingest操作

上一章節講述了Ingest操作的定義,下面來講一下Ingest的具體操作。Ingest實現上主要是兩部分:創建SST文件和導入SST文件:

創建SST文件

創建SST文件的過程比較簡單,創建了一個SstFileWriter對象之後,就可以打開一個文件,插入對應的數據,然後關閉文件即可。 寫文件的過程比較簡單,但是要注意下面三點:

  • 傳給SstFileWriter的Options會被用於指定表類型,壓縮選項等,用於創建sst文件。
  • 傳入SstFileWriter的Comparator必須與之後導入這個SST文件的DB的Comparator絕對一致。
  • 行必須嚴格按照增序插入

導入SST文件

導入SST文件也非常簡單,需要做的只是調用IngestExternalFile()然後把文件地址封裝成vector以及相關的options傳入參數即可。下面是IngestExternalFile()的實現邏輯:

  • 把文件拷貝,或者鏈接到DB的目錄。
  • 阻塞DB的寫入(而不是跳過),因為我們必須保證db狀態的一致性,所以我們必須確保,我們能給即將導入的文件裏的所有key都安全地分配正確的序列號。
  • 如果文件的key覆蓋了memtable的鍵範圍,把memtable進行flush操作。
  • 把文件安排到LSM樹的最合適的層級。
  • 給文件賦值一個全局序列號。
  • 重新啟動DB的寫操作。

為了減少compaction的壓力,我們總是想將目標的sst文件寫入最合適的層級,即合適寫入的最低層級,下面三個條件作為約束可以選出合適的層級:

  • 文件可以安排在這個層
  • 這個文件的key的範圍不會覆蓋上面任何一層的數據
  • 這個文件的key的範圍不會覆蓋當前層正在進行壓縮

另外,5.5以後的新版本的RocksDB的IngestExternalFile加載一個外部SST文件列表的時候,支持下層導入,意思是如果ingest_behind為true,那麼重複的key會被跳過。在這種模式下,我們總是導入到最底層。文件中重複的key會被跳過,而不是覆蓋已經存在的key。

總結

上面的基礎操作中可以看出來,RocksDB的各種操作是圍繞着LSM tree的特性展開的,各種優化點也是為了平衡LSM tree的讀放大,寫放大以及空間放大三個副作用而設計的。

現在看來這三個放大更像數據庫的CAP,不能同時解決,只能根據業務場景來解決主要矛盾,儘量減少次要矛盾,至於具體的方法,會在後續的高級特性中詳細講述。