先说结论
RocksDB 的 Level Compaction,本质上不是“压缩文件体积”这么简单。
它真正做的是:
把新写入的数据一层一层往下整理,把旧版本、删除标记、重复数据清掉,同时让磁盘上的文件保持一种对查询更友好的结构。
更通俗的解释是:
RocksDB 写入很快,是因为它先不管旧数据,直接写新的;
Level Compaction 就是后面慢慢帮它“收拾残局”的后台清理工。
为什么 RocksDB 需要 compaction?
先从最简单的场景开始,假设我们写入一个 key:
user:1 = Alice
后来更新成:
user:1 = Bob
再后来删除它:
delete user:1
如果是传统的 B+Tree 数据库,可能会去原来的位置改掉旧值。
但 RocksDB 不是这么做的。RocksDB 基于 LSM-Tree,它更偏向追加写。也就是说,它不会急着原地修改,而是不断写入新的记录,所以磁盘上可能会变成这样:
旧文件 A: user:1 = Alice
新文件 B: user:1 = Bob
新文件 C: delete user:1
这对写入很友好,因为不用频繁随机写磁盘,但问题也跟着来了:
- 同一个 key 可能有多个版本;
- 删除不是真的马上物理删除,而是写了一个 tombstone;
- 查询时可能要看很多文件,才能找到最新结果;
- 旧数据会占磁盘;
- 文件越来越多,读放大、空间放大都会变高。
所以 RocksDB 必须有一个后台机制,把这些东西定期整理掉,这个机制就是 compaction。
LSM-Tree 的磁盘结构长什么样?
RocksDB 写入路径大概是这样:
Write
-> WAL
-> MemTable
-> Immutable MemTable
-> Flush 成 SST 文件
MemTable 是内存里的有序结构。它满了之后,会变成 immutable memtable,然后被 flush 到磁盘,生成 SST 文件。这些 SST 文件不会乱堆,而是被分到不同的 level 里:
L0: [file1] [file2] [file3]
L1: [file4] [file5]
L2: [file6] [file7] [file8]
L3: ...
通常可以这么理解:
- 越上面的 level,数据越新;
- 越下面的 level,数据越旧,也越稳定;
- 数据会随着 compaction 从上往下移动。
所以 RocksDB 的磁盘结构,像一个分层漏斗:
新数据
↓
L0
↓
L1
↓
L2
↓
L3
↓
更老、更稳定的数据
Level Compaction 到底在干什么?
Level Compaction 做的事情可以拆成四类。
a. 把上层数据合并到下层
比如 L1 太大了,RocksDB 会选一些 L1 文件,把它们和 L2 中 key range 有重叠的文件一起拿出来,做归并,然后重新生成新的 L2 文件。
简单画一下:
L1:
fileA: [d, k]
L2:
fileB: [a, f]
fileC: [g, m]
fileD: [n, z]
fileA [d, k] 和 L2 的 fileB [a, f]、fileC [g, m] 有重叠。
所以这次 compaction 的输入就是:
Input:
L1 fileA
L2 fileB
L2 fileC
compaction 之后会生成新的 L2 文件,并删除旧文件。
b. 清理旧版本
假设不同文件里有这些记录:
user:1 = Alice
user:1 = Bob
如果没有 snapshot 还需要 Alice 这个旧版本,那么 compact 后只需要保留:
user:1 = Bob
这就是旧版本清理,但是这里有一个严谨点要注意:
RocksDB 不是看到旧版本就一定能删。
如果有 snapshot 或 iterator 还可能读到旧版本,那旧版本就不能随便丢。
所以 compaction 的清理是有条件的,不是简单粗暴地“只留最新”。
c. 清理 tombstone
删除在 RocksDB 里通常是写一个 tombstone:
delete user:1
它的作用是告诉读路径:
这个 key 已经被删除了,即使下面某一层还有旧值,也不能返回那个旧值。
比如:
L1:
delete user:1
L3:
user:1 = Alice
这时候 tombstone 不能丢。如果丢了,查询往下读到 L3,就可能把 Alice 又读出来,这是错误的。只有当 compaction 确认更底层已经没有需要被这个 tombstone 覆盖的旧数据时,tombstone 才能被清掉。
所以大量删除后,磁盘空间不一定马上下降。很多时候不是 RocksDB 没删,而是 tombstone 还没有通过 compaction 走到可以安全清理的位置。
c. 控制每层大小
Level Compaction 还有一个重要目标:控制每个 level 的大小。
一般来说,越下面的 level 目标容量越大。比如:
L1: 256 MB
L2: 2.56 GB
L3: 25.6 GB
L4: 256 GB
如果某一层超过目标大小,就会触发 compaction,把数据推到下一层。RocksDB 内部会给每一层算一个类似 “压力分数” 的东西,也就是 compaction score。
大致可以理解为:
compaction score = 当前 level 大小 / 当前 level 目标大小
如果 score 大于 1,说明这一层已经超标,需要 compact。
L0 为什么最特殊?
理解 Level Compaction,一定要先理解 L0。
L0 和其他 level 最大的区别是:
L0 文件之间的 key range 可以互相重叠。
为什么?
因为 L0 文件是 MemTable flush 直接生成的。每次 flush 出来的 key range 取决于当时内存里有什么数据,不一定规整。所以 L0 可能长这样:
L0:
file1: [a, z]
file2: [b, y]
file3: [a, m]
file4: [c, x]
这些文件范围大量重叠。这会带来一个很直接的问题:读一个 key 的时候,可能要查多个 L0 文件。
比如查 user:100,这些 L0 文件都有可能包含它。RocksDB 不能只看一个文件。
而 L1 及以下通常不一样。L1、L2、L3 这些 level 里,同一层的文件 key range 一般是不重叠的:
L2:
fileA: [a, f]
fileB: [g, m]
fileC: [n, z]
这样查一个 key,在每一层通常最多只需要看一个文件。所以 L0 文件数是 RocksDB 里非常关键的指标。如果 L0 文件数太多,会出现:
读放大变高
compaction 压力变大
写入 slowdown
严重时 write stall
常见相关参数有:
level0_file_num_compaction_trigger
level0_slowdown_writes_trigger
level0_stop_writes_trigger
可以这样理解:
L0 文件数达到 compaction_trigger -> 开始积极 compact
L0 文件数达到 slowdown_trigger -> 写入开始变慢
L0 文件数达到 stop_trigger -> 写入可能被暂停
所以线上看到 L0 file count 持续上升时,一般要非常重视。
一次 Level Compaction 的过程
假设现在 L1 超过了目标大小,RocksDB 决定把一部分 L1 数据 compact 到 L2。
它大概会做这些步骤。
第一步:从当前层选文件
比如选中 L1 的一个文件:
L1 fileA: [d, k]
第二步:找到下一层重叠文件
L2 里有这些文件:
L2 fileB: [a, f]
L2 fileC: [g, m]
L2 fileD: [n, z]
fileA [d, k] 跟 fileB [a, f] 和 fileC [g, m] 重叠。
所以输入文件是:
L1:
fileA: [d, k]
L2:
fileB: [a, f]
fileC: [g, m]
第三步:多路归并
SST 文件内部是按 key 有序的,所以 compaction 可以像归并排序一样,把多个文件按顺序读出来。
比如:
fileA:
d = new
h = new
k = new
fileB:
a = 1
d = old
f = 2
fileC:
g = 3
h = old
m = 4
归并后,旧版本会被清掉,输出可能是:
a = 1
d = new
f = 2
g = 3
h = new
k = new
m = 4
第四步:写出新文件,删除旧文件
compaction 会生成新的 SST 文件写入 L2,然后旧的输入文件就可以从元数据里移除,后续由后台清理。
所以整体是:
读旧文件
-> 合并
-> 清理旧版本和 tombstone
-> 写新文件
-> 删除旧文件
为什么 Level Compaction 能降低读放大?
原因就在于它维持了一个重要性质:
L1 及以下,同一层内的 SST 文件 key range 尽量不重叠。
于是点查一个 key 时,读路径大概是:
MemTable
Immutable MemTable
L0: 可能查多个文件
L1: 最多查一个文件
L2: 最多查一个文件
L3: 最多查一个文件
...
这个结构对读很友好。
如果没有这种分层和不重叠约束,某一层里可能有大量文件都覆盖同一个 key,查询就要挨个试,读放大会明显变高。
所以 Level Compaction 的优势是:
读延迟更稳定
点查成本更可控
空间放大相对可控
但它的代价是什么?
Level Compaction 最大的代价是:
写放大比较高。
为什么?
因为 compact 一个上层文件时,经常需要把下层所有重叠文件也拿出来重写。比如:
L1 有一个 64 MB 文件
它 compact 到 L2 时,可能和 L2 的 10 个文件重叠:
L2 重叠文件总大小 = 640 MB
那这次 compaction 可能要:
读:64 MB + 640 MB
写:接近 704 MB
从业务角度看,你只是新写了 64 MB 数据;但底层为了保持 level 结构,可能重写了几百 MB 的旧数据。 这就是写放大。 所以 Level Compaction 是一个典型的取舍:
用更高的后台写入成本
换更稳定的前台读取性能和更可控的空间放大
Trivial Move:不是每次都要重写
有一种情况很幸运:上层文件和下一层没有任何 key range 重叠。
比如:
L1:
fileA: [a, f]
L2:
fileB: [g, m]
fileC: [n, z]
fileA [a, f] 和 L2 里的文件完全不重叠。这种情况下,RocksDB 不需要真的读写文件内容,只要把元数据改一下:
fileA 从 L1 移到 L2
这叫 trivial move。它非常便宜,因为本质上只是改元数据,不需要重写 SST。但如果范围有重叠:
L1:
fileA: [d, k]
L2:
fileB: [a, f]
fileC: [g, m]
那就不能 trivial move,只能正常 compaction。
dynamic_level_bytes 是在解决什么问题?
RocksDB 有一个常见参数:
level_compaction_dynamic_level_bytes
它的作用是动态调整 level 的目标大小,尤其是 base level 的选择。不开启时,每层大小通常从 L1 开始按倍数增长:
L1: 256 MB
L2: 2.56 GB
L3: 25.6 GB
L4: 256 GB
开启后,RocksDB 会根据当前数据总量动态安排层级。如果当前数据量还不大,它可能不会强行让 L1、L2 都承担固定大小,而是让数据更合理地落到某个更深的 base level。粗略理解:
小数据量时:
L1/L2 可能目标很小,甚至接近空
L0 compact 出来的数据可能直接进入更深的 base level
数据量变大后:
base level 再逐渐往上调整
这样做的好处是:
减少不必要的层级搬运
让数据分布更贴近真实规模
降低一些空间放大和 compaction 抖动
但注意,它不是让 compaction 消失,而是让 level 规划更合理。
为什么 L0 文件会爆炸?
线上经常会看到一个现象:L0 file count 一直涨。
这通常说明:
flush 速度 > compaction 消化速度
也就是说,MemTable 生成 SST 的速度太快,而后台 compaction 把 L0 往下推的速度跟不上。
常见原因包括:
- 写入太猛;
- compaction 线程不够;
- 磁盘 IO 不够;
- 下层 compaction 卡住,导致 L0 下推不动;
- key 分布太随机,L0 文件和下层重叠范围太大;
- tombstone 太多,compaction 成本变高;
- snapshot 或 iterator 长时间存在,旧版本不能清理;
- rate limiter 限得太狠,后台消化能力不足。
L0 爆炸的典型表现:
L0 file count 持续上升
pending compaction bytes 持续上升
write stall 增多
写延迟抖动
读延迟也变差
这里不要只盯着“写入 QPS 大不大”。
有时候写入 QPS 不算离谱,但 key range 分布很差,每个 L0 文件都覆盖很宽的范围,导致每次 compaction 都牵连很多下层文件,照样会把 compaction 打爆。
为什么删除后磁盘空间不马上下降?
这个问题在 RocksDB 里非常常见。用户删除了很多数据,但磁盘没明显下降,通常不是 bug。原因主要有几个。
1. 删除先变成 tombstone
删除不是马上物理删除,而是写入删除标记。
delete user:1
只要更底层还有旧数据,这个 tombstone 就必须留下来。
2. tombstone 要等 compaction 传播
如果旧值在 L5,tombstone 还在 L1,那它还没真正覆盖到底层。只有 compaction 一层层把它往下推,直到确认可以安全清理时,空间才可能释放。
3. snapshot 会阻止旧版本清理
如果还有 snapshot 需要看到旧数据,那旧版本不能删除。长时间 snapshot、长 iterator、长事务、backup,都可能影响旧版本释放。
4. compaction 本身需要临时空间
compaction 通常是先写新文件,再删除旧文件。所以在 compaction 过程中,磁盘占用甚至可能短时间上升。
key range 为什么会影响 compaction 成本?
Level Compaction 的成本,不只跟写入量有关,还跟 key range 的重叠程度有关。如果上层文件范围很窄:
L1 file: [user:1000, user:2000]
它可能只和 L2 的少量文件重叠。但如果上层文件范围很宽:
L1 file: [user:1, user:999999999]
那它可能和 L2 的大量文件重叠。这会导致一次 compaction 读写很多下层文件,成本非常高。所以判断 compaction 压力时,要看三个因素:
写入量
key 分布
文件重叠范围
顺序写、范围写,通常比较容易形成局部性。随机写、热点分散写,就更容易让文件范围变宽,造成更大的 compaction 放大。
Level Compaction 和 Universal Compaction 有什么区别?
简单说:
| 对比项 | Level Compaction | Universal Compaction |
|---|---|---|
| 文件组织 | 分 level,L1 以下尽量不重叠 | 多个 sorted run,允许更多重叠 |
| 读放大 | 通常较低、较稳定 | 可能更高 |
| 写放大 | 通常较高 | 通常较低 |
| 空间放大 | 通常较可控 | 可能更高 |
| 适合场景 | 在线服务、点查多、读延迟敏感 | 写入吞吐优先、批量导入、读放大可接受 |
所以 Level Compaction 更像是:
我愿意多花后台写 IO,把数据整理得更适合读。
Universal Compaction 更像是:
我先尽量少重写,写入吞吐优先,读的时候再付出一些代价。
这不是谁绝对更好,而是取决于业务负载。
看 RocksDB compaction 是否健康,主要看什么?
常见关注指标:
num-files-at-level0
num-files-at-levelN
pending compaction bytes
compaction read bytes
compaction write bytes
write stall duration
flush write bytes
level size
background errors
disk util / iowait
一些典型判断:
L0 文件数持续上升
说明 L0 消化不过来,可能要检查 compaction 线程、磁盘 IO、下层是否阻塞、key range 是否太宽。
pending compaction bytes 持续上升
说明后台欠账越来越多。如果长期不下降,后面大概率会反映到写入延迟和 write stall。
write stall 频繁出现
说明 RocksDB 已经开始主动限制前台写入,防止后台彻底崩掉。这通常已经不是轻微问题了。
删除后空间不释放
优先排查:
tombstone 是否已经 compact 到底层
是否有长 snapshot
是否有长 iterator
是否有事务、backup、CDC 等长时间持有旧视图
调参应该怎么想?
调 Level Compaction,不建议上来就改一堆参数。要先判断瓶颈在哪里。
a. 如果 compaction 线程不够
可以看:
max_background_jobs
max_background_compactions
增加后台任务并发,可能提升消化能力。但要注意:如果磁盘已经满载,加线程没用,甚至会更抖。
b. 如果 L0 经常堆积
关注:
level0_file_num_compaction_trigger
level0_slowdown_writes_trigger
level0_stop_writes_trigger
但是不能只把阈值调大。阈值调大只是允许 L0 堆更多文件,短期可能减少 stall,长期可能让读放大和后续 compaction 更糟。真正要看的是:
为什么 L0 下不去?
是线程不够?
是 IO 不够?
是 L1/L2 compaction 卡住?
是 key range 太宽?
c. 如果磁盘 IO 被 compaction 打满
可以考虑 rate limiter:
rate_limiter
它可以限制后台 compaction 对 IO 的冲击。但也要小心:限制太狠,compaction 欠账会越来越多,最后 L0 还是会爆。
d. 如果 level 结构不合理
关注:
max_bytes_for_level_base
max_bytes_for_level_multiplier
level_compaction_dynamic_level_bytes
这些参数会影响每层目标大小,以及数据往下推的节奏。尤其在数据量变化比较大的场景,level_compaction_dynamic_level_bytes 通常值得关注。
e. 如果 SST 文件数量太多或单次 compaction 太重
关注:
target_file_size_base
target_file_size_multiplier
文件太小,会导致文件数量多、元数据多、查找成本高。文件太大,会导致单次 compaction 成本高,延迟抖动可能更明显。
最终怎么理解 Level Compaction
Level Compaction 的核心不是“压缩”,而是“整理”。
它整理的是:
重复版本
删除标记
文件重叠
level 大小
读路径复杂度
它的取舍是:
牺牲一部分后台写放大
换取更稳定的读性能和更可控的空间占用
所以当我们分析 RocksDB 的 compaction 问题时,不要只问:
为什么 compaction 慢?
更应该问:
是哪一层在欠账?
L0 为什么堆起来?
key range 是否太宽?
下层是否阻塞?
磁盘 IO 是否已经满了?
snapshot 是否阻止清理?
tombstone 是否堆积?
这些问题连起来,才是 Level Compaction 的真实工作方式。
总结
Level Compaction 可以这样理解:
- RocksDB 为了写得快,选择追加写,不原地更新;
- 追加写会产生旧版本、tombstone、文件重叠;
- compaction 负责在后台把这些东西整理掉;
- L0 最特殊,因为 L0 文件可以互相重叠,L0 文件数太多会严重影响读写;
- L1 及以下尽量保持同层文件 key range 不重叠,所以点查性能更稳定;
- Level Compaction 的读放大和空间放大相对可控,但写放大更高;
- 删除后空间不马上释放,通常和 tombstone、snapshot、compaction 进度有关;
- 排查 compaction 问题时,要同时看 level 大小、L0 文件数、pending compaction bytes、write stall、磁盘 IO 和 key range 重叠。
Level Compaction 是 RocksDB 用后台持续重写和整理数据的方式,来维持前台读写性能稳定。它不是免费的,代价就是后台 IO 和写放大。