问题背景
上一篇把写路径的锁与死锁讲完,留了一个尾巴:事务"排队改完"只是内存里的事,它到底怎么变成磁盘上摔不碎的事实?这正是崩溃恢复要回答的。相关的一线现场你大概率遇到过:mysqld 被 OOM killer 杀掉或 kill -9,重启后数据一行没丢一行没多——谁做的账?innodb_flush_log_at_trx_commit 设成 1 和设成 2,压测 TPS 差出一倍,生产到底该选哪个?还有一改就心里发毛的"redo log 满了":SHOW ENGINE INNODB STATUS 里 log 区报错,整个实例瞬间冻住只出不进。这些现象背后是同一条主线:WAL(Write-Ahead Logging,提前写日志)协议、LSN 水位线、checkpoint 推进,以及 redo/undo 两本方向相反的账。本篇回答五个问题:为什么改一个字节要写一条日志而不是直接改页;redo 与 undo 各自负责什么、和 430 篇的 MVCC 版本链什么关系;checkpoint 如何决定"重启要恢复多久";trx_commit=0/1/2 分别丢什么数据;以及 torn page(撕页)这个 fsync 都防不住的坑。
核心原理
第一,WAL:把随机写变顺序写的协议。一个事务改 3 行,可能涉及 5 个 16KB 页;若提交时同步把这些页写回磁盘,就是把"页在哪就得写哪"的随机 IO 压进提交路径——机械盘上几毫秒一次,再摊上并发就全堵死。WAL 的换法:改动先以紧凑记录追加进 redo log(顺序写、一块内存 log buffer 攒批),页允许留在 Buffer Pool 做脏页晚点再写;协议只要求一条铁律——提交返回客户端之前,这笔事务的 redo 必须先落盘。于是提交成本从"多次随机页写"降为"一次顺序日志 fsync"。redo 记录是"物理为主"的:页号 + 偏移 + 改成什么(严格说是 logical-physical,MLOG 类型自带幂等判断),恢复时不需要理解 SQL 语义。
第二,redo 与 undo 是同一枚硬币的两面。每条 undo 记录也走 redo 通道落盘(undo 的修改本身也要可恢复)。分工上:redo 管"已发生的别丢"——崩溃后从 checkpoint 水位起重放,把已提交、页却没来得及写回的改动补上;undo 管"不该发生的别留"——回滚未提交事务,以及 430 篇讲过的 MVCC 版本链供快照读走。两者串在同一条 LSN(逻辑日志序列号,全局单调递增)时间轴上:页头记着"我最后被哪条日志改过"(page LSN),日志记录自带 LSN,checkpoint 记"哪之前的脏页都已落盘"——三个数字对齐,重放不多不少。
第三,checkpoint:恢复时间的调节阀。重放 redo 的代价与"上次 checkpoint 之后累积的脏改动"成正比,所以 InnoDB 后台持续把脏页刷盘并推进 checkpoint 水位(LSN)。SHOW ENGINE INNODB STATUS 的 LOG 区里 Log sequence number 与 Last checkpoint at 的差值,就是此刻崩溃后需要重放的量:差值小,秒级拉起;差值逼近 redo 文件总容量,则触发最凶的机制——redo 写满即全局冻结:log 是环形文件,新日志要覆盖旧区间,而旧区间必须先靠 checkpoint 把对应脏页刷完才能复用;刷不上就只能让所有事务排队等 IO,表现是整个库"只读都不利索"。redo 容量(innodb_redo_log_capacity,8.0.30+;旧版为 innodb_log_file_size × 组数)本质是"允许的脏页水位 × 写峰值扛度",不是越大越好——太大时 checkpoint 更懒,崩溃恢复更久,还容易养成刷盘线程的慢性怠工。
第四,提交点持久性的三档取舍与撕页。innodb_flush_log_at_trx_commit 精确规定提交那一刻做什么:=1 提交线程同步 write+fsync 才回 OK——已回 OK 的事务对任何崩溃免疫(对进程崩溃与断电都成立);=2 提交只 write 到 OS page cache,后台每秒 fsync——进程崩溃不丢(OS 会把 cache 落盘),整机断电丢掉"已回 OK 但 fsync 未及"的约一秒;=0 提交什么都不做,每秒由后台 write+fsync——进程崩溃也丢约一秒。而就算 =1 也有一个 fsync 防不住的坑:torn page。盘以 512B/4KB 扇区为原子单位,InnoDB 的 16KB 页写可能被撕成"半新半旧",这样的页 redo 重放都会出错。解法是 doublewrite buffer:脏页先顺序写进一个 2MB 中转区再写目的地,重启时拿中转区副本校验修复——用一次额外顺序写换页级原子性,innodb_doublewrite=ON 是绝对不该关的默认值(它同时是 primary/replica 半同步之类无关的机制)。
第一次代码实验及输出
下面是确定性 WAL 模拟(纯 Python 内存模型,非真 mysqld):Engine 维护磁盘页、脏页表、带 LSN 的 redo 流与 undo 前像表。剧本:T1 提交;T2 改了但未提交;checkpoint 把未提交的脏页也冲下磁盘(真实 InnoDB 的 page cleaner 正是如此,回滚交给 undo);T3 提交但页仍脏在内存;此时崩溃。恢复阶段从日志重建提交名单:checkpoint 之后的已提交记录做 REDO,未提交事务按 undo 前像做 UNDO。
# InnoDB 日志体系最小模型: redo(带 LSN 的物理改动, 循环追加) + undo(逻辑前像)
# WAL 铁律: 事务提交前 redo 必须落盘, 脏页允许滞留内存晚写
class Engine:
def __init__(self):
self.disk_pages = {"acct_A": 100, "acct_B": 50} # 磁盘上的页
self.dirty = {} # Buffer Pool 脏页
self.redo = [] # 已落盘 redo: (lsn, trx, page, val)
self.redo_buf = [] # log buffer(未落盘)
self.undo = {} # trx -> [(page, 前像, 后像)] 供回滚
self.lsn = 0
self.ckpt_lsn = 0
def update(self, trx, page, val):
self.lsn += 1
self.undo.setdefault(trx, []).append((page, self.cur(page), val))
self.redo_buf.append((self.lsn, trx, page, val))
self.dirty[page] = val
def cur(self, page):
return self.dirty.get(page, self.disk_pages[page])
def commit(self, trx):
self.lsn += 1
self.redo_buf.append((self.lsn, trx, "COMMIT", None))
# WAL: 提交返回客户端之前, 同步把 log buffer 刷到磁盘(=trx_commit 1 的语义)
self.redo += self.redo_buf
self.redo_buf = []
def log_writer(self):
# log_writer 线程持续把 log buffer 写入日志文件(写不等 fsync)
self.redo += self.redo_buf
self.redo_buf = []
def checkpoint(self):
for p, v in self.dirty.items(): # 脏页全部写回(含未提交事务的!)
self.disk_pages[p] = v
self.dirty.clear()
self.log_writer()
self.ckpt_lsn = self.lsn
def crash(self):
self.dirty, self.redo_buf = {}, [] # 内存全丢, 未落盘日志蒸发
def recover(self):
committed = {t for _, t, p, _ in self.redo if p == "COMMIT"} # 从日志重建提交名单
plan = []
for lsn, trx, page, val in self.redo: # 1) REDO 阶段: 重放 ckpt 后的已提交改动
if lsn > self.ckpt_lsn and trx in committed and page != "COMMIT":
self.disk_pages[page] = val
plan.append("redo lsn=%d %s.%s=%s" % (lsn, trx, page, val))
for trx in sorted({t for _, t, _, _ in self.redo} – committed):
for page, old, new in reversed(self.undo.get(trx, [])): # 2) UNDO 阶段
if self.disk_pages.get(page) == new:
self.disk_pages[page] = old
plan.append("undo %s.%s=%s 回滚到前像 %s" % (trx, page, new, old))
return plan
e = Engine()
e.update("T1", "acct_A", 130); e.commit("T1")
print("T1: acct_A 100->130 已提交, lsn=%d" % e.lsn)
e.update("T2", "acct_B", 70)
print("T2: acct_B 50->70 改完未提交")
e.checkpoint()
print("checkpoint: 脏页写回磁盘 acct_A=%d acct_B=%d (未提交的也被冲下去了!), ckpt_lsn=%d"
% (e.disk_pages["acct_A"], e.disk_pages["acct_B"], e.ckpt_lsn))
e.update("T3", "acct_A", 999); e.commit("T3")
print("T3: acct_A 130->999 已提交, redo 落盘但页还脏在内存")
e.crash()
print("—- mysqld 崩溃(内存全丢) —-")
for step in e.recover():
print("恢复: " + step)
print("恢复后磁盘: acct_A=%d acct_B=%d | 期望: A=999(T3不丢) B=50(T2未提交须消失)"
% (e.disk_pages["acct_A"], e.disk_pages["acct_B"]))
运行输出:
T1: acct_A 100->130 已提交, lsn=2
T2: acct_B 50->70 改完未提交
checkpoint: 脏页写回磁盘 acct_A=130 acct_B=70 (未提交的也被冲下去了!), ckpt_lsn=3
T3: acct_A 130->999 已提交, redo 落盘但页还脏在内存
—- mysqld 崩溃(内存全丢) —-
恢复: redo lsn=4 T3.acct_A=999
恢复: undo T2.acct_B=70 回滚到前像 50
恢复后磁盘: acct_A=999 acct_B=50 | 期望: A=999(T3不丢) B=50(T2未提交须消失)
整个剧本里最反直觉的是 checkpoint 那一行:未提交事务 T2 的脏改动也会被写到数据文件里。这不是 bug 而是设计——page cleaner 只认页的水位、不认事务边界;如果恢复只会 REDO,磁盘上就永远留着 T2 的"罪证"。所以恢复必须两阶段:先按 redo 把所有痕迹补齐到崩溃瞬间的"最大世界",再用 undo 把未提交事务的修改逐条抹去(真实 InnoDB 里这步是 rollback segments 上的持久化 undo 链 + purge 收尾,模型简化为前像表)。注意 T3 的两世身份:它的 redo 在提交时已落盘(WAL 铁律),但它的页到崩溃仍是脏的——重放 lsn=4 那一条就一分不差。"已提交不丢、未提交不留"不是魔法,是这两条流水线的合取。
工程化改进
第一步,参数基线:金融/交易库双 1,可容忍秒级丢失的报表库才谈 =2。innodb_flush_log_at_trx_commit=1 + sync_binlog=1(下一篇的主角)是默认即正确的基线,动它们之前先问业务:"已告诉用户成功"的操作,重启后能不能少一笔?=2 省下的是每次提交的 fsync 调用,机械盘时代收益巨大,NVMe/带电容保护写缓存的 RAID 上往往只换来 10%~30% 吞吐——用一次断电丢一批订单去换这点数字,多数业务不划算。云盘注意:宿主掉电时"实例内看是 =2"和"云盘自己的持久化语义"是两层账,确认云厂商文档对 fsync 的承诺再下结论。
第二步,给 redo 做容量与水位监控。看两个数:SHOW ENGINE INNODB STATUS LOG 区 Log sequence number – Last checkpoint at 的差值占 redo 总容量的比例(逼近 100% 就是冻结前夜),以及 innodb_data_written 的分钟级增速推断"写峰值能否在 redo 转一圈内被 checkpoint 消化"。innodb_redo_log_capacity 经验值取"高峰期 1 小时的 redo 产量"量级并至少给几个 GB;8.0.30+ 支持在线调整该参数,大促前的扩容不必再重启。
第三步,别给恢复制造人祸。崩溃重启时 Streaming recovery … in progress 日志刷几分钟是正常重放,中途绝不再 kill——二次崩溃只会从头再来;恢复慢的根治是 redo 容量与 checkpoint 调优(第二步),不是 force。真遇到页损坏拉不起(checksum error),innodb_force_recovery=1..6 按最小级别逐个试、且只为把数据 mysqldump/SELECT INTO OUTFILE 抢救出来,级别 3 以上禁止写业务库;事先准备好 HA 切换与备份回放的预案,比事后调 force 参数体面得多。
第四步,把 undo 纳入日常巡检。undo 表空间(8.0 默认 2 个独立 undo 表空间自动回收)大小与 430 篇的 purge lag 直接挂钩:information_schema.INNODB_METRICS 里 trx_rseg_history_len(历史链表长度)持续上涨就是长事务钉住了旧版本,redo/undo 都会跟着膨胀,还会拉长崩溃恢复时要回滚的量。治理动作与 430 一致:告警 + kill 超时事务,这是同一颗药。
第二次代码实验及输出
把"三档 trx_commit × 两种灾难"做成损失矩阵(确定性模拟):trx1~trx8 在 t=1~8 依次提交并收到客户端 OK,t=5.5 突发灾难;模式 0/2 的后台周期 fsync 近似为每秒末一次。对比"进程崩溃"(OS 存活、内存丢失)与"整机断电"(OS cache 也丢)下各自吞掉了哪些已回 OK 的提交。
# innodb_flush_log_at_trx_commit 三档 x 两种灾难: 谁丢"已回 OK"的提交?
# 时间以 tick 计, trx1..trx8 在 t=1..8 提交并回 OK; t=5.5 突发灾难
# 设后台每秒末(t+0.75)做一次周期 fsync(模式2/0 的 flush 节奏近似)
COMMIT_T = {t: "trx%d" % t for t in range(1, 9)}
NOW = 5.5
acked = [c for t, c in sorted(COMMIT_T.items()) if t <= NOW]
print("断电前已提交并回 OK: %s" % acked)
print("(trx6..8 提交在灾难之后, 客户端只会看到失败, 不算丢失)\\n")
def lost(mode, disaster):
if mode == 1: # 提交点同步 fsync, 回 OK 即持久
return []
if mode == 2: # 提交只 write() 进 OS page cache
if disaster == "进程崩溃":
return [] # OS 还活着, cache 稍后自然落盘
# mode 0: 提交留在 InnoDB log buffer; 0/2 的断电都只剩周期 flush 追上的部分
last_flush = max((k for k in range(1, 9) if k + 0.75 <= NOW), default=0)
return [c for c in acked if int(c[3:]) > last_flush]
for mode in (1, 2, 0):
print("trx_commit=%d 进程崩溃丢: %-14s 整机断电丢: %s"
% (mode, lost(mode, "进程崩溃") or "无", lost(mode, "断电") or "无"))
print()
print("双 1 配置 (trx_commit=1 + sync_binlog=1):")
print(" redo 与 binlog 各自独立 fsync, XID 两阶段提交对齐, 任一单边崩溃都不丢已回 OK 的事务")
运行输出:
断电前已提交并回 OK: ['trx1', 'trx2', 'trx3', 'trx4', 'trx5']
(trx6..8 提交在灾难之后, 客户端只会看到失败, 不算丢失)
trx_commit=1 进程崩溃丢: 无 整机断电丢: 无
trx_commit=2 进程崩溃丢: 无 整机断电丢: ['trx5']
trx_commit=0 进程崩溃丢: ['trx5'] 整机断电丢: ['trx5']
双 1 配置 (trx_commit=1 + sync_binlog=1):
redo 与 binlog 各自独立 fsync, XID 两阶段提交对齐, 任一单边崩溃都不丢已回 OK 的事务
矩阵要横着读:=2 与 =0 的差别只发生在"进程崩、机器活着"时——=2 的数据已在 OS page cache,内核会替它落盘;=0 还躺在 mysqld 自己的内存里,人死账消。而断电时两者殊途同归(都丢最近一个 flush 周期),所以"我设了 =2 所以比 =0 安全"是半对的直觉:它只在进程级灾难里成立。=1 两列全零,是因为它在"回 OK"这个动作和"fsync 完成"之间画了等号——承诺的边界就是持久性的边界。最后一行提醒这条链还有另一半:redo 落盘只保证 InnoDB 自身一致,事务若还要进 binlog 供从库/订阅消费,两边各有一次 fsync、各崩各的,对齐靠 XID 两阶段提交——这正是下一篇的主菜。
常见陷阱
其一,把 =1 当万能保险,转头 innodb_doublewrite=OFF"提性能":fsync 防不了 16KB 页被 4KB 扇区撕开,redo 重放遇半新半旧页直接起不来——省那点写放大换来的是数据文件级损坏。其二,redo 配小了只看到"暂时没事":日常低峰 checkpoint 勉强跟得上,一次批量导数/大事务 UPDATE 直接把 Log sequence number – checkpoint 打满,全库冻结等刷盘,故障现场却是"什么都没做就是卡";容量按写峰值算,不要按默认值躺平。其三,恢复中反复 kill:recovery 无断点续传,每次都是全量重来,越 kill 越起不来;耐心等日志里的恢复进度,同时查备份与从库。其四,autocommit=0 配 =1 却感觉不到 fsync 开销:日志只在事务提交时刷,一个开着 40 分钟未提交的事务等于给 39 分钟的改动免了持久化承诺,还倒贴长事务锁与 purge 债——框架层的"事务边界"要与参数假设对齐。其五,以为 binlog 能代替 redo 做崩溃恢复:binlog 是 server 层的逻辑/行事件,按语句序消费、没有"页"概念,拿它恢复等于从 checkpoint 起重放整个业务的逻辑变更,慢几个数量级且无法定位页——两套日志的层次不同,谁也替不了谁。
落地清单
- 生产基线双 1(innodb_flush_log_at_trx_commit=1 + sync_binlog=1)+ innodb_doublewrite=ON,降级须业务书面确认丢失窗口
- 监控 SHOW ENGINE INNODB STATUS 的 LSN-checkpoint 差值占 redo 容量比例,>70% 告警;按写峰值配 innodb_redo_log_capacity
- 崩溃恢复期间禁止再次 kill;页损坏走 innodb_force_recovery 只做抢救导出,平时演练备份回放
- 巡检 INNODB_METRICS.trx_rseg_history_len 与 undo 表空间水位,长事务治理与 430 共用同一告警
- 大事务/批量导数前评估 redo 消化能力(分片提交、错峰),把冻结风险拆成小口
本篇把 InnoDB 自己的账本闭合了:redo 保已提交不丢,undo 保未提交不留,checkpoint 控制恢复时长。但 MySQL 其实有两套日志——server 层的 binlog 不参与崩溃恢复,却决定从库有没有数据、订阅能不能回放、误删能不能闪回。两本账各自 fsync 就有"主库提交了、从库丢了"的缝隙,于是才有了两阶段提交、位点与 GTID。下一篇《MySQL 内核实战(6):binlog 与主从复制原理》把复制链路和丢数据窗口讲透。
参考来源
- MySQL 8.0 Reference Manual:innodb_flush_log_at_trx_commit 系统变量:https://dev.mysql.com/doc/refman/8.0/en/server-system-variables.html#sysvar_innodb_flush_log_at_trx_commit
- MySQL 8.0 Reference Manual:Forcing InnoDB Recovery:https://dev.mysql.com/doc/refman/8.0/en/forcing-innodb-recovery.html
- MySQL 8.0 Reference Manual:InnoDB Undo Tablespaces:https://dev.mysql.com/doc/refman/8.0/en/innodb-undo-tablespaces.html
- Wikipedia:Write-ahead logging:https://en.wikipedia.org/wiki/Write-ahead_logging
- Wikipedia:Uninterruptible power supply(磁盘写缓存的电池保护备份):https://en.wikipedia.org/wiki/Uninterruptible_power_supply
👍 觉得有用就点个 赞 + 收藏,方便回头查阅;有疑问直接在评论区留言,我看到都会回。
🚀 本文属于 《MySQL 内核实战》 系列,持续更新,关注不迷路。
📌 文章里的代码都能直接跑。想要可直接 clone 的完整工程 + 配套部署脚本 / 踩坑清单?评论一声或发邮件到 cj2664@qq.com,我免费发你。
如果你正好在做类似系统、或有工程化难题想找人做,也欢迎邮件聊一句——我按实际情况评估,能落地的就接单或出方案。评论和邮件都能直接找到我,不用跳别的平台。




