自拍偷在线精品自拍偷,亚洲欧美中文日韩v在线观看不卡

漫談 LevelDB 數(shù)據(jù)結構之 LRU 緩存( LRUCache)

存儲 存儲軟件
早對 LevelDB 有所耳聞,這次心血來潮結合一些資料粗略過了遍代碼,果然名不虛傳。如果你對存儲感興趣、如果你想優(yōu)雅使用 C++、如果你想學習如何架構項目,都推薦來觀摩一下。更何況作者是 Sanjay Ghemawat 和 Jeff Dean 呢。

[[398307]]

本文轉載自微信公眾號「木鳥雜記」,作者穆尼奧  。轉載本文請聯(lián)系木鳥雜記公眾號。

早對 LevelDB 有所耳聞,這次心血來潮結合一些資料粗略過了遍代碼,果然名不虛傳。如果你對存儲感興趣、如果你想優(yōu)雅使用 C++、如果你想學習如何架構項目,都推薦來觀摩一下。更何況作者是 Sanjay Ghemawat 和 Jeff Dean 呢。

看過一遍如果不輸出點什么,以我的記性,定會很快拋諸腦后。便想寫點東西說說 LevelDB 之妙,但又不想走尋常路:從架構概覽說起,以模塊分析做合。讀代碼的這些天,一直在盤算從哪下筆比較好。在將將讀完之時,印象最深的反而是 LevelDB 的各種精妙的數(shù)據(jù)結構:貼合場景、從頭構建、剪裁得當、代碼精到。不妨, LevelDB 系列就從這些邊邊角角的小構件開始吧。

本系列主要想分享 LevelDB 中用到的三個工程中常用的經(jīng)典數(shù)據(jù)結構,分別是用以快速讀寫 memtable 的 Skip List、用以快速篩選 sstable 的 Bloom Filter 和用以部分緩存 sstable 的 LRUCache 。這是第三篇,LRUCache。

引子

LRU 是工程中多見的一個數(shù)據(jù)結構,常用于緩存場景。近年來,LRU 也是面試中一道炙手可熱的考題,一來工程用的多,二來代碼量較少,三來涉及的數(shù)據(jù)結構也很典型。LeetCode 中有一道相應的題目:lru-cache。相對實際場景,題目進行了簡化:本質上要求維護一個按訪問時間有序的 kv 集合,且 kv 皆是整數(shù)。經(jīng)典解法是使用一個哈希表(unordered_map)和一個雙向鏈表,哈希表解決索引問題,雙向鏈表維護訪問順序。這是我當時的一個解法,特點是用了兩個輔助函數(shù),并且可以返回節(jié)點自身,以支持鏈式調用,從而簡化了代碼。

說回 LevelDB 源碼,作為一個工業(yè)品,它使用 的 LRUCache 又做了哪些優(yōu)化和變動呢?下面讓我們一塊來拆解下 LevelDB 中使用的 LRUCache,看看有什么不同。

本文首先明確 LRUCache 的使用方法,然后總覽分析 LRUCache 的實現(xiàn)思路,最后詳述相關數(shù)據(jù)結構的實現(xiàn)細節(jié)。

緩存使用

在分析 LRUCache 的實現(xiàn)之前,首先了解下 LRUCache 的使用方法,以明確 LRUCache 要解決的問題。以此為基礎,我們才能了解為什么要這么實現(xiàn),甚至更進一步,探討有沒有更好的實現(xiàn)。

