自动调优 RocksDB

最近看到一篇 Paper,Auto-tuning RocksDB,顿时两眼放光。RocksDB 以配置多,难优化而著称,据传 RocksDB 配置多到连 RocksDB 自己的开发者都没法提供出一个好的配置,所以很多时候,我们都只能大概给一个比较优的配置,在根据用户实际的 workload 调整。所以这时候真的希望能有一个自动 tuning 的方案。

对于数据库来说,auto tuning 是当前一个非常热门的研究领域,譬如 CMU 知名的 Peloton 项目,但这些项目通常都会关注特别多的配置,使用 TensorFlow 等技术进行机器学习,靠人工智能来调优。这个当然也能用到 RocksDB 上面,不过对作者来说,这些都太复杂了(其实对我们也一样,虽然人工智能诱惑很大,但坑很多)。所以,作者主要关注的是如何更好的提升写入性能。而基本原理也很简单,在写入负载高的时候关掉 compaction,而在写入负载低的时候打开 compaction。那么自然要考虑的就是,如何去实现一个 compaction auto-tuner 了。

RocksDB 介绍

因为 RocksDB 在之前的文章中已经介绍了太多了,这里就稍微简单介绍一下。RocksDB 是基于 LSM-Tree 的,大概如下

虽然大部分读者对于 LSM 已经非常熟悉了, 但这里还是简单的介绍一下。首先,任何的写入都会先写到 WAL,然后在写入 Memory Table(Memtable)。当然为了性能,也可以不写入 WAL,但这样就可能面临崩溃丢失数据的风险。Memory Table 通常是一个能支持并发写入的 skiplist,但 RocksDB 同样也支持多种不同的 skiplist,用户可以根据实际的业务场景进行选择。

当一个 Memtable 写满了之后,就会变成 immutable 的 Memtable,RocksDB 在后台会通过一个 flush 线程将这个 Memtable flush 到磁盘,生成一个 Sorted String Table(SST) 文件,放在 Level 0 层。当 Level 0 层的 SST 文件个数超过阈值之后,就会通过 Compaction 策略将其放到 Level 1 层,以此类推。

这里关键就是 Compaction,如果没有 Compaction,那么写入是非常快的,但会造成读性能降低,同样也会造成很严重的空间放大问题。为了平衡写入,读取,空间这些问题,RocksDB 会在后台执行 Compaction,将不同 Level 的 SST 进行合并。但 Compaction 并不是没有开销的,它也会占用 I/O,所以势必会影响外面的写入和读取操作。

对于 RocksDB 来说,他有三种 Compaction 策略,一种就是默认的 Leveled Compaction,另一种就是 Universal Compaction,也就是常说的 Size-Tired Compaction,还有一种就是 FIFO Compaction。在之前介绍 Dostoevsky 的文章里面,已经详细的介绍了 Leveled 和 Tired,这里就不在重新说明了。对于 FIFO 来说,它的策略非常的简单,所有的 SST 都在 Level 0,如果超过了阈值,就从最老的 SST 开始删除,其实可以看到,这套机制非常适合于存储时序数据。

实际对于 RocksDB 来说,它其实用的是一种 Hybrid 的策略,在 Level 0 层,它其实是一个 Size-Tired 的,而在其他层就是 Leveled 的。

这里在聊聊几个放大因子,对于 LSM 来说,我们需要考虑写放大,读放大和空间放大,读放大可以认为是 RA = number of queries * disc reads,譬如用户要读取一个 page,但实际下面读取了 3 个 pages,那么读放大就是 3。而写放大则是 WA = data writeen to disc / data written to database,譬如用户写入了 10 字节,但实际写到磁盘的有 100 字节,那么写放大就是 10。而对于空间放大来说,则是 SA = size of database files / size of databases used on disk,也就是数据库可能是 100 MB,但实际占用了 200 MB 的空间,那么就空间放大就是 2。

这里简单的聊了聊 RocksDB 相关的一些知识,下面就来说说作者是如何做 Auto tuning 的。

Statistics

因为关注的目标是写入压力情况下面的 compaction 优化,所以自然我们需要关注的是 RocksDB 的 compaction 统计。RocksDB 会定期将很多统计信息给写入到日志里面,所以我们只需要分析日志就行了了。

我们需要关注的 RocksDB 日志如下:

Cumulative compaction: 2.09 GB write, 106.48 MB/s write, 1.19 GB read,
    60.66 MB/s read, 14.4 seconds
