LSM-Tree - LevelDb之LRU缓存(二)

简介: LSM-Tree - LevelDb之LRU缓存

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缓存实现有以下特点:

  1. 使用冷热分离链表维护数据,冷热数据之间的数据不存在交集:被客户端引用的 in-use 链表,和不被任何客户端引用的 lru_ 链表。
  2. 双向链表的设计,同时其中一端使用空链表作为边界判断。表头的 prev 指针指向最新的条目,next 指针指向最老的条目,最终形成了双向环形链表
  3. 使用 usage_ 表示缓存当前已用量,用 capacity_ 表示该缓存总量。
  4. 抽象出了几个基本操作:LRU_RemoveLRU_AppendRefUnref 作为辅助函数进行复用。
  5. 每个 LRUCache 由一把锁 mutex_ 守护。

LRUHandle - LRU节点

LRU节点 通过状态判断切换是否存在缓存当中,如果引用计数为0,则通过erased方法被移除哈希以及LRU的链表。

下面是具体的示意图:

image.png

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

image.png

该类继承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); }

由于 LRUCacheShardedLRUCache 都实现了 Cache 接口,因此 ShardedLRUCache 只需将所有 Cache 接口操作路由到对应 Shard 即可,总体来说 ShardedLRUCache 没有太多逻辑,这里不再赘述。

总结

整个LevelDB的LRU缓存实现,除了在起名的地方有点非主流之外,基本符合LRU的设计思想。

整个LevelDB的核心是哈希表和哈希函数,支持并发读写的哈希表以及resize函数核心部分都是值得推敲。

关于哈希表的优化实际上自出现开始就一直在优化,LevelDB上的实现是一个不错的参考。

写在最后

LevelDB比较重要的组件基本介绍完成,LevelDB的LRU缓存也可以看做是教科书一般的实现。

相关文章
|
2天前
|
缓存 算法 索引
LeetCode146:LRU缓存
LeetCode146:LRU缓存
10 1
|
21天前
|
缓存 算法 Java
数据结构~缓存淘汰算法--LRU算法(Java的俩种实现方式,万字解析
数据结构~缓存淘汰算法--LRU算法(Java的俩种实现方式,万字解析
|
21天前
|
缓存 算法 前端开发
前端开发者必知的缓存淘汰策略:LRU算法解析与实践
前端开发者必知的缓存淘汰策略:LRU算法解析与实践
|
23天前
|
缓存 算法
LRU(Least Recently Used)算法是一种常用的计算机缓存替换算法
【5月更文挑战第4天】LRU算法是基于页面使用频率的缓存策略,优先淘汰最近最久未使用的页面。实现可采用双向链表或数组,前者灵活,后者时间复杂度低。优点是利用时间局部性提高命中率,简单易实现;缺点是占用空间,对循环访问和随机访问场景适应性不佳。
40 0
|
23天前
|
存储 缓存 算法
面试遇到算法题:实现LRU缓存
V哥的这个实现的关键在于维护一个双向链表,它可以帮助我们快速地访问、更新和删除最近最少使用的节点,同时使用哈希表来提供快速的查找能力。这样,我们就可以在 O(1) 的时间复杂度内完成所有的缓存操作。哈哈干净利索,回答完毕。
|
7天前
|
缓存 监控 NoSQL
redis 缓存穿透 击穿 雪崩 的原因及解决方法
redis 缓存穿透 击穿 雪崩 的原因及解决方法
|
1天前
|
存储 缓存 NoSQL
了解Redis,第一弹,什么是RedisRedis主要适用于分布式系统,用来用缓存,存储数据,在内存中存储那么为什么说是分布式呢?什么叫分布式什么是单机架构微服务架构微服务的本质
了解Redis,第一弹,什么是RedisRedis主要适用于分布式系统,用来用缓存,存储数据,在内存中存储那么为什么说是分布式呢?什么叫分布式什么是单机架构微服务架构微服务的本质
|
8天前
|
存储 缓存 NoSQL
Redis 缓存失效策略及其应用场景
Redis 缓存失效策略及其应用场景
29 1
|
10天前
|
缓存 NoSQL 关系型数据库
redis(缓存)
redis(缓存)
17 0
|
12天前
|
存储 缓存 监控
利用Redis构建高性能的缓存系统
在现代Web应用中,性能优化是提升用户体验和响应速度的关键。Redis作为一款开源的内存数据结构存储系统,因其出色的性能、丰富的数据结构和灵活的使用方式,成为了构建高性能缓存系统的首选工具。本文将探讨Redis在缓存系统中的应用,分析其优势,并通过实例展示如何结合Redis构建高效、可靠的缓存系统,以应对高并发、大数据量等挑战。