RocksDB Partitioned Index/Filters 分区索引与过滤器:两级索引架构下的内存与 IO 优化实战
2026/9/20 8:01:44 网站建设 项目流程

RocksDB Partitioned Index/Filters 分区索引与过滤器:两级索引架构下的内存与 IO 优化实战

【免费下载链接】rocksdbA library that provides an embeddable, persistent key-value store for fast storage.项目地址: https://gitcode.com/gh_mirrors/ro/rocksdb

导读

随着数据库容量相对内存越来越大(DB/mem 比率不断上升),SST 文件中索引块(index block)与过滤器块(filter block)的内存占用变得不可忽视。本文围绕 RocksDB 官方博客《Partitioned Index/Filters》展开,系统讲解为什么大尺寸的 index/filter 会成为性能瓶颈、分区(Partitioned)机制如何通过"两级索引 + 按需加载分区"化解这一难题,并结合当前仓库源码给出完整的配置参数、实现原理与验证方法。读完本文,你将掌握kTwoLevelIndexSearchpartition_filtersmetadata_block_size等核心选项的正确用法,能够为内存受限场景设计出可落地的优化方案。

问题背景:一个 SST 文件里藏着多大的索引与过滤器?

RocksDB 默认采用 Block-based Table 格式,每个 SST 文件都包含若干数据块(data block),以及用于定位这些数据块的索引块和用于加速点查的过滤器块。在原文档的描述中,一个典型的配置是:256MB 的 SST 文件,索引块约 0.5MB、过滤器块约 5MB,而单个数据块的典型大小只有4–32KB。也就是说,元数据块的尺寸往往是数据块的数十到上千倍。

这一比例在源码的默认值中也能得到印证:include/rocksdb/table.hBlockBasedTableOptions::block_size的默认值是4 * 1024(4KB),而metadata_block_size的默认值是4096(4KB),它正是分区元数据的目标块大小(见 include/rocksdb/table.h 中metadata_block_size的注释:Target block size for partitioned metadata,应用于kTwoLevelIndexSearch的索引与partition_filters的过滤器)。

原文档指出:当所有 index/filter 都能完整放进内存时,每个 SST 生命周期内它们只需读取一次,此时大尺寸无伤大雅;可一旦它们需要与数据块竞争 block cache 空间、并可能多次从磁盘重读,问题就会迅速放大。

大尺寸 index/filter 的两大核心问题

原文档将问题概括为两个层面,它们本质上是同一个矛盾——"稀缺的 block cache"与"巨大的元数据块"之间的冲突。

1. 与数据块争抢 block cache 空间,抬高数据块缓存未命中率

cache_index_and_filter_blocks开启、index/filter 被放入 block cache 时,它们实际上与数据块(以及彼此)争夺这块稀缺资源。一个 5MB 的过滤器占据的空间,本可以缓存数千个 4KB 的数据块——这直接导致数据块缓存未命中率上升。与此同时,巨大的 index/filter 之间也更容易互相挤出缓存,进一步恶化元数据自身的高缓存未命中率。

更关键的一点是:一个 index/filter 块在缓存中存活的整个生命周期里,真正被访问到的往往只是其中一小部分。为一个点查(point lookup)加载整块 5MB 过滤器,绝大多数字节是无用功。这正是"按需加载分区"这一设计思路的原始动机。

2. 缓存未命中后从磁盘重读,放大磁盘 IO 开销

一旦 index/filter 发生缓存未命中,就必须从磁盘重新读取,而其巨大的尺寸使 IO 成本居高不下。原文档给出了一个直观的对比:一次简单的点查最多只需要从 LSM 的每一层各读取一个数据块(4KB 量级),但在此之前它可能已经加载了数 MB 的 index/filter 块。如果这种情况频繁发生,磁盘的大量带宽将被元数据吞噬,而不是服务于真正需要的数据块。

什么是分区(Partitioned)Index/Filter:两级索引架构

针对上述问题,RocksDB 在 5.14 版本引入了分区索引与分区过滤器(本文档发布于 2017 年 5 月,即该特性的官方介绍文章)。其核心思想可用一句话概括:把一个大元数据块切成多个小分区(partition),再为这些分区构建一个体积很小的顶层索引(top-level index)

