LSM-Tree 与 WAL:写放大与崩溃恢复
LSM-Tree(Log-Structured Merge-Tree)是写密集场景的事实标准:RocksDB、LevelDB、HBase、Cassandra 都在用它。它的核心思想只有一句话:把随机写变成顺序写。
写入路径:内存先行
写请求
→ 1. 追加到 WAL(顺序写磁盘,持久化)
→ 2. 写入 MemTable(内存跳表,可查询)
→ 3. MemTable 满 → 冻结为 Immutable MemTable
→ 4. 后台刷盘 → 生成 SSTable(不可变文件)
→ 5. 后台 Compaction 合并 SSTable
用户写请求只碰两个东西:WAL 的顺序追加和内存表。这就是 LSM 写快的全部原因。
为什么需要 WAL
MemTable 在内存里,崩溃就没了。WAL 是写给自己的「后悔药」:每次写先追加日志,崩溃后重放日志即可重建内存状态。顺序追加的性能开销很小,换来的是:
- 持久性:已确认(ACK)的写入不会丢
- 批量:一组写入可以合并刷一次 WAL
⚠️ 注意: 关掉 WAL(如
RocksDB的disableWAL)能再快一截,但换来的是「机器断电 = 丢最近写入」。线上绝不建议。
读路径:层层查找
LSM 没有「就地更新」,同一个键可能散落在多个层级:
读 key
→ 查 MemTable → Immutable → L0 SSTable → L1 → L2 → ...
(每一层都可能命中,都要查)
为了加速,每层内部维护布隆过滤器(Bloom Filter):大概率知道「这个键不在」,把无效的磁盘查找直接挡掉。
Compaction:LSM 的宿命
数据不断累积,层与层之间重叠越来越多,读放大越来越严重。后台线程周期性做 Compaction——合并重叠的 SSTable,生成新的、更有序的文件,删掉旧版本:
Compaction 前: L1: [a,b] [c,d] L2: [b,c] [d,e]
Compaction 后: L2: [a,b,c,d,e](旧文件删除)
代价是写放大:一条写下去的数据,可能在多次 Compaction 中被反复读改写。写放大系数(实际写盘量 / 逻辑写入量)是 LSM 调优的核心指标。
| 放大类型 | 含义 | 影响 |
|---|---|---|
| 写放大 | 实际写盘 / 逻辑写 | 磁盘寿命与吞吐 |
| 读放大 | 实际读盘 / 逻辑读 | 点查延迟 |
| 空间放大 | 占用空间 / 数据大小 | 磁盘成本 |
三者互相拉扯:Compaction 越勤,读放大越小、空间放大越小,但写放大越大。
与 B+ 树的取舍
| 维度 | B+ 树 | LSM-Tree |
|---|---|---|
| 写路径 | 随机页写(读改写) | 顺序追加 |
| 写吞吐 | 中 | 高 |
| 点查延迟 | 低(1~2 次 I/O) | 中(多层+布隆过滤) |
| 范围查询 | 优 | 中 |
| 最坏情况 | 稳定 | 依赖 Compaction 策略 |
一句话:写密集选 LSM,读密集选 B+ 树。混合负载则用分区(如 TiDB 的分区级选择)或双引擎(MySQL + Redis 缓存)。
小结
LSM-Tree 的设计哲学是「把昂贵的随机写延迟到后台批量解决」:前台只做顺序追加,后台慢慢整理。它用写放大换来了写吞吐,用布隆过滤器修补读路径,用 WAL 守住持久性底线——三个组件的配合,值得每个系统设计者反复琢磨。