RocksDB 核心架构:LSM-Tree 原理、MemTable、SST 文件、Compaction 机制详解
好,我们直接切入正题。RocksDB 之所以能扛住 Flink 这种高吞吐、低延迟的状态读写,核心秘密就藏在这套架构里。说白了,它用的不是传统 B+ 树,而是 LSM-Tree(Log-Structured Merge-Tree)。我第一次接触 LSM-Tree 时也觉得绕,但搞懂之后,你会发现它其实很符合人的直觉——先记在脑子里,等攒够了再整理到本子上。
LSM-Tree 的核心思想:延迟写入,批量合并
传统数据库为了读得快,写入时就要做大量排序和索引维护。LSM-Tree 反其道而行之:写入只管快,读的代价通过后台合并来分摊。你想想看,如果每次写一条记录都要去更新磁盘上的索引结构,那磁盘的随机 IO 会直接把你拖死。LSM-Tree 的做法是:先把所有写入都当成顺序日志追加到磁盘,同时在内存里维护一个有序结构。等内存满了,再一次性刷成只读文件。后台再慢慢把这些小文件合并成大文件。
核心权衡:LSM-Tree 牺牲了部分读性能(因为要查多个文件),换来了极致的写入吞吐。对于 Flink 这种写多读少(状态更新频繁,但每次只读当前 key)的场景,简直是天作之合。
我在项目中遇到过一位同事,非要用 B+ 树引擎来存 Flink 的窗口聚合状态,结果写入延迟从毫秒级飙升到几百毫秒,直接导致 checkpoint 超时。换回 RocksDB 后,一切恢复正常。这个教训让我深刻理解了:选对存储模型,比调一万个参数都重要。
MemTable:内存中的有序缓冲区
RocksDB 的写入第一站就是 MemTable。它是一个在内存中维护的有序数据结构,默认是跳表(SkipList)。写入时,数据先追加到 WAL(Write-Ahead Log)保证持久性,然后插入 MemTable。因为是在内存里操作,而且跳表的插入是 O(log n),所以写入极快。
每个 MemTable 都有一个大小上限,由 write_buffer_size 控制,默认 64MB。当 MemTable 满了,RocksDB 会把它变成只读的 Immutable MemTable,然后新建一个活跃的 MemTable 继续服务写入。后台的 Flush 线程会负责把 Immutable MemTable 刷成 SST 文件。
我的调优习惯:如果状态写入量特别大,我会适当调大 write_buffer_size 到 128MB 或 256MB,减少 Flush 频率。但别太大,否则 Flush 一次耗时过长,可能阻塞写入。我曾经在一个峰值写入 50MB/s 的任务上,把 write_buffer_size 从 64MB 调到 256MB,Flush 次数直接降了 60%,CPU 开销也下来了。
SST 文件:磁盘上的只读数据块
当 MemTable 被 Flush 到磁盘,就形成了 SST(Sorted String Table)文件。SST 文件是 RocksDB 在磁盘上的基本存储单元,内部数据是按 key 有序排列的。每个 SST 文件都包含多个数据块(Data Block)、索引块(Index Block)和布隆过滤器(Bloom Filter)。
布隆过滤器是个好东西。它能在不读取整个文件的情况下,快速判断某个 key 是否存在于这个 SST 中。虽然有一定的误判率(假阳性),但不会漏判。这意味着:查询时,可以跳过大量不包含目标 key 的 SST 文件,大幅减少 IO。
| SST 文件组件 | 作用 | 我关注的点 |
|---|---|---|
| Data Block | 存储实际的 key-value 数据,按 key 排序 | 块大小默认 4KB,调大能提高顺序读性能 |
| Index Block | 记录每个 Data Block 的起始 key 和偏移量 | 用于二分查找定位数据块 |
| Bloom Filter | 快速判断 key 是否在文件中 | 默认每个 SST 文件一个,可调精度 |
| Footer | 指向 Index Block 和 Meta Block 的指针 | 文件末尾固定位置,读取时先读它 |
随着时间推移,磁盘上会积累越来越多的 SST 文件。如果不加干预,读一条数据可能要查几十个文件,性能会急剧下降。这时候就需要 Compaction 登场了。
Compaction 机制:后台的整理大师
Compaction 是 LSM-Tree 的精华所在。它的任务就是:把多个小 SST 文件合并成更大的 SST 文件,同时清理掉被覆盖或删除的数据。RocksDB 默认使用 Leveled Compaction,这也是 Flink 官方推荐的策略。
Leveled Compaction 把数据分成多个层级(Level 0 到 Level N)。L0 是直接从 MemTable Flush 下来的文件,文件之间可能有 key 范围重叠。从 L1 开始,每个层级内的文件之间没有重叠,且每个层级的总大小呈指数增长(默认 10 倍)。
Compaction 的触发条件主要有三种:
- L0 文件数达到阈值(默认 4 个):触发 L0 到 L1 的合并
- 层级总大小超过限制:触发该层级到下一层级的合并
- 手动触发:通过
CompactRange接口强制合并
我曾经踩过的坑:在一个大状态任务中,我把 max_background_jobs 设成了 8,以为能加快 Compaction。结果 Compaction 线程和 Flush 线程抢 CPU,导致写入延迟剧烈抖动。后来我改成 4 个后台线程,并配合 soft_pending_compaction_bytes_limit 做限流,才稳定下来。记住:Compaction 不是越快越好,稳定才是王道。
整体架构流程图
下面这张图展示了 RocksDB 的完整写入和合并流程。你可以看到数据从写入到最终落盘的完整生命周期。
三种 Compaction 策略对比
RocksDB 提供了三种 Compaction 策略,Flink 默认使用 Leveled。我简单对比一下它们的差异:
| 策略 | 特点 | 写放大 | 读放大 | 空间放大 | 适用场景 |
|---|---|---|---|---|---|
| Leveled | 分层合并,每层大小指数增长 | 中等 (10-30x) | 低 | 低 | Flink 默认,读写均衡 |
| Universal | 全量合并,类似 Leveled 但更激进 | 低 (1-10x) | 中等 | 高 | 写多读少,SSD 友好 |
| FIFO | 按时间淘汰最旧文件 | 无 | 高 | 低 | TTL 过期场景,如日志 |
我的建议:除非你有非常特殊的场景(比如纯日志型状态),否则老老实实用 Leveled Compaction。Universal 虽然写放大低,但空间放大可能让你磁盘爆掉。我曾经在一个 500GB 状态的任务上试过 Universal,结果磁盘占用飙到 1.2TB,差点把集群搞崩。
写放大、读放大、空间放大
这三个概念是理解 LSM-Tree 性能的关键。我简单解释一下:
- 写放大:实际写入磁盘的数据量 / 应用写入的数据量。Compaction 会反复读写数据,导致写放大。Leveled 的写放大通常在 10-30 倍之间。
- 读放大:读取一条数据需要访问的 SST 文件数。因为数据分散在多个层级,读放大不可避免。布隆过滤器和缓存可以缓解。
- 空间放大:磁盘上实际占用的空间 / 有效数据大小。被覆盖或删除的数据要等到 Compaction 才会真正清理。
这三个指标是相互制约的。你想降低写放大,就得接受更高的空间放大;你想降低读放大,就得承受更高的写放大。RocksDB 的调优,本质上就是在三者之间找平衡点。
注意:Flink 的 checkpoint 机制会触发全量快照,这时候如果写放大太高,会导致 checkpoint 耗时剧增。我一般建议把 level0_file_num_compaction_trigger 从默认的 4 调到 2 或 3,让 L0 更早开始合并,避免 checkpoint 期间积压太多文件。