1 问题不在“写日志”,而在“两份日志必须一致”

MySQL 中一次事务提交并不是只写一份日志。开启 binlog 且使用 InnoDB 时,至少有两类日志参与提交路径:

  • redo log 属于 InnoDB,记录数据页的物理修改,服务于实例崩溃后的恢复。
  • binlog 属于 MySQL Server 层,记录逻辑变更事件,服务于主从复制和基于时间点的恢复。

这两份日志解决的问题不同,所属模块也不同。redo log 让主库自己在宕机后能恢复已提交事务;binlog 让从库、备份恢复工具和高可用切换能够看到同一批逻辑变更。

真正的难点是:一个事务一旦提交,redo log 和 binlog 必须对这个事务达成同一个结论。 如果 InnoDB 认为事务提交了,但 binlog 没有这个事务,从库和备份恢复结果就会少数据;如果 binlog 已经记录了事务,但 InnoDB 没有提交,外部系统会认为事务发生了,而主库本身没有这笔变更。

因此,两阶段提交解决的不是“如何把日志写得更快”,而是解决 InnoDB 层和 Server 层两份日志之间的原子一致性问题。

2 单阶段顺序写为什么不够

假设执行一条事务:

UPDATE t SET c = c + 1 WHERE id = 1;

如果采用简单顺序写,会遇到两个无法绕开的崩溃窗口。

第一种是先写 redo log,再写 binlog。事务的 redo log 写成功后实例崩溃,binlog 还没写。重启时,InnoDB 可以根据 redo log 恢复这次修改,主库看起来事务已经生效。但从库只能通过 binlog 同步,binlog 里没有这条变更,从库不会应用它。结果是主库多一笔,从库少一笔。

第二种是先写 binlog,再写 redo log。binlog 写成功后实例崩溃,redo log 还没写。重启时,主库没有这次修改;但从库或基于 binlog 的恢复工具已经能看到这条事务。结果是外部日志认为事务存在,主库本地状态却不承认它。

这说明只靠“规定一个固定写入顺序”并不能解决问题。只要两份日志之间存在中间窗口,崩溃就可能把系统停在半完成状态。两阶段提交的价值就在于:允许系统在崩溃后识别这个半完成状态,并用一个明确规则把它补成一致结果。

3 两阶段提交的核心流程

MySQL 的内部 XA 可以把 binlog 看作协调者,把 InnoDB 看作参与者。一次事务提交可以简化成三个动作:

  1. InnoDB Prepare:写 redo log,并把事务状态标记为 PREPARE
  2. Server 写 binlog:写入事务事件,并带上同一个 Xid
  3. InnoDB Commit:把 redo log 中的事务状态标记为 COMMIT

所谓“两阶段”,不是说只有两个具体动作,而是说提交被分成了两个语义阶段:

  • Prepare 阶段:InnoDB 先把事务修改写入 redo log,但不把事务最终提交,只留下一个“可以提交,但尚未最终决定”的状态。
  • Commit 阶段:Server 层写 binlog。binlog 一旦完整写入,就代表这个事务已经对复制和备份恢复可见,随后 InnoDB 再把 prepare 事务正式提交。

这里最容易误解的是第二步。为什么“写 binlog”算 Commit 阶段,而不是 Prepare 阶段的一部分?原因在于崩溃恢复的决策标准:binlog 是否完整写入,决定了 prepare 事务最终应该提交还是回滚。 从这个意义上看,binlog 是最终裁决点。

Xid=100 为例,正常路径如下:

  1. InnoDB 修改 buffer pool 中的数据页,并生成 undo log,用于事务回滚和 MVCC。
  2. InnoDB 生成 redo log,把该事务记录为 PREPARE,并关联 Xid=100
  3. redo log 按配置刷盘。此时磁盘上有一条“未最终提交”的事务记录。
  4. Server 层把事务变更写入 binlog,binlog 事件中也包含 Xid=100
  5. binlog 按配置刷盘。此时外部复制和恢复链路已经能识别这笔事务。
  6. Server 通知 InnoDB 提交,InnoDB 将该事务标记为 COMMIT

这个流程的关键不是“多写一次状态”,而是把事务从“已准备、可恢复、可回滚”推进到“binlog 已决定、必须提交”。

4 崩溃恢复时,Xid 如何把两份日志接起来

两阶段提交真正发挥作用的地方,是崩溃恢复。

实例重启时,InnoDB 扫描 redo log。如果发现某个事务已经处于 PREPARE,但还没有 COMMIT 标记,说明崩溃发生在 prepare 之后、InnoDB 最终 commit 之前。这个状态单看 redo log 是无法决策的,因为它只表示“事务准备好了”,不表示事务是否应该最终提交。

此时 InnoDB 会拿着事务的 Xid 去 binlog 中查找对应事务是否存在并且完整。

