第 8 章 · 故障恢复

上一章结尾欠的账现在还:事务凭什么叫「提交了就永不丢」(持久性 D)?凭什么「说回滚就一丝不留」(原子性 A)?答案全在一本流水账里。想象一种极端场景:数据库正在跑十几个事务,机房突然断电,内存(缓冲池里几十万条脏页)瞬间蒸发,只剩磁盘上参差不齐的现场——有的页是新的,有的是旧的,有的写了半页。重启之后,数据库要回答两个问题:哪些改动要补写回去?哪些改动要抹掉? 回答这两个问题的依据只有一个:磁盘上的日志。本章讲这本账怎么记、什么时候写、崩了之后怎么按账重建。

8.1 三档故障,三套急救

故障典型场景丢了什么急救手段
事务故障除零、死锁被选为牺牲者单个事务的中间状态undo 回滚
系统故障断电、内核 panic内存全部,磁盘完好redo + undo
介质故障磁盘坏、误删数据文件磁盘数据本身备份 + 日志重放

分档的意义:前两档磁盘数据没坏,只需要把「内存里没来得及落盘的已提交修改」补上(redo),把「没提交但已污染磁盘的修改」抹掉(undo)。第三档最狠,数据文件本身没了,救回来的原材料只剩「上一个备份 + 备份之后的全部日志」,备份间隔直接决定你最多丢多少数据(灾备术语叫 RPO)。顺带一提,「删库跑路」在介质故障那一档,运维圈那句「备份不做,十年白忙」就是给它准备的。

8.2 两本子账:redo 和 undo

日志里记两类条目,目的相反:

  • redo 记录:<T, A, 新值>。事务 T 把 A 改成了什么。用途:崩溃后把已提交事务的改动重放出来,撑持久性。
  • undo 记录:<T, A, 旧值>。事务 T 动 A 之前的原值。用途:把未提交事务的改动撤销回去,撑原子性。

一笔转账(A 减 100,B 加 100)在日志里的样子:

<T, 开始>
<T, A, 旧=1000, 新=900>
<T, B, 旧=500, 新=600>
<T, 提交>

恢复时读账本判生死:见 <T, 提交>,T 的所有改动重做(哪怕数据页已经落盘,重放一遍幂等无害);没见提交,T 的所有改动逆序撤销。一套账本,两个方向,解决全部前两档故障。

8.3 WAL:一条顺序压倒一切的铁律

恢复逻辑成立有一个前提:崩溃后日志必须比数据新。如果数据页先落盘、日志还在路上就断电,已提交的改动将无据可查。所以恢复体系的基石是那条著名的规则:

WAL(Write-Ahead Logging,预写式日志):任何数据页落盘之前,它对应的日志必须先落盘。

这条规则的妙处在于它同时是个性能优化。没有 WAL 的世界,每个提交都要把脏页刷盘,而脏页散在磁盘各处,全是随机写;有了 WAL,提交只需要把日志顺序追加写盘(顺序写比随机写快百倍,第 9 章的磁盘物理),脏页攒着慢慢刷。第 6 章讲过 SSD 怕随机小写,第 9 章会算机械盘顺序写的账,WAL 把「可靠性」和「性能」拧成了一个方向,这是存储领域最优雅的设计之一。Redis 的 AOF、Raft 的复制日志、Kafka 的分区日志,全是 WAL 思想的变体:先把意图顺序写下来,再慢慢消化。

8.4 检查点:别每次重启都读全部账本

日志越积越长,如果恢复要从开天辟地扫起,重启一次要几小时,等于换个姿势宕机。**检查点(checkpoint)**定期「结一次账」:把当前脏页全部刷盘、在日志里写下 <CHECKPOINT, 活跃事务清单>。之后的恢复从最近检查点起算,之前的账目「已两讫」不用再翻。

…… [检查点 C2] ……………… [崩溃]
恢复动作:C2 之后——已提交的 redo,未提交的 undo

检查点频率是道权衡题:密则恢复快(账本短)、但平时刷盘开销大;疏则平时快、重启久。InnoDB 的模糊检查点、PG 的 bgwriter 都在自动调这个平衡,运维只需看指标(innodb_checkpoint_age)别顶到上限。

8.5 完整的恢复剧本

把三档故障的处置串成剧本:

事务故障(活着的时候就处理):事务报错或死锁,用它的 undo 记录把改动逐个逆序抹掉,别的事务毫无感知。

系统故障(重启时处理):扫描最近检查点之后的日志;redo 已提交事务(按日志顺序重放新值);undo 未提交事务(逆序回旧值)。两阶段做完,磁盘回到「所有已提交事务生效、未提交痕迹归零」的一致点。

介质故障(换上备份后处理):装入最近一次全量备份(或全量 + 增量),再从备份时点起重放归档日志,追到崩溃瞬间。这套「备份 + 日志链」是所有数据库灾备的原型,MySQL 的 binlog 主从复制其实是「边重放边给另一台机器做」的实时版。

常见错误

  • 以为 COMMIT 返回就万事大吉。COMMIT 的语义是「日志已落盘」,不是「数据页已落盘」;脏页还在缓冲池里慢慢刷,WAL 保证这不影响正确性。反过来,「提交成功但磁盘阵列瞬间报废且备份缺失」时,数据真会丢,持久性承诺的有效半径就是日志和备份的保存位置。
  • 把 redo 当备份。日志重放的前提是数据文件还在;介质故障必须靠备份起步,日志只能「续命」,不能「无中生有」。
  • 乱动双一设置。innodb_flush_log_at_trx_commit = 1 + sync_binlog = 1(双 1)是资金类业务的标配,关掉换 TPS,掉电窗口变大,改之前算清 RPO。

练习

  1. 手算:日志序列 <T1 开始> <T1 A 旧9 新10> <T2 开始> <T2 B 旧7 新8> <T1 提交> [崩溃],写出恢复动作(A、B 各自最终值,谁被 redo 谁被 undo)。
  2. 手算:上题若 T2 也提交了,恢复动作和最终值变吗?再若 <T1 提交> 丢失(磁盘坏道),A 的最终值是多少?
  3. 动手:SQLite 试试 PRAGMA journal_mode;(默认 delete 模式),切到 WAL 后看看目录里多出的 -wal 文件;插一万行对比两种模式的写入耗时,亲测 WAL 的顺序写红利。
  4. 思考:主从复制架构下,「提交成功」返回给应用时,从库可能还没收到日志。主库此刻断电会怎样?这引出「同步复制 vs 异步复制」的取舍,查一下 MySQL 半同步复制(semi-sync)是怎么折中的。