Skip to content

InnoDB 更新事务全过程原理

学习 MySQL 事务时,很多人只背:

text
undo log 保证原子性,redo log 保证持久性,binlog 用于复制,锁保证隔离性,MVCC 保证快照读。

这句话没错,但太散。真正要学懂,必须把它们放进一次真实 update 里,看每一步发生了什么。

这一页用一个订单支付状态更新串起:

  1. 行锁怎么加。
  2. undo log 怎么生成。
  3. Buffer Pool 中的数据页怎么变脏。
  4. redo log 为什么先写。
  5. binlog 为什么也要写。
  6. 两阶段提交为什么存在。
  7. MVCC 的旧版本和 undo log 什么关系。
  8. 宕机后 MySQL 怎么判断恢复还是回滚。

示例 SQL

sql
update orders
set status = 'PAID',
    paid_at = now(),
    updated_at = now()
where order_no = 'O202607050001'
  and status = 'WAIT_PAY';

表结构:

sql
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;

总流程

mermaid
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 有唯一索引:

sql
unique key uk_order_no(order_no)

InnoDB 先查二级唯一索引:

mermaid
flowchart TD
    A["uk_order_no 根页"] --> B["内部页"]
    B --> C["叶子页"]
    C --> D["找到 order_no 对应主键 id"]
    D --> E["回到聚簇索引查完整行"]

为什么要回聚簇索引?

InnoDB 二级索引叶子通常保存:

text
二级索引列 + 主键值

完整行在聚簇索引叶子页里。

第二步:判断 where 条件

SQL 里有:

sql
where order_no = 'O202607050001'
  and status = 'WAIT_PAY'

找到订单后,还要判断当前状态是不是 WAIT_PAY

如果状态已经是 PAID,这条 SQL 不应该再次更新。

这就是支付、库存等业务里常见的状态机保护:

text
只有 WAIT_PAY 才能改成 PAID。
不是 WAIT_PAY 就说明已经处理过或状态不允许。

第三步:加行锁

更新行必须加写锁。

mermaid
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 要先记录旧值。

旧行:

text
status = WAIT_PAY
paid_at = null
updated_at = 2026-07-05 10:00:00

要改成:

text
status = PAID
paid_at = 2026-07-05 10:30:00
updated_at = 2026-07-05 10:30:00

undo log 要能支持回滚:

text
如果事务回滚,把 status 改回 WAIT_PAY,
paid_at 改回 null,
updated_at 改回旧值。
mermaid
flowchart TD
    A["更新前旧值"] --> B["写入 undo log"]
    B --> C["支持 rollback"]
    B --> D["支持 MVCC 快照读旧版本"]

undo log 的两个作用:

作用说明
回滚事务失败时恢复旧值
MVCC快照读需要旧版本时沿 undo 版本链查找

第五步:修改 Buffer Pool 中的数据页

InnoDB 找到这行所在的数据页后,会在 Buffer Pool 中修改页。

mermaid
flowchart TD
    A["数据页在 Buffer Pool 中"] --> B["修改记录内容"]
    B --> C["页变成脏页"]
    C --> D["磁盘上的页暂时还是旧值"]

脏页不是脏数据。它表示:

text
内存页已经是新内容,磁盘页还没同步。

为什么不直接刷磁盘?

  1. 每次更新都刷数据页是随机 IO。
  2. 随机 IO 很慢。
  3. 多次修改同一页可以合并后再刷。
  4. redo log 可以保证宕机恢复。

第六步:生成 redo log

redo log 记录的是数据页的物理修改,用于崩溃恢复。

mermaid
flowchart TD
    A["Buffer Pool 修改页"] --> B["生成 redo log record"]
    B --> C["写入 redo log buffer"]
    C --> D["提交时按策略刷入 redo log file"]

redo log 解决的问题是:

text
事务提交成功后,即使脏页还没刷盘,MySQL 宕机了,重启后也能恢复这次修改。

第七步:为什么还需要 binlog

