WAL、版本清单和压缩:崩溃后哪些写入算数

沿着 WAL 追加、内存表刷盘、版本清单更新、压缩结果安装和恢复流程,说明存储系统如何确认崩溃前的写入历史。

Engineering notesStorage systems

存储系统最麻烦的问题,不是正常路径写入成功,而是崩溃后怎么回答一句话:哪些写入已经算数,哪些只是半成品。

把几个崩溃点放在一起看,比孤立地记忆 WAL、版本清单和压缩更有用:

text
WAL 记录写了一半
WAL 写完但内存表还没更新
内存表刷盘生成了 SSTable,但版本清单尚未记录
版本清单记录了新文件,但旧文件还没删除
压缩输出了一半
删除墓碑已经写入,但低层旧值仍然存在

恢复逻辑的本质,就是在一堆文件和日志里重新确认正式历史。

call path

恢复时真正关心的是提交边界

写入路径WALSSTable版本清单恢复流程
  1. 1

    写入路径

    WAL

    追加记录

    写入先进入可以重放的事实来源

  2. 2

    写入路径

    SSTable

    将不可变内存表刷盘

    内存状态变成不可变文件

  3. 3

    SSTable

    版本清单

    安装版本更新

    文件被记录后才属于当前版本

  4. 4

    恢复流程

    版本清单

    重放元数据历史

    重启后确认有效文件和 WAL 重放边界

写入成功的边界在 WAL 策略里

WAL 遵循预写原则:数据进入内存结构前,先把日志写下来。

简化路径:

text
追加 WAL 记录
  -> 根据策略执行 fsync 或分组提交
  -> 更新内存表
  -> 返回成功

这里有一个必须说清楚的边界:系统什么时候对客户端返回成功?

如果返回前 WAL 没有持久化,崩溃后这次写入可能消失。很多系统会用 group commit 在多个写入之间摊 fsync 成本:

text
write 1
write 2
write 3
  -> one fsync
  -> all return

这不是免费优化。它改变的是延迟、吞吐和持久性窗口之间的取舍。

WAL 记录必须能识别未写完整的内容

WAL 写了一半进程挂掉,是恢复里非常具体的问题。

一个可靠的 WAL 格式通常需要:

  • 记录长度。
  • 类型与批次边界。
  • 校验和。
  • 序列号。
  • 分片记录处理。

恢复时读到错误校验和或不完整记录,不能把后面的随机字节解释成写入。恢复过程必须能停在最后一条完整有效记录。

text
有效记录 seq=101
有效记录 seq=102
不完整记录 seq=103
  -> 从这里忽略或截断

所以 WAL 格式不是文件格式洁癖。它直接决定崩溃后能不能找到最后的可信边界。

刷盘完成不等于文件已经进入版本历史

内存表刷盘会生成 SSTable,但 SSTable 文件存在,不代表它已经属于数据库当前版本。

常见的提交边界是版本清单更新:

text
写入表文件 000123.sst
对表文件和目录执行 fsync
向版本清单追加更新:在 L0 加入文件 000123
根据策略回收旧 WAL

如果崩溃发生在 SSTable 写完、版本清单更新之前,恢复时这个文件通常只能作为孤立文件或待确认输出处理,不能直接纳入有效文件集合。

这条规则很关键:文件存在不是事实,元数据承认才是事实。

版本清单记录数据文件的时间线

在 LSM 中,真正描述当前版本的不是目录里有哪些 .sst 文件,而是重放版本清单后得到的版本状态。

压缩会产生类似的更新:

text
delete file A from L1
delete file B from L1
add file C to L2
add file D to L2

只有这个更新成功安装以后,新文件才可见,旧文件才可以进入废弃文件清理流程。

如果版本清单写成功后崩溃,旧文件可能还在目录里,但恢复时它们不再属于有效文件。反过来,如果新文件已经写出,但版本清单没有写成功,恢复时不能把它们当作有效文件。

这就是为什么恢复入口通常先读取 CURRENT,找到版本清单,再重放其中的更新,而不是直接扫描所有 SSTable 猜测状态。

压缩的原子性依靠“新文件 + 版本清单更新”

压缩不会原地修改旧文件。它会读取旧文件、写入新文件,然后安装元数据更新。

text
inputs:
  L1: A, B
  L2: X, Y