如果 binlog 中存在完整的 Xid=100 事务记录,说明 Server 层已经完成了最终决定。这个事务已经对复制和备份恢复可见,主库必须与 binlog 保持一致。恢复过程会提交这个 prepare 事务,把它补成 commit 状态。

如果 binlog 中找不到 Xid=100,或者对应事务记录不完整,说明崩溃发生在 binlog 成功写入之前。外部系统并不知道这笔事务,主库也不能单方面把它恢复出来。恢复过程会回滚该事务,保证它像没有发生过一样。

可以把这个恢复规则压缩成一句话:

  • redo log 处于 PREPARE 时,看 binlog 是否有完整 Xid:有,则提交;没有,则回滚。

这也是两阶段提交相比顺序写的本质差异。顺序写只是在崩溃前尽力完成两份日志;两阶段提交则让系统在崩溃后仍然可以根据可验证的证据做出同一个决定。

5 组提交优化了性能,但没有改变提交语义

MySQL 5.6 之后,binlog group commit 用批量方式降低频繁刷盘的成本。它通常会把多个事务组织成一组,经过类似下面的阶段:

  1. Flush:把多个事务按照入队的顺序将各自的 binlog cache 写入 binlog 文件。
  2. Sync:把这一组 binlog 统一 fsync 到磁盘,多个事务的 binlog 合并一次刷盘。
  3. Commit:按顺序调用存储引擎提交,把对应 redo log 状态推进到 commit。

这会显著减少每个事务单独 fsync 的成本,也能让 binlog 顺序和引擎提交顺序保持一致。需要注意的是,组提交只是把多个事务的相同步骤合并执行,并没有改变 redo prepare -> binlog write/sync -> redo commit 这个逻辑顺序。

换句话说,组提交优化的是吞吐量,不是事务语义。崩溃恢复时,prepare 事务仍然要通过 Xid 去 binlog 中找最终答案。

6 刷盘参数决定持久性边界

两阶段提交保证的是 redo log 与 binlog 之间的提交一致性,但它不能脱离刷盘策略讨论持久性。生产环境最常关注两个参数:

  • innodb_flush_log_at_trx_commit=1:每次事务提交时,InnoDB 将 redo log 写入并刷到磁盘。
  • sync_binlog=1:每次事务提交时,Server 层将 binlog 刷到磁盘。

通常说的“双 1”配置,就是这两个参数都设置为 1。它的代价是更多同步刷盘,收益是崩溃时最大限度保证已提交事务不丢,并让两阶段提交的判断依据可靠落盘。

如果 sync_binlog 大于 1,binlog 可能已经写入文件系统缓存但尚未落盘。操作系统或机器崩溃时,这部分 binlog 可能丢失。如果 innodb_flush_log_at_trx_commit 不是 1,redo log 也可能存在类似持久性窗口。此时不能简单说“两阶段提交失效”,更准确的说法是:两阶段提交的逻辑仍在,但日志本身的落盘边界变弱,崩溃后可能丢失最近一小段事务。

7 常见误区

第一个误区是把 redo log commit 当成真正的最终决定点。实际上,在两阶段提交中,binlog 是否完整写入才是崩溃恢复时的裁决依据。redo log 的最终 commit 标记更像是引擎侧的收尾动作。

第二个误区是认为 prepare 状态表示事务已经提交。prepare 只表示 InnoDB 已经把事务修改准备好,并保证它具备提交或回滚的恢复能力。它不是用户可见语义上的提交完成。

第三个误区是把组提交理解为打破两阶段提交。组提交只是批量处理 binlog flush/sync 和引擎 commit,仍然遵守先 prepare、再写 binlog、最后 commit 的顺序。

第四个误区是认为只要有两阶段提交,就一定不会丢任何事务。是否丢事务还取决于 redo log 和 binlog 的刷盘策略、文件系统和硬件可靠性。两阶段提交解决的是两份日志之间的决策一致,不是替代所有持久化保障。

8 总结

MySQL redo log 和 binlog 两阶段提交,本质上是在单机内部解决一个跨模块一致性问题:InnoDB 必须维护自己的崩溃恢复日志,Server 层又必须维护复制和恢复用的 binlog,事务提交时这两份日志不能各说各话。

它的核心流程可以概括为:

  1. InnoDB 先写 redo log prepare
  2. Server 再写包含同一 Xid 的 binlog
  3. InnoDB 最后把事务标记为 commit

崩溃恢复时,如果看到 redo log 中还有 prepare 事务,就拿 Xid 去 binlog 中找答案。binlog 有完整事务,则提交;binlog 没有完整事务,则回滚。这个规则让 MySQL 即使停在提交中间状态,也能在重启后把 redo log 和 binlog 重新拉回同一个结论。