【rocksdb源碼分析】使用PinnableSlice減少Get時的內存拷貝

rocksdb v5.4.5版本引入一個PinnableSlice,來替換之前Get介面的出參,具體如下:

老版本:

Status Get(const ReadOptions& options,n ColumnFamilyHandle* column_family, const Slice& key,n std::string* value)n

新版本:

virtual Status Get(const ReadOptions& options,n ColumnFamilyHandle* column_family, const Slice& key,n PinnableSlice* value)n

按其說法,使用PinnableSlice* 替換std::string* 來減少一次內存拷貝,提高讀性能。我感覺挺有意思,所以看了代碼,了解了下具體實現,寫篇文章總結下。

1. 讀流程

rocksdb讀流程這裡就不展開講了,這裡僅給出一次從sst文件Get的簡單過程:

  • DBImpl::Get()
  • VersionSet::Get()
  • TableCache::Get()
  • BlockBasedTableReader::Get()

大的模塊調用就這4步,前三步與今天主題無關,暫且忽略,最後一步BlockBasedTableReader::Get()就是從某個sst文件中讀取,也是實際發生文件io,數據交換的地方,所以這一步需要詳細看下,主要下面2步:

  • 通過IndexBlock拿到要查找的key所在的DataBlock的Handle
  • 通過這個Handle拿到對應的DataBlock,建立對應的BlockIter,seek,然後開始遍歷查找

第2步關鍵代碼如下:

// DataBlock的iteratornBlockIter biter;n// 通過Handle,即iiter->value()來構造biternNewDataBlockIterator(rep_, read_options, iiter->value(), &biter);nn... ...nn// seek,然後遍歷查找nfor (biter.Seek(key); biter.Valid(); biter.Next()) {n ParsedInternalKey parsed_key;n if (!ParseInternalKey(biter.key(), &parsed_key)) {n s = Status::Corruption(Slice());n }n n // ---看這裡,重點---n // 數據交換的地方,如果找到,會把biter指向的數據交換到n // get_context的PinnableSlicen if (!get_context->SaveValue(parsed_key, biter.value(), &biter)) {n done = true;n break;n }n}ns = biter.status();nn... ...n nif (done) {n // Avoid the extra Next which is expensive in two-level indexesn break;n}n... ...n

我們知道,真正數據是存在Block對象中,所以上面代碼主要完成2件事,

  1. 數據生成:NewDataBlockIterator()
    1. 先在block_cache中查找,看有沒有緩存需要的Block對象的指針,如果有就直接返回Block對象地址
    2. 如果block_cache中沒找到,則發生磁碟io,在sst文件中讀取對應Block的內容,構造Block對象並將其地址緩存在block_cache中
  2. 數據交換:get_context->SaveValue()
    1. 老版本會在這裡把Block中的數據拷貝到用戶傳進來的std::string*中
    2. 新版本則是直接將Block中的數據地址賦給用戶傳進來的PinnableSlice*中,也就是說用戶最終拿到的值的地址其實就在這個Block中。

這樣就減少了一次數據拷貝,不過有一個疑問:用戶拿到的值就是Block中的值,而這個Block是緩存在block_cache中的,如果後來這個Block被淘汰,那豈不是用戶拿到的值被清除了?如果用引用計數來避免這個問題,那麼具體怎麼做呢?這個問題就是本篇的重點。

2. Cleannable

如果定義這麼一個基類Cleannable,它有如下特性:

  1. 可以通過RegisterCleanup方法來註冊CleanupFunction,這個CleanupFunction用戶自定義,一般是完成Cleannable對象申請的外部資源釋放,例如釋放某塊之前申請的內存
  2. 當其對象被析構時,會依次調用所有之前註冊的CleanupFunction,來完成外部資源釋放
  3. 兩個Cleannable子類對象A,B,可以通過A->DelegateCleanupsTo(B),將A註冊的所有CleanupFunction委託給B,這樣A在析構時就不會釋放它申請的外部資源,而是等到B析構時才釋放

有了這麼一個基類,如果將BlockIter和PinnableSlice都繼承它,那麼就可以BlockIter資源釋放的任務委託給PinnableSlice,使得BlockIter內的資源生命周期延續至用戶的PinnableSlice

原理就這個,下面詳細掰扯掰扯他們之間委託了什麼,直接上圖:

BlockIter肯定有其對應的Block,而Block肯定有其在block_cache中對應的Handle,所以在構造好BlockIter後,往往會執行RegisterCleanup來註冊一個CleanupFunction,保證BlockIter析構時,會Release掉block_cache中的對應的Handle,而Handle被Release則會回調對應Block的deleter來最終釋放Block。

BlockIter的生命周期很短,使用其在Block中查找指定key之後,他的作用域變結束。使用老版本不會有問題,因為在BlockIter作用域內,肯定會將其value拷貝給用戶傳入的std::string*中,而新版沒有這次拷貝,用戶拿到的數據地址實際就是在Block中,所以一定要想辦法延長Block的作用域,不能像上面說的那樣BlockIter析構便釋放。

所以新版本在BlockIter退出作用域之前,會通過DelegateCleanupsTo將其CleanupFunction委託給用戶傳入的PinnableSlice,這樣便延長了對應block_cache中Handle的生命周期,從而延長了對應的Block的生命周期,直到用戶的PinnableSlice退出作用域時,才最終回調之前BlockIter委託的CleanupFunction來進行最終的資源釋放。

註:這裡為了簡化問題,假設block_cache的Handle在Release時一定會釋放其指向的Block,實際這裡會有引用計數,直到為0是才會真正釋放Block

3. 總結

Cleannable基類還是挺好玩的,適合需要延長某個對象內部資源生命周期的場景。


推薦閱讀:

淺談分散式存儲系統數據分布方法
阿里雲做存儲的盤古團隊如何?
關於分散式文件系統負載均衡有哪些資料值得閱讀?
主流分散式文件系統的的應用場景和優缺點?
如何評價kudu存儲引擎?

TAG:RocksDB | 分布式存储 | CC |