Interval compaction: 1.85 GB write, 130.27 MB/s write, 1.19 GB read, 83.86
     MB/s read, 13.2 seconds
Cumulative writes: 10K writes, 10K keys, 10K commit groups, 1.0 writes per
     commit group, ingest: 0.93 GB, 47.57 MB/s
Cumulative WAL: 10K writes, 0 syncs, 10000.00 writes per sync, written:
    0.93 GB, 47.57 MB/s
Cumulative stall: 00:00:0.000 H:M:S, 0.0 percent
Interval writes: 7201 writes, 7201 keys, 7201 commit groups, 1.0 writes
    per commit group, ingest: 686.97 MB, 47.36 MB/s
Interval WAL: 7201 writes, 0 syncs, 7201.00 writes per sync, written: 0.67
     MB, 47.36 MB/s
Interval stall: 00:00:0.000 H:M:S, 0.0 percent

具体的分析脚本在 这里,这个脚本会提取相应的字段,然后绘制成图表,这样我们就能直观的看实际的 I/O 量了。

Compaction Tuner

要控制 auto compaction,RocksDB 有一个 disable_auto_compactions 参数,当设置为 false 的时候,就会停止 compaction,但这时候需要将 Level 0 的 slowdown 参数也设置大,不然就会出现 write stall 问题。

RocksDB 自身提供了一个 SetOptions 的函数,方便外面动态的去调整参数,但这样其实就需要自己在外面显示的维护 RocksDB 实例。另一种方式就是给 RocksDB 传一个共享的 environment,通过这个来控制几个参数的修改。权衡之后,作者决定使用共享 env 的方式,因为容易实现,同时也能更方便的去访问到 database 的内部。

所以作者定制了一个 env,提供了 Enable 和 Disable 两个函数,在 Disable 里面,将 level0_file_num_compaction_trigger 设置成了 (1<<30),这个也是 RocksDB PrepareForBulkLoad 函数里面的值。

bool disable_auto_compactions;
int prev_level0_file_num_compaction_trigger;
int level0_file_num_compaction_trigger;

void DisableCompactions() {
    if (!disable_auto_compactions) {
      prev_level0_file_num_compaction_trigger =
          level0_file_num_compaction_trigger;
      disable_auto_compactions = true;
      level0_file_num_compaction_trigger = (1<<30);
    }
};

void EnableCompactions() {
    if (disable_auto_compactions) {
      disable_auto_compactions = false;
      level0_file_num_compaction_trigger =
          prev_level0_file_num_compaction_trigger;
    } 
}

RocksDB 的 compaction 控制在 ColumnFamilyData 类里面,通过函数 RecalculateWriteStallConditions 来计算的,但 ColumnFamilyData 并没有 env,所以作者扩展了一下,给 ColumnFamilyData 的构造函数加了个 env 变量:

ColumnFamilyData* new_cfd = new ColumnFamilyData(
  id, name, dummy_versions, table_cache_, write_buffer_manager_, options,
  *db_options_, env_options_, this, Env::Default());

然后在改了下 RecalculateWriteStallConditions,让其能接受 env 的参数来控制。