具体工作流程如下:

  1. 写入阶段:SST 构建时,索引/过滤器不再作为一个整体块写出,而是被切分成多个尺寸接近metadata_block_size的小分区块,每个分区对应一段 key 区间;
  2. 读取阶段:读取 index/filter 时,首先只把顶层索引载入内存。顶层索引记录每个分区的边界 key 与块句柄(BlockHandle);
  3. 按需加载:查询时先用顶层索引做一次二分查找,定位到所需的分区,再将该分区按需加载进 block cache,而不是一次读入整块元数据;
  4. 驻留位置可调:顶层索引内存占用极小,根据cache_index_and_filter_blocks的设置,可以存放在堆上(table reader 内)或 block cache 中(见 docs/_posts/2017-05-12-partitioned-index-filter.markdown 原文描述)。

这套"顶层索引 + 分区 + 按需加载"的架构在源码中体现得非常清晰:include/rocksdb/table.hIndexType枚举中,kTwoLevelIndexSearch = 0x02的注释明确写道:"A two-level index implementation. Both levels are binary search indexes. Second level index blocks ('partitions') use block cache even when cache_index_and_filter_blocks=false."(两级索引实现,两级均为二分查找索引;第二级索引块即"分区"即使在cache_index_and_filter_blocks=false时也使用 block cache)。同理,partition_filters选项的注释也强调"Filter partition blocks use block cache even when cache_index_and_filter_blocks=false"。

配置指南:从零开启分区索引与过滤器

前置条件与依赖关系

include/rocksdb/table.h的注释可以确认以下依赖关系:

  • 分区过滤器依赖分区索引partition_filters的注释明确写着"currently this option requires kTwoLevelIndexSearch to be set as well",即开启分区过滤器必须先开启两级索引;
  • 分区过滤器与 block-based filter 不兼容partition_filters只适用于 full filter(如NewBloomFilterPolicy生成的 Bloom/Ribbon 过滤器),partition_filters注释指出该选项 incompatible with block-based filters;
  • 分区块无条件走 block cache:无论cache_index_and_filter_blocks取值如何,索引/过滤器分区块总是使用 block cache(只有顶层索引的位置受该选项控制)。

C++ API 配置示例

以下配置开启两级索引与分区过滤器:

#include "rocksdb/table.h" #include "rocksdb/filter_policy.h" rocksdb::BlockBasedTableOptions table_options; // 1. 开启两级索引:这是分区机制的基础 table_options.index_type = rocksdb::BlockBasedTableOptions::IndexType::kTwoLevelIndexSearch; // 2. 开启分区过滤器(full filter 专用) table_options.filter_policy.reset(rocksdb::NewBloomFilterPolicy(10, false)); table_options.partition_filters = true; // 3. 元数据分区目标大小(默认 4096 字节) table_options.metadata_block_size = 4096; // 4. 按需控制顶层索引的驻留位置与固定行为 // 顶层索引/过滤器块默认置入 cache 并 pin 住(pin_top_level_index_and_filter 默认 true) table_options.cache_index_and_filter_blocks = true; rocksdb::Options options; options.table_factory.reset(rocksdb::NewBlockBasedTableFactory(table_options));

关键参数一览表

以下参数均定义于 include/rocksdb/table.h 的BlockBasedTableOptions

参数默认值作用与说明
index_typekBinarySearch索引结构类型;设为kTwoLevelIndexSearch启用两级(分区)索引,两级均使用二分查找
partition_filtersfalse为每个 SST 文件使用分区 full filter;需要同时开启kTwoLevelIndexSearch,与 block-based filter 不兼容
metadata_block_size4096分区元数据(分区索引、分区过滤器)的目标块大小
decouple_partitioned_filterstrue分区索引与分区过滤器使用相互独立的切分边界,使两者都更精确地命中目标尺寸,降低 block cache 中的碎片与元数据开销;false时两者共享切分边界
cache_index_and_filter_blocksfalse为 false 时 table reader 在初始化阶段预加载 index/filter;分区块始终使用 block cache,本选项只影响顶层索引的位置(堆 vs block cache)
cache_index_and_filter_blocks_with_high_prioritytrue将 index/filter 等元数据块以高优先级放入 block cache,降低其相对数据块被淘汰的概率
pin_top_level_index_and_filtertrue顶层索引/过滤器块存入 cache 的同时在 table reader 中持有引用并 pin 住,仅在 table reader 释放时淘汰(不限于 L0)
pin_l0_filter_and_index_blocks_in_cachefalse已废弃的 L0 固定选项;现由MetadataCacheOptionspartition_pinning/unpartitioned_pinning取代
optimize_filters_for_memorytrue生成过滤器时优化内存内部碎片(需format_version >= 5且支持malloc_usable_size);实测在 Jemalloc 下可节省约 10% 过滤器内存占用

