LSM-Tree - LevelDb之LRU缓存(一)https://developer.aliyun.com/article/1395012
因为是缓存,所以有容量的限制,如果超过容量就必须要从冷链表当中剔除访问最少的元素。
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. 返回的句柄 std::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. (capacity_==0 is supported and turns off caching.) // capacity_==0 时表示关闭缓存,不进行任何缓存 // next is read by key() in an assert, so it must be initialized // next 由断言中的 key() 读取,因此必须对其进行初始化 e->next = nullptr; } 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); }
这里需要关注下面的代码:
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. (capacity_==0 is supported and turns off caching.) // 不要缓存。 (容量_==0 受支持并关闭缓存。) // next is read by key() in an assert, so it must be initialized // next 由断言中的 key() 读取,因此必须对其进行初始化 e->next = nullptr; }
在内部的代码需要注意FinishErase(table_.Insert(e));
方法,这部分代码需要结合之前介绍的哈希表(HandleTable)的LRUHandle* Insert(LRUHandle* h)
方法,,在内部如果发现同一个key值的元素已经存在,HandleTable的Insert函数会将旧的元素返回。
因此LRU的Insert
函数内部隐含了更新的操作,会将新的Node加入到Cache中,而老的元素会调用FinishErase函数来决定是移入冷宫还是彻底删除。
// If e != nullptr, finish removing *e from the cache; it has already been removed from the hash table. Return whether e != nullptr. // 如果e != nullptr,从缓存中删除*e;表示它已经被从哈希表中删除。同时返回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; }
扩展:Mysql的内存缓存页也是使用冷热分离的方式进行维护和处理,目的使平衡可用页和缓存命中,但是我们都知道8.0缓存直接删掉了,原因是现今稍大的OLTP业务对于缓存的命中率非常低。
小结
LevelDB当中到LRU缓存实现有以下特点:
- 使用冷热分离链表维护数据,冷热数据之间的数据不存在交集:被客户端引用的
in-use
链表,和不被任何客户端引用的lru_
链表。 - 双向链表的设计,同时其中一端使用空链表作为边界判断。表头的
prev
指针指向最新的条目,next
指针指向最老的条目,最终形成了双向环形链表。 - 使用
usage_
表示缓存当前已用量,用capacity_
表示该缓存总量。 - 抽象出了几个基本操作:
LRU_Remove
、LRU_Append
、Ref
、Unref
作为辅助函数进行复用。 - 每个
LRUCache
由一把锁mutex_
守护。
LRUHandle - LRU节点
LRU节点 通过状态判断切换是否存在缓存当中,如果引用计数为0,则通过erased方法被移除哈希以及LRU的链表。
下面是具体的示意图:
LRUHandle
其实就是缓存节点LRUNode
,LevelDB的Cache管理,通过作者的注释可以了解到,整个缓存节点维护有2个双向链表和一个哈希表,哈希表不需要过多介绍。
哈希的经典问题就是哈希碰撞,而使用链表节点解决哈希节点的问题是经典的方式,LevelDB也不例外,不过他要比传统的设计方式要复杂一点。
// 机翻自作者的注释,对于理解作者设计比较关键,翻得一般。建议对比原文多读几遍 // LRU缓存实现 // // 缓存条目有一个“in_cache”布尔值,指示缓存是否有 // 对条目的引用。如果没有传递给其“删除器”的条目是通过 Erase(), // 通过 Insert() 时, 插入具有重复键的元素,或在缓存销毁时。 // // 缓存在缓存中保存两个项目的链表。中的所有项目 // 缓存在一个列表或另一个列表中,并且永远不会同时存在。仍被引用的项目 // 由客户端但从缓存中删除的不在列表中。名单是: // - in-use: 包含客户端当前引用的项目,没有 // 特定顺序。 (这个列表用于不变检查。如果我们 // 删除检查,否则该列表中的元素可能是 // 保留为断开连接的单例列表。) // - LRU:包含客户端当前未引用的项目,按 LRU 顺序 // 元素通过 Ref() 和 Unref() 方法在这些列表之间移动, // 当他们检测到缓存中的元素获取或丢失它的唯一 // 外部参考。 // 一个 Entry 是一个可变长度的堆分配结构。 条目保存在按访问时间排序的循环双向链表中。 // LRU cache implementation // // Cache entries have an "in_cache" boolean indicating whether the cache has a // reference on the entry. The only ways that this can become false without the // entry being passed to its "deleter" are via Erase(), via Insert() when // an element with a duplicate key is inserted, or on destruction of the cache. // // The cache keeps two linked lists of items in the cache. All items in the // cache are in one list or the other, and never both. Items still referenced // by clients but erased from the cache are in neither list. The lists are: // - in-use: contains the items currently referenced by clients, in no // particular order. (This list is used for invariant checking. If we // removed the check, elements that would otherwise be on this list could be // left as disconnected singleton lists.) // - LRU: contains the items not currently referenced by clients, in LRU order // Elements are moved between these lists by the Ref() and Unref() methods, // when they detect an element in the cache acquiring or losing its only // external reference. // An entry is a variable length heap-allocated structure. Entries // are kept in a circular doubly linked list ordered by access time. struct LRUHandle { void* value; void (*deleter)(const Slice&, void* value); // 释放 key value 空间用户回调 LRUHandle* next_hash; // 用于 Hashtable 处理链表冲突 LRUHandle* next; // 双向链表维护LRU顺序 LRUHandle* prev; size_t charge; // TODO(opt): Only allow uint32_t? size_t key_length; bool in_cache; // 该 handle 是否在 cache table 中 uint32_t refs; // 该 handle 被引用次数 uint32_t hash; // key 的 hash值, 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. // 仅当 LRU 句柄是空列表的列表头时,next 才等于此值。此时列表头永远不会有有意义的键。 assert(next != this); return Slice(key_data, key_length); }
ShardedLRUCache
该类继承Cache接口,并且和所有的LRUCache一样都会加锁,但是不同的是ShardedLRUCache可以定义多个LRUCache分别处理不同的hash取模之后的缓存处理。
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); } 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++) { 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; } // 全局唯一自增ID 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; } };
ShardedLRUCache
内部有16个LRUCache
,查找key的时候,先计算属于哪一个LRUCache,然后在相应的LRUCache中上锁查找,这种策略并不少见,但是核心的代码并不在这一部分,所以放到了最后进行介绍。
16个LRUCache,Shard
方法利用 key
哈希值的前 kNumShardBits = 4
个 bit 作为分片路由,最终可以支持 kNumShards = 1 << kNumShardBits
16 个分片,这也是16个分片
static uint32_t Shard(uint32_t hash) { return hash >> (32 - kNumShardBits); }
由于 LRUCache
和 ShardedLRUCache
都实现了 Cache 接口,因此 ShardedLRUCache
只需将所有 Cache 接口操作路由到对应 Shard 即可,总体来说 ShardedLRUCache
没有太多逻辑,这里不再赘述。
总结
整个LevelDB的LRU缓存实现,除了在起名的地方有点非主流之外,基本符合LRU的设计思想。
整个LevelDB的核心是哈希表和哈希函数,支持并发读写的哈希表以及resize函数核心部分都是值得推敲。
关于哈希表的优化实际上自出现开始就一直在优化,LevelDB上的实现是一个不错的参考。
写在最后
LevelDB比较重要的组件基本介绍完成,LevelDB的LRU缓存也可以看做是教科书一般的实现。