【轉載】leveldb中的LRUCache設計
2016-11-26 | 分類 leveldb |
前言
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_; // 哈希表部分已經講過
服務關係一目瞭然。
打完收工。