redo、undo、binlog:InnoDB 提交时到底刷了什么
面试问「事务怎么保证持久」,答 ACID 的 D 不够。InnoDB 里至少有三份日志,职责不同:redo 让崩溃后的页能补完,undo 让回滚和 MVCC 有旧版本,binlog 让从库和备份看见这条事务。它们不在同一层,刷盘时机也不一样。混成「都是日志」后面的两阶段提交就讲不顺。
MySQL 那篇已经点过:隐藏列、undo 版本链、执行器后面「redo 持久、binlog 在 Server 层」。本篇把提交路径展开。查询怎么走连接器到引擎,仍看那张执行流程图。
一、先改内存,日志后刷
InnoDB 的数据页在 Buffer Pool 里。UPDATE 改的是内存页,页被标脏。不能每次提交都把脏页 fsync 到数据文件:随机写太慢,提交延迟会炸。
办法是 WAL:先把「怎么改的」追加到 redo,顺序写;提交只要 redo 落盘(按参数),脏页以后再刷。崩溃时:数据文件可能停在旧版本,redo 从最后一个检查点重放,把页补到崩溃前的状态。
redo 记录的是物理(或物理逻辑)改动:某个页、某个偏移、改成什么。它不负责「这条 SQL 是什么」,也不给从库用。从库要的是逻辑变更,那是 binlog。
redo 是固定大小的一组文件,逻辑上当成环。写入位置用 LSN(log sequence number)往前推。检查点把「这个 LSN 之前的脏页都已经进数据文件」记下来,环上那一段就可以覆盖。检查点落后、redo 要覆盖还没刷脏页的位置,后台会拼命刷脏页,用户线程提交被拖住——这就是 redo 空间不够的现场,不是「SQL 没走索引」。
undo 是另一条链:改行之前,把旧内容挂到 undo。回滚顺着链把旧值填回去。MVCC 的快照读也走这条链:Read View 判定不可见的版本,就沿 DB_ROLL_PTR 往回找。undo 最终也会被 purge,版本不再被任何视图需要时才能删。长事务不提交,purge 不敢删,undo 表空间涨,历史查询越来越慢。这是「有个报表连接挂了一下午」的典型后果。
所以一次更新:
- 改 Buffer Pool 中的页(脏页)。
- 写 undo(旧版本)。
- 写 redo(怎么改到新版本)。
- 提交时按下面的两阶段,和 binlog 对齐。
缺 2,回滚和 MVCC 没了。缺 3,崩溃后脏页等于没发生。缺 binlog,从库和基于 binlog 的备份丢事务。
Doublewrite buffer 是另一条保险:脏页往数据文件写可能只写了一半(4KB 页、磁盘 16KB 原子写对不上)。先把页完整抄到 doublewrite,再写真正的位置。崩溃后用这份完整拷贝把半页补上。它不是 redo,面试别并成「都是两份日志」。
二、binlog 为什么不能省
binlog 在 Server 层,不是 InnoDB 独有。MyISAM 也能写 binlog。格式有三种:
- STATEMENT: 记 SQL 文本。短,但不确定函数(
UUID()、NOW())在从库会跑出不同结果。 - ROW: 记行前后镜像。确定,体积大。
- MIXED: 能安全用语句就语句,否则行。
现在默认按 ROW 理解就好。从库、闪回、审计,吃的是 binlog。redo 回放只能在 同一份数据文件 上做崩溃恢复,不能拿到另一台机器当复制流。
主从路径:主库 dump 线程读 binlog → 从库 IO 线程写成 relay log → SQL 线程回放。延迟可能出在三段中的任何一段。GTID 把「位点」从「文件名 + 偏移」换成事务 ID,换从库、跳过事务更干净,但不是复制本身。
崩溃恢复如果只看 redo:引擎提交了,binlog 没写完,从库没有这事务,主从永久不一致。反过来 binlog 写了引擎没提交,从库多一个主库没有的事务。于是有了 内部 XA / 两阶段提交。
三、两阶段提交
顺序是:
- prepare。 InnoDB 把 redo 刷到磁盘(包含 prepare 状态),事务还不能对别的连接声称已提交。
- 写 binlog。 Server 把这次变更写入 binlog 并按
sync_binlog决定是否 fsync。 - commit。 通知 InnoDB 写 commit 标记。之后其它事务能看见。
崩溃发生在 1 之后、2 之前:binlog 没有,恢复时回滚 prepare。发生在 2 之后、3 之前:binlog 有完整事务,恢复时提交。判断依据是 binlog 里有没有这条,不是「redo 看起来像提交了」。
这和分布式 XA 的 2PC 同构:InnoDB 是一个资源管理器,binlog 是另一个,mysqld 自己当协调者。不要画成「另有一台协调机」。MySQL 那篇后面讲的跨库 XA / TCC,是多个 mysqld 或多个系统之间的协议,别和这一节并成一道题。
innodb_flush_log_at_trx_commit:
1:每次提交 fsync redo。最耐久,最慢。2:提交时 redo 写到 OS 缓存,每秒 fsync。机器没宕、只是 mysqld 崩,仍安全。0:每秒才写 OS。mysqld 崩可能丢约 1 秒。
sync_binlog:
1:每次事务 fsync binlog。0:交给 OS。N:每 N 次提交 fsync。
双 1(两个参数都是 1)才是「掉电不丢已提交事务」。很多业务用 1 + 1 在主库,从库或历史数据用更松的值。面试要能说出 丢的是哪一层、哪种故障:
| 组合 | mysqld 崩溃 | 整机掉电 |
|---|---|---|
| 双 1 | 不丢已提交 | 不丢已提交 |
| redo=2, binlog=1 | 不丢 | 可能丢约 1 秒 redo 侧 |
| 两个都 0 | 可能丢约 1 秒 | 可能丢约 1 秒 |
组提交(group commit)把多个事务的 fsync 合并成一次,是双 1 下还能撑 QPS 的关键,不是取消 fsync。redo 一组、binlog 一组,两阶段的等待会被排成队,后台线程一起刷。
崩溃恢复分阶段:先看 redo,把数据页补到一致点;再根据 binlog 决定 prepare 事务提交还是回滚;undo 把未提交的改回去。锁不用恢复:崩溃后内存里的锁表没了,未提交事务回滚即可,新连接重新抢锁。
四、和 MVCC、锁的边界
快照读不碰 redo 的提交路径,它读的是 Buffer Pool 里已经提交(或对本 Read View 可见)的版本,必要时沿 undo。当前读(SELECT ... FOR UPDATE、UPDATE)要等锁,看到的是最新已提交版本,然后自己改。
锁信息主要在内存。崩溃后锁不在,恢复靠 redo 把数据补对,undo 把未提交事务滚掉。不需要把锁表本身持久化成和 redo 同级的东西。
隔离级别改的是 Read View 怎么建、间隙锁开不开,不改「提交时刷哪份日志」。把 RR 和双 1 说成一件事,是概念搅在一起了。
fsync 的延迟来自盘。NVMe 和电池保护的 RAID 卡会让双 1 好过很多;云盘的 fsync 语义要单独确认,有的「flush」只到宿主缓存。这是架构题,不是 SQL 题,但参数讨论到最后会碰到。
五、排障时看哪
SHOW VARIABLES LIKE 'innodb_flush_log_at_trx_commit';
SHOW VARIABLES LIKE 'sync_binlog';
SHOW VARIABLES LIKE 'binlog_format';
SHOW ENGINE INNODB STATUS\G -- LOG 段:刷盘、检查点
SHOW BINARY LOGS;
SHOW MASTER STATUS;
SHOW SLAVE STATUS\G主从延迟先看 binlog 是否在主库堆积(写入慢)还是从库 SQL 线程慢(回放慢)。ROW 格式下从库要给每一行找主键,没主键的表回放会扫,延迟就堆在 SQL 线程。redo 刷不过来会在 STATUS 里看到检查点落后,脏页占比升高,这时不该再怪「SQL 没走索引」这一条。
误删行的闪回,走的是 binlog 解析成反向 SQL,或备份 + binlog 前滚。redo 帮不上:它不面向「逻辑逆操作」。
innodb_log_file_size 太小,检查点频繁,刷脏页凶;太大,崩溃恢复重放时间变长。改这个参数通常要停机重建 redo 文件,不是线上随手 SET。
六、收口
- redo:引擎崩溃恢复,物理,提交路径上的耐久。环状文件,靠 LSN 和检查点。
- undo:回滚 + MVCC 旧版本,最终会被 purge。长事务会把它撑爆。
- binlog:Server 层复制与审计,逻辑或行镜像。
- 两阶段提交:先 prepare redo,再落 binlog,再 commit,用 binlog 有无决定崩溃后提交还是回滚。
- 双 1 换的是延迟,不是正确性以外的东西。放松参数等于承认特定故障下丢失已提交事务。
- Doublewrite 修半页写,不是第四种业务日志。
分布式事务那套 XA 2PC 是跨多个资源管理器。这里的两阶段是 mysqld 内部 InnoDB 与 binlog 两个参与者,别把协调者画成另一台独立机器。
