做者:鄭志銓linux
Titan 是由 PingCAP 研發的一個基於 RocksDB 的高性能單機 key-value 存儲引擎,其主要設計靈感來源於 USENIX FAST 2016 上發表的一篇論文 WiscKey。WiscKey
提出了一種高度基於 SSD 優化的設計,利用 SSD 高效的隨機讀寫性能,經過將 value 分離出 LSM-tree
的方法來達到下降寫放大的目的。c++
咱們的基準測試結果顯示,當 value 較大的時候,Titan 在寫、更新和點讀等場景下性能都優於 RocksDB。可是根據 RUM Conjecture
,一般某些方面的提高每每是以犧牲其餘方面爲代價而取得的。Titan 即是以犧牲硬盤空間和範圍查詢的性能爲代價,來取得更高的寫性能。隨着 SSD 價格的下降,咱們認爲這種取捨的意義會愈來愈明顯。git
Titan 做爲 TiKV 的一個子項目,首要的設計目標即是兼容 RocksDB。由於 TiKV 使用 RocksDB 做爲其底層的存儲引擎,而 TiKV 做爲一個成熟項目已經擁有龐大的用戶羣體,因此咱們須要考慮已有的用戶也能夠將已有的基於 RocksDB 的 TiKV 平滑地升級到基於 Titan 的 TiKV。github
所以,咱們總結了四點主要的設計目標:算法
LSM-tree
中分離出來單獨存儲,以下降寫放大。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
有幾點值得關注的地方:app
BlobFile
中的 key-value 是有序存放的,目的是在實現 Iterator
的時候能夠經過 prefetch 的方式提升順序讀取的性能。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
的主要工做流程:dom
圖 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 (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
來做爲做爲候選的文件。
每次進行 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 作說明,更詳細的測試分析報告將會放在下一篇文章。
數據集選定的基本原則是原始數據大小(不算上寫放大因素)要比可用內存大,這樣能夠防止全部數據被緩存到內存中,減小 Cache 所帶來的影響。這裏咱們選用的數據集大小是 64GB,進程的內存使用限制是 32GB。
Value Size | Number of Keys (Each Key = 16 Bytes) | Raw Data Size |
---|---|---|
1KB | 64M | 64GB |
16KB | 4M | 64GB |
咱們主要測試 5 個經常使用的場景:
BlobFile
中沒有可丟棄數據),所以咱們還須要經過更新來測試 GC
對性能的影響。圖 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。
#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());
和 RocksDB 同樣,Titan 使用 rocksdb::Status
來做爲絕大多數 API 的返回值,使用者能夠經過它檢查執行結果是否成功,也能夠經過它打印錯誤信息:
rocksdb::Status s = ...; if (!s.ok()) cerr << s.ToString() << endl;
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);
目前 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 速度來加快空間的回收。
TiKV 在某些場景下僅須要判斷某個 key 是否存在,而不須要讀取對應的 value。經過提供一個這樣的 API 能夠極大地提升性能,由於咱們已經看到將 value 移出 LSM-tree
以後,LSM-tree
自己會變的很是小,以致於咱們能夠將更多地 index
、filter
和 DataBlock
存放到內存當中去,這樣去檢索某個 key 的時候能夠作到只須要少許甚至不須要 IO 。