关于 pinning 行为的更细粒度控制,源码提供了MetadataCacheOptions结构(同文件),其中top_level_index_pinningpartition_pinningunpartitioned_pinning三个PinningTier字段分别控制"顶层索引"、"分区块"、"未分区元数据块"三个层级的固定策略,PinningTier支持kFallbackkNonekFlushedAndSimilarkAll四档(kFlushedAndSimilar指来自 memtable flush 且体积小于 1.5 倍write_buffer_size的 L0 文件)。

OPTIONS 文件配置

分区相关选项同样可以在 OPTIONS 文件中以字符串形式配置,仓库中的 examples/rocksdb_option_file_example.ini 展示了block_based_table_factory的配置范式(其中index_type=kBinarySearch为默认值)。对应地可写为:

[DBOptions] ... [TableOptions/BlockBasedTable] index_type=kTwoLevelIndexSearch partition_filters=true metadata_block_size=4096 cache_index_and_filter_blocks=true

动态调整

依据table.h的说明,除no_block_cache等少数例外,这些选项大多支持通过SetOptions动态调整(只对新构建的 SST 生效),例如:

db->SetOptions({{"block_based_table_factory", "{index_type=kTwoLevelIndexSearch;partition_filters=true;}"}});

需要说明的是,index_type属于写路径选项(只影响新文件构建),而 pin 类选项属于读路径选项,表读取器(table reader)可能存活到 SST 文件本身的生命周期结束,因此读路径选项的生效存在"仅对新打开的文件"的时间窗口。

源码级实现剖析:从构建到查询的完整链路

分区机制在仓库中有完整的实现,相关文件集中在table/block_based/目录:

  • table/block_based/partitioned_index_reader.h:PartitionIndexReader,继承自BlockBasedTable::IndexReaderCommon,负责在两级索引结构中进行二分查找。其NewIterator返回一个"两级迭代器:第一级在分区索引上";内部维护partition_map_用于缓存已 pin 的分区块;
  • table/block_based/partitioned_index_iterator.h:PartitionedIndexIterator,封装对分区的遍历。它持有第一级index_iter_(在顶层索引上迭代)与block_iter_(在具体分区内迭代),通过Seek先定位顶层索引条目,再按需加载对应分区块,并维护prev_block_offset_避免重复拉取同一个数据块;
  • table/block_based/partitioned_filter_block.h:PartitionedFilterBlockBuilder(写路径)与PartitionedFilterBlockReader(读路径)。Builder 继承自FullFilterBlockBuilder,内部维护一个"过滤器分区的索引"(index_on_filter_block_builder_),并通过keys_per_partition_控制每个分区的键数量;Reader 通过KeyMayMatch/PrefixMayMatch(以及 MultiGet 批量版本)完成查询,先SeekFilterPartitionHandle定位分区句柄,再GetFilterPartitionBlock按需加载分区。

从这些实现可以推断出读取路径的完整调用链:

点查/范围查询 → 顶层索引二分查找(PartitionIndexReader::NewIterator) → 定位目标分区句柄(BlockHandle) → 按需加载分区块到 block cache(PartitionedFilterBlockReader::GetFilterPartitionBlock) → 在分区内完成索引二分或过滤器判存(KeyMayMatch)

值得注意的设计细节(来自源码注释):

  • 分区分块策略PartitionedFilterBlockBuilderDecideCutAFilterBlock()决定何时切开一个新的过滤器分区;metadata_block_size直接参与目标尺寸计算(见 table/block_based/partitioned_filter_block_test.cc 中对table_options_.metadata_block_size的大量构造与断言);
  • 索引与过滤器分区对齐的历史设计:注释说明"目前索引与过滤器保持相同数量的分区,以便将来优化;如果该优化未实现,则可改用不同分区数"——这正是后来decouple_partitioned_filters(默认 true)引入独立切分边界的原因,它使两类元数据都能更精确地命中目标尺寸,减少 block cache 的碎片并让淘汰策略对待各块更公平;
  • 并行压缩的线程安全UpdateFilterSizeEstimate等路径在并行压缩场景下可能被后台工作线程调用,因此相关的分区尺寸统计使用原子类型(RelaxedAtomic)。

如何验证分区生效:测试与观测手段

单元测试

