在上一章 数据块 (Block) 中,我们深入到了 排序字符串表 (SSTable) 文件的内部,理解了数据是如何被组织成一个个紧凑且易于查找的小单元的。我们知道了 LevelDB 从磁盘读取的最小单位就是数据块。
但这引出了一个性能上的关键问题:磁盘 I/O 终究是缓慢的。如果我们的应用程序频繁地访问某些相同的数据,这意味着 LevelDB 需要反复地从磁盘上读取同一个数据块。这就像一位图书管理员,每次有读者想看同一本热门书的某一页时,他都要跑到遥远的书库深处去取一次。这显然效率低下。
有没有一种方法,能让管理员把这些"热点"书页放在手边的桌子上,以便下次有人需要时能立刻递过去呢?当然有!这个"手边的桌子",就是我们本章的主角------缓存 (Cache)。
什么是缓存?
Cache
是 LevelDB 中一个通用的、位于内存中的键值存储接口。它的核心使命就是用内存换取时间,通过将频繁访问的数据保存在高速的内存中,来避免对低速磁盘的重复访问,从而极大地提升读取性能。
LevelDB 中最重要的缓存应用就是块缓存 (Block Cache)。它的工作流程非常直观:
- 当 LevelDB 需要从某个
SSTable
文件中读取一个数据块时,它会先去块缓存里查找。 - 缓存命中 (Cache Hit):如果在缓存中找到了这个数据块,就直接从内存中返回,避免了任何磁盘 I/O。这就像管理员在手边的桌子上找到了书页,速度极快。
- 缓存未命中 (Cache Miss) :如果在缓存中没找到,LevelDB 就会从磁盘读取这个数据块。但在将数据返回给上层之前,它会顺手将这个数据块的一个副本放入缓存中。这就像管理员去书库取回了书页,并在给读者前,先在自己的桌子上放一份复印件。
- 这样,下次再有人需要同一个数据块时,就会发生"缓存命中"。
如何使用(配置)块缓存
与我们之前学习的很多组件不同,我们通常不会直接调用 Cache
的 Insert
或 Lookup
方法。相反,我们是在打开数据库时,通过 选项 (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
实例。当需要对一个键进行操作时,它会先根据键的哈希值,计算出这个键应该属于哪个分片,然后只在那个小缓存内部进行操作和加锁。
(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
)是如何在这两个链表之间移动的。
发现不再被外部使用 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
的分片逻辑
ShardedLRUCache
的 Insert
方法清晰地展示了分片策略。
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 个分片中的一个,并将工作完全委托给它。Lookup
、Erase
等其他方法也遵循同样的模式。
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),它们本身不存储数据,但它们的 next
和 prev
指针分别指向链表的第一个和最后一个真实元素,这让链表操作(如插入和删除)变得更简单。
Ref
和 Unref
的逻辑
Ref
和 Unref
方法是控制缓存项在两个链表之间移动的核心。
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 按字节顺序比较键,但这能满足所有需求吗?如果我们的键是数字或者有特殊的比较规则怎么办?