【轉載】leveldb中的LRUCache設計

【轉載】leveldb中的LRUCache設計


關鍵點:
1. hash
(1)rehash+預防碰撞(總節點數超過hashtable大小)
2. LRU
(1)冷鏈+熱鏈
(2)引用計數的使用
3.sharedLRUCache
(1)分區

首頁分類標籤留言關於訂閱

2016-11-26 分類 leveldb  標籤 leveldb 

http://bean-li.github.io/leveldb-LRUCache/

前言

leveldb是Google大神Jeff Dean的作品,只有2萬行出頭的代碼量,非常精彩,非常值得解剖學習。本文介紹leveldb的LRUCache設計。 我們知道,相對於塊設備,內存永遠都是奢侈品。局部性原理應該算是計算機科學中數一數二的重要原理,無數精巧的設計,不過是讓有限的內存提供更高的用戶數據訪問命中,茫茫海量的數據,我需要你的時候,你恰巧在內存,這纔是工程師不不懈的追求。

內存是稀缺資源,緩存替換算法是計算機科學的重要Topic。LRU (Last Recent Used)是一個重要的緩存替換算法。Leveldb採用的就是這種算法。但是僅僅知道LRU這三個字母是沒啥用的,我們一起學習下leveldb的緩存替換算法,體會下大神的精巧設計。

內部實現

沒有文檔的情況下,沉入代碼細節,容易只見樹木不見森林,需要花費好久才能體會出作者的設計思路。代碼告訴你How,而設計文檔才告訴你Why。

LevelDB的LRUCache設計有4個數據結構,是依次遞進的關係,分別是:

  • LRUHandle
  • HandleTable
  • LRUCache
  • ShardedLRUCache

事實上到了第三個數據結構LRUCache,LRU的緩存管理數據結構已經實現了,之所以引入第四個數據結構,就是因爲減少競爭。因爲多線程訪問需要加鎖,爲了減少競爭,提升效率,ShardedLRUCache內部有16個LRUCache,查找key的時候,先計算屬於哪一個LRUCache,然後在相應的LRUCache中上鎖查找。

class ShardedLRUCache : public Cache {  
 private:  
  LRUCache shard_[kNumShards];  
  ...
}

這不是什麼高深的思路,這種減少競爭的策略非常常見。因此,讀懂緩存管理策略的關鍵在前三個數據結構。

LevelDB的Cache管理,維護有2個雙向鏈表和一個哈希表。哈希表是非常容易理解的。如何確定一個key值到底存不存在,如果存在如何快速獲取key值對應的value值。我們都學過數據結構,這活,哈希表是比較適合的。

注意,我們都知道,hash表存在一個重要的問題,就是碰撞,有可能多個不同的鍵值hash之後值相同,解決碰撞的一個重要思路是鏈表,將hash之後計算的key相同的元素鏈入同一個表頭對應的鏈表。

可是我們並不滿意這種速度,LevelDB做了進一步的優化,即及時擴大hash桶的個數,儘可能地不會發生碰撞。因此LevelDB自己實現了一個hash表,即HandleTable數據結構。

說句題外話,我不太喜歡數據結構的命名方式,比如HandleTable,命名就是個HashTable,如果出現Hash會好理解很多。這個名字還自罷了,LRUHandle這個名字更是讓人摸不到頭腦,明明就是一個數據節點,如果名字中出現Node,整個代碼都會好理解很多。好了吐槽結束,看下HandleTable的數據結構:

class HandleTable {
 public:
  
 ...
 
 private:

  uint32_t length_;
  uint32_t elems_;
  LRUHandle** list_;

第一個元素length_ 紀錄的就是當前hash桶的個數,第二個元素 elems_維護在整個hash表中一共存放了多少個元素。第三個就更好理解了,二維指針,每一個指針指向一個桶的表頭位置。

爲了提升查找效率,提早增加桶的個數來儘可能地保持一個桶後面只有一個元素,在插入的算法中有如下內容:

  LRUHandle* Insert(LRUHandle* h) {
    LRUHandle** ptr = FindPointer(h->key(), h->hash);
    LRUHandle* old = *ptr;  //老的元素返回,LRUCache會將相同key的老元素釋放,詳情看LRUCache的Insert函數。
    h->next_hash = (old == NULL ? NULL : old->next_hash);
    *ptr = h;
    if (old == NULL) {
      ++elems_;
      if (elems_ > length_) {
        // Since each cache entry is fairly large, we aim for a small
        // average linked list length (<= 1).
        Resize();
      }
    }
    return old;
  }

注意,當整個hash表中元素的個數超過 hash表桶的的個數的時候,調用Resize函數,該函數會將桶的個數增加一倍,同時將現有的元素搬遷到合適的桶的後面。正是這種提早擴大桶的個數,良好的hash函數會保證每個桶對應的鏈表中儘可能的只有1個元素,從這個角度講,LevelDB使用這種優化後的哈希表,查找的效率爲O(1)。