首先來看下 LevelDB 的導出接口 Cache:

  1. // 插入一個鍵值對(key,value)到緩存(cache)中, 
  2. // 并從緩存總容量中減去該鍵值對所占額度(charge)  
  3. //  
  4. // 返回指向該鍵值對的句柄(handle),調用者在用完句柄后, 
  5. // 需要調用 this->Release(handle) 進行釋放 
  6. // 
  7. // 在鍵值對不再被使用時,鍵值對會被傳入的 deleter 參數(shù) 
  8. // 釋放 
  9. virtual Handle* Insert(const Slice& key, void* value, size_t charge, 
  10.                        void (*deleter)(const Slice& key, void* value)) = 0; 
  11.  
  12. // 如果緩存中沒有相應鍵(key),則返回 nullptr 
  13. // 
  14. // 否則返回指向對應鍵值對的句柄(Handle)。調用者用完句柄后, 
  15. // 要記得調用 this->Release(handle) 進行釋放 
  16. virtual Handle* Lookup(const Slice& key) = 0; 
  17.  
  18. // 釋放 Insert/Lookup 函數(shù)返回的句柄 
  19. // 要求:該句柄沒有被釋放過,即不能多次釋放 
  20. // 要求:該句柄必須是同一個實例返回的 
  21. virtual void Release(Handle* handle) = 0; 
  22.  
  23. // 獲取句柄中的值,類型為 void*(表示任意用戶自定義類型) 
  24. // 要求:該句柄沒有被釋放 
  25. // 要求:該句柄必須由同一實例所返回 
  26. virtual void* Value(Handle* handle) = 0; 
  27.  
  28. // 如果緩存中包含給定鍵所指向的條目,則刪除之。 
  29. // 需要注意的是,只有在所有持有該條目句柄都釋放時,該條目所占空間才會真正被釋放 
  30. virtual void Erase(const Slice& key) = 0; 
  31.  
  32. // 返回一個自增的數(shù)值 id。當一個緩存實例由多個客戶端共享時, 
  33. // 為了避免多個客戶端的鍵沖突,每個客戶端可能想獲取一個獨有 
  34. // 的 id,并將其作為鍵的前綴。類似于給每個客戶端一個單獨的命名空間。 
  35. virtual uint64_t NewId() = 0; 
  36.  
  37. // 驅逐全部沒有被使用的數(shù)據(jù)條目 
  38. // 內存吃緊型的應用可能想利用此接口定期釋放內存。 
  39. // 基類中的 Prune 默認實現(xiàn)為空,但強烈建議所有子類自行實現(xiàn)。 
  40. // 將來的版本可能會增加一個默認實現(xiàn)。 
  41. virtual void Prune() {} 
  42.  
  43. // 返回當前緩存中所有數(shù)據(jù)條目所占容量總和的一個預估 
  44. virtual size_t TotalCharge() const = 0; 

依據(jù)上述接口,可捋出 LevelDB 緩存相關需求:

  1. 多線程支持
  2. 性能需求
  3. 數(shù)據(jù)條目的生命周期管理

用狀態(tài)機來表示 Cache 中的 Entry 的生命周期如下:

leveldb entry lifecycle

可以看出該狀態(tài)機要比 LeetCode 中復雜一些,首先增加了多線程的引用,其次需要區(qū)分被引用(inuse) 和空閑(idle) 狀態(tài)。

多個線程可以通過 Insert、Lookup 對同一個條目進行插入和引用,因此緩存需要維護每個條目(entry)的引用數(shù)量。只有引用數(shù)量為 0 的條目才會進入一個待驅逐(idle)的狀態(tài),將所有待驅逐的條目按 LRU 順序排序,在用量超過容量時,將依據(jù)上述順序對最久沒使用過的條目進行驅逐。

此外,需要進行線程間同步和互斥,以保證 Cache 是線程安全的,最簡單的方法是整個 Cache 使用一把鎖,但這樣多線程間爭搶比較嚴重,會有性能問題。

接下來看看 LevelDB 的 LRUCache 是如何解決這些問題的。

思路總覽

總體上來說,LevelDB 的 LRUCache 也使用了哈希表和雙向鏈表的實現(xiàn)思路,但又有些不同:

使用數(shù)組+鏈表處理沖突定制了一個極簡哈希表,便于控制分配以及伸縮。

多線程支持。了提高并發(fā),引入了分片;為了區(qū)分是否被客戶端引用,引入兩個雙向鏈表。

整個代碼相當簡潔,思想也比較直觀。

定制哈希表

