主题
三大日志与两阶段提交
本文是 MySQL 系统学习系列的核心篇。前置:MySQL 架构与一条 SQL 的执行过程。 学完可以配合面试题食用:redo log、undo log、binlog 与两阶段提交
一条 UPDATE 引出的三个问题
从一条最普通的更新说起:
sql
UPDATE account SET balance = balance - 100 WHERE id = 1;这条语句执行时,InnoDB 并不是直接把磁盘上的那行数据改掉,而是先在内存里改(Buffer Pool 的数据页),再择机刷回磁盘。中间任何一个时刻机器都可能断电。围绕这条语句,有三个各自独立的问题:
- 断电时内存里的修改丢了,磁盘上的旧数据还在,这笔扣款就凭空消失了——怎么保证提交过的事务不丢?
- 事务执行到一半要回滚(显式 ROLLBACK,或者语句报错)——怎么把改了一半的数据恢复原样?
- 生产环境 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_logfile0 和 ib_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":
- 事务写完 binlog 并落盘,还没来得及写 redo commit,崩溃;
- 重启后主库恢复:redo 里没有 commit 记录,事务回滚,余额没扣;
- 从库重放 binlog:binlog 完整,执行扣款;
- 结果:主库没扣钱、从库扣了,主从不一致,且没有任何报错。
反过来"先 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 runsql
-- 双 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 提交路径与两阶段状态机)。