Rocksdb iterator 的 Forward-scan 和 Reverse-scan 的性能差异
前言
最近在讀 MyRocks 存儲引擎2020年的論文,因為這個存儲引擎是在Rocksdb之上進行封裝的,并且作為Facebook 內部MySQL的底層引擎,用來解決Innodb的空間利用率低下 和 壓縮效率低下的問題。而且MyRocks 在接入他們UDB 之后成功達成了他們的目標:將以萬為單位的服務器集群server個數縮減了一半。
MyRocks 在facebook內部的成功實踐證明了LSM-tree的存儲引擎經過一系列優化之后能夠達到和B±tree存儲引擎性能接近且節約大量存儲成本的目的。對于我們底層存儲引擎開發者來說,MyRocks 的實踐過程在rocksdb上做了大量的通用型特性的開發。其中就包括針對rocksdb的反向迭代的優化。
迭代器 正向 或者 反向 掃描 描述
關于迭代器的掃描方向,大體代碼如下:
- 正向掃描(字節序升序掃描)
最終的結果類似:auto begin_it = db->NewIterator(rocksdb::ReadOptions()); for (begin_it->SeekToFirst(); begin_it->Valid(); begin_it->Next()) {assert(begin_it->Valid()); } delete begin_it;3499211612 3586334585 3890346734 545404204 581869302 - 反向掃描(字節序降序掃描)
最終的掃描結果類似:auto it = db->NewIterator(rocksdb::ReadOptions()); for (it->SeekToLast(); it->Valid(); it->Prev()) {assert(it->Valid()); } delete it;581869302 545404204 3890346734 3586334585 3499211612
這兩種掃描方式中我們使用單機存儲引擎大多數的scan都是第一種方式,即按照key寫入的順序,從頭開始遍歷。
然而方向掃描 則是在數據庫使用單機存儲引擎中出現的需求,單機引擎沒辦法在分布式場景支持MVCC,所以一般上層分布式數據庫會為每一個寫入到引擎的key都會增加一個時間戳,來支持多版本。很多時候需要按照key的更新時間降序掃描key,這個時候一般情況就需要通過第二種方式來進行掃描。這個問題在 MyRocks 接入 UDB 的實現過程中就是一個痛點,UDB 會有固定的場景需要按照更新時間降序獲取好友關系。
先不說兩種方式的實現,我們先看看兩種方式的掃描性能。
性能測試
測試描述:完全隨機寫入2百萬 條key,掃描的時候不打開block-cache。
測試代碼:
#include <iostream>
#include <unistd.h>
#include <random>
#include <sys/time.h>#include <rocksdb/db.h>
#include <rocksdb/table.h>
#include <rocksdb/status.h>
#include <rocksdb/options.h>using namespace std;
using namespace rocksdb;static const int write_count = 2000000;
rocksdb::DB* db = nullptr;
rocksdb::Options option;
std::mt19937 generator_; // 隨機數生成器static string seq_value(int num) {return string(100, 'a' + (num % 26));
}uint64_t NowMicros() {struct timeval tv;gettimeofday(&tv, nullptr);return static_cast<uint64_t>(tv.tv_sec* 1000000 + tv.tv_usec);
}void OpenDB() {option.create_if_missing = true;option.compression = rocksdb::CompressionType::kNoCompression;option.comparator = rocksdb::ReverseBytewiseComparator();rocksdb::BlockBasedTableOptions table_options;table_options.no_block_cache = true;table_options.cache_index_and_filter_blocks = false;option.table_factory.reset(NewBlockBasedTableFactory(table_options));auto s = rocksdb::DB::Open(option, "./db", &db);if (!s.ok()) {cout << "open faled : " << s.ToString() << endl;exit(-1);}cout << "Finish open !"<< endl;
}void DoWrite() {int j = 0;while (j < write_count) {string key = std::to_string(generator_());string value = seq_value(j);auto s = db->Put(rocksdb::WriteOptions(),key, value);if (!s.ok()) {cout << "Put value failed: " << s.ToString() << endl;exit(-1);}j++;}cout << "Finish write !" << endl;
}void ForwardTraverse() {uint64_t start_ts = NowMicros();auto begin_it = db->NewIterator(rocksdb::ReadOptions());for (begin_it->SeekToFirst(); begin_it->Valid(); begin_it->Next()) {assert(begin_it->Valid());}delete begin_it;cout << "ForwardTraverse use time: " << NowMicros() - start_ts << endl;
}void ReverseTraverse() {uint64_t start_ts = NowMicros();auto begin_it = db->NewIterator(rocksdb::ReadOptions());for (begin_it->SeekToLast(); begin_it->Valid(); begin_it->Prev()) {assert(begin_it->Valid());}delete begin_it;cout << "BackwardTraverse use time: " << NowMicros() - start_ts << endl;
}int main() {OpenDB();DoWrite();ForwardTraverse();ReverseTraverse();return 0;
}
測試結果:
...
87
Finish open !
Finish write !
ForwardTraverse use time: 496311
BackwardTraverse use time: 565935
88
Finish open !
Finish write !
ForwardTraverse use time: 447470
BackwardTraverse use time: 565372
89
Finish open !
Finish write !
ForwardTraverse use time: 441705
BackwardTraverse use time: 563440
...
測試了100次,基本都是這個耗時的量級,可以發現兩種掃描的耗時相差15%-25%。如果MyRocks 中使用傳統的反向掃描,性能則相比于本身的正向掃描損失15-25%,這對于想要節約50% 集群server的目標來收顯然不可忍。
當然,還有更為方便的測試方式,就是使用db_bench,可以使用它的readseq和readreverse兩種benchmark進行測試,因為我是在mac上,rocksdb的完整編譯不太方便,跑db_bench有點麻煩,就寫一個簡單的小腳本測試就可以了。
正反 掃描迭代器 實現
通過使用兩種迭代器,我們能夠很容易發現兩種掃描的方式差異主要體現在獲取下一個key的方式,一個是Next,一個是Prev。
我們將剛才的sst文件dump出來,可以發現sst內部的datablock 存儲的key的方式本身就是字節升序存儲,而且為了節約存儲成本,底層實際采用的是delta方式的存儲。即如果兩個key擁有公共前綴,則兩個key的實際形態是公共前綴+后面一個不同的字符進行存儲,并非存儲完整的兩個key,所以 在prev的時候不僅僅需要讀當前key,還需要找到前面第比他小的key來進行key的delta補全。
再分別看看我們的Next和 Prev的實現
在Next中,無需考慮其他,只需要按照順序拿到key,并根據key的類型進行相應的處理就可以了。
void DBIter::Next() {assert(valid_);assert(status_.ok());PERF_CPU_TIMER_GUARD(iter_next_cpu_nanos, env_);// Release temporarily pinned blocks from last operation// 釋放上一次Next占用的資源ReleaseTempPinnedData();local_stats_.skip_count_ += num_internal_keys_skipped_;local_stats_.skip_count_--;num_internal_keys_skipped_ = 0;bool ok = true;if (direction_ == kReverse) {is_key_seqnum_zero_ = false;if (!ReverseToForward()) {ok = false;}} else if (!current_entry_is_merged_) {// If the current value is not a merge, the iter position is the// current key, which is already returned. We can safely issue a// Next() without checking the current key.// If the current key is a merge, very likely iter already points// to the next internal position.assert(iter_.Valid());iter_.Next();PERF_COUNTER_ADD(internal_key_skipped_count, 1);}local_stats_.next_count_++;if (ok && iter_.Valid()) { // 根據key的類型處理就可以了。FindNextUserEntry(true /* skipping the current user key */,prefix_same_as_start_);} else {is_key_seqnum_zero_ = false;valid_ = false;}if (statistics_ != nullptr && valid_) {local_stats_.next_found_count_++;local_stats_.bytes_read_ += (key().size() + value().size());}
}
而Prev的實現則沒有這么單一了,因為底層的存儲本身是升序存儲,Next只需要在mem/imm/l0(多個iter)/l1/l2… 這一些迭代器做堆排序就可以了。prev還需要不斷得向前讀,直到讀到一個restart點(讀完完整的data-block)確認好當前key的delta部分。
如下代碼是Prev的核心部分實現:
void DBIter::PrevInternal() {while (iter_.Valid()) {saved_key_.SetUserKey(ExtractUserKey(iter_.key()),!iter_.iter()->IsKeyPinned() || !pin_thru_lifetime_ /* copy */);if (prefix_extractor_ && prefix_same_as_start_ &&prefix_extractor_->Transform(saved_key_.GetUserKey()).compare(prefix_start_key_) != 0) {// Current key does not have the same prefix as startvalid_ = false;return;}assert(iterate_lower_bound_ == nullptr || iter_.MayBeOutOfLowerBound() ||user_comparator_.Compare(saved_key_.GetUserKey(),*iterate_lower_bound_) >= 0);if (iterate_lower_bound_ != nullptr && iter_.MayBeOutOfLowerBound() &&user_comparator_.Compare(saved_key_.GetUserKey(),*iterate_lower_bound_) < 0) {// We've iterated earlier than the user-specified lower bound.valid_ = false;return;}// 對當前key的type進行處理,確保讀到的key是最新的// 如果讀到的是merge類型,則需要不斷得向前mege// 如果讀到的是Delete類型,則需要不斷得向前讀,// 直到遇到一個大于這個key版本的key//(reverse過程中遇到的delete版本較低,需要繼續向前讀才能讀到同一個userkey的更新版本)if (!FindValueForCurrentKey()) { // assigns valid_return;}// Whether or not we found a value for current key, we need iter_ to end up// on a smaller key.// 不斷得向前掃描,直到找到了一個小于當前key的key才結束。if (!FindUserKeyBeforeSavedKey()) {return;}if (valid_) {// Found the value.return;}if (TooManyInternalKeysSkipped(false)) {return;}}// We haven't found any key - iterator is not validvalid_ = false;
}
總結下來就是Prev的過程需要讀額外的key,因為prev的過程同一個user key的更新版本需要不斷得向前讀,才能讀到。相比于Next原生 就能讀到當前userkey的最新版本來說 scan的代價確實大了不少。
更底層的迭代器的Prev和Next可以看block.cc中的DataBlockIter,它提供了在不同 datablock 之間如何通過和 indexblock 的 restart點來讀取指定的key。
優化
MyRocks 開發之前的性能測試 就發現了Rocksdb的這個問題,那他們也提出了比較友好的解決方案,那就是Reverse Comparator。我們知道Rocksdb 的key的寫入/讀取順序是依賴 Comparator ,也就是sst內部的有序是由Comparator決定的。
就像是 我們為一個vector排序,可以通過一個自定義comparator來決定vector中元素的排序行為。對于Rocksdb來說,這個Comparator 作用的地方主要是 memtable中skiplist的構建以及 compaction過程中將key寫入一個新的sst。所以這個comparator 決定了keys在sst文件中的順序。所以,MyRocks 針對 Reverse-scan 痛點 實現了Reverse Comparator : ReverseBytewiseComparator,它就是指定key的存儲順序和原來相反。
可以通過options.comparator = rocksdb::ReverseBytewiseComparator();指定,因為UDB的這種reverse-scan 模式比較固定,所以可以在構建db的時候直接用這個comparator。很明顯這個優化的好處就是將原來的Reverse-scan 變更為 Forward-scan,性能能夠提升10-15%的樣子(可以使用上面的代碼進行測試)。
總結
這里MyRocks 開發團隊 僅僅實現了一個ReverseBytewiseComparator 在MyRocks 的 scan 痛點中 得到這樣的優化提升,所以能夠看出來 Rocksdb 的可擴展性。在MyRocks 的論文中也有很多次表示對Rocksdb 可擴展性的認可。
同樣,我們也能理解到rocksdb 這個引擎的定位 并不是說實現了一個全方位性能超越B±tree 的引擎,而是針對 任何用戶的workload 經過非常簡單的開發或者參數調整 能夠達到超越其他引擎 或者 與其他引擎持平 的性能/功能。Rocksdb 將應用的可擴展性做到了極致,這是它廣受歡迎的關鍵。
總結
以上是生活随笔為你收集整理的Rocksdb iterator 的 Forward-scan 和 Reverse-scan 的性能差异的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 妄想山海雪熊怎么进化?
- 下一篇: MyRocks: 为facebool 的