最近看到一篇 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。