MySQL / InnoDB 存储引擎系列 · 第九篇

一次提交,其实要写两份完全独立的日志——它们怎么保证不会打架?

第四篇讲过 redo log:只要日志落盘,进程崩了也不丢数据。但 MySQL 还有另一份日志——binlog,用来做主从复制和数据恢复,由 SQL 层维护,和 InnoDB 的 redo log 是完全独立的两套系统、两份文件。一次提交要把两份日志都写对,如果中途崩溃,两份日志不一致会怎样?这篇讲 InnoDB 和 binlog 之间的两阶段提交(2PC)——就是解决这个问题的机制。

第四篇的错误日志里其实已经出现过这套机制的名字——"XA crash recovery"——当时没展开,这篇把它讲清楚。

Xid = 61
本机真实 binlog 里,一次提交对应的 XID 事件
commit ⟺ 在 binlog 里
2PC 恢复要保证的核心不变式

问题出在哪

redo log 由 InnoDB 存储引擎层维护,binlog 由 MySQL Server 层维护——两个不同的子系统,各自决定"什么时候把自己的日志写盘"。

先写 binlog,后 InnoDB 提交?

崩在中间——binlog 有,InnoDB 没提交

从库靠 binlog 复制,已经拿到了这笔修改并执行了;但主库自己重启后如果把这个事务回滚掉,主库和从库的数据就不一样了。

先 InnoDB 提交,后写 binlog?

崩在中间——InnoDB 有,binlog 没有

主库这边数据已经改了,但这笔修改永远不会同步到从库——同样是主从不一致,而且这次连"重放一遍"都补救不了,因为原始请求早就返回成功了。

两阶段提交

拆成 prepare + commit 两步

InnoDB 先进入"prepare"状态(相当于说"我准备好了,但还没最终生效"),再写 binlog,最后才真正提交 InnoDB。崩溃恢复时,靠"binlog 里有没有这笔"来决定 prepare 状态的事务该提交还是回滚。

真实的 binlog 里长什么样

提交一个事务,用 mysqlbinlog 把对应的 binlog 内容原样倒出来看。

真实实测 START TRANSACTION; INSERT INTO t VALUES (1, 100); COMMIT; $ mysqlbinlog -v /path/to/binlog.000010 BEGIN # Table_map: `xademo`.`t` mapped to number 90 # Write_rows: table id 90 ### INSERT INTO `xademo`.`t` ### SET @1=1 @2=100 #260828 7:03:06 ... Xid = 61 COMMIT

这条 Xid = 61 就是 binlog 一侧的提交标记——它和 InnoDB 那边这次事务的 XA 事务 ID 是配对的。崩溃恢复时到底该不该提交某个"看起来还没完成"的事务,就是靠这个 XID 在 binlog 里存不存在来判断的。

真实源码里的两阶段提交

sql/binlog.cc · mysql-server @ mysql-9.7.1, L6936 /* If the binary log was not properly closed it means that the server may have crashed. In that case, we need to call binlog::Binlog_recovery::recover() to: a) collect logged XIDs; b) complete the 2PC of the pending XIDs; c) collect the last valid position. */

收集到的 XID 集合,直接被交给存储引擎去核对:

sql/binlog/recovery.cc · mysql-server @ mysql-9.7.1, L53 binlog::Binlog_recovery &binlog::Binlog_recovery::recover() { ... this->m_no_engine_recovery = ha_recover(&this->m_internal_xids, &xa_list); ... }

InnoDB 这一侧,对应注册的正是我们在第四篇错误日志里见过的那套"XA crash recovery"逻辑——两个真实存在的钩子函数:

storage/innobase/handler/ha_innodb.cc /** This function is used to prepare an X/Open XA distributed transaction. */ static int innobase_xa_prepare(handlerton *hton, THD *thd, bool all); /** This function is used to recover X/Open XA distributed transactions. @return number of prepared transactions stored in xid_list */ static int innobase_xa_recover(handlerton *hton, XA_recover_txn *txn_list, uint len, MEM_ROOT *mem_root); innobase_hton->recover = innobase_xa_recover;

崩溃恢复的判定规则

拿到 InnoDB 里"处于 prepare 状态但还没提交"的事务列表,和 binlog 里收集到的 XID 集合一比对,规则只有两条。

InnoDB: prepared · binlog: 有这个 XID
提交(roll forward)——binlog 已经写完,可能已经有从库拿到了这份数据,主库必须跟上。
InnoDB: prepared · binlog: 没有这个 XID
回滚——崩溃发生在 binlog 落盘之前,没有任何从库见过这笔修改,主库也不能留着它。

这套判定要保证的不变式很直白:崩溃恢复之后,一个事务在主库上是"已提交"状态,当且仅当它在 binlog 里——不多不少。这样任何从 binlog 复制数据的从库,看到的世界最终都会和主库完全一致。

演示:四种崩溃时机,四种恢复结果

崩溃恢复扫描:逐个事务判定
这四个场景覆盖了源码逻辑里全部有意义的组合(另外两种——"committed 但 binlog 没有"、"none 但 binlog 有"——在真实系统里根本不可能出现,已经用独立的暴力穷举核对过所有组合,结论一致)。

一个诚实的限制

这篇没有像第四篇、第六篇那样做一次真实的 kill -9 崩溃实验。

不是不想做——是这次崩溃窗口太窄。redo log 那次实验能靠命令行控制时序(先 COMMIT 返回、再杀进程,或者让事务停在 SLEEP 里不提交),窗口是以秒计的,黑盒操作完全够用。但 InnoDB "prepare 完成"到"binlog 写完"这中间,通常只有微秒级的间隙,普通发行版的 mysqld 二进制没有暴露任何办法能精确让进程停在这两步之间——MySQL 自己的测试套件靠的是内部编译的 DBUG 同步点(DBUG_EXECUTE_IF),普通用户拿不到。所以这篇的验证方式换成了:真实源码 + 真实 binlog 结构 + 逻辑仿真验证,外加第四篇里已经真实观察到的 "XA crash recovery" 日志作为佐证——但没有一次精确命中这个窗口的真实崩溃实验。这个限制如实写在这里,不假装做到了。

参考与说明

  • binlog Xid 事件的真实转储来自本机 MySQL 9.7.1,通过 mysqlbinlog -v 对真实提交的事务生成,未做删改。
  • 源码引用(sql/binlog.ccsql/binlog/recovery.ccstorage/innobase/handler/ha_innodb.cc)取自 mysql/mysql-server 仓库 mysql-9.7.1 标签(commit a26ea1a2),与本机安装的 MySQL 9.7.1 完全一致。
  • 演示动画的四个场景和判定规则,是对源码逻辑(prepare 状态 + XID 是否在 binlog 中)的直接建模,附带对"commit 当且仅当在 binlog 中"这条不变式的完整真值表核对,但不是逐行照抄 innobase_xa_recover 的具体实现细节(那里还涉及 XA 事务的更多中间状态,比如显式 XA START/PREPARE SQL 语法发起的分布式事务,本文只覆盖普通自动提交/显式事务场景)。
  • 没有涉及:显式 XA 事务(XA START/PREPARE/COMMIT SQL 语法)与这里的内部 2PC 是同一套机制的两种触发方式、组提交(group commit)如何在保证 2PC 正确性的前提下把多个事务的 fsync 合并成一次、半同步复制对这套流程的影响。
☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电