LSM 树笔记:一个键从写入到压缩的生命周期

沿着一个键的写入与压缩过程,梳理 WAL、MemTable、SSTable、墓碑标记和写入停顿如何共同影响后台开销。

Engineering notesStorage systems

LSM 树最容易被一句“写入快”概括得过于简单。

更准确地说,LSM 树把随机原地更新改成顺序追加和后台整理。前台写入变轻了,但代价并没有消失,而是转移成读放大、写放大、空间放大、压缩债务和写入停顿。

我现在更愿意沿着一个键的生命周期理解 LSM,而不是孤立地记忆内存表、SSTable 和压缩。

text
写入 key=a
  -> WAL 记录写入意图
  -> 内存表保存最新版本
  -> 刷盘生成 L0 SSTable
  -> 压缩将它逐层下移
  -> 后续更新或删除产生新版本
  -> 确认安全后才移除旧版本

这条线能解释 LSM 为什么快,也能解释它为什么会突然慢。

flow

一个键在 LSM 中的生命周期

写入路径尽量短;后台路径负责逐步整理旧版本、墓碑标记和重叠文件。

  1. 1

    WAL

    写入先成为可恢复事实

    追加写入序列号分组提交
  2. 2

    MemTable

    内存有序结构接住最新版本

    可变不可变跳表 / 内存区
  3. 3

    L0 SSTable

    刷盘输出不可变文件,键范围可能重叠

    范围重叠布隆过滤器块索引
  4. 4

    压缩

    合并有序文件,丢弃已覆盖版本和可以清理的墓碑标记

    任务选择合并迭代器版本清单更新

前台写入只做能快速确认的事

一次写入通常会被拆成两步:

text
append WAL
  -> insert into memtable

WAL 是恢复边界,内存表是查询边界。只要 WAL 策略允许,前台不需要去磁盘上寻找旧值的位置,也不需要更新某个 B+ 树页。它只需追加日志,再把新版本放进内存结构。

这就是 LSM 的写入优势。

但这个优势建立在一个前提上:后面有人会收拾旧版本。否则系统只是把垃圾堆到了未来。

SSTable 的不可变性让后台整理变得可控

内存表满了以后会变成不可变内存表,然后刷盘生成 SSTable。SSTable 写出后不再原地修改。

不可变文件有几个工程好处:

  • 读线程不需要担心文件内容被改。
  • 文件可以带块索引、过滤器块和范围元数据。
  • 压缩可以写新文件,再通过版本清单更新原子切换。
  • 崩溃恢复时可以根据文件编号、校验和版本清单判断哪些文件有效。

代价是更新不会覆盖旧值。

text
seq=30 key=a value=v3  in L0
seq=20 key=a value=v2  in L1
seq=10 key=a value=v1  in L3

读取路径必须找到当前快照可见的最新版本。删除也一样,只是写入一个更新的墓碑标记,而不是立刻从所有低层文件中抹掉旧值。

L0 是 LSM 的交通事故现场

L0 和其它层不同。刷盘生成的文件直接进入 L0,不同文件的键范围可能互相重叠。

这对单键查询很不友好。低层如果范围不重叠,系统可以根据键范围快速定位少数文件;L0 重叠时,一个键可能需要查询多个 L0 文件。

text
L0:
  file A: key range [a, z]
  file B: key range [b, y]
  file C: key range [a, m]

lookup key=k
  -> A/B/C may all need checking

所以 L0 文件数量是很重要的健康信号。它升高时,通常说明刷盘速度超过了压缩任务的消化速度,随后可能出现:

  • 单键查询变慢。
  • 布隆过滤器、过滤块和索引的查询次数增加。
  • 压缩被迫变得更激进。
  • 开始出现写入停顿。

LSM 的很多尾延迟毛刺不在写入入口,而在 L0 积压。

压缩任务选择器决定系统先偿还哪笔债

压缩不是简单的后台合并,它要先决定偿还哪笔债。

压缩任务选择器通常会考虑:

  • L0 文件数量。
  • 层大小是否超过目标。
  • 文件范围是否和下一层重叠。
  • 墓碑标记是否有机会清理。
  • 压缩评分。
  • 是否有正在运行的压缩任务。
  • 是否可以直接移动文件而无需重写。

被选中的文件会进入 merge iterator:

