44.2 存储引擎:WAL 与崩溃恢复

一句话

WAL(Write-Ahead Log)只做一件事:先写日志并落盘,再改数据页;崩溃后用日志重放 “已提交但未落页” 的修改,用 undo 回滚 “未提交” 的修改——这样 “提交” 只依赖一次顺序写 + fsync,而不依赖随机写页。

三个动作

阶段 做什么 落盘点
① 写日志 把修改记成 redo 记录(含 LSN) 顺序追加到 WAL
② fsync 把日志刷到磁盘 提交时(commit)
③ 改页 修改缓冲池中的数据页(脏页) 后台/检查点时刷
崩溃恢复 从检查点重放 redo,回滚未提交 undo 启动时

关键顺序:日志先落盘、页后落盘(Write-Ahead)。反过来就会 “数据页已改、日志没落”,崩溃后无法恢复。

实测:崩溃只丢 “未 fsync 的尾巴”

写 1000 条 WAL 记录,在第 500 条后 os._exit(9)(模拟 kill -9):

观察 结果
文件中完整记录 500 条
尾部半条 有(可能截断),重放时需丢弃
已 fsync 的记录 全部可恢复

这验证了 WAL 的 “前缀性质”:日志是顺序追加的,崩溃只会截断尾部,不会破坏已落盘的部分。所以恢复逻辑可以简单到 “从头读、遇到坏记录就停”。

// 提交路径:只有 fsync 成功才算提交
func (db *DB) Commit(tx *Tx) error {
    rec := encode(tx)                 // 生成 redo 记录(含 LSN)
    if _, err := db.wal.Write(rec); err != nil { return err }
    if err := db.wal.Sync(); err != nil { return err }   // 关键:fsync
    return nil                                            // 成功返回 = 已持久化
}

检查点:把 “日志无限增长” 掐住

检查点流程:
1. 记录当前 LSN(checkpoint_lsn)
2. 把该 LSN 之前的所有脏页刷盘
3. 更新 checkpoint 元数据(可复用日志空间)
崩溃恢复:从 checkpoint_lsn 开始重放,而不是从日志头开始

没有检查点,恢复时间会随日志增长无限变长(实测 20 万条回放 11.32ms 是内存模型,真实磁盘要按 IOPS 估算)。

反模式

反模式 后果
先改页后写日志 崩溃后无法恢复,数据页与日志不一致
批量提交时 “攒够再 fsync” 却不设上限 崩溃丢一批已 “返回成功” 的事务
日志与数据放同一块盘 顺序写与随机写互相干扰,fsync 变慢
不做检查点 恢复时间随运行时长线性增长
重放不幂等 重复应用 redo 导致数据翻倍
忽略 “半条记录” 恢复时解析崩溃,启动失败

三条铁律

🔑 先日志、后数据页;顺序反了 WAL 就失效。

🔑 提交 = fsync 成功;返回成功前必须落盘。

🔑 恢复要幂等 + 能处理截断的尾部。

自检

Q1 为什么 “先改数据页、再写日志” 会导致无法恢复?

▸ 答案:因为崩溃可能发生在 “页已改、日志未写” 的窗口:磁盘上是新数据,但日志里没有对应记录,恢复时既不能重放(没记录)也不能回滚(旧值已丢)。WAL 的意义就是 “日志是唯一真相”,页只是缓存,必须后写。

Q2 实测中崩溃后正好剩 500 条,为什么不是 499 或 501?

▸ 答案:因为每条记录写完后都 fsync,然后才写第 501 条时进程被杀:第 500 条已落盘,第 501 条还没开始写。如果 fsync 是批量做的,结果就会是 “最后一批全丢”;这正是 “提交时 fsync” 与 “定期 fsync” 的差别。

Q3 为什么检查点能缩短恢复时间?

▸ 答案:因为恢复只需从最近的检查点开始重放,而不是从日志开头。检查点之前的修改已经保证刷到数据页,日志空间也可以复用。代价是检查点时要刷脏页,会带来一次 IO 高峰——所以通常限速并避开业务高峰。

Q4 redo 与 undo 各解决什么?

▸ 答案:redo 解决 “已提交但页未落盘”:重放把修改补上(保证持久性);undo 解决 “未提交但页可能已改”:回滚把修改撤销(保证原子性)。崩溃恢复通常 “先 redo 再 undo”,得到 “已提交都在、未提交都不在”。

Q5 为什么日志要顺序写,而不能随机写?

▸ 答案:因为顺序写能利用磁盘/SSD 的批量与预写优势:机械盘无需寻道,SSD 减少写放大。随机写日志会让 fsync 延迟大幅上升,直接拖慢提交。这也是 “WAL 用追加文件” 的根本原因。

Q6 半条记录怎么处理?

▸ 答案:用记录头里的长度 + 校验和判断:读到不完整或校验失败的记录就停止重放(后面的都不可信)。这依赖 “日志是顺序追加” 的前缀性质——一旦出现坏记录,后面不可能还有完整记录。

Q7 为什么 “日志和数据放同一块盘” 会变慢?

▸ 答案:因为两者 IO 模式冲突:日志是顺序追加 + 频繁 fsync,数据页是随机读写。同一块盘上它们互相争抢队列,fsync 延迟被随机 IO 拉长。常见优化是日志单独放一块低延迟盘(或 NVMe),数据放容量盘。

关联