3. 读写放大之谜:写放大、读放大、空间放大的权衡艺术

RocksDB 的调优,说白了就是在三个魔鬼之间走钢丝——写放大、读放大、空间放大。我刚开始接触时也觉得头大,这三个东西互相牵扯,动一个参数,另外两个就跟着变脸。今天咱们就把这层窗户纸捅破。

3.1 三个放大,到底在放大什么?

先给个直观的定义。你想想看,RocksDB 底层是 LSM-Tree,数据一层层往下 compaction。每次 compaction 都要把数据读出来、合并、再写回去。这就产生了放大效应。

放大类型 定义 对 Flink 的影响
写放大 (Write Amplification) 实际写入磁盘的数据量 / 应用写入的数据量 CPU 和 IO 飙升,Checkpoint 变慢
读放大 (Read Amplification) 实际读取的磁盘数据量 / 应用需要的数据量 查询延迟增加,Keyed State 访问变慢
空间放大 (Space Amplification) 磁盘实际占用 / 有效数据大小 磁盘浪费,GC 频繁,甚至 OOM

核心矛盾:这三个放大是跷跷板。你压低了写放大,读放大和空间放大就会翘起来。没有银弹,只有权衡。

3.2 写放大:最直接的性能杀手

写放大是 RocksDB 调优里我最先关注的点。为什么?因为 Flink 的 Checkpoint 本质就是一次大规模写入。写放大高了,Checkpoint 就慢,端到端延迟就崩。

写放大的主要来源是 compaction。数据从 L0 往下刷,每层都要重写。我见过一个极端案例,默认配置下写放大系数到了 30+,意味着你写 1GB 数据,磁盘实际要写 30GB。

3.2.1 写放大的计算公式

写放大 ≈ (L1 大小 / L0 大小) × (层数 - 1) + 1

举个例子:如果每层是上一层的 10 倍,有 4 层,那写放大就是 10 × 3 + 1 = 31。恐怖吧?

3.2.2 如何降低写放大?

  • 增大每层的大小倍数:target_file_size_base 和 max_bytes_for_level_multiplier。倍数越大,层数越少,写放大越低。但代价是读放大上升。
  • 减少层数:num_levels 设小一点。我习惯设 4 或 5,默认 7 对大多数 Flink 场景偏多。
  • 使用 Level-Compaction 而非 Universal:Universal 的写放大更高,适合日志场景,不适合 Flink State。

我的经验:对于大多数 Flink 作业,把 max_bytes_for_level_multiplier 从默认的 10 调到 20,写放大能从 30 降到 15 左右。代价是读放大增加约 20%,但整体吞吐提升明显。

3.3 读放大:隐蔽的延迟陷阱

读放大很多人容易忽略。为什么?因为 Flink 的 State 访问模式是写多读少?其实不是。Keyed Process Function 里每次来一条数据都要查 State,这就是读操作。

读放大的来源是 LSM-Tree 的层次结构。你要查一个 key,得从 L0 开始,一层层往下找。每层可能有好几个 SST 文件,每个文件都要检查。

3.3.1 读放大的构成

读放大 ≈ L0 文件数 + (层数 - 1) × 每层文件数

假设 L0 有 10 个文件,下面 3 层每层 5 个文件,那读放大就是 10 + 3 × 5 = 25。意味着一次 get 操作,可能要读 25 个文件。

3.3.2 如何降低读放大?

  • 开启 Bloom Filter:这是最有效的。Bloom Filter 能快速判断 key 不在某个 SST 里,避免无效 IO。我建议 bloom_locality 设为 1,bits_per_key 设为 10。
  • 控制 L0 文件数:level0_file_num_compaction_trigger 不要设太大。默认 4,我一般设 8,平衡写放大和读放大。
  • 使用 Partitioned Filter:大 SST 文件时,Partitioned Filter 比 Full Filter 更省内存。

我曾经踩过的坑:有一次 Bloom Filter 没开,结果读放大飙到 50+,State 查询延迟从 1ms 涨到 50ms,整个作业的吞吐直接腰斩。排查了两天才找到原因。嗯,从那以后 Bloom Filter 成了我的必开选项。

3.4 空间放大:磁盘的隐形杀手

空间放大最容易被忽视,因为它不直接影响延迟。但磁盘满了,作业就挂了。

空间放大的来源是过期数据和 compaction 的中间产物。RocksDB 的删除操作只是标记删除,实际空间要等 compaction 时才释放。

3.4.1 空间放大的典型场景

我遇到过最夸张的一次,一个 Flink 作业跑了 3 天,State 实际数据只有 50GB,但磁盘占用了 200GB。空间放大系数 4。原因是什么?TTL 过期数据太多,compaction 跟不上。

3.4.2 如何控制空间放大?

  • 调整 TTL 策略:ttl_seconds 设合理值。不要设太长,过期数据会堆积。
  • 开启 avoid_flush_during_shutdown:避免 shutdown 时 flush 导致空间暴涨。
  • 使用 max_background_compactions:增加 compaction 线程数,让过期数据尽快清理。

我的建议:监控磁盘使用率,设置告警阈值 70%。一旦超过,优先检查空间放大。用 rocksdb.estimate-live-data-size 和 rocksdb.total-sst-files-size 两个指标对比,就能算出空间放大系数。

3.5 三角权衡:如何找到你的平衡点?

这三个放大不可能同时最优。你需要根据作业特征做取舍。下面这张图是我自己总结的决策逻辑。

读写放大三角权衡决策图 写放大 读放大 空间放大 写放大 ↓ → 读放大 ↑ + 空间放大 ↑ 读放大 ↓ → 写放大 ↑ + 空间放大 ↑ Checkpoint 密集型 优先降低写放大 增大层倍数、减少层数 查询密集型 优先降低读放大 开启 Bloom Filter 磁盘敏感型 优先降低空间放大 加速 compaction

3.6 实战参数配置建议

说了这么多理论,给点干货。下面是我在不同场景下的参数配置模板。

参数 写优先(Checkpoint 密集) 读优先(查询密集) 空间优先(磁盘紧张)
max_bytes_for_level_multiplier 20 10 15
num_levels 4 6 5
level0_file_num_compaction_trigger 8 4 6
bloom_locality 0 1 0
max_background_compactions 2 4 6

重要提醒:这些参数不是死的。我每次调优都会先跑一个基准测试,观察三个放大的实际值,再微调。记住,没有万能配置,只有适合你作业的配置。

3.7 监控三放大的实用方法

调优的前提是能监控。RocksDB 暴露了很多有用的指标。我常用的几个:

  • 写放大:rocksdb.bytes-written / rocksdb.bytes-ingested
  • 读放大:rocksdb.number-of-keys-read / rocksdb.number-of-keys-found
  • 空间放大:rocksdb.total-sst-files-size / rocksdb.estimate-live-data-size

我的习惯:把这些指标接入 Grafana,做成实时看板。一旦某个放大系数超过阈值,立刻告警。我曾经靠这个提前发现了一次 compaction 风暴,避免了作业重启。