在先前我们讨论了 RocksDB 的 statistics 和 write stall,但这些只能让我们发现问题,最终我们还是需要通过调整 RocksDB 的参数来提升性能。但 RocksDB 的参数以其数据多和复杂著称,要全部弄懂也要费一番功夫,这里也仅仅会说一下我们使用的一些参数,还有很多我们也需要后面慢慢去研究。
Parallelism
RocksDB 有两个后台线程,flush 和 compaction,两个都可以同时并行执行。在优先级上面,flush 是 HIGH,而 compaction 是 LOW,也就是 flush 的优先级会比 compaction 更高,这也很容易理解,如果数据都没有 memtable flush 到 level 0,后面也没法做 compaction。我们可以设置 flush 和 compaction 的最大线程数:
-
max_background_compaction
:最大 compaction 线程数,默认是 1,但通常我们会调大,不然 compaction 会忙不过来。 -
max_background_flushes
:最大 flush 线程数,默认是 1,在 TiKV 里面我们默认是 2,因为有多个 CF 可能会同时 flush。
General
-
filter_policy
:也就是 bloom filter,通常在点查Get
的时候我们需要快速判断这个 key 在 SST 文件里面是否存在,如果 bloom filter 已经确定不存在了,就可以过滤掉这个 SST,减少没必要的磁盘读取操作了。我们使用rocksdb::NewBloomFilterPolicy(bits_per_key)
来创建 bloom filter,bits_per_key
默认是 10,表示可能会有 1% 的误判率,bits_per_key
越大,误判率越小,但也会占用更多的 memory 和 space amplification。 -
block_cache
:为了加快从文件读取数据的速度,RocksDB 会将 block 缓存,虽然操作系统也有 OS cache,但通常,block cache 是缓存的没有被压缩的 block,而 OS cache 则是缓存的已经压缩好的 block。现在 RocksDB 也支持 direct IO 模式,这样就不会有 OS cache 了,但我们还没有使用过。另外,RocksDB 也支持一种 compressed block cache,类似 OS cache 的机制,但我们现阶段也没有使用过。另外,在 TiKV 里面,尤其是在内存比较小的机器上面,我们通常都会将 block 的 index 和 cache 也放在 cache 里面,防止 OOM。通常我们会使用rocksdb::NewLRUCache(cache_capacity, shard_bits)
来创建一个 LRU cache。 -
max_open_files
:RocksDB 会将打开的 SST 文件句柄缓存这,这样下次访问的时候就可以直接使用,而不需要重新在打开。当 缓存的文件句柄超过max_open_files
之后,一些句柄就会被 close 掉。如果使用 -1,RocksDB 将一直缓存所有打开的句柄,但这个会造成比较大量的内存开销,尤其是在内存较小的机器上面,很容易造成 OOM。 -
block_size
:RocksDB 会将一批 data 打包放到一个 block 里面,当需要访问某一个 key 的时候,RocksDB 会将整个 block 都 load 到内存里面。一个 SST 文件会包含很多个 block,每个 SST table 都包含一个 index 用来快速定位到对应的 block。如果block_size
越大,那么一个 SST 文件里面 block 的个数就越少,这样 index 占用的 memory 和 space amplification 就越小,但这样就会增大 read amplification。
Flush
对于新插入的数据,RocksDB 会首先将其放到 memtable 里面,所以 RocksDB 的写入速度是很快的。当一个 memtable full 之后,RocksDB 就会将这个 memtable 变成 immutable 的,然后用另一个新的 memtable 来处理后续的写入,immutable 的 memtable 就等待被 flush 到 level 0。也就是同时,RocksDB 会有一个活跃的 memtable 和 0 或者多个 immutable memtable。对于 flush,我们需要关注:
-
write_buffer_size
:memtable 的最大 size,如果超过了这个值,RocksDB 就会将其变成 immutable memtable,并在使用另一个新的 memtable。 -
max_write_buffer_number
:最大 memtable 的个数,如果 active memtable full 了,并且 active memtable 加上 immutable memtable 的个数已经到了这个阀值,RocksDB 就会停止后续的写入。通常这都是写入太快但是 flush 不及时造成的。 -
min_write_buffer_number_to_merge
:在 flush 到 level 0 之前,最少需要被 merge 的 memtable 个数。如果这个值是 2,那么当至少有两个 immutable 的 memtable 的时候,RocksDB 会将这两个 immutable memtable 先 merge,在 flush 到 level 0。预先 merge 能减小需要写入的 key 的数据,譬如一个 key 在不同的 memtable 里面都有修改,那么我们可以 merge 成一次修改。但这个值太大了会影响读取性能,因为 Get 会遍历所有的 memtable 来看这个 key 是否存在。
一个 Flush 的例子:
write_buffer_size = 512MB;
max_write_buffer_number = 5;
min_write_buffer_number_to_merge = 2;
假设我们的写入速率是 16MB/s,那么每 32s 的时间都会有一个新的 memtable 生成,每 64s 的时间就会有两个 memtable 开始 merge。取决于实际的数据,需要 flush 到 level 0 的大小可能在 512MB 和 1024MB 之间,一次 flush 也可能需要几秒的时间(取决于盘的顺序写入速度)。最多有 5 个 memtable,当达到这个阀值,RocksDB 就会组织后续的写入了。
Level Style Compaction
RocksDB 默认的将 SST 文件放在不同的 level,自然就是用的 level style compaction。Memtable 的被 flush 到 level 0,level 0 有最新的数据,其他更上层的 level 则是有老的数据。Level 0 里面的 SST 文件可能会有重叠,也就是不同的 SST 文件保护的数据 key range 会重叠,但 level 1 以及之上的 level 则不会重叠。对于一次 Get 操作来说,通常会在所有的 level 0 文件里面检查是否存在,但如果在其他层,如果在一个 SST 里面找到了这个 key,那么其他 SST 都不会包含这个 key。每一层都比上一层大 10 倍,当然这个是可以配置的。
一次 compaction 会将 level N 的一些文件跟 level N + 1 里面跟这些文件重叠的文件进行 compact 操作。两个不同的 compaction 操作会在不会的 level 或者不同的 key ranges 之间进行,所以可以同时并发的进行多个 compaction 操作。
在 level 0 和 level 1 之间的 compaction 比较 tricky,level 0 会覆盖所有的 key range,所以当 level 0 和 level 1 之间开始进行 compaction 的时候,所有的 level 1 的文件都会参与合并。这时候就不能处理 level 1 到 level 2 的 compaction,必须等到 level 0 到 level 1 的 compaction 完成,才能继续。如果 level 0 到 level 1 的速度比较慢,那么就可能导致整个系统大多数时候只有一个 compaction 在进行。
Level 0 到 level 1 的 compaction 是一个单线程的,也就意味着这个操作其实并不快,RocksDB 后续引入了一个 max_subcompactions
,解决了 level 0 到 level 1 的 compaction 多线程问题,但现在 TiKV 还没有测试引入。通常,为了加速 level 0 到 level 1 的 compaction,我们会尽量保证level 0 和 level 1 有相同的 size。
当决定了 level 1 的大概 size,我们就需要决定 level multiplier。假设 level 1 的 size 是 512MB,level multiplier 是 10,整个 DB 的 size 是 500GB。Level 2 的 size 是 5GB,level 3 是 51GB,level 4 是 512GB,level 5 以及更上层的 level 就是空的。
那么 size amplification 就很容易计算了 (512 MB + 512 MB + 5GB + 51GB + 512GB) / (500GB) = 1.14
,write amplification 的计算则是:任何一个 byte 首先写入 level 0,然后 compact 到 level 1,因为 level 1 的 size 跟 level 0 是一样的,所以 write amplification 在 level 0 到 level 1 的 compaction 是 2。当这个 byte compact 到 level 2 的时候,因为 level 2 比 level 1 大 10 倍,所以 write amplification 是 10。对于 level 2 到 level 3,level 3 到 level 4 也是一样。
所以总的 write amplification 就是 1 + 2 + 10 + 10 + 10 = 33
。对于点查来说,通常会访问所有的 level 0 文件或者其他 level 的至多一个文件,这里我们可以使用 bloom filter 来减少 read amplification,但这个对于 range scans(也就是 iterator seek 这些)没啥作用,所以 range scans 的 read amplification 是 level 0 的文件数据 + 非空 level 的数量。
理解了上面的 level compaction 的流程,我们就可以开始配置相关的参数了。
-
level0_file_num_compaction_trigger
:当 level 0 的文件数据达到这个值的时候,就开始进行 level 0 到 level 1 的 compaction。所以通常 level 0 的大小就是write_buffer_size * min_write_buffer_number_to_merge * level0_file_num_compaction_trigger
。 -
max_bytes_for_level_base
和max_bytes_for_level_multiplier
:max_bytes_for_level_base
就是 level1 的总大小,在上面提到,我们通常建议 level 1 跟 level 0 的 size 相当。上层的 level 的 size 每层都会比当前层大max_bytes_for_level_multiplier
倍,这个值默认是 10,通常也不建议修改。 -
target_file_size_base
和target_file_size_multiplier
:target_file_size_base
则是 level 1 SST 文件的 size。上面层的文件 size 都会比当前层大target_file_size_multiplier
倍,默认target_file_size_multiplier
是 1,也就是每层的 SST 文件都是一样的。增加target_file_size_base
会减少整个 DB 的 size,这通常是一件好事情,也通常建议target_file_size_base
等于max_bytes_for_level_base / 10
,也就是 level 1 会有 10 个 SST 文件。 -
compression_per_level
:使用这个来设置不同 level 的压缩级别,通常 level 0 和 level 1 不压缩,更上层压缩。也可以对更上层的选择慢的压缩算法,这样压缩效率更高,而对下层的选择快的压缩算法。TiKV 默认全选择的 lz4 的压缩方式。 -
num_levels
:预计的 level 的层数,如果实际的 DB level 超过了这么多层,也是安全的。默认是 7,实际也不会有超过这么多层的数据。
小结
上面只列举了一些 RocksDB 的调优参数,当然实际还远远不止这些,很多优化我们也在摸索阶段。对于不同的调优参数,后面也会继续解释。