text
input files from level N
  + overlapping files from level N+1
  -> merge by key + sequence number
  -> keep newest visible version
  -> drop obsolete versions
  -> output new SSTables
  -> 更新并安装新的版本清单

这里最关键的是“可见”。如果仍有快照需要旧序列号,压缩就不能随意删除旧版本。

这也是为什么快照、长生命周期迭代器和事务读取会影响压缩回收。旧读取者存活得越久,旧版本和墓碑标记越难清理。

墓碑标记记录删除事实,但不立即回收空间

LSM 删除一个键时,通常会写入墓碑标记:

text
seq=40 key=a tombstone
seq=30 key=a value=v3
seq=20 key=a value=v2

读路径看到可见的墓碑标记后,就认为该键不存在,但磁盘空间未必立刻下降。只有压缩过程确认低层旧值都已被覆盖,并且没有快照需要旧版本时,墓碑标记和旧值才能一起清理。

大量删除的负载,危险就在这里:

  • 墓碑标记本身会增加写入。
  • 单键查询可能一路查到墓碑标记。
  • 范围扫描要跳过大量删除标记。
  • 空间回收依赖压缩是否及时。

所以遇到“删除了很多数据,磁盘占用为什么没降”,不应先怀疑错误,而应先看压缩是否已经推进到能够清理墓碑标记的层级。

三个放大是同一个账本的不同列

LSM 的代价常被分成三个放大:

text
写放大:
  用户写入字节数 -> 磁盘实际写入字节数

读放大:
  一次逻辑读取 -> 多个数据结构、文件或数据块

空间放大:
  逻辑有效数据量 -> 磁盘物理占用字节数

它们不是孤立指标。

更积极的分层压缩可以降低读放大和空间放大,但会提高写放大。更宽松的分层策略可以降低写放大,但会加重读取路径和空间占用。

text
分层压缩(leveling):
  读放大较低
  空间放大较低
  写放大较高

分级压缩(tiering):
  写放大较低
  读放大较高
  临时空间放大较高

所以讨论 LSM 参数时不能脱离实际负载。写多读少、读多写少、范围扫描多、TTL 或墓碑标记多,对应的最优点完全不同。

写入停顿意味着后台债务已经影响前台

LSM 的设计看起来把 compaction 放到了后台,但后台债务最终会回到前台。

常见触发包括:

  • 内存表太多,刷盘追不上。
  • L0 文件数超过软阈值或硬阈值。
  • 待压缩字节数过高。
  • 磁盘空间不足。
  • 后台 I/O 被限速或抢占。

这时系统会减慢甚至暂停写入,让压缩任务追赶积压。

text
前台写入
  -> 生成 WAL 并更新内存表
  -> 刷盘产生 L0 文件
  -> 压缩速度跟不上
  -> L0 文件持续增加
  -> 写入停顿

所以观察线上的 LSM,不要只看 QPS 和平均延迟。至少还要看 L0 文件数、待压缩字节数、停顿时间、刷盘与压缩吞吐,以及块缓存命中率。

我会怎么读一个 LSM 实现

读 RocksDB/LevelDB 这类实现时,我会按生命周期找入口:

text
写入路径:
  DBImpl::Write / WriteBatch
  WAL 写入器
  插入内存表

刷盘路径:
  不可变内存表
  表文件构建器
  安装 L0 文件

读取路径:
  查询内存表
  选择版本与文件
  布隆过滤器
  块缓存
  合并迭代器

压缩路径:
  选择压缩任务
  合并迭代器
  构建输出表文件
  版本编辑与清单文件

恢复路径:
  CURRENT
  重放清单文件
  重放 WAL
  清理废弃文件

具体函数名会随实现变化,但边界不会变:谁把写入变成可恢复事实,谁把内存变成不可变文件,谁把旧文件替换成新版本,谁在崩溃后确认当前历史。

LSM 的核心不是写入快,而是债务管理

LSM 树的前台写入路径很克制:追加 WAL,更新内存表,然后尽快返回。

真正决定系统能否长期稳定的,是后台能否持续还债:L0 能不能压住,旧版本能不能清掉,墓碑标记能不能回收,压缩能不能在不拖垮前台尾延迟的情况下推进。

写入快只是第一眼。债务管理才是 LSM 的工程核心。