仓库中的 table/block_based/partitioned_filter_block_test.cc 是验证分区过滤器行为最直接的测试,它组合使用NewBloomFilterPolicymetadata_block_size参数,覆盖了不同目标分区尺寸下的构建与读取路径(包括将metadata_block_size设为 1 等极端值来强制大量分区)。运行方式(在仓库根目录编译后):

./partitioned_filter_block_test

此外table/block_based/partitioned_index_iterator.cc对应的两级索引迭代器也有专门的测试覆盖(PartitionedIndexIteratorTest),可验证 Seek/Next/Prev 在分区边界上的正确性。

运行期观测

  • block cache 统计:开启partition_filters后,过滤器分区以CacheEntryRole::kFilterPartition(过滤器分区)、索引分区以kIndexPartition的角色计入 block cache 统计,可通过db->GetProperty("rocksdb.block-cache-entry-stats")观察各角色的占用与命中情况,确认元数据不再以整块大对象形式驻留;
  • sst_dump 检查布局:使用 tools/sst_dump.cc 提供的sst_dump --file=xxx.sst可以查看 SST 内部 block 布局,观察索引/过滤器是否被切分为多个小分区块并附带顶层索引。

适用场景与注意事项

什么时候该用分区 Index/Filter

根据原文档的问题分析与源码选项语义,分区机制在以下场景收益最大:

  1. DB/mem 比率很高:数据总量远超 block cache 容量,元数据必须与数据竞争缓存空间;
  2. 元数据块尺寸与数据块尺寸差距悬殊:如大 SST(256MB 甚至更大)配合较大 bits-per-key 的过滤器;
  3. 内存非常受限的部署:如原文档中"100TB 数据配 60MB block cache"的极端场景。

需要权衡的代价

  • 分区索引/过滤器本身增加了一层顶层索引查找,查询路径多一次间接寻址;但由于第二级分区普遍远小于整块元数据,点查的实际 IO 往往显著下降;
  • partition_filterskTwoLevelIndexSearch强绑定,若当前索引形态是 hash index(kHashSearch)或依赖kBinarySearchWithFirstKey,需要评估切换成本;
  • 分区过滤器只适用于 full filter,block-based filter 无法使用;
  • 旧版本 SST 若以整块元数据格式写出,新配置不会重写已有文件,需要依赖 compaction 逐步重写才能全面生效(配置只影响新构建的 SST)。

实测数据参考(原文档案例)

原文档提供了两组官方测试数据,用以说明分区的实际收益。需要说明,这两组数据来自 2017 年该特性发布时的测试环境,具体数值会随 RocksDB 版本、硬件与配置变化,此处仅作为理解量级与收益方向的参考:

场景一:HDD + 100TB 级数据库

  • 环境:86GB 数据库部署在 HDD 上,通过 direct IO(绕过 OS 文件缓存)+ 仅 60MB 的极小 block cache 来模拟"100TB 数据对应的小内存"场景;
  • 结果:分区后吞吐从5 op/s 提升到 55 op/s,提升约 11 倍

场景二:SSD + Linkbench 工作负载

  • 环境:300GB 数据库部署在 SSD 上,同样使用 direct IO,block cache 分别设为 6GB 与 2GB,模拟同一节点上存在多个 DB 时内存被压缩的情况;
  • 结果:不分区时,block cache 从 6GB 降到 2GB,Linkbench 吞吐从 38k tps 跌至 23k tps;启用分区后,同样缩容只跌至30k tps——在内存减半的情况下保留了约 79% 的吞吐。

两组数据的共同结论是:当元数据成为缓存与 IO 瓶颈时,分区机制用很小的顶层索引开销换来了大幅的吞吐改善,尤其适合内存与磁盘资源严重不平衡的部署形态。

总结

Partitioned Index/Filters 是 RocksDB 面向"大数据量、小内存"场景的关键优化:它把每个 SST 中动辄数 MB 的整块索引/过滤器切分为多个 4KB 量级的小分区,通过两级索引按需加载,从根源上缓解了元数据与数据块争抢 block cache、以及大块元数据重读放大磁盘 IO 两个问题。实践中只需两步即可开启——设置index_type=kTwoLevelIndexSearch并打开partition_filters,再结合metadata_block_sizecache_index_and_filter_blocks与 pinning 相关选项做精细调优。其完整的构建、读取与测试实现都可以在当前仓库的table/block_based/目录与 partitioned_filter_block_test.cc 中深入研读。

【免费下载链接】rocksdbA library that provides an embeddable, persistent key-value store for fast storage.项目地址: https://gitcode.com/gh_mirrors/ro/rocksdb

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询