一、先认识一下这几个"角色"
在深入流程之前,先搞清楚这几个日志各自是干什么的:
1.1 Undo Log(回滚日志)
- 作用:记录数据修改前的旧值
- 目的:事务回滚、MVCC(多版本并发控制)
- 存储位置 :表空间(
undo tablespace) - 写入时机:事务修改数据时,先写 Undo 页到 Buffer Pool(对 Undo 页的修改同样会生成 Redo Log,由 Redo Log 保证持久性)
- 特点:逻辑日志,记录的是"反向操作"
1.2 Redo Log(重做日志)
- 作用:记录数据页的物理修改(包括对普通数据页和 Undo 页的修改)
- 目的:崩溃恢复(即使脏页没刷盘,也能通过 Redo Log 恢复)
- 存储位置 :磁盘上的
ib_logfile0、ib_logfile1... - 结构:循环写,写满后覆盖
- 特点:物理日志,记录的是"页面的修改"
1.3 Binlog(二进制日志)
- 作用:记录所有修改数据的 SQL 语句(以事件形式)
- 目的:主从复制、基于时间点的数据恢复
- 存储位置 :磁盘上的
mysql-bin.xxxxxx - 特点:逻辑日志,追加写,不会覆盖
1.4 数据页(Data Page)
- 作用:InnoDB 存储数据的最小单位(默认 16KB)
- 存储位置 :磁盘的表空间文件(
.ibd) - 修改特点:先在内存的 Buffer Pool 中修改(脏页),异步刷盘
二、核心概念:两阶段提交(2PC)
MySQL 为什么要搞这么复杂?因为要保证:
- 一致性:Binlog 和 Redo Log 要么都成功,要么都失败
- 持久性:事务提交后,即使 MySQL 崩溃,数据也不能丢
解决方案就是两阶段提交 ,通过 XID(事务标识符) 关联 Redo Log 和 Binlog:
阶段1:prepare 阶段
├─ 写入 Redo Log(标记为 prepare 状态,并带上 XID)
└─ Redo Log 持久化到磁盘(fsync)
阶段2:commit 阶段
├─ 写入 Binlog(Binlog 中也包含相同的 XID)
├─ Binlog 持久化到磁盘(fsync)
└─ 将 Redo Log 标记为 commit 状态(此标记不必须持久化,崩溃恢复时通过 XID 比对即可)
三、完整流程:从 BEGIN 到 COMMIT
假设执行这样一条 SQL:
sql
BEGIN;
UPDATE users SET balance = balance - 100 WHERE id = 1;
COMMIT;
步骤 1:事务开始(BEGIN)
- 创建一个事务 ID(
TRX_ID)和一个 XID(用于两阶段提交) - 在内存中分配事务结构
- 此时什么都没写磁盘
步骤 2:执行 UPDATE 语句
2.1 查找数据
- 通过索引(主键/二级索引)定位到数据页
- 先从 Buffer Pool 查找,如果不在内存,则从磁盘加载
2.2 记录 Undo Log(在 Buffer Pool 中修改 Undo 页)
在修改数据之前,先记录旧值到 Undo 页(位于 Buffer Pool):
sql
-- Undo Log 记录的内容(示例)
TRX_ID: 1000
XID: 0x1234
TYPE: UPDATE
OLD_VALUE: {id: 1, balance: 1000}
NEW_VALUE: {id: 1, balance: 900}
关键点:Undo 页本身也是数据页,对 Undo 页的修改同样会生成 Redo Log,因此 Undo 的持久性由 Redo Log 间接保证。
Undo 页变成脏页,后续通过 Redo Log 和刷盘机制持久化。
2.3 修改数据页(Buffer Pool)
- 在内存中修改数据页:
balance从 1000 改为 900 - 这块数据页变成脏页(Dirty Page),还没同步到磁盘
2.4 记录 Redo Log(Redo Log Buffer)
记录对数据页的物理修改(包括对 Undo 页和数据页的修改):
sql
-- Redo Log 记录的内容(示例)
LOG_SEQ_NO: 12345
PAGE_ID: 0x0001 (数据页)
OFFSET: 120
OLD_DATA: 0x000003E8 (1000 的十六进制)
NEW_DATA: 0x00000384 (900 的十六进制)
-- 此外还有对 Undo 页的修改记录,格式类似
- Redo Log 写入内存的 Redo Log Buffer
- 注意:此时 Redo Log 还没有刷到磁盘!
步骤 3:事务提交(COMMIT)- 阶段1(Prepare)
当执行 COMMIT 时,真正的重头戏开始了:
3.1 将 Redo Log 标记为 Prepare 状态
Redo Log 的最后一条记录会加上一个标记:
{LOG_TYPE: XA_PREPARE, TRX_ID: 1000, XID: 0x1234}
3.2 Redo Log 刷盘(关键!)
- 调用
fsync()强制将 Redo Log Buffer 写入磁盘 - 此时 Redo Log 已经持久化了
为什么要在这里刷 Redo?
- 如果 MySQL 在这一步之后崩溃,恢复时发现 Redo Log 是 prepare 状态,Binlog 还没写,说明事务没提交,可以回滚
- Redo Log 虽然刷了,但状态是 prepare,不会用于恢复数据(除非 Binlog 中已有对应 XID)
步骤 4:事务提交(COMMIT)- 阶段2(Commit)
4.1 写入 Binlog
- 将事务的 SQL 语句以事件形式写入 Binlog,并附上 XID
- 调用
fsync()刷盘(取决于sync_binlog配置) - 此时 Binlog 已经持久化了
4.2 将 Redo Log 标记为 Commit 状态
在 Redo Log 中追加一条记录:
{LOG_TYPE: XA_COMMIT, TRX_ID: 1000}
重要说明 :这个 commit 标记只需要写入 Redo Log Buffer,不需要立即刷盘,甚至即使永远不刷盘也不影响正确性。
崩溃恢复时,如果 Redo Log 处于 prepare 状态,就去 Binlog 中查找相同 XID 的事务:
- 找到 → 认为事务已提交,重做 Redo Log 中的修改
- 未找到 → 回滚事务
步骤 5:事务结束
- 释放事务持有的锁
- 清理内存中的事务结构
- 此时对用户来说,事务已提交完成
四、数据页什么时候刷盘?
你可能注意到了,整个流程中数据页本身并没有刷盘!
是的,数据页的刷盘是异步的,由后台线程负责:
4.1 什么时候刷盘?
| 触发条件 | 说明 |
|---|---|
| Checkpoint | 定期触发,将脏页刷盘 |
| Buffer Pool 空间不足 | 内存不够时,需要淘汰旧页 |
| Redo Log 空间不够 | Redo Log 循环写,要覆盖旧记录前,必须先刷对应的脏页 |
| 脏页比例超过阈值 | innodb_max_dirty_pages_pct(默认 75%),触发刷脏页 |
| MySQL 关闭 | 优雅关闭时刷所有脏页 |
innodb_flush_method=O_DIRECT |
每次写数据页都直接绕过系统缓存(但依然是异步批量刷) |
4.2 为什么数据页可以延迟刷盘?
因为有 Redo Log!
即使脏页没刷盘,MySQL 崩溃了:
- 重启时读取 Redo Log
- 通过 XID 比对 Binlog,找出所有已提交的事务
- 重新应用这些事务对数据页的修改
- 数据就恢复了
这就是 Redo Log 的价值:用顺序写(Redo Log)代替随机写(数据页),性能提升巨大!
五、崩溃恢复:如果 MySQL 中途挂了怎么办?
MySQL 崩溃后的恢复流程非常巧妙,核心是 XID 匹配:
情况1:Redo Log = prepare,Binlog 中没有这个 XID
判断逻辑:
- Redo Log 是 prepare,说明事务还没完全提交
- Binlog 中没有相同 XID,说明事务确实失败了
处理:回滚事务
- 根据 Undo Log 将数据改回旧值
情况2:Redo Log = prepare,Binlog 中有这个 XID
判断逻辑:
- Redo Log 是 prepare,说明事务处于中间状态
- Binlog 中有相同 XID,说明事务已经提交成功(Binlog 已刷盘)
处理:提交事务
- 将 Redo Log 标记为 commit(内存中即可)
- 根据 Redo Log 重做数据页修改
情况3:Redo Log = commit
判断逻辑:
- Redo Log 已经是 commit,说明事务完全提交
处理:正常恢复
- 根据 Redo Log 重做数据页修改
- Binlog 已经存在,无需额外处理
注意:崩溃恢复时只关心已刷盘的 Redo Log 和 Binlog。内存中未刷盘的 Redo Log commit 标记并不影响判断,因为 XID 比对已经足够。
六、写入顺序总结(重要!)
6.1 内存中的顺序(快速)
1. 写 Undo Log(在 Buffer Pool 中修改 Undo 页,同时生成对应的 Redo Log)
2. 修改数据页(Buffer Pool)
3. 写 Redo Log(Redo Log Buffer)
4. 提交时:Redo Log prepare → 刷盘(fsync)
5. 提交时:写 Binlog → 刷盘(fsync)
6. 提交时:Redo Log commit(仅内存,不强制刷盘)
6.2 磁盘持久化的顺序
1. Redo Log(prepare 阶段刷盘)
2. Binlog(commit 阶段刷盘)
3. 数据页(异步刷盘,由后台线程在 checkpoint 等时机触发)
4. Undo 页(同样通过 Redo Log 和异步刷盘持久化)
5. Redo Log commit 标记(从不强制刷盘,也不依赖它)
七、关键配置参数
| 参数 | 默认值 | 作用 |
|---|---|---|
innodb_flush_log_at_trx_commit |
1 | Redo Log 刷盘策略 0: 每秒刷(可能丢1秒事务) 1: 每次提交调用 fsync 2: 每次提交只调用 write(写系统缓存),不调用 fsync |
sync_binlog |
1 | Binlog 刷盘策略 0: 交给操作系统控制 1: 每次提交调用 fsync N: 每N次提交调用一次 fsync |
innodb_flush_method |
空 | 数据页刷盘方式 O_DIRECT: 绕过系统缓存,直接写磁盘 |
innodb_max_dirty_pages_pct |
75 | 脏页比例阈值,超过后触发刷脏页 |
生产环境推荐:
sql
-- 最安全(性能较差,但数据零丢失)
SET GLOBAL innodb_flush_log_at_trx_commit = 1;
SET GLOBAL sync_binlog = 1;
-- 性能优先(有一定风险,最多丢1秒事务或一组事务)
SET GLOBAL innodb_flush_log_at_trx_commit = 2;
SET GLOBAL sync_binlog = 0;
八、流程图总结
BEGIN
│
├─ 创建事务 TRX_ID 和 XID
│
UPDATE 语句
│
├─ [1] 在 Buffer Pool 中写 Undo 页(同时生成对 Undo 页的 Redo Log)
├─ [2] 修改数据页(Buffer Pool,变成脏页)
└─ [3] 写 Redo Log(Redo Log Buffer,记录对数据页和 Undo 页的修改)
│
COMMIT
│
├─ 阶段1:Prepare
│ ├─ Redo Log 标记为 XA_PREPARE(带上 XID)
│ └─ Redo Log 刷盘(fsync)← 第一次持久化
│
├─ 阶段2:Commit
│ ├─ 写 Binlog(带上相同 XID)
│ └─ Binlog 刷盘(fsync)← 第二次持久化
│
└─ Redo Log 标记为 XA_COMMIT(仅内存,不刷盘)
│
事务结束(释放锁)
│
后台异步任务:
├─ 数据页刷盘(Checkpoint、脏页比例阈值等触发)
├─ Undo 页刷盘(同样由后台线程处理)
└─ (Redo Log commit 标记永不强制刷盘)
九、为什么是这样的设计?
9.1 为什么要先写 Undo?
- 为了回滚和 MVCC,必须知道旧值
- Undo 是逻辑日志,相对小,写得不亏
- 注意:Undo 本身也通过 Redo Log 保护,并非直接写磁盘
9.2 为什么要 Redo Log?
- 数据页是随机写(16KB 一页,改一个字节也要写整页)
- Redo Log 是顺序写,性能高 10 倍以上
- 用 Redo Log 代替数据页的即时刷盘
9.3 为什么要 Binlog?
- Redo Log 是物理日志,无法用于主从复制(不同版本、不同存储引擎的物理格式不同)
- Binlog 是逻辑日志,记录 SQL 或行变更,适合复制和恢复
9.4 为什么要两阶段提交?
- 保证 Redo Log 和 Binlog 的一致性
- 崩溃恢复时能够通过 XID 判断事务是否真正提交
9.5 为什么数据页可以延迟刷盘?
- 有 Redo Log 撑着,崩溃也能恢复
- 脏页批量刷盘,减少 I/O 次数
十、常见面试题(修正版)
Q1:为什么需要两阶段提交?
答:保证 Binlog 和 Redo Log 的一致性。如果是单阶段提交,可能出现 Redo 写了但 Binlog 没写(主从不一致),或者 Binlog 写了但 Redo 没写(数据丢失)。通过 prepare + commit 两阶段,配合 XID,崩溃恢复时总能判断事务的真实状态。
Q2:Redo Log 和 Binlog 有什么区别?
| 对比项 | Redo Log | Binlog |
|---|---|---|
| 日志类型 | 物理日志(页修改) | 逻辑日志(SQL 或行事件) |
| 存储位置 | MySQL 数据目录 | MySQL 数据目录 |
| 写入方式 | 循环写,可覆盖 | 追加写,不会覆盖 |
| 主要用途 | 崩溃恢复 | 主从复制、数据恢复 |
| 内容 | 记录数据页的修改 | 记录 SQL 语句或行变更 |
| 所属层 | InnoDB 引擎层 | MySQL Server 层 |
Q3:什么是 WAL(Write-Ahead Logging)?
答 :先写日志,再写数据。Redo Log 就是 WAL 的典型应用,确保即使数据页没刷盘,也能通过日志恢复。注意:这里"先写日志"指 Redo Log 先于对应的数据页刷盘,但 Undo 的写入同样遵循 WAL。
Q4:innodb_flush_log_at_trx_commit = 2 会丢数据吗?
答 :会 ,但只在操作系统崩溃或断电时丢数据。如果只是 MySQL 进程崩溃,由于数据已写入操作系统 page cache,操作系统会负责将其落盘,因此不丢数据。=2 表示每次提交只调用 write(写 page cache),不调用 fsync,所以操作系统崩溃会导致 page cache 中的日志丢失。
Q5:为什么 Binlog 不能像 Redo Log 一样循环写?
答:Binlog 用于主从复制和基于时间点的恢复,如果循环写覆盖了旧数据,从库就无法同步,或者无法恢复到历史时间点。Binlog 需要保留完整的历史记录。
Q6:Undo Log 是如何持久化的?也是靠 Redo Log 吗?
答 :是的。Undo 页本身是 InnoDB 表空间中的普通数据页,对 Undo 页的修改同样会生成 Redo Log。因此 Undo 的持久性由 Redo Log 保证,遵循 WAL 原则。崩溃恢复时,Redo Log 会重放对 Undo 页的修改,确保 Undo 数据完整。
十一、总结
MySQL 事务的写入流程,核心就是两个原则:
- 先写日志,再写数据(WAL 原则)
- 两阶段提交保证一致性(通过 XID 关联 Redo Log 和 Binlog)
整个流程精心设计,平衡了:
- 性能:用顺序写(Redo Log)代替随机写(数据页)
- 可靠性:多重重放机制,崩溃可恢复
- 一致性:两阶段提交确保日志同步