存储引擎 K/V 分离下的index回写问题
前言
近期在做on nvme hash引擎相關的事情,對于非全序的數據集的存儲需求,相比于我們傳統的LSM或者B-tree的數據結構來說 能夠減少很多維護全序上的計算/存儲資源。當然我們要保證hash場景下的高寫入能力,append-only 還是比較友好的選擇。
像 Riak的bitcask 基于索引都在內存的hash引擎這種,在后期的針對data-file 的merge 完成之后會將新的key-value-index回填到內存中的hash索引中,這個過程在實際的生產環境對性能有多大的影響還不太好確定。但是,很明顯的一點是正確的hash引擎索引在高并發場景中的更新是需要加鎖的。而一旦有了排他鎖,也就意味著CPU的獨占,這個時候用戶的讀取和插入 就會和merge 之后的index回填發生鎖的競爭,從而影響引擎的外部性能。
而同樣的問題在以 Wisckey 為首的 LSM-tree key-value 分離策略中尤為明顯,包括Titan, rocksdb的BlobDB,BadgerDB 都會面臨這樣的問題,他們在compaction 之后的回填 大value-index 還需要產生I/O操作,這個代價可能會更高,那他們是怎么解決這個問題的呢?
探索他們的解決辦法不一定完全能夠借鑒到hash 引擎的實現中,不過是可以提供一個解決思路。
Titan 的回寫策略
關于Titan的 GC 策略介紹可以參考:Titan GC策略實現
Titan 是 pingcap 早期基于wisckey 做出來的key-value 分離存儲引擎,可以作為rocksdb 的一個插件來使用。
它的解決辦法是提供一個可配置項gc_merge_rewrite:
- 關閉:會在GC 過程中將key-value寫入新的blobfile 之后,通過正常的Write with Callback + Put 接口回寫blob-index到lsm-tree。這個也就是默認回寫的方式,Titan的Callback 是一個Get操作,在寫入之前會先嘗試讀一下這個key 是否在lsm-tree中,如果不在就不會寫入了。而且會將新的key + key-index 完全寫入。
- 開啟:則是一個回寫產生的性能問題和讀性能之間的一個trade-off。開啟之后直接寫入 一個
kMergeTypeblob-index,這種情況下不需要去執行Callback了,而是直接寫入Merge操作,后續通過compaction 進行 key的blob-index的合并 或者 讀請求命中這個key的時候會進行merge。merge請求本省不會攜帶原本大小的value,所以不會產生較大的寫放大,只是在讀的時候需要將當前key之前的merge都進行合并,對讀性能可能有較大的影響。
相關的實現代碼可以參考:
void BlobGCJob::BatchWriteNewIndices(BlobFileBuilder::OutContexts& contexts,Status* s) {...// 關閉merge,調用默認的寫入方式if (!gc_merge_rewrite_) {merge_blob_index.EncodeToBase(&index_entry);// Store WriteBatch for rewriting new Key-Index pairs to LSM// 在這個策略下,rewrite_batches_ 最后的消費是通過 Rocksdb::WriteWithCallback實現// 的,在寫入的時候會執行 Callback,里面會去查一下key是否存在。GarbageCollectionWriteCallback callback(cfh, ikey.user_key.ToString(),std::move(original_index));callback.value = index_entry;rewrite_batches_.emplace_back(std::make_pair(WriteBatch(), std::move(callback)));auto& wb = rewrite_batches_.back().first;*s = WriteBatchInternal::PutBlobIndex(&wb, cfh->GetID(), ikey.user_key,index_entry);} else { // 開啟,rewrite_batches_without_callback_ 的消費過程是 直接寫入Merge 類型的keymerge_blob_index.EncodeTo(&index_entry);rewrite_batches_without_callback_.emplace_back(std::make_pair(WriteBatch(), original_index.blob_handle.size));auto& wb = rewrite_batches_without_callback_.back().first;*s = WriteBatchInternal::Merge(&wb, cfh->GetID(), ikey.user_key,index_entry);}...
}
最終對兩個數據結構的消費邏輯統一是在RewriteValidKeyToLSM函數中。
BlobDB 的回寫策略
BlobDB 的大體特性可以參考BlobDB 特性及性能測試結果。
因為BlobDB 新版本是社區比較推薦的一個k/v分離的穩定版本,基本的Rocksdb特性都已經支持了,包括trasaction/checkpoint/backup 等這一些不常用但很重要的功能都已經支持了。除了像merge/ingest等更為偏的能力暫時還不支持。
BlobDB的在GC上的一個考慮就不想因為后續頻繁的回寫處理影響正常的請求。
如果開啟了GC enable_blob_garbage_collection:
- 則在compaction過程中,迭代器 處理 類型
kTypeBlobIndex的key時會進入到GarbageCollectBlobIfNeeded,因為分離存儲的時候lsm中存放的value 是key-index,即這個value能夠索引的到blobfile的一個index。 - 確認當前blob能夠參與GC 且 當前key需要被保留,則根據key-index 讀取到blob_value 并 直接寫入到新的blob-file中。并且將新的blob-index 作為當前key的value,提取出來。
- key 和 新的key-index 繼續參與compaction后續的落盤行為。
主體第二步,也就是想要GC的話會在compaction過程中直接將過期的blob-value直接回收,compaction完成之后 lsm的sst 以及 blob都會被更新到,只需要維護后續的舊的blob回收即可。
代碼實現如下:
- compaciton過程中(迭代器按key處理階段) 調度GC
void CompactionIterator::PrepareOutput() {if (valid_) {if (ikey_.type == kTypeValue) {ExtractLargeValueIfNeeded();} else if (ikey_.type == kTypeBlobIndex) {// 調度GCGarbageCollectBlobIfNeeded();}... } - 按照上面的步驟進行處理:
void CompactionIterator::GarbageCollectBlobIfNeeded() {...// 開啟GCif (compaction_->enable_blob_garbage_collection()) {BlobIndex blob_index;{// 1. 獲取blobindexconst Status s = blob_index.DecodeFrom(value_);if (!s.ok()) {status_ = s;valid_ = falsereturn;}}if (blob_index.IsInlined() || blob_index.HasTTL()) {status_ = Status::Corruption("Unexpected TTL/inlined blob index");valid_ = false;return;}// 2. 確認當前blob-index 允許參與GCif (blob_index.file_number() >=blob_garbage_collection_cutoff_file_number_) {return;}const Version* const version = compaction_->input_version();assert(version);{// 3. 解析讀出來當前blob數據const Status s =version->GetBlob(ReadOptions(), user_key(), blob_index, &blob_value_);if (!s.ok()) {status_ = s;valid_ = false;return;}}value_ = blob_value_;// 4. 將讀出來的blob數據寫入到新的blob file,并構造新的 value-index 作為當前lsm-tree// 即將存儲的key的value.if (ExtractLargeValueIfNeededImpl()) {return;}ikey_.type = kTypeValue;current_key_.UpdateInternalKey(ikey_.sequence, ikey_.type);return;}... }
問題1: compaction過程中讀取大value和我們rocksdb 未k/v分離 場景下的讀取有什么區別?
這里的讀取只會是保留的key的real value,對于那一些要清理的key,則不會讀取。為了避免業務峰值觸發大量的compaciton以及 GC的讀取,GC的觸發可以通過SetOption 來動態調整。
問題2: 相比于 Titan GC 調度的優劣?
個人覺得,BlobDB的GC調度更為簡潔高效低成本。
來,我們對比一下GC過程中產生I/O的步驟:
- TitanDB,通過EventListener 在compaction過程中拿到需要參與GC 的blobfile 集合,compaction完成之后 對待GC的 blobfile 進行iter 迭代。
a. 拿到每一個key 去 LSM 點查 是否存在。
b. 存在,則讀取其所在blobfile 的 大value,寫入到新的blobfile
c. 寫入key 以及 新的value-index 到 LSM -tree(伴隨著后續的逐層compaction,或者 merge的合并)。 - BlobDB,直接在compaction過程中一起調度GC。
a. 不需要反查,compaction過程中知道這個key是要keep還是要skip,直接對keep下來的key 讀取blobfile的大value,寫入到新的blobfile.
b. 繼續compaction時直接將當前要keep下來的key 以及 新的 value-index 寫入 lsm即可。
可以看到,blobdb 的第二個步驟是正常的compaction寫入邏輯,相比于Titan來說,其實也就只進行了 Titan有效的第二步,少了第一步的點查和第三步的回寫。除此之外,Rocksdb的可調性更高一些,可以針對必要的GC時的大value讀寫進行控制,允許動態調整,從而最大程度得減少了GC對上層請求的性能影響。
具體在 GC 過程中的性能差異會在后續補充上。
BadgerDB 的回寫策略
Badger 作為 dGraph 社區備受 cgo 折磨之后推出的自研k/v 分離存儲引擎,在go 語言中還是非常受歡迎的。
本文僅討論BadgerDB 在k/v 分離場景的回寫策略,對于其測試優于Rocksdb(rocksdb的默認參數) 以及 其相比于Rocksdb 的其他優秀設計暫不展開討論。
Badger的大value是存放在value log文件中,它很聰明的一點是GC 接口只交給用戶來調度,而不是自己內部自主觸發,這樣的責任劃分就非常清晰了,用戶自己選擇開啟關閉GC,來自己承擔GC引入的讀寫問題,真是機智。
當然BadgerDB 這里的GC回寫并沒有看到太亮眼的設計,就是在對 value log 進行GC的時候和Titan不開啟gc_merge_rewrite 邏輯差不多。
- 選擇好了待GC的value-log文件,先從lsm中嘗試讀取key,存在則需要將value寫入到新的value log中。
- 完成寫入新的value-log之后,會將最終的key, value-index 更新到lsm-tree中。
回寫源代碼基本在RunValueLogGC 函數中的rewrite處理邏輯中,感興趣的可以看一下。
總結
可以看到為了解決在LSM-tree中大value 不隨著compaction一起調度而造成的性能問題,大家可謂是煞費苦心。Titan 嘗試做了一些優化,但整體來看還是不盡人意。Rocksdb 的 Blobdb 還是更加成熟,可以說是考慮得很全面了,從實現上看確實有很明顯的效果。而BadgerDB的做法更為徹底,這個問題我們不管,交給用戶自由調度,因為用戶大多數情況還是知道自己的業務什么時候處于高峰,什么時候處于低谷,產生的I/O競爭問題那是你們自己調度造成的,自己解決哈,🐂。
而回到最初的我們 hash-engine 的 hash-index回寫問題,其實可以考慮借鑒一下 BlobDB的做法,不過需要接口做的更靈活一些。
總結
以上是生活随笔為你收集整理的存储引擎 K/V 分离下的index回写问题的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Mac 从Makefile 编译 Roc
- 下一篇: 50平米装修多少钱啊?