-WriteStallCondition ColumnFamilyData::RecalculateWriteStallConditions(
-   const MutableCFOptions& mutable_cf_options) {
+WriteStallCondition ColumnFamilyData::RecalculateWriteStallConditions() {
    auto write_stall_condition = WriteStallCondition::kNormal;
+    if (current_ != nullptr) {
+        if (mutable_cf_options_.atuo_tuned_compaction) {
+            mutable_cf_options_.level0_file_num_compaction_trigger = env_->level0_file_num_compaction_trigger;
+            mutable_cf_options_.disable_auto_compations = env_disable_auto_compations;
+        }
+    }
+    const MutableCFOptions& mutable_cf_options = mutable_cf_options_;

Rate Limiter

在 RocksDB 里面,我们也可以通过 Rate Limiter 来 控制 I/O,通常有几个参数:

  • rate_limit_bytes_per_sec:控制 compaction 和 flush 每秒总的写入量
  • refill_period_us:控制 tokens 多久再次填满,譬如 rate_limit_bytes_per_sec 是 10MB/s,而 refill_period_us 是 100ms,那么每 100ms 的流量就是 1MB/s。
  • fairness:用来控制 high 和 low priority 的请求,防止 low priority 的请求饿死。

另外,RocksDB 还提供了一个 Auto-tuned Rate Limiter,它使用了一个 Multiplicative Increase Multiplicative Decrease(MIMD) 算法,auto-tuned 发生条件如下:

if (auto_tuned_) {
    static const int kRefillsPerTune = 100;
    std::chrono::microseconds now(NowMicrosMonotonic(env_));
    if (now - tuned_time_ >=
        kRefillsPerTune * std::chrono::microseconds(refill_period_us_))
    {
        Tune(); 
    }
}

Auto-tuned RateLimiter 里面已经有很高效的 I/O 判断了,但是这个 I/O 包含的是 flush 和 compaction 的请求的,作者需要区分两种不同的请求。这个在 RocksDB 里面很容易,因为 compaction 和 low priority 请求,而 flush 是 high priority 的。作者把 GenericRateLimiter::Request 里面计算 num_drain_ 的方式改了下,引入了 num_high_drains_num_low_drains_ 两个变量,然后得到 num_drains,如下:num_drains_ = num_high_drains_ + num_low_drains_;

有了 high 和 low 的 drains 变量,就可以直接来控制 compaction 了,作者新增了一个 TuneCompaction 函数,类似原来的 Tune

Status GenericRateLimiter::TuneCompaction(Statistics* stats) {
    const int kLowWatermarkPct = 50;
    const int kHighWatermarkPct = 90;
    std::chrono::microseconds prev_tuned_time = tuned_time_;
    tuned_time_ = std::chrono::microseconds(NowMicrosMonotonic(env_));
    int64_t elapsed_intervals = (tuned_time_ - prev_tuned_time +
        std::chrono::microseconds(refill_period_us_) -
        std::chrono::microseconds(1)) /
        std::chrono::microseconds(refill_period_us_);
    // We tune every kRefillsPerTune intervals, so the overflow and division by
    // zero conditions should never happen.
    assert(num_drains_ - prev_num_drains_ <= port::kMaxInt64 / 100);
    assert(elapsed_intervals > 0);
    int64_t drained_high_pct =
        (num_high_drains_ - prev_num_high_drains_) * 100 /
        elapsed_intervals;
    int64_t drained_low_pct =
        (num_low_drains_ - prev_num_low_drains_) * 100 /
        elapsed_intervals;
    int64_t drained_pct = drained_high_pct + drained_low_pct;
    if (drained_pct == 0) {
        // Nothing
    } else if (drained_pct <= kHighWatermarkPct && drained_high_pct <
        kLowWatermarkPct) {
        env_->EnableCompactions();
    } else if (drained_pct >= kHighWatermarkPct && drained_high_pct >=
        kLowWatermarkPct) {
        env_->DisableCompactions();
        RecordTick(stats, COMPACTION_DISABLED_COUNT, 1);
    }
    num_low_drains_ = prev_num_low_drains_;
    num_high_drains_ = prev_num_high_drains_;
    num_drains_ = prev_num_drains_;
    return Status::OK();
}

触发规则也比较容易,如果 flush I/O 高于 50%,而总的 I/O 超过了 90%,就关掉 compaction,反之则打开 compaction。

DB bench

准备好了所有东西,下一步自然是测试,验证 tuning 能否有效了。作者在 RocksDB 官方的 db_bench 上面加入了一种 Sine Wave 模式,也就是让写入满足如下规则:

这个模式现在已经加入了 db_bench 里面,后面我们也可以尝试一下。然后就是确定下 RocksDB 的一些参数,开始测试了。这里具体不说了,反正就是改参数,做实验,得到一个比较优的配置的过程。然后作者对比了 RocksDB 默认开启 compaction,不开启 compaction 以及使用自己的 Auto-tuner 的情况,一些结果:

可以看到,数据还是很不错的。详细的数据可以看作者的 Paper。

总结

总的来说,作者实现的 Auto-tuner 通过控制 compaction,取得了比较好的效果,后面对我们的参数调优也有很好的借鉴意义。另外,RocksDB team 也一直在致力于 I/O 的优化,我还是很坚信 RocksDB 会越来越快的。现在我们也在进行 TiKV 的 tuning 工作,会分析 TiKV 当前的 workload 来调整 RocksDB 的参数,如果你对这方面感兴趣,欢迎联系我 tl@pingcap.com

推荐阅读更多精彩内容