WAL、版本清单和压缩:崩溃后哪些写入算数
沿着 WAL 追加、内存表刷盘、版本清单更新、压缩结果安装和恢复流程,说明存储系统如何确认崩溃前的写入历史。
存储系统最麻烦的问题,不是正常路径写入成功,而是崩溃后怎么回答一句话:哪些写入已经算数,哪些只是半成品。
把几个崩溃点放在一起看,比孤立地记忆 WAL、版本清单和压缩更有用:
WAL 记录写了一半
WAL 写完但内存表还没更新
内存表刷盘生成了 SSTable,但版本清单尚未记录
版本清单记录了新文件,但旧文件还没删除
压缩输出了一半
删除墓碑已经写入,但低层旧值仍然存在
恢复逻辑的本质,就是在一堆文件和日志里重新确认正式历史。
call path
恢复时真正关心的是提交边界
- 1
写入路径
WAL
追加记录
写入先进入可以重放的事实来源
- 2
写入路径
SSTable
将不可变内存表刷盘
内存状态变成不可变文件
- 3
SSTable
版本清单
安装版本更新
文件被记录后才属于当前版本
- 4
恢复流程
版本清单
重放元数据历史
重启后确认有效文件和 WAL 重放边界
写入成功的边界在 WAL 策略里
WAL 遵循预写原则:数据进入内存结构前,先把日志写下来。
简化路径:
追加 WAL 记录
-> 根据策略执行 fsync 或分组提交
-> 更新内存表
-> 返回成功
这里有一个必须说清楚的边界:系统什么时候对客户端返回成功?
如果返回前 WAL 没有持久化,崩溃后这次写入可能消失。很多系统会用 group commit 在多个写入之间摊 fsync 成本:
write 1
write 2
write 3
-> one fsync
-> all return
这不是免费优化。它改变的是延迟、吞吐和持久性窗口之间的取舍。
WAL 记录必须能识别未写完整的内容
WAL 写了一半进程挂掉,是恢复里非常具体的问题。
一个可靠的 WAL 格式通常需要:
- 记录长度。
- 类型与批次边界。
- 校验和。
- 序列号。
- 分片记录处理。
恢复时读到错误校验和或不完整记录,不能把后面的随机字节解释成写入。恢复过程必须能停在最后一条完整有效记录。
有效记录 seq=101
有效记录 seq=102
不完整记录 seq=103
-> 从这里忽略或截断
所以 WAL 格式不是文件格式洁癖。它直接决定崩溃后能不能找到最后的可信边界。
刷盘完成不等于文件已经进入版本历史
内存表刷盘会生成 SSTable,但 SSTable 文件存在,不代表它已经属于数据库当前版本。
常见的提交边界是版本清单更新:
写入表文件 000123.sst
对表文件和目录执行 fsync
向版本清单追加更新:在 L0 加入文件 000123
根据策略回收旧 WAL
如果崩溃发生在 SSTable 写完、版本清单更新之前,恢复时这个文件通常只能作为孤立文件或待确认输出处理,不能直接纳入有效文件集合。
这条规则很关键:文件存在不是事实,元数据承认才是事实。
版本清单记录数据文件的时间线
在 LSM 中,真正描述当前版本的不是目录里有哪些 .sst 文件,而是重放版本清单后得到的版本状态。
压缩会产生类似的更新:
delete file A from L1
delete file B from L1
add file C to L2
add file D to L2
只有这个更新成功安装以后,新文件才可见,旧文件才可以进入废弃文件清理流程。
如果版本清单写成功后崩溃,旧文件可能还在目录里,但恢复时它们不再属于有效文件。反过来,如果新文件已经写出,但版本清单没有写成功,恢复时不能把它们当作有效文件。
这就是为什么恢复入口通常先读取 CURRENT,找到版本清单,再重放其中的更新,而不是直接扫描所有 SSTable 猜测状态。
压缩的原子性依靠“新文件 + 版本清单更新”
压缩不会原地修改旧文件。它会读取旧文件、写入新文件,然后安装元数据更新。
inputs:
L1: A, B
L2: X, Y
outputs:
L2: C, D
install:
delete A, B, X, Y
add C, D
崩溃点有三类:
第一,输出文件没写完。恢复时应该清理。
第二,输出文件写完但版本清单更新尚未安装。恢复时仍然不能使用这些新文件。
第三,版本清单更新已经安装但旧文件尚未删除。恢复时有效文件集合已经指向新版本,旧文件只是待清理内容,后续可以删除。
这个设计把危险的原地修改变成了可恢复的版本切换。
墓碑标记不等于删除完成
在 LSM 中,删除通常通过写入墓碑标记完成:
seq=200 key=a tombstone
seq=150 key=a value=v2
seq=100 key=a value=v1
读取路径看到当前快照可见的墓碑标记,就认为键不存在。但低层旧值仍然可能留在磁盘上。
墓碑标记什么时候能被清理,取决于:
- 是否已经覆盖所有可能包含旧值的更低层。
- 是否还有快照需要旧序列号。
- 是否有范围墓碑或合并操作数等复杂语义。
如果快照长时间不释放,压缩可能无法丢弃旧版本。于是即使删除很多数据,空间占用仍然不会下降。
序列号是恢复与可见性的共同依据
序列号把写入排成一条时间线。
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 和版本清单相关逻辑。
恢复入口不是“读所有文件”
一个典型恢复流程更像:
读取 CURRENT
-> 定位版本清单
-> 重放版本清单更新
-> 构建有效文件集合
-> 找出需要重放的 WAL 文件
-> 重放有效 WAL 记录
-> 重建内存表
-> 清理废弃文件和孤立文件
这里每一步都在缩小不确定性:
- 版本清单告诉你哪些 SSTable 属于正式版本。
- WAL 告诉你哪些写入还没进入 SSTable。
- 校验和与记录边界告诉你日志读到哪里可信。
- 废弃文件清理负责处理崩溃留下的旧文件和半成品。
恢复过程越依赖明确的元数据,就越不需要靠猜测。
基准测试不该只看正常运行
存储系统的基准测试经常只看吞吐、平均延迟和 p99,但恢复成本也是可用性的一部分。
我会额外测:
- 写入高峰后重启需要重放多久。
- WAL 堆积很多时打开数据库需要多久。
- 版本清单很大时,重放是否变慢。
- 压缩积压很高时,重启是否产生额外清理成本。
- 大量墓碑标记产生后,空间何时回收。
- 崩溃注入后是否有孤立文件残留。
线上最怕的是:正常跑的时候指标漂亮,一重启恢复几十分钟。
我会怎样做故障注入
读或验证一个存储引擎时,我更喜欢按崩溃点打断:
追加 WAL 之后、写入内存表之前
写入内存表之后、向客户端返回之前
写入 SSTable 文件之后、更新版本清单之前
更新版本清单之后、清理废弃文件之前
压缩输出过程中
写入墓碑标记之后、压缩之前
然后检查:
- 重启后数据是否符合持久性承诺。
- 半写 WAL 是否被截断。
- 孤立 SSTable 是否被清理。
- 有效文件集合是否只来自版本清单。
- 旧快照语义是否保持。
- 空间回收是否最终发生。
如果这些点都能解释清楚,WAL、版本清单、压缩和恢复才真正连成了一个存储系统。
崩溃恢复是在确认历史,不是在修文件
WAL 记录尚未稳定成 SSTable 的写入,版本清单记录哪些 SSTable 属于当前版本,压缩用新文件和元数据更新替换旧历史,恢复流程则在重启时重新核对这些证据。
所以恢复不是“尽量把文件救回来”,而是确认哪些历史已经被系统正式承认。