kv数据库-leveldb (15) 过滤器策略 (FilterPolicy)

72 阅读8分钟

在上一章 比较器 (Comparator) 中,我们学习了如何为 LevelDB 定义一套“规则手册”,来决定键(key)的排序方式。这确保了数据库内部所有数据结构的有序性。

现在,我们来考虑一个非常常见的性能问题。当我们调用 db->Get("一个不存在的键") 时会发生什么?根据我们之前的学习,LevelDB 会:

  1. 检查内存中的 内存表 (MemTable)。
  2. 如果没找到,它会逐一检查磁盘上的一系列 排序字符串表 (SSTable) 文件。
  3. 对于每个 SSTable,它需要读取其索引块,定位到可能的数据块,甚至可能从磁盘读取一个 数据块 (Block),最终才发现这个键不存在。

这个过程,尤其是对于不存在的键的查询,可能会涉及大量的、毫无结果的磁盘 I/O 操作,而磁盘 I/O 正是数据库性能的主要瓶颈。

我们能否有一种机制,像一个门卫一样,在我们进入大楼(读取 SSTable 文件)之前,就快速地告诉我们:“你要找的人肯定不在这栋楼里!” 这样我们就可以省去搜寻整栋大楼的力气了。这个“门卫”机制,就是本章的主角——过滤器策略(FilterPolicy)。

什么是过滤器策略?

FilterPolicy 是一种用于显著减少查询时磁盘读取次数的优化机制。它为每一个 SSTable 文件额外创建一份非常紧凑的“摘要”或“索引”,我们称之为过滤器(Filter)

当你查询一个键时,LevelDB 在尝试从磁盘读取任何数据块之前,会首先咨询这个过滤器。过滤器会给出两种回答之一:

  1. “这个键绝对不存在。” (No)
  2. “这个键可能存在。” (Maybe)

这个机制最神奇的地方在于:

  • 如果过滤器说“不”,那它 100% 准确。LevelDB 就可以完全跳过对这个 SSTable 文件的读取,从而避免了一次昂贵的磁盘 I/O。
  • 如果过滤器说“可能”,那么这个键有很大概率真的存在。但偶尔它也会“误报”(这种情况我们称为“假阳性” False Positive),即它说了“可能”,但实际上键并不存在。即便如此,我们也只是多做了一次原本就要做的磁盘读取,并没有太大损失。

它就像一本词典的附录,上面列出了所有“不包含”的生僻词。查词典前先看附录,如果附录说这个词肯定没有,你就不用再翻那本厚重的词典了。

布隆过滤器 (Bloom Filter)

FilterPolicy 只是一个接口,它最常见、也是 LevelDB 内置的实现,就是布隆过滤器 (Bloom Filter)

布隆过滤器是一种空间效率极高的概率性数据结构。它的工作原理如下:

  1. 初始化:准备一个很长的位数组(bit array),所有位都初始化为 0。
  2. 添加键:当要添加一个键时,用多个不同的哈希函数对这个键进行计算,得到多个位置。然后将位数组中这些位置上的位都设置为 1。
  3. 检查键:当要检查一个键是否存在时,同样用那几个哈希函数计算出相应的位置。然后检查位数组中这些位置上的位是否全部为 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 如何实现跨平台,并对这些底层操作进行抽象呢?