kv数据库-leveldb (13) 缓存 (Cache)

在上一章 数据块 (Block) 中,我们深入到了 排序字符串表 (SSTable) 文件的内部,理解了数据是如何被组织成一个个紧凑且易于查找的小单元的。我们知道了 LevelDB 从磁盘读取的最小单位就是数据块。

但这引出了一个性能上的关键问题:磁盘 I/O 终究是缓慢的。如果我们的应用程序频繁地访问某些相同的数据,这意味着 LevelDB 需要反复地从磁盘上读取同一个数据块。这就像一位图书管理员,每次有读者想看同一本热门书的某一页时,他都要跑到遥远的书库深处去取一次。这显然效率低下。

有没有一种方法,能让管理员把这些"热点"书页放在手边的桌子上,以便下次有人需要时能立刻递过去呢?当然有!这个"手边的桌子",就是我们本章的主角------缓存 (Cache)。

什么是缓存?

Cache 是 LevelDB 中一个通用的、位于内存中的键值存储接口。它的核心使命就是用内存换取时间,通过将频繁访问的数据保存在高速的内存中,来避免对低速磁盘的重复访问,从而极大地提升读取性能。

LevelDB 中最重要的缓存应用就是块缓存 (Block Cache)。它的工作流程非常直观:

  1. 当 LevelDB 需要从某个 SSTable 文件中读取一个数据块时,它会先去块缓存里查找。
  2. 缓存命中 (Cache Hit):如果在缓存中找到了这个数据块,就直接从内存中返回,避免了任何磁盘 I/O。这就像管理员在手边的桌子上找到了书页,速度极快。
  3. 缓存未命中 (Cache Miss) :如果在缓存中没找到,LevelDB 就会从磁盘读取这个数据块。但在将数据返回给上层之前,它会顺手将这个数据块的一个副本放入缓存中。这就像管理员去书库取回了书页,并在给读者前,先在自己的桌子上放一份复印件。
  4. 这样,下次再有人需要同一个数据块时,就会发生"缓存命中"。

如何使用(配置)块缓存

与我们之前学习的很多组件不同,我们通常不会直接调用 CacheInsertLookup 方法。相反,我们是在打开数据库时,通过 选项 (Options) 来配置它。

LevelDB 提供了一个默认的缓存实现,基于 LRU (Least Recently Used,最近最少使用) 淘汰策略。你可以把它想象成一个书架,新看过的书放在最左边。当书架满了需要放新书时,就从最右边(也就是最久没碰过的那本)拿走一本。

配置块缓存非常简单。你只需要创建一个缓存对象,然后将它赋值给 options.block_cache 即可。

cpp 复制代码
#include "leveldb/db.h"
#include "leveldb/cache.h"
#include "leveldb/options.h"

int main() {
  leveldb::Options options;
  // 创建一个容量为 100MB 的 LRU 缓存
  options.block_cache = leveldb::NewLRUCache(100 * 1024 * 1024);

  leveldb::DB* db;
  leveldb::Status status = leveldb::DB::Open(options, "/tmp/testdb", &db);
  
  // ... 数据库操作 ...
  
  // LevelDB 会在内部自动管理这个缓存的生命周期
  // 当 db 被 delete 时, options.block_cache 也会被 delete
  delete db;
  return 0;
}

在这段代码中,我们通过 leveldb::NewLRUCache 创建了一个 100MB 的缓存池。之后,LevelDB 在进行所有读操作时,都会自动使用这个缓存来存放和查找数据块,我们无需再进行任何干预。默认情况下,如果不指定,LevelDB 也会创建一个 8MB 的小缓存。

Cache 内部是如何工作的?

Cache 的接口 (include/leveldb/cache.h) 是一个抽象基类,定义了缓存的基本行为。而 NewLRUCache 创建的则是一个高效的、线程安全的具体实现。这个实现有两个关键的设计:分片 (Sharding)LRU 列表

1. 分片以提升并发性能

如果整个缓存系统只有一个数据结构和一把锁来管理,那么在高并发场景下,所有线程都会争抢这把锁,导致性能瓶颈。

为了解决这个问题,LevelDB 的缓存实现 (ShardedLRUCache) 采用了一个简单的策略:分片 。它不是创建一个巨大的缓存,而是创建了 16 个(kNumShards)独立的小型 LRUCache 实例。当需要对一个键进行操作时,它会先根据键的哈希值,计算出这个键应该属于哪个分片,然后只在那个小缓存内部进行操作和加锁。

