Skip to content

三大日志与两阶段提交

本文是 MySQL 系统学习系列的核心篇。前置:MySQL 架构与一条 SQL 的执行过程。 学完可以配合面试题食用:redo log、undo log、binlog 与两阶段提交

一条 UPDATE 引出的三个问题

从一条最普通的更新说起:

sql
UPDATE account SET balance = balance - 100 WHERE id = 1;

这条语句执行时,InnoDB 并不是直接把磁盘上的那行数据改掉,而是先在内存里改(Buffer Pool 的数据页),再择机刷回磁盘。中间任何一个时刻机器都可能断电。围绕这条语句,有三个各自独立的问题:

  1. 断电时内存里的修改丢了,磁盘上的旧数据还在,这笔扣款就凭空消失了——怎么保证提交过的事务不丢
  2. 事务执行到一半要回滚(显式 ROLLBACK,或者语句报错)——怎么把改了一半的数据恢复原样
  3. 生产环境 MySQL 几乎都是主从架构,从库要重放主库的事务——拿什么告诉从库"改了什么"

三个问题对应三份日志:redo log 管断电恢复,undo log 管回滚(顺带支撑 MVCC),binlog 管复制和归档。注意层级:redo 和 undo 属于 InnoDB 引擎,binlog 属于 Server 层、所有存储引擎共用。这个层级差异正是后面两阶段提交的伏笔。

三份日志各管一段

redo log(重做日志):InnoDB 层,物理日志。事务修改数据页时,先把"哪个页、哪个偏移、改成了什么"记进 redo,落盘后就认为事务持久化了——数据页本身(脏页)可以慢慢刷。崩溃重启后从检查点开始重放 redo,把没来得及刷盘的修改补回来。

undo log(回滚日志):InnoDB 层,逻辑日志。每改一行就记一条反向操作:INSERT 记 DELETE,UPDATE 记旧值。回滚时逆序重放即可。它还有第二个身份:MVCC 的版本链载体,旧版本数据就存在 undo 里供快照读回溯(详见事务与 MVCC)。所以事务提交后 undo 不能立刻删,要等没有任何快照可能读到它为止。

binlog(归档日志):Server 层,逻辑日志,有 statement(记 SQL 原文)和 row(记行的前后镜像)两种主流格式。两个用途:主从复制(从库 IO 线程拉取 binlog 重放)、数据恢复(全量备份 + 重放 binlog 到任意时间点)。因为它在 Server 层,MyISAM 时代的产物,天然不关心引擎内部状态,只描述"发生了什么变更"。

redo 和 binlog 功能上有重叠感(都记录变更),但谁也替代不了谁:redo 是 InnoDB 私有的页级物理格式,从库没法高效重放,也没法解析出行数据给订阅方用;binlog 是逻辑格式,重放代价高,没法做到崩溃恢复那种"从检查点快速幂等重放"的效率。

redo 的关键设计:WAL、循环写与组提交

**WAL(Write-Ahead Logging)**是 redo 的核心思路:先写日志、后写数据页。动机是 IO 特性差异——数据页落盘是随机写(一页 16KB,散落在文件各处),redo 落盘是顺序追加。顺序写 NVMe SSD 上可以到 GB/s 级,随机写差一个数量级。把"每事务一次随机写"换成"每事务一次顺序写",就是 MySQL 高吞吐的关键交换。

循环写、固定大小:redo log 文件组(如 ib_logfile0ib_logfile1 各 1GB)是环形使用的,写满就从头覆盖。所以 redo 只保留"还没刷盘的修改",不是归档。一旦写满,InnoDB 必须停下来强制推进 checkpoint(把脏页刷盘),此时线上会看到明显的写入抖动——"redo 空间太小导致毛刺"就是这个问题。

LSN 与 checkpoint:LSN 是全局单调递增的日志字节位置。checkpoint LSN 表示"此前的修改已全部落到数据文件"。崩溃恢复从 checkpoint LSN 开始重放 redo,之后的都算没刷盘、需要补。脏页刷得越勤,checkpoint 越靠前,恢复越快,但刷盘 IO 越重——这是后台线程动态调节的平衡点。

组提交(group commit):多个并发事务同时提交时,各自的 redo fsync 可以合并成一次磁盘 IO。并发越高,单次 fsync 摊到的事务越多,吞吐越高。这也是"加连接数反而提升写 TPS"这类反直觉现象的来源之一。

两阶段提交:为什么顺序不能乱

现在把 redo(InnoDB)和 binlog(Server)放到同一次提交里,问题来了:两份日志是独立的文件,没法原子地同时写入。先写谁?

用反证法推演"先 binlog 后 redo commit":

  1. 事务写完 binlog 并落盘,还没来得及写 redo commit,崩溃;
  2. 重启后主库恢复:redo 里没有 commit 记录,事务回滚,余额没扣;
  3. 从库重放 binlog:binlog 完整,执行扣款;
  4. 结果:主库没扣钱、从库扣了,主从不一致,且没有任何报错。