  void Resize() {
    uint32_t new_length = 4;
    while (new_length < elems_) {
      new_length *= 2;
    }
    LRUHandle** new_list = new LRUHandle*[new_length];
    memset(new_list, 0, sizeof(new_list[0]) * new_length);
    uint32_t count = 0;
    for (uint32_t i = 0; i < length_; i++) {
      LRUHandle* h = list_[i];
      while (h != NULL) {
        LRUHandle* next = h->next_hash;
        uint32_t hash = h->hash;
        LRUHandle** ptr = &new_list[hash & (new_length - 1)]; //各個已有的元素重新計算,應該落在哪個桶的鏈表中。
        h->next_hash = *ptr;
        *ptr = h;
        h = next;
        count++;
      }
    }
    assert(elems_ == count);
    delete[] list_;
    list_ = new_list;
    length_ = new_length;
  }

設計緩存,快速找到位置是一個重要指標,但是毫無疑問,不僅僅只有這一個設計指標。因爲使用LRU,當空間不夠的時候,需要踢出某些元素的時候,必需能夠快速地找到,哪些元素Last Recent Used,作爲替換的犧牲品。這個指標哈希表可就愛莫能助了。

當然,我們可以講最近訪問時間作爲元素的一個字段保存起來,但是我們不得不掃描整個hash表,將訪問時間排序才能知道哪個元素更應該被剔除。毫無疑問效率太低。

LRUCache不管需要哈希表來快速查找,還需要鏈表能夠快速插入和刪除。LRUCache 維護有兩條雙向鏈表:

  • lru_
  • in_use_
  LRUHandle lru_;      // lru_ 是冷鏈表,屬於冷宮,

  LRUHandle in_use_;   // in_use_ 屬於熱鏈表,熱數據在此鏈表

  HandleTable table_; // 哈希表部分已經講過

Ref 函數表示要使用該cache,因此如果對應元素位於冷鏈表,需要將它從冷鏈表溢出,鏈入到熱鏈表:

void LRUCache::Ref(LRUHandle* e) {
  if (e->refs == 1 && e->in_cache) {  // If on lru_ list, move to in_use_ list.
    LRU_Remove(e);
    LRU_Append(&in_use_, e);
  }
  e->refs++;
}

void LRUCache::LRU_Remove(LRUHandle* e) {
  e->next->prev = e->prev;
  e->prev->next = e->next;
}

void LRUCache::LRU_Append(LRUHandle* list, LRUHandle* e) {
  // Make "e" newest entry by inserting just before *list
  e->next = list;
  e->prev = list->prev;
  e->prev->next = e;
  e->next->prev = e;
}

Unref正好想法,表示客戶不再訪問該元素,需要將引用計數--,如果徹底沒人用了,引用計數爲0了,就可以刪除這個元素了,如果引用計數爲1,則可以將元素打入冷宮,放入到冷鏈表:


void LRUCache::Unref(LRUHandle* e) {
  assert(e->refs > 0);
  e->refs--;
  if (e->refs == 0) { // Deallocate. 徹底沒人訪問了,而且也在冷鏈表中,可以刪除了
    assert(!e->in_cache);
    (*e->deleter)(e->key(), e->value); //元素的deleter函數,此時回調。
    free(e);
  } else if (e->in_cache && e->refs == 1) {  // 移入冷鏈表 lru_
    LRU_Remove(e);
    LRU_Append(&lru_, e);
  }
}

注意,緩存必須要要有容量的概念,超過了容量,緩存必須要踢出某些元素,對於我們這種場景而言,就是從冷鏈表中踢人。

對於LevelDB而言,插入的時候,會判斷是否超過了容量,如果超過了事先規劃的容量,就會從冷鏈表中踢人:

size_t capacity_; // LRUCache的容量
size_t usage_;    // 當前使用的容量


Cache::Handle* LRUCache::Insert(
    const Slice& key, uint32_t hash, void* value, size_t charge,
    void (*deleter)(const Slice& key, void* value)) {
  MutexLock l(&mutex_);

  LRUHandle* e = reinterpret_cast<LRUHandle*>(
      malloc(sizeof(LRUHandle)-1 + key.size()));
  e->value = value;
  e->deleter = deleter;
  e->charge = charge;
  e->key_length = key.size();
  e->hash = hash;
  e->in_cache = false;
  e->refs = 1;  // for the returned handle.
  memcpy(e->key_data, key.data(), key.size());

  if (capacity_ > 0) {
    e->refs++;  // for the cache's reference.
    e->in_cache = true;
    LRU_Append(&in_use_, e);  //鏈入熱鏈表
    usage_ += charge;         //使用的容量增加
    FinishErase(table_.Insert(e));  // 如果是更新的話,需要回收老的元素
  } // else don't cache.  (Tests use capacity_==0 to turn off caching.)

  while (usage_ > capacity_ && lru_.next != &lru_) { 
   //如果容量超過了設計的容量,並且冷鏈表中有內容,則從冷鏈表中刪除所有元素
    LRUHandle* old = lru_.next;
    assert(old->refs == 1);
    bool erased = FinishErase(table_.Remove(old->key(), old->hash));
    if (!erased) {  // to avoid unused variable when compiled NDEBUG
      assert(erased);
    }
  }

  return reinterpret_cast<Cache::Handle*>(e);
}

bool LRUCache::FinishErase(LRUHandle* e) {
  if (e != NULL) {
    assert(e->in_cache);
    LRU_Remove(e);
    e->in_cache = false;
    usage_ -= e->charge;
    Unref(e);
  }
  return e != NULL;
}

我們要重點注意下插入邏輯中的