LevelDB 中哈希表保持桶的個數(shù)為 2 的次冪,從而使用位運算來通過鍵的哈希值快速計算出桶位置。通過 key 的哈希值來獲取桶的句柄方法如下:

LRUHandle** ptr = &list_[hash & (length_ - 1)];

每次調整時,在擴張時將桶數(shù)量增加一倍,在縮減時將桶數(shù)量減少一倍,并需要對所有數(shù)據(jù)條目進行重新分桶。

兩個鏈表

LevelDB 使用兩個雙向鏈表保存數(shù)據(jù),緩存中的所有數(shù)據(jù)要么在一個鏈表中,要么在另一個鏈表中,但不可能同時存在于兩個鏈表中。這兩個鏈表分別是:

  1. in-use 鏈表。所有正在被客戶端使用的數(shù)據(jù)條目(an kv item)都存在該鏈表中,該鏈表是無序的,因為在容量不夠時,此鏈表中的條目是一定不能夠被驅逐的,因此也并不需要維持一個驅逐順序。
  2. lru 鏈表。所有已經(jīng)不再為客戶端使用的條目都放在 lru 鏈表中,該鏈表按最近使用時間有序,當容量不夠用時,會驅逐此鏈表中最久沒有被使用的條目。

另外值得一提的是,哈希表中用來處理沖突的鏈表節(jié)點與雙向鏈表中的節(jié)點使用的是同一個數(shù)據(jù)結構(LRUHandle),但在串起來時,用的是 LRUHandle 中不同指針字段。

數(shù)據(jù)結構

LRUCache 實現(xiàn)主要涉及到了四個數(shù)據(jù)結構:LRUHandle、HandleTable、LRUCache 和 ShardedLRUCache。前三者組織形式如下:

lru cache architecture

ShardedLRUCache 由一組 LRUCache 組成,每個 LRUCache 作為一個分片,同時是一個加鎖的粒度,他們都實現(xiàn)了 Cache 接口。下圖畫了 4 個分片,代碼中是 16 個。

shared lru cache

LRUHandle——基本數(shù)據(jù)單元

LRUHandle 是雙向鏈表和哈希表的基本構成單位。其結構如下:

  1. struct LRUHandle { 
  2.   void* value; 
  3.   void (*deleter)(const Slice&, void* value); // 釋放 key,value 空間的用戶回調 
  4.   LRUHandle* next_hash;  // 用于 hashtable 中鏈表處理沖突 
  5.   LRUHandle* next;       // 用于雙向鏈表中維護 LRU 順序 
  6.   LRUHandle* prev; 
  7.   size_t charge;     // TODO(opt): Only allow uint32_t? 
  8.   size_t key_length; 
  9.   bool in_cache;     // 該 handle 是否在 cache table 中 
  10.   uint32_t refs;     // 該 handle 被引用的次數(shù) 
  11.   uint32_t hash;     // key 的 hash 值,用于確定分片和快速比較 
  12.   char key_data[1];  // key 的起始 
  13.  
  14.   Slice key() const { 
  15.     // next_ is only equal to this if the LRU handle is the list head of an 
  16.     // empty list. List heads never have meaningful keys. 
  17.     assert(next != this); 
  18.  
  19.     return Slice(key_data, key_length); 
  20.   } 
  21. }; 

特別要注意的是,LRUHandle 中的 refs 和我們前一小節(jié)中所畫圖中的狀態(tài)機中 ref 含義并不一樣。LevelDB 實現(xiàn)時,把 Cache 的引用也算一個引用。因此在 Insert 時,會令 refs = 2,一個為客戶端的引用,一個為 LRUCache 的引用。refs==1 && in_cache即說明該數(shù)據(jù)條目只被 LRUCache 引用了。

這個設計開始看著有點別扭,但是想了想反而覺得很貼切自然。

HandleTable——哈希表索引

使用位操作來對 key 進行路由,使用鏈表來處理沖突,實現(xiàn)比較直接。鏈表中節(jié)點是無序的,因此每次查找都需要全鏈表遍歷。

