在上一章 比较器 (Comparator) 中,我们学习了如何为 LevelDB 定义一套“规则手册”,来决定键(key)的排序方式。这确保了数据库内部所有数据结构的有序性。
现在,我们来考虑一个非常常见的性能问题。当我们调用 db->Get("一个不存在的键") 时会发生什么?根据我们之前的学习,LevelDB 会:
- 检查内存中的 内存表 (MemTable)。
- 如果没找到,它会逐一检查磁盘上的一系列 排序字符串表 (SSTable) 文件。
- 对于每个
SSTable,它需要读取其索引块,定位到可能的数据块,甚至可能从磁盘读取一个 数据块 (Block),最终才发现这个键不存在。
这个过程,尤其是对于不存在的键的查询,可能会涉及大量的、毫无结果的磁盘 I/O 操作,而磁盘 I/O 正是数据库性能的主要瓶颈。
我们能否有一种机制,像一个门卫一样,在我们进入大楼(读取 SSTable 文件)之前,就快速地告诉我们:“你要找的人肯定不在这栋楼里!” 这样我们就可以省去搜寻整栋大楼的力气了。这个“门卫”机制,就是本章的主角——过滤器策略(FilterPolicy)。
什么是过滤器策略?
FilterPolicy 是一种用于显著减少查询时磁盘读取次数的优化机制。它为每一个 SSTable 文件额外创建一份非常紧凑的“摘要”或“索引”,我们称之为过滤器(Filter)。
当你查询一个键时,LevelDB 在尝试从磁盘读取任何数据块之前,会首先咨询这个过滤器。过滤器会给出两种回答之一:
- “这个键绝对不存在。” (No)
- “这个键可能存在。” (Maybe)
这个机制最神奇的地方在于:
- 如果过滤器说“不”,那它 100% 准确。LevelDB 就可以完全跳过对这个
SSTable文件的读取,从而避免了一次昂贵的磁盘 I/O。 - 如果过滤器说“可能”,那么这个键有很大概率真的存在。但偶尔它也会“误报”(这种情况我们称为“假阳性” False Positive),即它说了“可能”,但实际上键并不存在。即便如此,我们也只是多做了一次原本就要做的磁盘读取,并没有太大损失。
它就像一本词典的附录,上面列出了所有“不包含”的生僻词。查词典前先看附录,如果附录说这个词肯定没有,你就不用再翻那本厚重的词典了。
布隆过滤器 (Bloom Filter)
FilterPolicy 只是一个接口,它最常见、也是 LevelDB 内置的实现,就是布隆过滤器 (Bloom Filter)。
布隆过滤器是一种空间效率极高的概率性数据结构。它的工作原理如下:
- 初始化:准备一个很长的位数组(bit array),所有位都初始化为 0。
- 添加键:当要添加一个键时,用多个不同的哈希函数对这个键进行计算,得到多个位置。然后将位数组中这些位置上的位都设置为 1。
- 检查键:当要检查一个键是否存在时,同样用那几个哈希函数计算出相应的位置。然后检查位数组中这些位置上的位是否全部为 1。
- 如果有任何一个位是 0,那么这个键绝对没有被添加过。
- 如果所有位都是 1,那么这个键很可能被添加过。
graph TD
subgraph "添加 'hello'"
direction LR
A["hello"] -- "Hash1" --> P1(位置 2)
A -- "Hash2" --> P2(位置 5)
A -- "Hash3" --> P3(位置 9)
end
subgraph "位数组"
direction LR
B0[0] --> B1[0] --> B2[1] --> B3[0] --> B4[0] --> B5[1] --> B6[0] --> B7[0] --> B8[0] --> B9[1] --> B10[0]
end
subgraph "检查 'world'"
direction LR
C["world"] -- "Hash1" --> Q1(位置 3)
C -- "Hash2" --> Q2(位置 5)
C -- "Hash3" --> Q3(位置 8)
end
subgraph "检查结果"
D{检查位 3, 5, 8} --> E{位 3 是 0<br/>所以 'world' 绝对不存在}
end
style B2 fill:#f9f
style B5 fill:#f9f
style B9 fill:#f9f
如何使用过滤器策略
启用过滤器策略非常简单,只需在打开数据库时,通过 选项 (Options) 进行配置即可。LevelDB 提供了一个方便的工厂函数 NewBloomFilterPolicy。
#include "leveldb/db.h"
#include "leveldb/filter_policy.h"
#include "leveldb/options.h"
int main() {
leveldb::Options options;
options.create_if_missing = true;
// 关键配置:创建一个布隆过滤器策略
// 参数 10 表示每个键大约使用 10 个比特位,
// 这能提供大约 1% 的假阳性率,是一个很好的默认值。
options.filter_policy = leveldb::NewBloomFilterPolicy(10);
leveldb::DB* db;
// 用这个配置好的 options 打开数据库
leveldb::Status status = leveldb::DB::Open(options, "/tmp/testdb_with_filter", &db);
// ... 之后所有的 SSTable 文件都会内建一个布隆过滤器 ...
// LevelDB 会自动管理 filter_policy 的生命周期
// 当 db 被 delete 时, options.filter_policy 也会被 delete
delete db;
return 0;
}
只需要添加一行 options.filter_policy = ...,你就为数据库开启了一个强大的性能优化。之后的一切都是自动的,LevelDB 在创建 SSTable 时会自动构建过滤器,在查询时会自动使用它。
过滤器策略的内部工作流程
FilterPolicy 的工作主要分为两个阶段:创建过滤器 和 使用过滤器。
1. 创建过滤器 (CreateFilter)
当 LevelDB 将一个 内存表 (MemTable) 的内容写入一个新的 排序字符串表 (SSTable) 文件时,它会收集这个 SSTable 中所有的键,然后调用 filter_policy->CreateFilter()。这个方法会根据所有这些键,生成一块二进制数据(也就是布隆过滤器的位数组),这块数据最终会被存放在 SSTable 文件的一个特殊块——过滤器块 (Filter Block) 中。
2. 使用过滤器 (KeyMayMatch)
当一个 Get 请求需要在某个 SSTable 中查找数据时,会发生以下流程:
sequenceDiagram
participant App as 你的应用
participant DB as DB 实例
participant TableCache as Table 缓存
participant Filter as 过滤器块 (内存中)
participant Disk as SSTable 文件 (磁盘)
App ->> DB: Get("my_key")
Note over DB: 在 MemTable 未找到, 开始查 SSTable
DB ->> TableCache: 查找文件 F 的 Table 对象
TableCache ->> Filter: 1. KeyMayMatch("my_key")?
alt 过滤器说 "肯定不存在"
Filter -->> TableCache: 返回 false
TableCache -->> DB: 未找到,跳过此文件
else 过滤器说 "可能存在"
Filter -->> TableCache: 返回 true
Note right of TableCache: 继续执行正常的查找流程
TableCache ->> Disk: 2. 读取索引块和数据块
Disk -->> TableCache: ...
TableCache -->> DB: ...
end
这个流程的关键在于,第一步 KeyMayMatch 的检查速度极快,因为它操作的是内存中已经加载的过滤器数据。只有在它返回 true 时,才会触发第二步昂贵的磁盘读取。
深入代码实现
让我们看看 FilterPolicy 背后的一些关键源码。
FilterPolicy 接口 (include/leveldb/filter_policy.h)
这是 FilterPolicy 的抽象基类定义,它规定了所有过滤器策略都必须实现的三个核心方法。
// 来自 include/leveldb/filter_policy.h
class LEVELDB_EXPORT FilterPolicy {
public:
virtual ~FilterPolicy();
// 策略的名称
virtual const char* Name() const = 0;
// 根据一组 keys[0,n-1] 创建一个过滤器,并追加到 *dst 中
virtual void CreateFilter(const Slice* keys, int n,
std::string* dst) const = 0;
// 检查 key 是否可能匹配 filter
virtual bool KeyMayMatch(const Slice& key, const Slice& filter) const = 0;
};
我们自己的实现需要继承这个类,并提供这三个方法的具体逻辑。
BloomFilterPolicy::CreateFilter (util/bloom.cc)
这是 LevelDB 内置的布隆过滤器实现中,用于创建过滤器的部分。
// 来自 util/bloom.cc (简化逻辑)
void BloomFilterPolicy::CreateFilter(const Slice* keys, int n,
std::string* dst) const {
// 1. 计算布隆过滤器需要的总比特数和字节数
size_t bits = n * bits_per_key_;
if (bits < 64) bits = 64;
size_t bytes = (bits + 7) / 8;
bits = bytes * 8;
// 2. 准备好存储位数组的内存
const size_t init_size = dst->size();
dst->resize(init_size + bytes, 0);
dst->push_back(static_cast<char>(k_)); // k_ 是哈希函数的数量
char* array = &(*dst)[init_size];
// 3. 对每一个 key 进行哈希,并设置相应的位
for (int i = 0; i < n; i++) {
uint32_t h = BloomHash(keys[i]);
const uint32_t delta = (h >> 17) | (h << 15); // 双重哈希的技巧
for (size_t j = 0; j < k_; j++) {
const uint32_t bitpos = h % bits;
array[bitpos / 8] |= (1 << (bitpos % 8)); // 设置位
h += delta;
}
}
}
这段代码完整地实现了布隆过滤器的添加逻辑:计算大小,分配空间,然后对每个键进行多次哈希(通过 delta 增量实现)并设置相应的位。
BloomFilterPolicy::KeyMayMatch (util/bloom.cc)
这是布隆过滤器的检查逻辑,它与创建过程相对应。
// 来自 util/bloom.cc (简化逻辑)
bool BloomFilterPolicy::KeyMayMatch(const Slice& key,
const Slice& bloom_filter) const {
// ... 解析出位数组 array, 位数 bits, 和哈希函数数量 k ...
uint32_t h = BloomHash(key);
const uint32_t delta = (h >> 17) | (h << 15);
for (size_t j = 0; j < k_; j++) {
const uint32_t bitpos = h % bits;
// 检查相应的位是否为 0
if ((array[bitpos / 8] & (1 << (bitpos % 8))) == 0) {
// 只要有一个位是 0,就说明 key 绝对不存在
return false;
}
h += delta;
}
// 所有位都是 1,说明 key 可能存在
return true;
}
这个函数的逻辑非常高效。它只是进行几次哈希计算和位运算,如果能提前返回 false,就为数据库节省了一次昂贵的磁盘访问。
总结
在本章中,我们学习了 LevelDB 中一个重要的性能优化工具——FilterPolicy。
FilterPolicy旨在通过一个快速的内存检查,来避免对不存在的键进行不必要的磁盘读取。- 它最常见的实现是布隆过滤器,一种空间效率极高的概率性数据结构。
- 布隆过滤器的特点是:它可能误报“存在”(假阳性),但绝不会误报“不存在”(无假阴性)。
- 通过在
Options中设置filter_policy,我们可以轻松地为数据库启用这项优化,从而显著提升Get操作的性能,特别是对于那些“缓存未命中”的查询。
至此,我们已经探索了 LevelDB 的绝大部分核心组件,从用户接口到底层数据结构,再到性能优化策略。但所有这些组件的运行,都离不开一个基础平台的支持,这个平台需要能处理文件 I/O、线程、定时器等与操作系统交互的底层任务。LevelDB 如何实现跨平台,并对这些底层操作进行抽象呢?