目录
引言
本次给大家介绍鼎鼎大名的LRU(Least Recently Used,即最近最少使用)算法在leveldb中是如何实现的,leveldb又是怎么实现高并发LRU以及提高LRU Cache查找效率的。
先做一个概述,让大家心里有底:leveldb中的Cache实际上是一个LRU链表 + 哈希表,哈希表用于提升查找效率,解决哈希冲突的方式为链地址法,不过这个链地址被Hack到而解决访问冲突的方式是引入一个LRU数组。
LRUHandle
struct LRUHandle {
void* value;
void (*deleter)(const Slice&, void* value);
// 链地址法的hack
LRUHandle* next_hash;
LRUHandle* next;
LRUHandle* prev;
size_t charge; // TODO(opt): Only allow uint32_t?
size_t key_length;
// 类似PG_referenced
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 {
// next_ is only equal to this if the LRU handle is the list head of an
// empty list. List heads never have meaningful keys.
assert(next != this);
return Slice(key_data, key_length);
}
};
LRUHandle是LRU节点的数据结构,首先我们先对它进行介绍。
成员变量
这里的LRU链表是一条双向循环链表,这些常规的next
、prev
我们就不做解释了。
我们需要注意的是next_hash
,它被用于解决哈希冲突(一般链地址都会放在哈希表中,所以说是一种“Hack”)。
key_data
是一个柔性数组,能够为我们节省一个指针的空间,而为什么说它不写成char key_data[]
或者char key_data[0]
,我想可能是因为兼容问题吧,前一种写法属于GCC扩展,未必所有编译器都能通过,因此我们在后面的一些申请空间的函数中可以看到有一个减一之类的操作。
让我们先感受一下,免得后面看不懂:
LRUHandle* e =
reinterpret_cast<LRUHandle*>(malloc(sizeof(LRUHandle) - 1 + key.size()));
hash
可以理解为一个缓存,它让我们在一些场景之下不必反复调用哈希函数,所以我们传参时会多传一个hash形参。
LRUHandle** FindPointer(const Slice& key, uint32_t hash);
refs
和in_cache
是重中之重,前者为引用计数,后者可以理解为决定是否被LRU算法淘汰的标志之一,主要用于Unref
和Ref
函数,后面我们再详细介绍它们。
HandleTable
前面提到leveldb中的哈希表是一种“Hack”,LRUHandle和HandleTable实际上是一个相辅相成的整体,谁也离不开谁。
下面我们来介绍它。
成员变量
uint32_t length_;
uint32_t elems_;
LRUHandle** list_;
list_
用于存放桶的指针,length_
为桶的大小。
elems_
用于记录HandleTable中元素数量,当elems_
很大(在leveldb中,这个值为桶的数量length_
),会产生比较大的哈希冲突,意味着有比较严重的读放大现象,这时候就会调用Resize函数重新分配。
FindPointer
// Return a pointer to slot that points to a cache entry that
// matches key/hash. If there is no such cache entry, return a
// pointer to the trailing slot in the corresponding linked list.
LRUHandle** FindPointer(const Slice& key, uint32_t hash) {
// 这俩老哥用位运算代替取余用上瘾了
LRUHandle** ptr = &list_[hash & (length_ - 1)];
while (*ptr != nullptr && ((*ptr)->hash != hash || key != (*ptr)->key())) {
ptr = &(*ptr)->next_hash;
}
return ptr;
}
FindPointer根据key和hash(上文提到的缓存)不断遍历next_hash
查找对应的值,查找位置是hash & (length_ - 1)
。
不过关于为什么会有(*ptr)->hash != hash
,我还不太了解,我觉得既然放在一个桶,hash
必然是相同的,没必要反复判断。
该函数直接被Lookup调用,所以我们不提Lookup了。
Resize
// Rehash
void Resize() {
uint32_t new_length = 4;
while (new_length < elems_) {
new_length *= 2;
}
LRUHandle** new_list = new LRUHandle*[new_length];
// malloc出来的空间未必是0,calloc才是,因此memset一下
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 != nullptr) {
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;
}
代码比较直接,遍历旧桶,取出元素放入新桶,这里也没有用到哈希函数,说明hash
这个缓存为了性能优化还是比较值的开销。
有一个小细节,堆空间虽然初始化为0,但malloc申请到的空间如果是之前使用过的(先前malloc然后free了),这个值是未定义的,所以需要memset一下,也可以直接用calloc来直接返回必然为0的地址。
new_list
和new_length
这两变量是为了异常安全性,不直接处理传入变量。
Insert
LRUHandle* Insert(LRUHandle* h) {
LRUHandle** ptr = FindPointer(h->key(), h->hash);
LRUHandle* old = *ptr;
// 头插法(优点在于不用判断是否为NULL)
h->next_hash = (old == nullptr ? nullptr : old->next_hash);
*ptr = h;
if (old == nullptr) {
++elems_;
if (elems_ > length_) {
// Since each cache entry is fairly large, we aim for a small
// average linked list length (<= 1).
// 元素数量大于桶数量时调用Resize(此时平均每个桶都有一个元素)
Resize();
}
}
return old;
}
插入用到头插法,当FindPointer返回的指针指向nullptr(注意这里是二级指针)时也能正常工作。
当哈希表中元素过多则需要调用Resize,桶数量每次增长一倍。
Remove
LRUHandle* Remove(const Slice& key, uint32_t hash) {
LRUHandle** ptr = FindPointer(key, hash);
LRUHandle* result = *ptr;
if (result != nullptr) {
// value的生存周期可不归HandleTable管
*ptr = result->next_hash;
--elems_;
}
return result;
}
Remove操作也是比较简单,找出LRUHandle删除,并更新elems_
即可。
LRUCache
成员变量
// Initialized before use.
size_t capacity_;
// mutex_ protects the following state.
mutable port::Mutex mutex_;
// GUARDED_BY:事实上是什么也不做的注释
size_t usage_ GUARDED_BY(mutex_);
// Dummy head of LRU list.
// lru.prev is newest entry, lru.next is oldest entry.
// Entries have refs==1 and in_cache==true.
// in_use_中元素可能移入lru_
// 类似inactive_list
LRUHandle lru_ GUARDED_BY(mutex_);
// Dummy head of in-use list.
// Entries are in use by clients, and have refs >= 2 and in_cache==true.
// 所有新元素都会被放到in_use_
// 类似active_list
// 和lru_都是双向循环链表
LRUHandle in_use_ GUARDED_BY(mutex_);
HandleTable table_ GUARDED_BY(mutex_);
capacity_
为LRU链表的最大容量,当节点数量usage_
大于它,则表示应该淘汰一些Cache了。
table_
是前文提到的HandleTable,它使查找LRU节点更迅速。
mutex_
用来保证该LRUCache的并发访问。
重点是,leveldb中LRU实现有两条链表:lru_
和in_use_
。
in_use_
存放最新访问的节点,它是一个active_list。任何新元素都会先放入in_use_
,一定情况会降至lru_
中。
它的门槛很清楚:
// Entries are in use by clients, and have refs >= 2 and in_cache==true.
lru_
类似一个备选的LRU缓冲区,是一种inactive_list。注意,我们不会直接淘汰in_use_
中的元素,leveldb觉得只有一条链表太不公平,再给它一次机会,放入lru_
,如果它表现良好(近期再次被使用),再提至in_use_
。
lru_
中则要求节点:
// Entries have refs==1 and in_cache==true.
注意,这个refs == 1
是考虑到for the returned handle.
,意思是返回的Handle句柄持有一个引用计数,这样能有效防止“悬空指针”的发生。
LRUCache
LRUCache::LRUCache() : capacity_(0), usage_(0) {
// Make empty circular linked lists.
lru_.next = &lru_;
lru_.prev = &lru_;
in_use_.next = &in_use_;
in_use_.prev = &in_use_;
}
构造函数主要就是初始化lru_
和in_use_
这两双向循环链表,很简单。
LRU_Remove和LRU_Append
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;
}
双向链表基本操作,不解释了。
Ref
void LRUCache::Ref(LRUHandle* e) {
// 如果处于lru_且in_cache,移至in_use_
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++;
}
Ref用来增加节点的引用计数,如果处于lru_
且in_cache
,说明它又被“重新需要了”,移至in_use_
。
Unref
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);
free(e);
} else if (e->in_cache && e->refs == 1) {
// No longer in use; move to lru_ list.
LRU_Remove(e);
LRU_Append(&lru_, e);
}
}
与Ref对应,Unref用来减少节点引用计数。如果减少之后为0(这一般是持有句柄的用户调用了),说明它真的是不被需要了,删掉它,如果它暂时没有被使用,移至lru_
。
注意Unref不一定会真的删除节点,FinishErase才会。
FinishErase
// If e != nullptr, finish removing *e from the cache; it has already been
// removed from the hash table. Return whether e != nullptr.
bool LRUCache::FinishErase(LRUHandle* e) {
if (e != nullptr) {
assert(e->in_cache);
LRU_Remove(e);
e->in_cache = false;
usage_ -= e->charge;
Unref(e);
}
return e != nullptr;
}
可以看到FinishErase其实调用了Unref,正是in_cache
被设为false
,Unref才有机会彻底释放它。
注意在这里还更新了usage_
,charge
是LRUHandle的空间大小。
Unref和FinishErase的区别
Unref只是代表持有者不再使用它,需要将refs
减一了,至于它到底是从in_use_
移至lru_
还是真的彻底删除,由Unref函数再判断。
而FinishErase代表就是要把节点彻底删除,不管它是不是在in_use_
中。
Lookup
Cache::Handle* LRUCache::Lookup(const Slice& key, uint32_t hash) {
MutexLock l(&mutex_);
LRUHandle* e = table_.Lookup(key, hash);
if (e != nullptr) {
// 近期用到了它
Ref(e);
}
return reinterpret_cast<Cache::Handle*>(e);
}
table_
的优势在此体现,直接调用Lookup查找节点。
如果查找到,说明确实它被用到了,那就Ref它。
Prune
void LRUCache::Prune() {
MutexLock l(&mutex_);
while (lru_.next != &lru_) {
LRUHandle* e = lru_.next;
assert(e->refs == 1);
bool erased = FinishErase(table_.Remove(e->key(), e->hash));
if (!erased) { // to avoid unused variable when compiled NDEBUG
assert(erased);
}
}
}
该函数用来释放整个LRU链表的节点,同时也将节点从table_
中删除。
~LRUCache
LRUCache::~LRUCache() {
// 仍在使用,不能释放
assert(in_use_.next == &in_use_); // Error if caller has an unreleased handle
for (LRUHandle* e = lru_.next; e != &lru_;) {
// next:安全释放
LRUHandle* next = e->next;
assert(e->in_cache);
e->in_cache = false;
assert(e->refs == 1); // Invariant of lru_ list.
Unref(e);
e = next;
}
}
为什么要有一个next
指针?这里其实类似于Linux内核遍历struct list_head
安全版本,由于遍历时会修改迭代器,所以需要预先保存下一个节点的值,普通版本不需要它,因为这样会产生额外的开销。
ShardedLRUCache
// 为了高并发
class ShardedLRUCache : public Cache {
private:
LRUCache shard_[kNumShards];
port::Mutex id_mutex_;
uint64_t last_id_;
static inline uint32_t HashSlice(const Slice& s) {
return Hash(s.data(), s.size(), 0);
}
// 取高4位
static uint32_t Shard(uint32_t hash) { return hash >> (32 - kNumShardBits); }
public:
explicit ShardedLRUCache(size_t capacity) : last_id_(0) {
const size_t per_shard = (capacity + (kNumShards - 1)) / kNumShards;
for (int s = 0; s < kNumShards; s++) {
// 默认per_shard一定大于0
shard_[s].SetCapacity(per_shard);
}
}
~ShardedLRUCache() override {}
Handle* Insert(const Slice& key, void* value, size_t charge,
void (*deleter)(const Slice& key, void* value)) override {
const uint32_t hash = HashSlice(key);
return shard_[Shard(hash)].Insert(key, hash, value, charge, deleter);
}
Handle* Lookup(const Slice& key) override {
const uint32_t hash = HashSlice(key);
return shard_[Shard(hash)].Lookup(key, hash);
}
void Release(Handle* handle) override {
LRUHandle* h = reinterpret_cast<LRUHandle*>(handle);
shard_[Shard(h->hash)].Release(handle);
}
void Erase(const Slice& key) override {
const uint32_t hash = HashSlice(key);
shard_[Shard(hash)].Erase(key, hash);
}
void* Value(Handle* handle) override {
return reinterpret_cast<LRUHandle*>(handle)->value;
}
uint64_t NewId() override {
MutexLock l(&id_mutex_);
return ++(last_id_);
}
void Prune() override {
for (int s = 0; s < kNumShards; s++) {
shard_[s].Prune();
}
}
size_t TotalCharge() const override {
size_t total = 0;
for (int s = 0; s < kNumShards; s++) {
total += shard_[s].TotalCharge();
}
return total;
}
};
大家都知道,KV数据的核心数据结构之一就是Cache,因此Cache也是最经常被访问的结构之一,因此leveldb中实现了一个高并发的Cache——ShardedLRUCache,说起来很高大上,其实就是含有LRUCache数组的一个类。
原理用大白话讲,其实就是:既然冲突可能导致性能下降,那我尽量干脆就不访问同一个地址,这也是死锁的避免方法之一。
ShardedLRUCache中LRUCache数组的大小为四位无符号数所能表示的最大整数:
static const int kNumShardBits = 4;
// LRUCache实际数量
static const int kNumShards = 1 << kNumShardBits;
分配KV的方式也很简单,也是取Hash的前四位:
// 取高4位
static uint32_t Shard(uint32_t hash) { return hash >> (32 - kNumShardBits); }
Handle* Insert(const Slice& key, void* value, size_t charge,
void (*deleter)(const Slice& key, void* value)) override {
const uint32_t hash = HashSlice(key);
return shard_[Shard(hash)].Insert(key, hash, value, charge, deleter);
}
其他成员函数均为LRUCache的包装,很简单就不赘述啦。
相关代码
util/cache.cc
我个人的注释版本(不定期更新)