outputs:
  L2: C, D

install:
  delete A, B, X, Y
  add C, D

崩溃点有三类:

第一,输出文件没写完。恢复时应该清理。

第二,输出文件写完但版本清单更新尚未安装。恢复时仍然不能使用这些新文件。

第三,版本清单更新已经安装但旧文件尚未删除。恢复时有效文件集合已经指向新版本,旧文件只是待清理内容,后续可以删除。

这个设计把危险的原地修改变成了可恢复的版本切换。

墓碑标记不等于删除完成

在 LSM 中,删除通常通过写入墓碑标记完成:

text
seq=200 key=a tombstone
seq=150 key=a value=v2
seq=100 key=a value=v1

读取路径看到当前快照可见的墓碑标记,就认为键不存在。但低层旧值仍然可能留在磁盘上。

墓碑标记什么时候能被清理,取决于:

  • 是否已经覆盖所有可能包含旧值的更低层。
  • 是否还有快照需要旧序列号。
  • 是否有范围墓碑或合并操作数等复杂语义。

如果快照长时间不释放,压缩可能无法丢弃旧版本。于是即使删除很多数据,空间占用仍然不会下降。

序列号是恢复与可见性的共同依据

序列号把写入排成一条时间线。

text
seq=105 key=a value=v3
seq=101 key=b value=x
seq=98  key=a tombstone
seq=70  key=a value=v1

读取时要按快照序列号判断可见性。恢复时也要知道 WAL 从哪里开始重放、哪些内存表已经刷盘、哪些序列号已经进入 SSTable。

没有这条时间线,系统就很难同时处理:

  • 新写覆盖旧写。
  • 快照读取。
  • 墓碑标记清理。
  • WAL 重放。
  • 压缩丢弃旧版本。

这也是为什么序列号通常会贯穿 WAL、内存表、SSTable 和版本清单相关逻辑。

恢复入口不是“读所有文件”

一个典型恢复流程更像:

text
读取 CURRENT
  -> 定位版本清单
  -> 重放版本清单更新
  -> 构建有效文件集合
  -> 找出需要重放的 WAL 文件
  -> 重放有效 WAL 记录
  -> 重建内存表
  -> 清理废弃文件和孤立文件

这里每一步都在缩小不确定性:

  • 版本清单告诉你哪些 SSTable 属于正式版本。
  • WAL 告诉你哪些写入还没进入 SSTable。
  • 校验和与记录边界告诉你日志读到哪里可信。
  • 废弃文件清理负责处理崩溃留下的旧文件和半成品。

恢复过程越依赖明确的元数据,就越不需要靠猜测。

基准测试不该只看正常运行

存储系统的基准测试经常只看吞吐、平均延迟和 p99,但恢复成本也是可用性的一部分。

我会额外测:

  • 写入高峰后重启需要重放多久。
  • WAL 堆积很多时打开数据库需要多久。
  • 版本清单很大时,重放是否变慢。
  • 压缩积压很高时,重启是否产生额外清理成本。
  • 大量墓碑标记产生后,空间何时回收。
  • 崩溃注入后是否有孤立文件残留。

线上最怕的是:正常跑的时候指标漂亮,一重启恢复几十分钟。

我会怎样做故障注入

读或验证一个存储引擎时,我更喜欢按崩溃点打断:

text
追加 WAL 之后、写入内存表之前
写入内存表之后、向客户端返回之前
写入 SSTable 文件之后、更新版本清单之前
更新版本清单之后、清理废弃文件之前
压缩输出过程中
写入墓碑标记之后、压缩之前

然后检查:

  • 重启后数据是否符合持久性承诺。
  • 半写 WAL 是否被截断。
  • 孤立 SSTable 是否被清理。
  • 有效文件集合是否只来自版本清单。
  • 旧快照语义是否保持。
  • 空间回收是否最终发生。

如果这些点都能解释清楚,WAL、版本清单、压缩和恢复才真正连成了一个存储系统。

崩溃恢复是在确认历史,不是在修文件

WAL 记录尚未稳定成 SSTable 的写入,版本清单记录哪些 SSTable 属于当前版本,压缩用新文件和元数据更新替换旧历史,恢复流程则在重启时重新核对这些证据。

所以恢复不是“尽量把文件救回来”,而是确认哪些历史已经被系统正式承认。