  if (capacity_ > 0) {
    e->refs++;  // for the cache's reference.
    e->in_cache = true;
    LRU_Append(&in_use_, e);  //鏈入熱鏈表
    usage_ += charge;         //使用的容量增加
    FinishErase(table_.Insert(e));  // 如果是更新的話,需要回收老的元素
  } // else don't cache.  (Tests use capacity_==0 to turn off caching.)

爲什麼會調用:

FinishErase(table_.Insert(e));

插入hash表的時候,如果找到了同一個key值的元素已經存在,HandleTable的Insert函數會將老的元素返回:


  LRUHandle* Insert(LRUHandle* h) {
    LRUHandle** ptr = FindPointer(h->key(), h->hash);
    LRUHandle* old = *ptr;  //老的元素返回,LRUCache會將相同key的老元素釋放,詳情看LRUCache的Insert函數。
    h->next_hash = (old == NULL ? NULL : old->next_hash);
    *ptr = h;
    if (old == NULL) {
      ++elems_;
      if (elems_ > length_) {
        // Since each cache entry is fairly large, we aim for a small
        // average linked list length (<= 1).
        Resize();
      }
    }
    return old;
  }

因此LRU的Insert函數內部隱含了更新的操作,會將新的Node加入到Cache中,而老的元素會調用FinishErase函數來決定是移入冷宮還是徹底刪除。

最後的最後,看下元素長什麼樣,就是很坑爹的LRUHandle數據結構,名字太坑爹了,如果叫LRUNode,會好理解很多

struct LRUHandle {
  void* value;
  void (*deleter)(const Slice&, void* value);
  LRUHandle* next_hash;
  LRUHandle* next;
  LRUHandle* prev;
  size_t charge;      // TODO(opt): Only allow uint32_t?
  size_t key_length;
  bool in_cache;      // Whether entry is in the cache.
  uint32_t refs;      // References, including cache reference, if present.
  uint32_t hash;      // Hash of key(); used for fast sharding and comparisons
  char key_data[1];   // Beginning of key

  Slice key() const {
    // For cheaper lookups, we allow a temporary Handle object
    // to store a pointer to a key in "value".
    if (next == this) {
      return *(reinterpret_cast<Slice*>(value));
    } else {
      return Slice(key_data, key_length);
    }
  }
};

這裏面的next_hash 會鏈入到哈希表對應的桶對應的鏈表中,而next和prev,不是在熱鏈表就是在冷鏈表。

// LRUHandle數據結構

  LRUHandle* next_hash;
  LRUHandle* next;
  LRUHandle* prev;
  
//LRUCahe對應的數據結構
  LRUHandle lru_;      // lru_ 是冷鏈表,屬於冷宮,

  LRUHandle in_use_;   // in_use_ 屬於熱鏈表,熱數據在此鏈表

  HandleTable table_; // 哈希表部分已經講過

服務關係一目瞭然。

打完收工。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章