其中值得一說的是 FindPointer 這個查找輔助函數(shù),該函數(shù)用了雙重指針,在增刪節(jié)點時比較簡潔,開始時可能不太好理解。在通常實現(xiàn)中,增刪節(jié)點時,我們會找其前驅節(jié)點。但其實增刪只用到了前驅節(jié)點中的 next_hash 指針:

  1. 刪除:會修改 next_hash 的指向。
  2. 新增:首先讀取 next_hash,找到下一個鏈節(jié),將其鏈到待插入節(jié)點后邊,然后修改前驅節(jié)點 next_hash 指向。

由于本質上只涉及到前驅節(jié)點 next_hash 指針的讀寫,因此返回前驅節(jié)點 next_hash 指針的指針是一個更簡潔的做法:

  1. LRUHandle** FindPointer(const Slice& key, uint32_t hash) { 
  2.   LRUHandle** ptr = &list_[hash & (length_ - 1)]; 
  3.   while (*ptr != nullptr && ((*ptr)->hash != hash || key != (*ptr)->key())) { 
  4.     ptr = &(*ptr)->next_hash; 
  5.   } 
  6.   return ptr; 

該函數(shù)首先使用 hash 值通過位運算,定位到某個桶。然后在該桶中逐個遍歷節(jié)點:

  1. 如果節(jié)點的 hash 或者 key 匹配上,則返回該節(jié)點的雙重指針(前驅節(jié)點的 next_hash 指針的指針)。
  2. 否則,返回該鏈表的最后一個節(jié)點的雙重指針(邊界情況,如果是空鏈表,最后一個節(jié)點便是桶頭)。

由于返回的是其前驅節(jié)點 next_hash 的地址,因此在刪除時,只需將該 next_hash 改為待刪除節(jié)點后繼節(jié)點地址,然后返回待刪除節(jié)點即可。

  1. LRUHandle* Remove(const Slice& key, uint32_t hash) { 
  2.   LRUHandle** ptr = FindPointer(key, hash); 
  3.   LRUHandle* result = *ptr; 
  4.   if (result != nullptr) { 
  5.     *ptr = result->next_hash; 
  6.     --elems_; 
  7.   } 
  8.   return result; 

在插入時,也是利用 FindPointer 函數(shù)找到待插入桶的鏈表尾部節(jié)點 next_hash 指針的指針,對于邊界條件空桶來說,會找到桶的空頭結點。之后需要判斷是新插入還是替換,如果替換,則把被替換的舊節(jié)點返回,下面是插入新節(jié)點示意圖:

leveldb lru table insert

如果是新插入節(jié)點,節(jié)點總數(shù)會變多,如果節(jié)點總數(shù)多到大于某個閾值后,為了保持哈希表的性能,就需要進行 resize,以增加桶的個數(shù),同時將所有節(jié)點進行重新分桶。LevelDB 選定的閾值是 length_ —— 桶的個數(shù)。

resize 的操作比較重,因為需要對所有節(jié)點進行重新分桶,而為了保證線程安全,需要加鎖,但這會使得哈希表一段時間不能提供服務。當然通過分片已經(jīng)減小了單個分片中節(jié)點的數(shù)量,但如果分片不均勻,仍然會比較重。這里有提到一種漸進式的遷移方法:Dynamic-sized NonBlocking Hash table,可以將遷移時間進行均攤,有點類似于 Go GC 的演化。

LRUCache—— 哈希表索引+雙向環(huán)形鏈表

將之前分析過的導出接口 Cache 所包含的函數(shù)去掉后,LRUCache 類簡化如下:

  1. class LRUCache { 
  2.  public
  3.   LRUCache(); 
  4.   ~LRUCache(); 
  5.  
  6.   // 從構造函數(shù)分離出此參數(shù)的設置方法,可以讓調用者在使用時進行靈活的調整 
  7.   void SetCapacity(size_t capacity) { capacity_ = capacity; } 
  8.  
  9.  private: 
  10.   // 輔助函數(shù):將鏈節(jié) e 從雙向鏈表中摘除 
  11.   void LRU_Remove(LRUHandle* e); 
  12.   // 輔助函數(shù):將鏈節(jié) e 追加到鏈表頭 
  13.   void LRU_Append(LRUHandle* list, LRUHandle* e); 
  14.   // 輔助函數(shù):增加鏈節(jié) e 的引用 
  15.   void Ref(LRUHandle* e); 
  16.   // 輔助函數(shù):減少鏈節(jié) e 的引用 
  17.   void Unref(LRUHandle* e); 
  18.   // 輔助函數(shù):從緩存中刪除單個鏈節(jié) e 
  19.   bool FinishErase(LRUHandle* e) EXCLUSIVE_LOCKS_REQUIRED(mutex_); 
  20.  
  21.   // 在使用 LRUCache 前必須先初始化此值 
  22.   size_t capacity_; 
  23.  
  24.   // mutex_ 用以保證此后的字段的線程安全 
  25.   mutable port::Mutex mutex_; 
  26.   size_t usage_ GUARDED_BY(mutex_); 
  27.  
  28.   // lru 雙向鏈表的空表頭 
  29.   // lru.prev 指向最新的條目,lru.next 指向最老的條目 
  30.   // 此鏈表中所有條目都滿足 refs==1 和 in_cache==true 
  31.   // 表示所有條目只被緩存引用,而沒有客戶端在使用 
  32.   LRUHandle lru_ GUARDED_BY(mutex_); 
  33.  
  34.   // in-use 雙向鏈表的空表頭 
  35.   // 保存所有仍然被客戶端引用的條目 
  36.   // 由于在被客戶端引用的同時還被緩存引用, 
  37.   // 肯定有 refs >= 2 和 in_cache==true
  38.   LRUHandle in_use_ GUARDED_BY(mutex_); 
  39.  
  40.   // 所有條目的哈希表索引 
  41.   HandleTable table_ GUARDED_BY(mutex_); 
  42. }; 

可以看出該實現(xiàn)有以下特點:

  1. 使用兩個雙向鏈表將整個緩存分成兩個不相交的集合:被客戶端引用的 in-use 鏈表,和不被任何客戶端引用的 lru_ 鏈表。
  2. 每個雙向鏈表使用了一個空的頭指針,以便于處理邊界情況。并且表頭的 prev 指針指向最新的條目,next 指針指向最老的條目,從而形成了一個雙向環(huán)形鏈表。
  3. 使用 usage_ 表示緩存當前已用量,用 capacity_ 表示該緩存總量。
  4. 抽象出了幾個基本操作:LRU_Remove、LRU_Append、Ref、Unref 作為輔助函數(shù)進行復用。
  5. 每個 LRUCache 由一把鎖 mutex_ 守護。

了解了所有字段,以及之前的狀態(tài)機,每個函數(shù)的實現(xiàn)應該比較容易理解。下面不再一一羅列所有函數(shù)的實現(xiàn),僅挑比較復雜的 Insert 進行注釋說明:

  1. Cache::Handle* LRUCache::Insert(const Slice& key, uint32_t hash, void* value, 
  2.                                 size_t charge, 
  3.                                 void (*deleter)(const Slice& key
  4.                                                 void* value)) { 
  5.   MutexLock l(&mutex_); 
  6.  
  7.   // 構建數(shù)據(jù)條目句柄 
  8.   LRUHandle* e = 
  9.       reinterpret_cast<LRUHandle*>(malloc(sizeof(LRUHandle) - 1 + key.size())); 
  10.   e->value = value; 
  11.   e->deleter = deleter; 
  12.   e->charge = charge; 
  13.   e->key_length = key.size(); 
  14.   e->hash = hash; 
  15.   e->in_cache = false
  16.   e->refs = 1;  // 客戶端引用 
  17.   std::memcpy(e->key_data, key.data(), key.size()); 
  18.  
  19.   if (capacity_ > 0) { 
  20.     e->refs++;  // 緩存本身引用 
  21.     e->in_cache = true
  22.     LRU_Append(&in_use_, e); 
  23.     usage_ += charge; 
  24.  
  25.     FinishErase(table_.Insert(e)); // 如果是替換,要刪除原來數(shù)據(jù) 
  26.   } else {  // capacity_==0 時表示關閉緩存,不進行任何緩存 
  27.     // next 會在 key() 函數(shù)中被 assert 測試,因此要初始化一下 
  28.     e->next = nullptr; 
  29.   } 
  30.  
  31.   // 當超數(shù)據(jù)條目超出容量時,根據(jù) LRU 策略將不被客戶端引用的數(shù)據(jù)條目驅逐出內存 
  32.   while (usage_ > capacity_ && lru_.next != &lru_) { 
  33.     LRUHandle* old = lru_.next
  34.     assert(old->refs == 1); 
  35.     bool erased = FinishErase(table_.Remove(old->key(), old->hash)); 
  36.     if (!erased) {  // to avoid unused variable when compiled NDEBUG 
  37.       assert(erased); 
  38.     } 
  39.   } 
  40.  
  41.   return reinterpret_cast<Cache::Handle*>(e); 

ShardedLRUCache——分片 LRUCache

引入 SharedLRUCache 的目的在于減小加鎖的粒度,提高讀寫并行度。策略比較簡潔—— 利用 key 哈希值的前 kNumShardBits = 4 個 bit 作為分片路由,可以支持 kNumShards = 1 << kNumShardBits 16 個分片。通過 key 的哈希值計算分片索引的函數(shù)如下:

  1. static uint32_t Shard(uint32_t hash) { return hash >> (32 - kNumShardBits); } 

由于 LRUCache 和 ShardedLRUCache 都實現(xiàn)了 Cache 接口,因此 ShardedLRUCache 只需將所有 Cache 接口操作路由到對應 Shard 即可,總體來說 ShardedLRUCache 沒有太多邏輯,更像一個 Wrapper,這里不再贅述。

參考

LevelDB 緩存代碼:https://github.com/google/leveldb/blob/master/util/cache.cc

LevelDB handbook 緩存系統(tǒng):https://leveldb-handbook.readthedocs.io/zh/latest/cache.html#dynamic-sized-nonblocking-hash-table【編輯推薦】

原文鏈接:https://mp.weixin.qq.com/s/zcvgWO4cIxfphfE7I41GYA

 

責任編輯:武曉燕 來源: 木鳥雜記
相關推薦

2023-11-16 08:22:14

LruCacheAndroid

2021-09-04 07:29:57

Android

2021-07-16 07:57:34

Python數(shù)據(jù)結構

2021-07-15 06:43:12

Python數(shù)據(jù)結構

2017-03-01 13:58:46

Python數(shù)據(jù)結構鏈表

2023-03-28 07:44:23

數(shù)據(jù)結構數(shù)組

2021-07-13 07:52:03

Python數(shù)據(jù)結構

2009-07-02 14:59:28

Java考研試題

2012-02-02 10:21:05

單鏈表nexthead

2021-07-11 12:06:43

python數(shù)據(jù)結構

2018-06-06 08:54:23

數(shù)據(jù)結構存儲

2010-04-08 09:27:04

PHP設計模式結構模式

2024-10-11 16:43:05

高并發(fā)數(shù)據(jù)結構技巧

2022-01-18 19:13:52

背包問題數(shù)據(jù)結構算法

2020-10-30 09:56:59

Trie樹之美

2022-09-21 07:57:33

二叉搜索樹排序二叉樹

2022-09-26 07:56:53

AVL算法二叉樹

2009-08-12 18:35:17

C#數(shù)據(jù)結構

2021-08-29 07:41:48

數(shù)據(jù)HashMap底層

2023-10-31 08:51:25

數(shù)據(jù)結構存儲數(shù)據(jù)
點贊
收藏

51CTO技術棧公眾號