作者:鄭志銓
Titan 是由 PingCAP 研發的一個基於 RocksDB 的高效能單機 key-value 儲存引擎,其主要設計靈感來源於 USENIX FAST 2016 上發表的一篇論文 WiscKey。WiscKey
提出了一種高度基於 SSD 優化的設計,利用 SSD 高效的隨機讀寫效能,通過將 value 分離出 LSM-tree
的方法來達到降低寫放大的目的。
我們的基準測試結果顯示,當 value 較大的時候,Titan 在寫、更新和點讀等場景下效能都優於 RocksDB。但是根據 RUM Conjecture
,通常某些方面的提升往往是以犧牲其他方面為代價而取得的。Titan 便是以犧牲硬碟空間和範圍查詢的效能為代價,來取得更高的寫效能。隨著 SSD 價格的降低,我們認為這種取捨的意義會越來越明顯。
設計目標
Titan 作為 TiKV 的一個子專案,首要的設計目標便是相容 RocksDB。因為 TiKV 使用 RocksDB 作為其底層的儲存引擎,而 TiKV 作為一個成熟專案已經擁有龐大的使用者群體,所以我們需要考慮已有的使用者也可以將已有的基於 RocksDB 的 TiKV 平滑地升級到基於 Titan 的 TiKV。
因此,我們總結了四點主要的設計目標:
- 支援將 value 從
LSM-tree
中分離出來單獨儲存,以降低寫放大。 - 已有 RocksDB 例項可以平滑地升級到 Titan,這意味著升級過程不需要人工干預,並且不會影響線上服務。
- 100% 相容目前 TiKV 所使用的所有 RocksDB 的特性。
- 儘量減少對 RocksDB 的侵入性改動,保證 Titan 更加容易升級到新版本的 RocksDB。
架構與實現
Titan 的基本架構如下圖所示:
圖 1:Titan 在 Flush 和 Compaction 的時候將 value 分離出
LSM-tree
,這樣做的好處是寫入流程可以和 RockDB 保持一致,減少對RocksDB
的侵入性改動。
Titan 的核心元件主要包括:BlobFile
、TitanTableBuilder
、Version
和 GC
,下面將逐一進行介紹。
BlobFile
BlobFile
是用來存放從 LSM-tree
中分離出來的 value 的檔案,其格式如下圖所示:
圖 2:
BlobFile
主要由 blob record 、meta block、meta index block 和 footer 組成。其中每個 blob record 用於存放一個 key-value 對;meta block 支援可擴充套件性,可以用來存放和BlobFile
相關的一些屬性等;meta index block 用於檢索 meta block。
BlobFile
有幾點值得關注的地方:
-
BlobFile
中的 key-value 是有序存放的,目的是在實現Iterator
的時候可以通過 prefetch 的方式提高順序讀取的效能。 - 每個 blob record 都保留了 value 對應的 user key 的拷貝,這樣做的目的是在進行 GC 的時候,可以通過查詢 user key 是否更新來確定對應 value 是否已經過期,但同時也帶來了一定的寫放大。
-
BlobFile
支援 blob record 粒度的 compression,並且支援多種 compression algorithm,包括Snappy
、LZ4
和Zstd
等,目前 Titan 預設使用的 compression algorithm 是LZ4
。
TitanTableBuilder
TitanTableBuilder
是實現分離 key-value 的關鍵。我們知道 RocksDB 支援使用使用者自定義 table builder 建立 SST
,這使得我們可以不對 build table 流程做侵入性的改動就可以將 value 從 SST
中分離出來。下面將介紹 TitanTableBuilder
的主要工作流程:
圖 3:
TitanTableBuilder
通過判斷 value size 的大小來決定是否將 value 分離到BlobFile
中去。如果 value size 大於等於min_blob_size
則將 value 分離到BlobFile
,並生成 index 寫入SST
;如果 value size 小於min_blob_size
則將 value 直接寫入SST
。
Titan 和 Badger
的設計有很大區別。Badger
直接將 WAL
改造成 VLog
,這樣做的好處是減少一次 Flush 的開銷。而 Titan 不這麼設計的主要原因有兩個:
- 假設
LSM-tree
的 max level 是 5,放大因子為 10,則LSM-tree
總的寫放大大概為 1 + 1 + 10 + 10 + 10 + 10,其中 Flush 的寫放大是 1,其比值是 42 : 1,因此 Flush 的寫放大相比於整個 LSM-tree 的寫放大可以忽略不計。 - 在第一點的基礎上,保留
WAL
可以使 Titan 極大地減少對 RocksDB 的侵入性改動,而這也正是我們的設計目標之一。
Version
Titan 使用 Version
來代表某個時間點所有有效的 BlobFile
,這是從 LevelDB
中借鑑過來的管理資料檔案的方法,其核心思想便是 MVCC
,好處是在新增或刪除檔案的同時,可以做到併發讀取資料而不需要加鎖。每次新增檔案或者刪除檔案的時候,Titan
都會生成一個新的 Version
,並且每次讀取資料之前都要獲取一個最新的 Version
。
圖 4:新舊
Version
按順序首尾相連組成一個雙向連結串列,VersionSet
用來管理所有的Version
,它持有一個current
指標用來指向當前最新的Version
。
Garbage Collection
Garbage Collection (GC) 的目的是回收空間,一個高效的 GC 演算法應該在權衡寫放大和空間放大的同時,用最少的週期來回收最多的空間。在設計 GC 的時候有兩個主要的問題需要考慮:
- 何時進行 GC
- 挑選哪些檔案進行 GC
Titan 使用 RocksDB 提供的兩個特性來解決這兩個問題,這兩個特性分別是 TablePropertiesCollector
和 EventListener
。下面將講解我們是如何通過這兩個特性來輔助 GC 工作的。
BlobFileSizeCollector
RocksDB 允許我們使用自定義的 TablePropertiesCollector
來蒐集 SST
上的 properties 並寫入到對應檔案中去。Titan
通過一個自定義的 TablePropertiesCollector
—— BlobFileSizeCollector
來蒐集每個 SST
中有多少資料是存放在哪些 BlobFile
上的,我們將它收集到的 properties 命名為 BlobFileSizeProperties
,它的工作流程和資料格式如下圖所示:
圖 5:左邊
SST
中 Index 的格式為:第一列代表BlobFile
的檔案 ID,第二列代表 blob record 在BlobFile
中的 offset,第三列代表 blob record 的 size。右邊BlobFileSizeProperties
中的每一行代表一個BlobFile
以及SST
中有多少資料儲存在這個BlobFile
中,第一列代表BlobFile
的檔案 ID,第二列代表資料大小。
EventListener
我們知道 RocksDB 是通過 Compaction 來丟棄舊版本資料以回收空間的,因此每次 Compaction 完成後 Titan 中的某些 BlobFile
中便可能有部分或全部資料過期。因此我們便可以通過監聽 Compaction 事件來觸發 GC,通過蒐集比對 Compaction 中輸入輸出 SST
的 BlobFileSizeProperties
來決定挑選哪些 BlobFile
進行 GC。其流程大概如下圖所示:
圖 6:inputs 代表參與 Compaction 的所有
SST
的BlobFileSizeProperties
,outputs 代表 Compaction 生成的所有SST
的BlobFileSizeProperties
,discardable size 是通過計算 inputs 和 outputs 得出的每個BlobFile
被丟棄的資料大小,第一列代表BlobFile
的檔案 ID,第二列代表被丟棄的資料大小。
Titan 會為每個有效的 BlobFile
在記憶體中維護一個 discardable size 變數,每次 Compaction 結束之後都對相應的 BlobFile
的 discardable size 變數進行累加。每次 GC 開始時就可以通過挑選 discardable size 最大的 BlobFile
來作為作為候選的檔案。
Sample
每次進行 GC 前我們都會挑選一系列 BlobFile
作為候選檔案,挑選的方法如上一節所述。為了減小寫放大,我們可以容忍一定的空間放大,所以我們只有在 BlobFile
可丟棄的資料達到一定比例之後才會對其進行 GC。我們使用 Sample 演算法來獲取每個候選檔案中可丟棄資料的大致比例。Sample 演算法的主要邏輯是隨機取 BlobFile
中的一段資料 A,計其大小為 a,然後遍歷 A 中的 key,累加過期的 key 所在的 blob record 的 size 計為 d,最後計算得出 d 佔 a 比值 為 r,如果 r >= discardable_ratio
則對該 BlobFile
進行 GC,否則不對其進行 GC。上一節我們已經知道每個 BlobFile
都會在記憶體中維護一個 discardable size,如果這個 discardable size 佔整個 BlobFile
資料大小的比值已經大於或等於 discardable_ratio
則不需要對其進行 Sample。
基準測試
我們使用 go-ycsb 測試了 TiKV 在 Txn Mode 下分別使用 RocksDB 和 Titan 的效能表現,本節我會簡要說明下我們的測試方法和測試結果。由於篇幅的原因,我們只挑選兩個典型的 value size 做說明,更詳細的測試分析報告將會放在下一篇文章。
測試環境
- CPU:Intel(R) Xeon(R) CPU E5-2630 v4 @ 2.20GHz(40個核心)
- Memory:128GB(我們通過 Cgroup 限制 TiKV 程式使用記憶體不超過 32GB)
- Disk:SATA SSD 1.5TB(fio 測試:4KB block size 混合隨機讀寫情況下讀寫 IOPS 分別為 43.8K 和 18.7K)
測試計劃
資料集選定的基本原則是原始資料大小(不算上寫放大因素)要比可用記憶體大,這樣可以防止所有資料被快取到記憶體中,減少 Cache 所帶來的影響。這裡我們選用的資料集大小是 64GB,程式的記憶體使用限制是 32GB。
Value Size | Number of Keys (Each Key = 16 Bytes) | Raw Data Size |
---|---|---|
1KB | 64M | 64GB |
16KB | 4M | 64GB |
我們主要測試 5 個常用的場景:
- Data Loading Performance:使用預先計算好的 key 數量和固定的 value 大小,以一定的速度併發寫入。
- Update Performance:由於 Titan 在純寫入場景下不需要 GC(
BlobFile
中沒有可丟棄資料),因此我們還需要通過更新來測試GC
對效能的影響。 - Output Size:這一步我們會測量更新場景完成後引擎所佔用的硬碟空間大小,以此反映 GC 的空間回收效果。
- Random Key Lookup Performance:這一步主要測試點查效能,並且點查次數要遠遠大於 key 的數量。
- Sorted Range Iteration Performance:這一步主要測試範圍查詢的效能,每次查詢 2 million 個相連的 key。
測試結果
圖 7 Data Loading Performance:Titan 在寫場景中的效能要比 RocksDB 高 70% 以上,並且隨著 value size 的變大,這種效能的差異會更加明顯。值得注意的是,資料在寫入 KV Engine 之前會先寫入 Raft Log,因此 Titan 的效能提升會被攤薄,實際上裸測 RocksDB 和 Titan 的話這種效能差異會更大。
圖 8 Update Performance:Titan 在更新場景中的效能要比 RocksDB 高 180% 以上,這主要得益於 Titan 優秀的讀效能和良好的 GC 演算法。
圖 9 Output Size:Titan 的空間放大相比 RocksDB 略高,這種差距會隨著 Key 數量的減少有略微的縮小,這主要是因為
BlobFile
中需要儲存 Key 而造成的寫放大。
圖 10 Random Key Lookup: Titan 擁有比 RocksDB 更卓越的點讀效能,這主要得益與將 value 分離出
LSM-tree
的設計使得LSM-tree
變得更小,因此 Titan 在使用同樣的記憶體量時可以將更多的index
、filter
和DataBlock
快取到 Block Cache 中去。這使得點讀操作在大多數情況下僅需要一次 IO 即可(主要是用於從BlobFile
中讀取資料)。
圖 11 Sorted Range Iteration:Titan 的範圍查詢效能目前和 RocksDB 相比還是有一定的差距,這也是我們未來優化的一個重要方向。
本次測試我們對比了兩個具有代表性的 value size 在 5 種不同場景下的效能差異,更多不同粒度的 value size 的測試和更詳細的效能報告我們會放在下一篇文章去說明,並且我們會從更多的角度(例如 CPU 和記憶體的使用率等)去分析 Titan 和 RocksDB 的差異。從本次測試我們可以大致得出結論,在大 value 的場景下,Titan 會比 RocksDB 擁有更好的寫、更新和點讀效能。同時,Titan 的範圍查詢效能和空間放大都遜於 RocksDB 。
相容性
一開始我們便將相容 RocksDB 作為設計 Titan 的首要目標,因此我們保留了絕大部分 RocksDB 的 API。目前僅有兩個 API 是我們明確不支援的:
Merge
SingleDelete
除了 Open
介面以外,其他 API 的引數和返回值都和 RocksDB 一致。已有的專案只需要很小的改動即可以將 RocksDB
例項平滑地升級到 Titan。值得注意的是 Titan 並不支援回退回 RocksDB。
如何使用 Titan
建立 DB
#include <assert>
#include "rocksdb/utilities/titandb/db.h"
// Open DB
rocksdb::titandb::TitanDB* db;
rocksdb::titandb::TitanOptions options;
options.create_if_missing = true;
rocksdb::Status status =
rocksdb::titandb::TitanDB::Open(options, "/tmp/testdb", &db);
assert(status.ok());
...
或
#include <assert>
#include "rocksdb/utilities/titandb/db.h"
// open DB with two column families
rocksdb::titandb::TitanDB* db;
std::vector<rocksdb::titandb::TitanCFDescriptor> column_families;
// have to open default column family
column_families.push_back(rocksdb::titandb::TitanCFDescriptor(
kDefaultColumnFamilyName, rocksdb::titandb::TitanCFOptions()));
// open the new one, too
column_families.push_back(rocksdb::titandb::TitanCFDescriptor(
"new_cf", rocksdb::titandb::TitanCFOptions()));
std::vector<ColumnFamilyHandle*> handles;
s = rocksdb::titandb::TitanDB::Open(rocksdb::titandb::TitanDBOptions(), kDBPath,
column_families, &handles, &db);
assert(s.ok());
Status
和 RocksDB 一樣,Titan 使用 rocksdb::Status
來作為絕大多數 API 的返回值,使用者可以通過它檢查執行結果是否成功,也可以通過它列印錯誤資訊:
rocksdb::Status s = ...;
if (!s.ok()) cerr << s.ToString() << endl;
銷燬 DB
std::string value;
rocksdb::Status s = db->Get(rocksdb::ReadOptions(), key1, &value);
if (s.ok()) s = db->Put(rocksdb::WriteOptions(), key2, value);
if (s.ok()) s = db->Delete(rocksdb::WriteOptions(), key1);
在 TiKV 中使用 Titan
目前 Titan 在 TiKV 中是預設關閉的,我們通過 TiKV 的配置檔案來決定是否開啟和設定 Titan,相關的配置項包括 [rocksdb.titan]
和 [rocksdb.defaultcf.titan]
, 開啟 Titan 只需要進行如下配置即可:
[rocksdb.titan]
enabled = true
注意一旦開啟 Titan 就不能回退回 RocksDB 了。
未來的工作
優化 Iterator
我們通過測試發現,目前使用 Titan 做範圍查詢時 IO Util 很低,這也是為什麼其效能會比 RocksDB 差的重要原因之一。因此我們認為 Titan 的 Iterator
還存在著巨大的優化空間,最簡單的方法是可以通過更加激進的 prefetch 和並行 prefetch 等手段來達到提升 Iterator
效能的目的。
GC
速度控制和自動調節
通常來說,GC 的速度太慢會導致空間放大嚴重,過快又會對服務的 QPS 和延時帶來影響。目前 Titan 支援自動 GC,雖然可以通過減小併發度和 batch size 來達到一定程度限制 GC 速度的目的,但是由於每個 BlobFile
中的 blob record 數目不定,若 BlobFile
中的 blob record 過於密集,將其有效的 key 更新回 LSM-tree
時仍然可能堵塞業務的寫請求。為了達到更加精細化的控制 GC 速度的目的,後續我們將使用 Token Bucket
演算法限制一段時間內 GC 能夠更新的 key 數量,以降低 GC 對 QPS 和延時的影響,使服務更加穩定。
另一方面,我們也正在研究自動調節 GC 速度的演算法,這樣我們便可以,在服務高峰期的時候降低 GC 速度來提供更高的服務質量;在服務低峰期的時候提高 GC 速度來加快空間的回收。
增加用於判斷 key 是否存在的 API
TiKV 在某些場景下僅需要判斷某個 key 是否存在,而不需要讀取對應的 value。通過提供一個這樣的 API 可以極大地提高效能,因為我們已經看到將 value 移出 LSM-tree
之後,LSM-tree
本身會變的非常小,以至於我們可以將更多地 index
、filter
和 DataBlock
存放到記憶體當中去,這樣去檢索某個 key 的時候可以做到只需要少量甚至不需要 IO 。