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),数据放容量盘。
关联
- 前置:44.1 存储引擎:B+ 树与 LSM 树
- 并列:44.3 存储引擎:缓冲池与页面管理、18.3 复制日志 (语句 / 行 / 逻辑)
- 下游:10.4 快照与备份、44.10 事务与隔离级别的实现 (锁 / MVCC)