InnoDB 更新事务全过程原理
学习 MySQL 事务时,很多人只背:
undo log 保证原子性,redo log 保证持久性,binlog 用于复制,锁保证隔离性,MVCC 保证快照读。这句话没错,但太散。真正要学懂,必须把它们放进一次真实 update 里,看每一步发生了什么。
这一页用一个订单支付状态更新串起:
- 行锁怎么加。
- undo log 怎么生成。
- Buffer Pool 中的数据页怎么变脏。
- redo log 为什么先写。
- binlog 为什么也要写。
- 两阶段提交为什么存在。
- MVCC 的旧版本和 undo log 什么关系。
- 宕机后 MySQL 怎么判断恢复还是回滚。
示例 SQL
update orders
set status = 'PAID',
paid_at = now(),
updated_at = now()
where order_no = 'O202607050001'
and status = 'WAIT_PAY';表结构:
create table orders (
id bigint primary key auto_increment,
order_no varchar(64) not null,
status varchar(20) not null,
paid_at datetime null,
updated_at datetime not null,
unique key uk_order_no(order_no)
) engine = InnoDB default charset = utf8mb4;总流程
flowchart TD
A["执行 update"] --> B["通过 uk_order_no 定位记录"]
B --> C["判断 where 条件"]
C --> D["加行锁"]
D --> E["生成 undo log"]
E --> F["修改 Buffer Pool 中的数据页"]
F --> G["生成 redo log"]
G --> H["写 binlog"]
H --> I["redo commit"]
I --> J["事务提交成功"]这个流程里,每个机制都有职责:
| 机制 | 解决什么问题 |
|---|---|
| 行锁 | 防止两个事务同时修改同一行 |
| undo log | 事务失败能回滚,快照读能找旧版本 |
| Buffer Pool | 在内存中修改页,提高性能 |
| redo log | 宕机后恢复已提交修改 |
| binlog | 主从复制、时间点恢复、审计回放 |
| 两阶段提交 | 保证 redo log 和 binlog 对同一事务一致 |
第一步:通过索引定位记录
order_no 有唯一索引:
unique key uk_order_no(order_no)InnoDB 先查二级唯一索引:
flowchart TD
A["uk_order_no 根页"] --> B["内部页"]
B --> C["叶子页"]
C --> D["找到 order_no 对应主键 id"]
D --> E["回到聚簇索引查完整行"]为什么要回聚簇索引?
InnoDB 二级索引叶子通常保存:
二级索引列 + 主键值完整行在聚簇索引叶子页里。
第二步:判断 where 条件
SQL 里有:
where order_no = 'O202607050001'
and status = 'WAIT_PAY'找到订单后,还要判断当前状态是不是 WAIT_PAY。
如果状态已经是 PAID,这条 SQL 不应该再次更新。
这就是支付、库存等业务里常见的状态机保护:
只有 WAIT_PAY 才能改成 PAID。
不是 WAIT_PAY 就说明已经处理过或状态不允许。第三步:加行锁
更新行必须加写锁。
sequenceDiagram
participant A as 事务A
participant B as 事务B
A->>A: update 订单,拿到行锁
B->>A: update 同一订单,等待行锁
A->>A: commit
B->>B: 继续判断条件并执行为什么需要锁?
如果没有锁,两个支付回调可能同时看到 WAIT_PAY,然后都改状态、都写流水,造成重复处理。
行锁保证同一行的写操作串行化。
注意:InnoDB 行锁是加在索引记录上的。如果条件没有命中索引,扫描范围变大,锁影响也可能变大。
第四步:生成 undo log
更新前,InnoDB 要先记录旧值。
旧行:
status = WAIT_PAY
paid_at = null
updated_at = 2026-07-05 10:00:00要改成:
status = PAID
paid_at = 2026-07-05 10:30:00
updated_at = 2026-07-05 10:30:00undo log 要能支持回滚:
如果事务回滚,把 status 改回 WAIT_PAY,
paid_at 改回 null,
updated_at 改回旧值。flowchart TD
A["更新前旧值"] --> B["写入 undo log"]
B --> C["支持 rollback"]
B --> D["支持 MVCC 快照读旧版本"]undo log 的两个作用:
| 作用 | 说明 |
|---|---|
| 回滚 | 事务失败时恢复旧值 |
| MVCC | 快照读需要旧版本时沿 undo 版本链查找 |
第五步:修改 Buffer Pool 中的数据页
InnoDB 找到这行所在的数据页后,会在 Buffer Pool 中修改页。
flowchart TD
A["数据页在 Buffer Pool 中"] --> B["修改记录内容"]
B --> C["页变成脏页"]
C --> D["磁盘上的页暂时还是旧值"]脏页不是脏数据。它表示:
内存页已经是新内容,磁盘页还没同步。为什么不直接刷磁盘?
- 每次更新都刷数据页是随机 IO。
- 随机 IO 很慢。
- 多次修改同一页可以合并后再刷。
- redo log 可以保证宕机恢复。
第六步:生成 redo log
redo log 记录的是数据页的物理修改,用于崩溃恢复。
flowchart TD
A["Buffer Pool 修改页"] --> B["生成 redo log record"]
B --> C["写入 redo log buffer"]
C --> D["提交时按策略刷入 redo log file"]redo log 解决的问题是:
事务提交成功后,即使脏页还没刷盘,MySQL 宕机了,重启后也能恢复这次修改。第七步:为什么还需要 binlog
redo log 是 InnoDB 的日志,只服务 InnoDB 崩溃恢复。
binlog 是 Server 层日志,用于:
- 主从复制。
- 时间点恢复。
- 数据同步。
- 审计回放。
flowchart TD
A["事务更新订单"] --> B["redo log: InnoDB 崩溃恢复"]
A --> C["binlog: 主从复制和恢复"]如果只有 redo,没有 binlog:
- 主库自己能恢复。
- 从库不知道这次变更。
- 备份后无法通过 binlog 做时间点恢复。
如果只有 binlog,没有 redo:
- Server 层知道这次变更。
- InnoDB 宕机恢复时无法可靠修复数据页。
所以两者都需要。
第八步:两阶段提交
更新事务提交时,要保证 redo log 和 binlog 对同一事务一致。
典型过程:
flowchart TD
A["事务提交"] --> B["redo log 写 prepare"]
B --> C["写 binlog"]
C --> D["redo log 写 commit"]
D --> E["返回提交成功"]为什么要这么麻烦?
假设没有两阶段提交,可能出现:
| 情况 | 后果 |
|---|---|
| redo 写了,binlog 没写,宕机 | 主库恢复有这条数据,从库没有 |
| binlog 写了,redo 没写,宕机 | 从库可能有这条数据,主库恢复后没有 |
两阶段提交让 MySQL 崩溃恢复时能判断事务到底应该提交还是回滚。
宕机恢复怎么判断
简化理解:
flowchart TD
A["MySQL 重启"] --> B["扫描 redo log"]
B --> C{"事务 redo 是 prepare 吗"}
C -- "否,已 commit" --> D["按 redo 恢复"]
C -- "是" --> E["检查 binlog 是否完整"]
E -- "完整" --> F["提交事务并恢复"]
E -- "不完整" --> G["回滚事务"]这就是两阶段提交的意义:让 redo 和 binlog 在崩溃边界上能对齐。
MVCC 怎么参与
更新会改变行上的隐藏信息,例如事务 ID 和回滚指针。
简化理解:
当前行记录 trx_id = 本次更新事务 ID
roll_pointer 指向 undo log 里的旧版本快照读时:
flowchart TD
A["普通 select"] --> B["生成 Read View"]
B --> C["读取当前行版本"]
C --> D{"当前版本对 Read View 可见吗"}
D -- "可见" --> E["返回当前版本"]
D -- "不可见" --> F["沿 roll_pointer 找 undo 旧版本"]
F --> G["判断旧版本是否可见"]这就是为什么普通 select 能读到历史版本,而不需要等待正在更新的事务。
当前读和快照读区别
普通查询:
select *
from orders
where order_no = 'O202607050001';这是快照读,通常不加锁,读 Read View 可见版本。
当前读:
select *
from orders
where order_no = 'O202607050001'
for update;当前读要读最新已提交版本,并加锁,准备修改。
更新语句本身也是当前读:
update orders
set status = 'PAID'
where order_no = 'O202607050001';因为它必须基于当前最新状态来修改。
一次支付回调并发场景
两个支付回调同时到达:
sequenceDiagram
participant A as 回调A
participant B as 回调B
participant DB as MySQL
A->>DB: update status WAIT_PAY -> PAID
DB-->>A: 拿到行锁,更新成功
B->>DB: update status WAIT_PAY -> PAID
DB-->>B: 等待 A 的行锁
A->>DB: commit
DB-->>B: 继续执行,发现 status 已不是 WAIT_PAY
B-->>B: 影响行数为 0,按重复回调处理关键是 SQL 条件里必须带状态:
where order_no = ?
and status = 'WAIT_PAY'否则第二个回调可能在锁释放后继续重复写业务数据。
为什么长事务危险
长事务不提交会带来:
- 锁持有时间长,阻塞别人。
- undo log 旧版本不能清理。
- Read View 很老,导致历史版本链变长。
- 主从复制可能被大事务拖慢。
- 崩溃恢复和回滚成本变高。
flowchart TD
A["长事务开启"] --> B["持有 Read View"]
B --> C["其他事务不断 update"]
C --> D["产生大量 undo 旧版本"]
D --> E["旧版本不能及时 purge"]
E --> F["undo 堆积 / 查询变慢"]常见坑
| 坑 | 后果 | 正确理解 |
|---|---|---|
| update 条件不走索引 | 扫描和锁范围变大 | 更新条件必须命中合适索引 |
| 事务里调用外部接口 | 锁持有时间不可控 | 外部调用尽量放事务外 |
| 只依赖代码防重复 | 并发下会重复处理 | 状态条件和唯一约束要放数据库 |
| 以为 commit 就刷数据页 | 不理解 redo | commit 关键是 redo/binlog 可靠 |
| 忽略 binlog | 不懂主从和恢复 | binlog 是复制和时间点恢复关键 |
面试标准回答
InnoDB 执行 update 时,会先通过索引定位目标记录,并对要修改的索引记录加锁,防止并发写冲突。修改前会生成 undo log,undo 既用于事务回滚,也用于 MVCC 快照读构造旧版本。随后 InnoDB 修改 Buffer Pool 中的数据页,页变成脏页,同时生成 redo log。事务提交时先写 redo prepare,再写 binlog,最后写 redo commit,这就是两阶段提交,用来保证 InnoDB 崩溃恢复日志和 Server 层 binlog 对同一事务一致。提交成功不要求数据页立即刷盘,只要 redo 可靠,宕机后就能恢复。普通 select 通过 Read View 和 undo 版本链做快照读,update 和 select for update 是当前读,会读取最新版本并加锁。