反过来"先 redo commit 后 binlog":崩溃后主库已提交、binlog 没写,从库丢这笔事务,同样不一致。

MySQL 的解法是内部 XA(两阶段提交):redo 分两段写。时序是 redo prepare → 写 binlog → redo commit。崩溃恢复时的裁决规则:扫到处于 prepare 状态的事务,去 binlog 里找该事务的 XID——binlog 里存在完整记录则提交,否则回滚。妙处在于:binlog 落盘是这个协议的"表决点",一旦 binlog 完整,主库恢复后必然提交,从库也必然重放,两边收敛到同一结果;binlog 不完整则两边一起丢弃。代价是正常路径多一次 redo 写入。

动手实操

一条 UPDATE 在两阶段提交下的完整日志流转:

mermaid
sequenceDiagram
    participant C as 客户端
    participant E as 执行器/InnoDB
    participant BP as Buffer Pool
    participant U as undo log
    participant R as redo log
    participant B as binlog

    C->>E: UPDATE ... WHERE id=1
    E->>U: 记反向操作(旧值 balance=500)
    E->>BP: 改内存数据页(变脏页)
    E->>R: 写 redo(prepare, 含 XID)
    Note over R: fsync, 受 innodb_flush_log_at_trx_commit 控制
    E->>B: 写 binlog(同一 XID)
    Note over B: fsync, 受 sync_binlog 控制
    E->>R: 写 redo(commit)
    E-->>C: 返回 commit OK
    Note over BP: 脏页由后台线程异步刷盘, 与提交解耦

想直观感受两个刷盘参数的代价,用 sysbench 压一把写入:

bash
# 准备:8 张表 x 100 万行
sysbench oltp_write_only --mysql-user=root --mysql-db=test \
  --tables=8 --table-size=1000000 --threads=16 prepare

# 分别在两组参数下跑, 每组 5 分钟
sysbench oltp_write_only --mysql-user=root --mysql-db=test \
  --tables=8 --table-size=1000000 --threads=16 --time=300 run
sql
-- 双 1: 每次提交 redo 与 binlog 都 fsync, 最安全
SET GLOBAL sync_binlog = 1;
SET GLOBAL innodb_flush_log_at_trx_commit = 1;

-- 双 0: 都交给后台每秒刷, 最快
SET GLOBAL sync_binlog = 0;
SET GLOBAL innodb_flush_log_at_trx_commit = 0;

典型数量级参考(16 线程写密集、NVMe SSD、MySQL 8.0,具体数值依机器浮动):双 1 约 2000~4000 TPS,双 0 约 15000~25000 TPS,差距 5~8 倍。原因很直接:双 1 下每个事务提交要等两次 fsync(组提交能合并一部分),双 0 下 fsync 频率固定为每秒一次。取舍也就清楚了:交易、账务类库老老实实双 1,宕机最多丢正在执行的事务、不丢已提交的;日志类、统计类中间结果可以接受"宕机丢 1 秒",双 0 或折中配置(trx_commit=2 只写 OS 缓存)换吞吐。半同步复制(rpl_semi_sync)下双 1 更是标配,否则主库掉电可能连 binlog 都没来得及发给从库。

常见误区与小结

  • "redo 和 binlog 记的都是变更,留一个就够"——层级和格式都不同:redo 是 InnoDB 私有物理格式,做不了复制和归档;binlog 是逻辑格式,做不了高效崩溃恢复。MySQL 同时需要两份。
  • "commit 返回时数据已经落盘"——commit 只保证 redo(必要时含 binlog)落盘,脏页还在 Buffer Pool,由后台异步刷。持久性靠重放 redo 兜底,不是靠数据页及时落盘。
  • "binlog 一直默认开启"——5.7 及以前默认关闭,单机自用的库可能根本没有 binlog,此时两阶段提交退化为单阶段;8.0 起才默认开启。
  • "undo 在事务提交后就删了"——还要服务 MVCC 版本链,长事务会拖着旧版本 undo 不能清理,undo 表空间膨胀、快照读变慢都从这里来。
  • "双 0 只是慢一点换快一点,没风险"——宕机会丢最多 1 秒的已提交事务,对账务类数据是事故,参数选择先问业务能不能接受丢。

至此 MySQL 的"内存与日志"主线补完了:Buffer Pool、MVCC、锁、三大日志两阶段提交,共同构成 InnoDB 事务的完整实现。下一篇《慢 SQL 优化方法论:从 EXPLAIN 到索引设计》转入实战篇,把前面 B+ 树索引、执行计划的知识串成一套排查慢查询的固定流程。

参考

参考:MySQL 8.0 官方文档 The Binary Log 与 InnoDB Redo Log 两章(dev.mysql.com/doc/refman/8.0/en/binary-log.html);《MySQL 技术内幕:InnoDB 存储引擎》第 2 章关于 WAL 与 checkpoint 的实现;MySQL 8.0 源码 storage/innobase/trx/trx0trx.cc(trx_commit_for_mysql 提交路径与两阶段状态机)。

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。
粤ICP备2026104257号-1