graph TD subgraph "ShardedLRUCache" A["Key: file1_block10"] -- "Hash(Key) % 16" --> B{分片 2} C["Key: file3_block5"] -- "Hash(Key) % 16" --> D{分片 11} end subgraph "16 个独立的 LRUCache 实例" Shard0["分片 0
(LRUCache)"] Shard1["分片 1
(LRUCache)"] B["分片 2
(LRUCache)"] Ellipsis[...] D["分片 11
(LRUCache)"] end style B fill:#f9f style D fill:#f9f

这就像把一个大图书馆的一个超大咨询台,换成了 16 个独立的小咨询台。读者根据自己要查的书的编号,去对应的咨询台,互不干扰,大大提高了效率。

2. LRU 缓存的实现

每个分片内部 (LRUCache) 才是真正的 LRU 逻辑实现的地方。它主要由两个数据结构组成:

  • 一个哈希表 (HandleTable) :用于实现 O(1) 时间复杂度的快速查找。键是缓存项的 key,值是指向缓存项元数据的指针。
  • 两个双向链表:用于维护所有缓存项的 LRU 顺序。

这两个链表非常巧妙,它们分别是:

  • lru_ 链表 : 存放未使用的缓存项。这些项是缓存的"候选人",它们当前没有被任何外部代码引用。链表的头部是"最新"的,尾部是"最旧"的,也就是最应该被淘汰的。
  • in_use_ 链表 : 存放正在使用 的缓存项。当一个客户端通过 Lookup 找到了一个缓存项并持有它的句柄(Handle)时,这个项就会被移到 in_use_ 链表中。只要它在这个链表里,就绝对不会被淘汰,即使它是最"旧"的。

缓存项的生命周期

让我们通过一个序列图来理解一个缓存项(LRUHandle)是如何在这两个链表之间移动的。

sequenceDiagram participant Client as 客户端代码 participant Cache as LRUCache participant InUseList as in_use_ 链表 participant LruList as lru_ 链表 Note over Cache: 缓存项 E 当前在 lru_ 链表中 Client->>Cache: Lookup("key_E") Cache->>LruList: 从 lru_ 中移除 E LruList-->>Cache: ok Cache->>InUseList: 将 E 加入 in_use_ InUseList-->>Cache: ok Cache-->>Client: 返回 E 的句柄 (Handle) Note over Client: 客户端使用 E... Client->>Cache: Release(Handle_E) Note right of Cache: E 的引用计数减 1,
发现不再被外部使用 Cache->>InUseList: 从 in_use_ 中移除 E InUseList-->>Cache: ok Cache->>LruList: 将 E 加入 lru_ 链表的头部
(标记为最新) LruList-->>Cache: ok

这个过程的核心是引用计数 。每个缓存项都有一个引用计数器 refs

  • 当缓存自身持有一个项时,refs 至少为 1。
  • 当客户端通过 Lookup 获取一个句柄时,refs 会加 1。
  • 当客户端调用 Release 释放句柄时,refs 会减 1。
  • 只有当 refs 降为 1 时(意味着只有缓存自己引用它了),它才会从 in_use_ 链表移回 lru_ 链表,成为可被淘汰的候选者。
  • 当缓存需要空间时,它会从 lru_ 链表的尾部移除一项,并最终释放其内存。

深入代码实现

让我们看看 util/cache.cc 中的一些关键代码片段。

ShardedLRUCache 的分片逻辑

ShardedLRUCacheInsert 方法清晰地展示了分片策略。

cpp 复制代码
// 来自 util/cache.cc
Handle* ShardedLRUCache::Insert(const Slice& key, void* value, size_t charge,
                               void (*deleter)(const Slice& key, void* value)) {
  // 1. 计算 key 的哈希值
  const uint32_t hash = HashSlice(key);
  // 2. 根据哈希值的高位选择一个分片,然后调用该分片的 Insert 方法
  return shard_[Shard(hash)].Insert(key, hash, value, charge, deleter);
}

// 帮助函数,用于选择分片
static uint32_t Shard(uint32_t hash) {
  return hash >> (32 - kNumShardBits); // kNumShardBits 是 4
}

这个实现非常简单:计算哈希,然后通过位移操作选取 16 个分片中的一个,并将工作完全委托给它。LookupErase 等其他方法也遵循同样的模式。

LRUCache 的核心数据结构

LRUCache 类的定义中,我们可以看到我们之前讨论的哈希表和两个链表头。

cpp 复制代码
// 来自 util/cache.cc (简化后)
class LRUCache {
 private:
  // ...
  // mutex_ 保护以下状态
  mutable port::Mutex mutex_;
  size_t usage_ GUARDED_BY(mutex_);

  // LRU 链表的哑元头节点
  // lru.prev 是最新项, lru.next 是最旧项
  LRUHandle lru_ GUARDED_BY(mutex_);

  // in-use 链表的哑元头节点
  LRUHandle in_use_ GUARDED_BY(mutex_);

  HandleTable table_ GUARDED_BY(mutex_);
};

这里的 lru_in_use_ 是哑元节点(dummy node),它们本身不存储数据,但它们的 nextprev 指针分别指向链表的第一个和最后一个真实元素,这让链表操作(如插入和删除)变得更简单。

RefUnref 的逻辑

RefUnref 方法是控制缓存项在两个链表之间移动的核心。

cpp 复制代码
// 来自 util/cache.cc (简化后)
void LRUCache::Ref(LRUHandle* e) {
  // 如果它在 lru_ 链表上 (refs==1),就将它移动到 in_use_ 链表
  if (e->refs == 1 && e->in_cache) {
    LRU_Remove(e);
    LRU_Append(&in_use_, e);
  }
  e->refs++;
}

void LRUCache::Unref(LRUHandle* e) {
  e->refs--;
  if (e->refs == 0) {
    // 引用归零,释放内存
    (*e->deleter)(e->key(), e->value);
    free(e);
  } else if (e->in_cache && e->refs == 1) {
    // 不再被外部使用,移回 lru_ 链表
    LRU_Remove(e);
    LRU_Append(&lru_, e);
  }
}

这段代码完美地诠释了基于引用计数的链表迁移逻辑。当一个 lru_ 链表中的项被 Lookup 时,它的 refs 从 1 变为 2,于是 Ref 函数将它移入 in_use_ 链表。当它被 Release 时,refs 从 2 降为 1,于是 Unref 函数又将它移回 lru_ 链表。

总结

在本章中,我们学习了 LevelDB 的性能加速器------Cache

  • Cache 的主要作用是缓存数据块 (Block Cache),通过将热点数据保留在内存中,来减少昂贵的磁盘读取操作。
  • LevelDB 提供了一个默认的、基于 LRU(最近最少使用) 策略的缓存实现。
  • 为了提高并发性能,该实现采用了分片 (ShardedLRUCache) 技术,将负载分散到多个独立的小缓存中。
  • 每个小缓存 (LRUCache) 内部通过哈希表 实现快速查找,并通过两个双向链表 (in_use_lru_)引用计数来精确管理缓存项的生命周期和淘汰顺序。

我们现在已经了解了 LevelDB 是如何组织、存储和加速数据访问的。但还有一个基础问题没有解决:LevelDB 一直在对键进行"排序",无论是 内存表 (MemTable) 中的跳表,还是 排序字符串表 (SSTable) 中的数据块,都依赖于键的有序性。那么,"顺序"到底是如何定义的呢?默认情况下,LevelDB 按字节顺序比较键,但这能满足所有需求吗?如果我们的键是数字或者有特殊的比较规则怎么办?

相关推荐
重启的码农2 小时前
kv数据库-leveldb (12) 数据块 (Block)
数据库
lypzcgf2 小时前
Coze源码分析-资源库-创建数据库-后端源码-应用/领域/数据访问层
数据库·go·后台·coze·coze源码分析·ai应用平台·agent平台
枫叶丹42 小时前
金仓数据库替代MongoDB:电子证照系统国产化改造实战
数据库·mongodb
麦兜*2 小时前
Redis 7.0 新特性深度解读:迈向生产级的新纪元
java·数据库·spring boot·redis·spring·spring cloud·缓存
可涵不会debug3 小时前
金仓数据库:破解电子证照国产化难题,开启政务效能新篇
数据库·政务
元闰子3 小时前
对 Agent-First 数据库的畅想
数据库·后端·aigc
java水泥工3 小时前
学科竞赛管理系统|基于SpringBoot和Vue的学科竞赛管理系统(源码+数据库+文档)
数据库·vue.js·spring boot
kobe_OKOK_4 小时前
django 数据库迁移
数据库·oracle·django
寻星探路4 小时前
数据库造神计划第二十一天---JDBC编程
数据库·oracle