随着 DB/内存比例变大,过滤器/索引块的内存空间变得越来越重要。尽管 cache_index_and_filter_blocks 允许在块缓存中只存储这些内容的一个子集,但是他们本身巨大的尺寸会通过以下方式降低性能:
- 占用本来是用于缓存数据的块缓存空间。
- 在未命中的时候,加载数据到内存,增加磁盘存储的压力。
这里我们讲解释问题的细节,以及如何给索引和过滤器分片,以减少消耗。
索引/过滤器块有多大?¶
RocksDB 默认对每个 SST 文件有一个索引/过滤器块。根据配置的不同,索引/过滤器块的大小也会有所不同,但是,对于一个 256MB 的 SST 文件,通常他的索引/过滤器块的大小就是 0.5/5MB,比常见的 4~32KB 的数据块要大很多。如果索引/过滤器块能完美的加载进内存,那是完全可以的,这样他们在 SST 的生命周期中只要被读取一次就可以,而不是需要跟数据块竞争块缓存空间,导致他们需要被重复从磁盘读取。
巨大的索引/过滤器块会有什么大问题吗?¶
如果索引/过滤器块存储在块缓存,他们会跟数据块(以及他们自己)竞争这部分稀有资源。一个 5MB 的过滤器占用的空间可以被 1000 多个数据块(4KB 大小)使用。这会导致数据块出现更多的缓存未命中。巨大的索引/过滤器同时还会把对方踢出块缓存,导致他们自己的缓存命中率下降。这会导致只有一小部分的索引/过滤器块会在他的缓存生命周期内被实际使用。
在一个索引/过滤器的缓存未命中发生后,他需要从磁盘中重新加载,他巨大的尺寸不能帮助减轻 IO 开销。就算是一个简单的点查询也需要从 LSM 的每一层读取好几个数据块,这也可能导致载入大量的索引/过滤器块。如果这种情况经常发生,磁盘甚至可能会花费比 服务数据块 更多的时间来服务索引/过滤器。
什么是分片索引/过滤器?¶
使用分片,一个 SST 文件的索引/过滤器会被分片成多个更小的块,然后加入一个新的上层索引给他们。当读取一个索引/过滤器的时候,只有最上层的索引被加载到内存。分片索引/过滤器之后会使用顶层索引来按需加载该查询需要使用的索引/过滤器到块缓存。顶层索引有更小的内存空间,可以被存储在堆上,或者是块缓存,具体配置依赖于 cache_index_and_filter_blocks。
优点:¶
- 更高的缓存命中率:不再使用巨型的索引/过滤器块污染缓存,分片允许索引/过滤器以一个更小的单位加载,因此加大了缓存空间利用率。
- 更小的 IO 单位:当一个分片的索引/过滤器块缓存未命中发生的时候,只有一个分片需要从磁盘被载入内存,相比较读取整个索引/过滤器,他大大减小了磁盘压力。
- 不需要牺牲索引/过滤器:不使用分片直接降低索引/过滤器内存使用的的策略通常是牺牲他们的准确性,比如说,使用更大的数据块或者更少的额 bloom 位来生成一个更小的索引和过滤器。
缺点:¶
- 存储顶级索引的额外磁盘空间: 挺小的,大概是 0.1~1% 的索引/过滤器大小。
- 更多的磁盘 IO:如果顶级索引不在缓存,他会需要一个额外的 IO。为了避免这种情况,他们可以存放在堆里,或者是高优先级缓存(TODO 的工作)。
- 失去空间局部性:如果有一个工作压力,频繁的请求,然后从同一个 SST 文件做随机读取,会导致每次读取都要额外读取一次独立的索引/过滤器,这比直接把整个索引读取起来要低效一些。不过我们在我们的压力测试中没有看到这种情况,他通常发生在 LSM 树的 L0/L1 层,这里分片可以关闭(TODO)
成功案例¶
HDD, 100TB DB¶
在这个例子里,我们有一个 86G 的 DB 存放在 HDD 上,用这个 DB,使用直接 IO,来模拟一个 100TB 数据的节点的小内存(跳过 OS 的文件缓存),使用块大小 60MB。分片把性能从 5 op/s 提升了大概 11 倍吞吐到 55ops/s。
./db_bench --benchmarks="readwhilewriting[X3],stats" --use_direct_reads=1 -compaction_readahead_size 1048576 --use_existing_db --num=2000000000 --duration 600 --cache_size=62914560 -cache_index_and_filter_blocks=false -statistics -histogram -bloom_bits=10 -target_file_size_base=268435456 -block_size=32768 -threads 32 -partition_filters -partition_indexes -index_per_partition 100 -pin_l0_filter_and_index_blocks_in_cache -benchmark_write_rate_limit 204800 -max_bytes_for_level_base 134217728 -cache_high_pri_pool_ratio 0.9
SSD,Linkbench¶
在这个例子里我们有一个 SSD 上的 300GB 的数据库,同样通过直接 IO(跳过 OS 文件缓存)扮演本地另一个 DB 的小内存,块缓存大小为 6G 和 2G。不使用分片的时候,把块缓存大小从 6G 调到 2G,linkbench 的吞吐从 38k tps 掉到 23kb。使用分片,吞吐下降为从 38k 掉到 30k。
如何使用?¶
- index_type = IndexType::kTwoLevelIndexSearch
- 打开分片索引
- NewBloomFilterPolicy(BITS, false)
- 使用全过滤器
- partition_filters = true
- 为了打开分片过滤器
- metadata_block_size = 4096
- 索引分片的块大小
- cache_index_and_filter_blocks = false [如果你的版本 <= 5.14]
- 分片总是存在块缓存中。这是为了控制顶级索引的位置(可以更好的填入内存):固定在堆还是缓存到块缓存中。存储到块缓存比较少用。
- cache_index_and_filter_blocks = true and pin_top_level_index_and_filter = true [如果你的版本 >= 5.15]
- 把所有东西都丢进块缓存,同时固定顶级索引,这个索引不会很大的。
- cache_index_and_filter_blocks_with_high_priority = true
- 推荐配置
- pin_l0_filter_and_index_blocks_in_cache = true
- 推荐设置,因为这个属性可以拓展到索引/过滤器分片。
- 只有压缩方式是基于 level 的压缩方式的时候才使用他
- 注意: 如果固定这些块到块缓存,如果 strict_capacity_limit 没有设置(默认),可能会使容量过大。
- 块缓存大小:如果你习惯于把过滤器/索引存储在堆里,不要忘了把你从堆那边节省出来的缓存归还给块缓存。
当前限制¶
- 分片过滤器如果不使用分片索引,就不能打开。
- 我们有一样数量的过滤器和索引分片。换句话说,不管索引块怎么切,过滤器块也那么切。如果这样有问题,我们以后可能会考虑改掉他。
- 过滤器块大小是根据索引块切分的时候决定的。我们会尽快拓展 metadata_block_size 来限制过滤器和索引块的最大大小,比如,一个过滤器块会在 索引块被切割,或者,他的大小可能超过 metadata_block_size 的时候,被切割
底层实现¶
这里展示开发者关心的实现细节。
BlockBasedTable 格式¶
可以在 这里 了解 BlockBasedTable 格式。使用分片的时候,差异会是索引块 [index block],会被存储成:
[index block - partition 1]
[index block - partition 2]
...
[index block - partition N]
[index block - top-level index]
然后 SST 的脚注指向顶级索引块(他自身就是一个索引分片块的索引)。每个独立的索引块分片遵从与 kBinarySearch 相同的格式。顶级索引格式同事遵从 kBinarySearch,因此可以使用普通的数据块读取器来读取。
类似的结果被用于过滤器块分片。每个独立的过滤器块的格式遵从 kFullFilter 的格式。顶级索引格式遵从 kBinarySearch 的格式,类似索引块的顶级索引。
注意,对于带分片的 SST 文件,检查 SST 的工具,如 sst_dump,会报告索引/过滤器上的顶级索引的大小,而不是索引和过滤器块的总大小。
构造器¶
分片索引和过滤器可以通过 PartitionedIndexBuilder 和 PartitionedFilterBlockBuilder 分别构造。
PartitionedIndexBuilder 维护 sub_index_builder_,一个指向 ShortenedIndexBuilder 的指针,用来构建当前的索引分片。当通过 flush_policy_ 指定的时候,构造器会把指针跟索引块中最后一个 key 一起保存,然后创建一个新的活跃 ShortenedIndexBuilder。当你对这个构造器调用::Finish,他会对最早的子索引构造器调用::Finish 然后返回分片块的结果。下次调用 PartitionedIndexBuilder::Finish 同样会包含之前返回的 SST 文件分片的偏移,会被用作顶级索引的值。最后一次 PartitionedIndexBuilder::Finish 的调用会完成顶级索引,这次会返回该顶级索引。把顶级索引存储到 SST 文件之后,他的偏移会被用作索引块的偏移。
PartitionedFilterBlockBuilder 继承 FullFilterBlockBuilder,它带有一个 FilterBitsBuilder 可以用于构建 bloom 过滤器。他也有一个指针指向 PartitionedIndexBuilder,并且使用该指针调用 ShouldCutFilterBlock,来决定什么时候应该吧一个过滤器块切片(在一个索引块被切片后)。为了切割一个过滤器块,他执行完 FilterBitsBuilder 并且把 返回的块 和 PartitionedIndexBuilder::GetPartitionKey() 提供的分片 key 存储在一起,然后重置 FilterBitsBuilder,一边下一个分片使用。在最后一次 PartitionedFilterBlockBuilder::Finish 被调用的时候,其中一个分片会返回,并且前面分片的偏移会被用于构建顶级索引。调用::Finish 会返回顶级索引块。
之所以基于 PartitionedIndexBuilder 实现 PartitionedFilterBlockBuilder,是为了优化索引/过滤器分片在 SST 文件的插入。不过这个优化效果不是很明显,我们以后可能会放弃这个依赖。
读取器¶
分片索引通过 PartitionIndexReader 来读取,他会操作顶级索引块。当 NewIterator 被调用,一个 TwoLevelIterator 会作用于顶级索引块。这个简单的实现的原理是这样的,因为每个索引分片都是 kBinarySearch 格式,他们跟数据块是一样的,因此他们可以简单地插入一层迭代器来实现迭代。如果 pin_l0_filter_and_index_blocks_in_cache 被设置了,底层的迭代器会被固定到 PartitionIndexReader,这样,只要 PartitionIndexReader 还存活,他们对应的索引分片都会被固定到块缓存。BlockEntryIteratorState 使用一系列的固定的分片偏移来避免两次解除一个索引分片的固定。
PartitionedFilterBlockReader 使用顶级索引找到过滤器分片的偏移。然后调用 BlockBasedTable 对象的 GetFilter,通过 FilterBlockReader 对象读取块缓存上的过滤器分片(如果没有,则从磁盘读入到这里来) ,然后释放 FilterBlockReader 对象。为了让 table_options.pin_l0_filter_and_index_blocks_in_cache 支持分片索引,PartitionedFilterBlockReader 并不会释放这些块的缓存句柄(或者说,保证他们会被固定到块缓存)。他从另一个方向维护了 filter_cache_,一个固定的 FilterBlockReader 映射表,被用于在 PartitionedFilterBlockReader 析构的时候释放缓存项。