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月前
|
缓存 算法 数据挖掘
深入理解缓存更新策略:从LRU到LFU
【10月更文挑战第7天】 在本文中,我们将探讨计算机系统中缓存机制的核心——缓存更新策略。缓存是提高数据检索速度的关键技术之一,无论是在硬件还是软件层面都扮演着重要角色。我们会详细介绍最常用的两种缓存算法:最近最少使用(LRU)和最少使用频率(LFU),并讨论它们的优缺点及适用场景。通过对比分析,旨在帮助读者更好地理解如何选择和实现适合自己需求的缓存策略,从而优化系统性能。
68 3
|
2月前
|
缓存 分布式计算 NoSQL
大数据-47 Redis 缓存过期 淘汰删除策略 LRU LFU 基础概念
大数据-47 Redis 缓存过期 淘汰删除策略 LRU LFU 基础概念
82 2
|
4月前
|
缓存 算法 前端开发
深入理解缓存淘汰策略:LRU和LFU算法的解析与应用
【8月更文挑战第25天】在计算机科学领域,高效管理资源对于提升系统性能至关重要。内存缓存作为一种加速数据读取的有效方法,其管理策略直接影响整体性能。本文重点介绍两种常用的缓存淘汰算法:LRU(最近最少使用)和LFU(最不经常使用)。LRU算法依据数据最近是否被访问来进行淘汰决策;而LFU算法则根据数据的访问频率做出判断。这两种算法各有特点,适用于不同的应用场景。通过深入分析这两种算法的原理、实现方式及适用场景,本文旨在帮助开发者更好地理解缓存管理机制,从而在实际应用中作出更合理的选择,有效提升系统性能和用户体验。
223 1
|
4月前
|
存储 缓存 Java
|
4月前
|
存储 缓存 算法
Python 从零开始实现一个简单的LRU缓存
Python 从零开始实现一个简单的LRU缓存
47 0
|
4天前
|
存储 缓存 NoSQL
解决Redis缓存数据类型丢失问题
解决Redis缓存数据类型丢失问题
118 85
|
2月前
|
消息中间件 缓存 NoSQL
Redis 是一个高性能的键值对存储系统,常用于缓存、消息队列和会话管理等场景。
【10月更文挑战第4天】Redis 是一个高性能的键值对存储系统,常用于缓存、消息队列和会话管理等场景。随着数据增长,有时需要将 Redis 数据导出以进行分析、备份或迁移。本文详细介绍几种导出方法:1)使用 Redis 命令与重定向;2)利用 Redis 的 RDB 和 AOF 持久化功能;3)借助第三方工具如 `redis-dump`。每种方法均附有示例代码,帮助你轻松完成数据导出任务。无论数据量大小,总有一款适合你。
80 6
|
1天前
|
缓存 监控 NoSQL
Redis经典问题:缓存穿透
本文详细探讨了分布式系统和缓存应用中的经典问题——缓存穿透。缓存穿透是指用户请求的数据在缓存和数据库中都不存在,导致大量请求直接落到数据库上,可能引发数据库崩溃或性能下降。文章介绍了几种有效的解决方案,包括接口层增加校验、缓存空值、使用布隆过滤器、优化数据库查询以及加强监控报警机制。通过这些方法,可以有效缓解缓存穿透对系统的影响,提升系统的稳定性和性能。
|
1月前
|
缓存 NoSQL 关系型数据库
大厂面试高频:如何解决Redis缓存雪崩、缓存穿透、缓存并发等5大难题
本文详解缓存雪崩、缓存穿透、缓存并发及缓存预热等问题,提供高可用解决方案,帮助你在大厂面试和实际工作中应对这些常见并发场景。关注【mikechen的互联网架构】,10年+BAT架构经验倾囊相授。
大厂面试高频:如何解决Redis缓存雪崩、缓存穿透、缓存并发等5大难题
|
1月前
|
存储 缓存 NoSQL
【赵渝强老师】基于Redis的旁路缓存架构
本文介绍了引入缓存后的系统架构,通过缓存可以提升访问性能、降低网络拥堵、减轻服务负载和增强可扩展性。文中提供了相关图片和视频讲解,并讨论了数据库读写分离、分库分表等方法来减轻数据库压力。同时,文章也指出了缓存可能带来的复杂度增加、成本提高和数据一致性问题。
【赵渝强老师】基于Redis的旁路缓存架构