文章目录
关于compaction的详细逻辑介绍可以参考:
1. SST文件详细格式源码解析
2. Compaction 完整实现过程 概览
本文仅关注于讨论标题中提到的优化,不会对compaction细节有过多描述。
ps: 涉及到的源代码都是基于rocksdb 6.4.6版本
现象
最近观察一个k/v存储项目中rocksdb的一些行为,发现一个奇怪的现象。
Rocksdb 纯写,观察底层文件分布的过程中发现L0做完compaction之后直接写入到了L6(num_leves=7),也就是L0做完compaction跳过了L1–L5。
也就是上面图片中的日志。
这个现象让我们对rocksdb 实现的LSM tree的level compaction机制有点理解上的偏差,level compaction的过程应该是逐层compaction。
也就是memtable flush 到L0之后,L0向L1compaction,L1会和 L2的sst文件compaction到L2。但是现在,L0 达到4个sst文件触发的compaction最终却输出到了L6。
难以理解。
分析
看到这个有趣现象,首先想到的是确认一下compaction的sst文件output逻辑。
从挑选参与compaction的文件到最后生成一个compaction 对象会通过LevelCompactionBuilder::PickCompaction
函数来进行。
Compaction* LevelCompactionBuilder::PickCompaction() {
// Pick up the first file to start compaction. It may have been extended
// to a clean cut.
// 开始挑选一些参与compaction的文件,并确认输出的层
SetupInitialFiles();
if (start_level_inputs_.empty()) {
return nullptr;
}
...
}
确定compaction完成后要输出的Level 则会通过SetupInitialFiles
函数。
void LevelCompactionBuilder::SetupInitialFiles() {
// Find the compactions by size on all levels.
bool skipped_l0_to_base = false;
// 逐层选择文件,所以需要遍历所有层
for (int i = 0; i < compaction_picker_->NumberLevels() - 1; i++) {
start_level_score_ = vstorage_->CompactionScore(i);
start_level_ = vstorage_->CompactionScoreLevel(i);
assert(i == 0 || start_level_score_ <= vstorage_->CompactionScore(i - 1));
// 当前层满足compaction的条件,即该层维护的一个变量score>1
// 从该层中选择文件。
if (start_level_score_ >= 1) {
if (skipped_l0_to_base && start_level_ == vstorage_->base_level()) {
// If L0->base_level compaction is pending, don't schedule further
// compaction from base level. Otherwise L0->base_level compaction
// may starve.
continue;
}
// 确认compaction结束后的sst文件所在的输出层
output_level_ =
(start_level_ == 0) ? vstorage_->base_level() : start_level_ + 1;
...
}
}
可以看到变量output_level_
的确定是通过(start_level_ == 0) ? vstorage_->base_level() : start_level_ + 1;
这样表达式来取值的。
可以看到如果起始level不为0时(Higher level compaction),则output_level 会在起始层的基础上+1。
但是 如果为0,则会从其他地方取值vstorage_->base_level()
。
最终取到的是 base_level()
函数中的base_level_
变量,而这个变量则是在VersionStorageInfo::CalculateBaseBytes
函数中修改的。
这个函数是在创建column family的时候根据一些参数来计算接下来LSM 的形态,层和层之间的放大系数来计算 即每一层的大小,并且在每一层compaction完成之后写入MANIFEST的时候都会做一次重新计算。
查看这个函数代码的那一刻基本就知道为什么L0的sst文件会直接输出到L6了,核心就是如下代码的level_compaction_dynamic_level_bytes
变量,这个变量是rocksdb options传入进来的。目的是为了能够根据LSM 不同层之间的大小 动态调整层之间的level_max_bytes_(即每一层能存储的数据上限)。
void VersionStorageInfo::CalculateBaseBytes(const ImmutableCFOptions& ioptions,
const MutableCFOptions& options) {
...
// level_compaction_dynamic_level_bytes 为false,即不支持动态调整level.
// 所以刚开始的level就是1
if (!ioptions.level_compaction_dynamic_level_bytes) {
base_level_ = (ioptions.compaction_style == kCompactionStyleLevel) ? 1 : -1;
...
} else {//level_compaction_dynamic_level_bytes 为true的时候
...
// 遍历每一层的文件,取到第一个不为空的level
// 并保存之前层文件总大小最大的值。
for (int i = 1; i < num_levels_; i++) {
uint64_t total_size = 0;
for (const auto& f : files_[i]) {
total_size += f->fd.GetFileSize();
}
if (total_size > 0 && first_non_empty_level == -1) {
first_non_empty_level = i;
}
if (total_size > max_level_size) {
max_level_size = total_size;
}
}
...
// 刚开始只有L0有文件,其他层都是空的,且上面的循环是从底层层开始确认文件大小的
// 所以max_level_size会为0
if (max_level_size == 0) {
// No data for L1 and up. L0 compacts to last level directly.
// No compaction from L1+ needs to be scheduled.
base_level_ = num_levels_ - 1; // 为base_level_赋值,也就是N-1=6,(N=7)
}
...
}
可以看到level_compaction_dynamic_level_bytes 为true的时候我们L0向compaction的输出层会是N-1层。
然后核对了一下项目的参数配置,发现果然这个参数被某人 修改为true。
当然,这个分析过程只是探索一下这个现象,并没有什么性能问题。 本身这个参数在一些场景是有优势的,也是接下来要讲的。
优化
知道了是某人改的这个参数,导致了 L0的compaction 会优先向最底层compaction 这个现象。
接下来详细看看level_compaction_dynamic_level_bytes 参数 的作用。
这个参数核心目的是为了保证rocksdb在写入流量大的情况下维持LSM tree 数据分布的均衡,而不用我们人为介入,调整LSM tree的不同层之间的大小。所谓均衡,就是让数据尽可能均匀分布在整个LSM tree的不同层之间,这样的分布对整个rocksdb的性能会带来什么优势?
-
先看看level_compaction_dynamic_level_bytes 为 false 的时候 LSM tree不同层之间的大小。
关于level的配置如下:level_compaction_dynamic_level_bytes = false max_bytes_for_level_base = 268435456 max_bytes_for_level_multiplier = 10 max_bytes_for_level_multiplier_additional[0]=1 max_bytes_for_level_multiplier_additional[1]=1 ...
计算不同层之间大小的公式为:
Target_Size(Ln+1) = Target_Size(Ln) * max_bytes_for_level_multiplier * max_bytes_for_level_multiplier_additional[n]在这个配置下L1,L2,L3,L4,L5,L6…的大小分别是递增的:256M, 2.56G, 25.6G,256G,2.56T,25.6T…
同时compaction的过程中start_level 和 output_level 仅仅会相差1
-
再看看level_compaction_dynamic_level_bytes 配置为true 的时候 LSM tree不同层之间大小配置的差异
在这个配置下,会先确定num_levels -1 层的大小,直接将当前level0的大小配置为level(num_levels-1)层大小。
其他层的大小通过当前如下公式计算:
Target_Size(Ln-1) = Target_Size(Ln) / max_bytes_for_level_multiplier如果Target_Size(Ln-1) 不满足sst文件的最小存储要求, 则会跳过这一层,直到遇到一个能够满足的层才会存储。
当然num_levels-1 层的大小最开始会最做一个初始化,后续会随着数据的增加而不断动态增加(调整每一层大小的过程是每一轮 compaction完成之后会将compaction的sst文件变更更新新到MANIFEST文件,同时生成一个新的CF对象伴随着重新调整该CF中的相关level配置)。假如Targetsize(level6) = 265G。在这个配置下L1,L2,L3,L4,L5,L6…的大小分别是: 0,0,0.256M,2.56G,25.6G,256G。取社区wiki中的一张图更加直观
总结
通过这个参数level_compaction_dynamic_level_bytes 的配置,我们能够将90% 以上的数据下沉到最后一层,带来的收益主要是:
- 后续 针对Delete/多次put的数据一次compaction就能够清理,因为已经在最后一层了。节约存储空间,提升delete效率。
- 进一步降低Higher level compaction所带来的资源占用,大量数据集中在最后一层,减少了多层之间的写放大。
存在的问题是 会带来一些读性能的影响,数据的随机读取会大量集中在最后一层。
总之,这个配置在写heavy的场景相比于静态的Level配置还是有一定的优势的。