redo log 是 InnoDB 的日志,只服务 InnoDB 崩溃恢复。

binlog 是 Server 层日志,用于:

  1. 主从复制。
  2. 时间点恢复。
  3. 数据同步。
  4. 审计回放。
mermaid
flowchart TD
    A["事务更新订单"] --> B["redo log: InnoDB 崩溃恢复"]
    A --> C["binlog: 主从复制和恢复"]

如果只有 redo,没有 binlog:

  1. 主库自己能恢复。
  2. 从库不知道这次变更。
  3. 备份后无法通过 binlog 做时间点恢复。

如果只有 binlog,没有 redo:

  1. Server 层知道这次变更。
  2. InnoDB 宕机恢复时无法可靠修复数据页。

所以两者都需要。

第八步:两阶段提交

更新事务提交时,要保证 redo log 和 binlog 对同一事务一致。

典型过程:

mermaid
flowchart TD
    A["事务提交"] --> B["redo log 写 prepare"]
    B --> C["写 binlog"]
    C --> D["redo log 写 commit"]
    D --> E["返回提交成功"]

为什么要这么麻烦?

假设没有两阶段提交,可能出现:

情况后果
redo 写了,binlog 没写,宕机主库恢复有这条数据,从库没有
binlog 写了,redo 没写,宕机从库可能有这条数据,主库恢复后没有

两阶段提交让 MySQL 崩溃恢复时能判断事务到底应该提交还是回滚。

宕机恢复怎么判断

简化理解:

mermaid
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 和回滚指针。

简化理解:

text
当前行记录 trx_id = 本次更新事务 ID
roll_pointer 指向 undo log 里的旧版本

快照读时:

mermaid
flowchart TD
    A["普通 select"] --> B["生成 Read View"]
    B --> C["读取当前行版本"]
    C --> D{"当前版本对 Read View 可见吗"}
    D -- "可见" --> E["返回当前版本"]
    D -- "不可见" --> F["沿 roll_pointer 找 undo 旧版本"]
    F --> G["判断旧版本是否可见"]

这就是为什么普通 select 能读到历史版本,而不需要等待正在更新的事务。

当前读和快照读区别

普通查询:

sql
select *
from orders
where order_no = 'O202607050001';

这是快照读,通常不加锁,读 Read View 可见版本。

当前读:

sql
select *
from orders
where order_no = 'O202607050001'
for update;

当前读要读最新已提交版本,并加锁,准备修改。

更新语句本身也是当前读:

sql
update orders
set status = 'PAID'
where order_no = 'O202607050001';

因为它必须基于当前最新状态来修改。

一次支付回调并发场景

两个支付回调同时到达:

mermaid
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 条件里必须带状态:

sql
where order_no = ?
  and status = 'WAIT_PAY'

否则第二个回调可能在锁释放后继续重复写业务数据。

为什么长事务危险

长事务不提交会带来:

  1. 锁持有时间长,阻塞别人。
  2. undo log 旧版本不能清理。
  3. Read View 很老,导致历史版本链变长。
  4. 主从复制可能被大事务拖慢。
  5. 崩溃恢复和回滚成本变高。
mermaid
flowchart TD
    A["长事务开启"] --> B["持有 Read View"]
    B --> C["其他事务不断 update"]
    C --> D["产生大量 undo 旧版本"]
    D --> E["旧版本不能及时 purge"]
    E --> F["undo 堆积 / 查询变慢"]

常见坑

后果正确理解
update 条件不走索引扫描和锁范围变大更新条件必须命中合适索引
事务里调用外部接口锁持有时间不可控外部调用尽量放事务外
只依赖代码防重复并发下会重复处理状态条件和唯一约束要放数据库
以为 commit 就刷数据页不理解 redocommit 关键是 redo/binlog 可靠
忽略 binlog不懂主从和恢复binlog 是复制和时间点恢复关键

面试标准回答

text
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 是当前读,会读取最新版本并加锁。