从 Level0直接将sst文件写入到LevelN 现象 来看rocksdb对compaction的优化

文章目录


关于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。
从 Level0直接将sst文件写入到LevelN 现象 来看rocksdb对compaction的优化
也就是上面图片中的日志。

这个现象让我们对rocksdb 实现的LSM tree的level compaction机制有点理解上的偏差,level compaction的过程应该是逐层compaction。
从 Level0直接将sst文件写入到LevelN 现象 来看rocksdb对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中的一张图更加直观
    从 Level0直接将sst文件写入到LevelN 现象 来看rocksdb对compaction的优化

总结

通过这个参数level_compaction_dynamic_level_bytes 的配置,我们能够将90% 以上的数据下沉到最后一层,带来的收益主要是:

  • 后续 针对Delete/多次put的数据一次compaction就能够清理,因为已经在最后一层了。节约存储空间,提升delete效率。
  • 进一步降低Higher level compaction所带来的资源占用,大量数据集中在最后一层,减少了多层之间的写放大。

存在的问题是 会带来一些读性能的影响,数据的随机读取会大量集中在最后一层。

总之,这个配置在写heavy的场景相比于静态的Level配置还是有一定的优势的。

上一篇:计算机视觉面试考点(6)正则化


下一篇:深度学习与Pytorch入门实战(十五)LSTM