MySQL MVCC
MVCC 是 Multi-Version Concurrency Control,多版本并发控制。它让 InnoDB 在很多情况下做到“读不阻塞写,写不阻塞普通读”。
一句话理解:
MVCC 不是让数据没有锁,而是让普通查询可以根据 Read View 去读取某个历史版本,从而减少读写互相等待。
为什么需要 MVCC
如果没有 MVCC,读写并发通常只能依赖锁:
flowchart TD
A["事务 A 正在 update 一行"] --> B["持有写锁"]
B --> C["事务 B 想 select 同一行"]
C --> D["如果只能读最新值,就必须等待写锁释放"]这样读多写少的业务会被大量写操作阻塞。MVCC 的做法是:写操作产生新版本,普通读根据快照判断能看到哪个版本。
flowchart TD
A["事务 A 修改数据"] --> B["当前行变成新版本"]
B --> C["旧版本进入 undo log 版本链"]
D["事务 B 普通 select"] --> E["根据 Read View 判断可见版本"]
E --> F["返回可见的旧版本或新版本"]MVCC 解决什么问题
| 问题 | MVCC 的作用 |
|---|---|
| 普通读被写阻塞 | 普通读可以读历史可见版本 |
| 同一事务多次读不一致 | RR 下复用同一个 Read View |
| 已提交数据什么时候可见 | RC 下每次查询生成新 Read View |
| 回滚怎么恢复旧值 | undo log 保存旧版本 |
MVCC 主要服务普通 select,也叫快照读。update、delete、select ... for update 是当前读,仍然需要加锁。
核心概念
| 概念 | 说明 |
|---|---|
trx_id | 记录最近修改该行的事务 ID |
roll_pointer | 指向 undo log 中的旧版本 |
| undo log | 保存修改前的旧版本,用于回滚和快照读 |
| Read View | 一致性读视图,用来判断某个版本是否可见 |
| 版本链 | 当前记录通过 roll_pointer 串起多个历史版本 |
这里最容易混淆的是 undo log。很多人只记得“undo log 用于回滚”,但在 InnoDB 里,undo log 同时也是 MVCC 旧版本链的载体。
MVCC 和 undo log 到底是什么关系
先给结论:
MVCC 的“多版本”不是复制了多份完整表数据,而是当前记录加上 undo log 中的历史版本。Read View 负责判断哪个版本可见,undo log 负责保存找得到的旧版本。
也就是说,MVCC 至少依赖三样东西:
| 组件 | 在 MVCC 中的作用 |
|---|---|
| 当前聚簇索引记录 | 保存最新版本的数据和隐藏字段 |
| undo log 版本链 | 保存一次次修改前的旧版本 |
| Read View | 判断当前事务应该看哪个版本 |
flowchart TD
A["普通 select"] --> B["生成或复用 Read View"]
B --> C["读取当前记录"]
C --> D{"当前版本对 Read View 可见吗"}
D -- "可见" --> E["返回当前版本"]
D -- "不可见" --> F["根据 roll_pointer 找 undo 旧版本"]
F --> G{"旧版本可见吗"}
G -- "可见" --> H["返回旧版本"]
G -- "不可见" --> F所以两句话要分清:
- undo log 不是 MVCC 本身,但 MVCC 找历史版本主要靠 undo log。
- Read View 不保存行数据,它只保存事务可见性判断信息。
如果没有 undo log,事务可以通过锁阻塞来保证一致性,但普通查询就很难在不阻塞写入的情况下读到历史版本。如果没有 Read View,即使 undo log 里有旧版本,事务也不知道哪个版本对自己可见。
undo log 本身解决什么问题
undo log 有两个核心作用:
| 作用 | 解释 |
|---|---|
| 事务回滚 | 事务失败时,根据 undo log 把数据恢复到修改前 |
| MVCC 快照读 | 普通 select 需要历史版本时,沿 undo log 版本链查找 |
假设原始数据是:
id = 1, name = 'Tom'事务执行:
update mvcc_user
set name = 'Jerry'
where id = 1;InnoDB 会把“修改前的 Tom”记录到 undo log 中,然后当前记录变成 Jerry。事务如果回滚,就根据 undo log 把 Jerry 改回 Tom;其他事务如果根据 Read View 不能看到 Jerry,也可以沿 undo log 找到 Tom。
flowchart TD
A["更新前当前记录 name=Tom"] --> B["生成 undo log:旧值 Tom"]
B --> C["当前记录改为 name=Jerry"]
C --> D{"事务后续结果"}
D -- "rollback" --> E["根据 undo 恢复 Tom"]
D -- "commit" --> F["Jerry 成为已提交版本"]
F --> G["旧 Read View 仍可能通过 undo 看到 Tom"]注意:事务提交不代表 undo log 立刻删除。只要还有旧 Read View 可能需要这个版本,undo log 就不能被清理。
undo log 的两类常见形态
从用途上可以先这样理解:
| 类型 | 产生场景 | 特点 |
|---|---|---|
| insert undo | 插入新记录 | 主要用于事务回滚,事务提交后通常可以较快清理 |
| update undo | 更新或删除记录 | 既用于回滚,也可能用于 MVCC 版本链 |
为什么 insert undo 提交后通常更容易清理?因为一条记录如果是某个未提交事务新插入的,其他事务本来就看不到它;提交之后,它没有“插入前的旧版本”要给别人读。update undo 不同,更新前的旧值可能仍然被其他旧 Read View 读取,所以不能马上删。
删除也不是简单地把物理记录立刻从页里抹掉。InnoDB 通常会先做删除标记,旧版本仍然可能被 MVCC 读取;等确认没有事务需要旧版本后,再由 purge 线程真正清理。
隐藏字段如何串起版本链
InnoDB 聚簇索引记录中有几个和 MVCC 密切相关的隐藏信息:
| 隐藏信息 | 作用 |
|---|---|
DB_TRX_ID | 最近一次修改该行的事务 ID |
DB_ROLL_PTR | 指向 undo log 中上一个历史版本 |
DB_ROW_ID | 没有显式主键时生成的隐藏行 ID |
这三个字段不用在 select * 里看到,但要理解它们的职责。
flowchart TD
A["聚簇索引当前记录\nname=Jerry\nDB_TRX_ID=120\nDB_ROLL_PTR=p1"] --> B["undo 版本 p1\nname=Tom\ntrx_id=110\nroll_ptr=p0"]
B --> C["undo 版本 p0\nname=Alice\ntrx_id=100"]普通查询读取当前记录后,会拿记录上的 DB_TRX_ID 和自己的 Read View 做比较:
- 如果当前版本可见,直接返回。
- 如果当前版本不可见,通过
DB_ROLL_PTR找到 undo 旧版本。 - 对旧版本继续做可见性判断。
- 直到找到可见版本,或者发现没有可见版本。
这就是版本链。
版本链
当一行数据被多次修改时,InnoDB 会通过 undo log 保留旧版本。
flowchart TD
A["当前记录<br/>name=Jerry<br/>trx_id=120"] --> B["undo 版本 1<br/>name=Tom<br/>trx_id=110"]
B --> C["undo 版本 2<br/>name=Alice<br/>trx_id=100"]普通查询不会简单地返回当前记录,而是要判断当前版本对自己是否可见。如果不可见,就沿着 undo log 版本链找上一个版本。
update 时版本链怎么形成
用一个更具体的例子说明。
初始数据:
insert into mvcc_user(id, name) values (1, 'Alice');事务 100 提交后,当前记录可以理解为:
| 版本 | name | trx_id | roll_pointer |
|---|---|---|---|
| 当前记录 | Alice | 100 | null |
事务 110 执行并提交:
update mvcc_user set name = 'Tom' where id = 1;此时当前记录变成 Tom,旧的 Alice 进入 undo:
| 位置 | name | trx_id | roll_pointer |
|---|---|---|---|
| 当前记录 | Tom | 110 | 指向 Alice 的 undo |
| undo 版本 | Alice | 100 | null |
事务 120 又执行并提交:
update mvcc_user set name = 'Jerry' where id = 1;此时版本链变成:
| 位置 | name | trx_id | roll_pointer |
|---|---|---|---|
| 当前记录 | Jerry | 120 | 指向 Tom 的 undo |
| undo 版本 1 | Tom | 110 | 指向 Alice 的 undo |
| undo 版本 2 | Alice | 100 | null |
如果一个旧事务的 Read View 只能看到 trx_id <= 110 的版本,它读取当前 Jerry 时发现不可见,就会沿版本链找到 Tom。
undo log 与 redo log 的关系
undo log 负责“撤销”和“旧版本”,redo log 负责“崩溃恢复”。它们不是替代关系,而是互相配合。
| 日志 | 作用 | 类比 |
|---|---|---|
| undo log | 记录修改前的数据,用于回滚和 MVCC | 后悔药 |
| redo log | 记录页修改,用于宕机后重做 | 保险单 |
一个关键点:undo log 本身也需要 redo log 保护。
因为 undo log 也是写在 InnoDB 页里的数据。如果事务修改了业务数据,同时生成了 undo log,但宕机后 undo log 自己丢了,就无法正确回滚未提交事务,也无法给旧 Read View 提供旧版本。因此,对 undo 页的修改也会产生 redo log。
flowchart TD
A["执行 update"] --> B["写 undo log 旧值"]
B --> C["修改当前数据页"]
B --> D["undo 页变化产生 redo"]
C --> E["数据页变化产生 redo"]
D --> F["宕机恢复时恢复 undo 页"]
E --> G["宕机恢复时恢复数据页"]
F --> H["未提交事务可回滚"]
G --> H这能解释一个深层问题:事务没提交就宕机了,MySQL 重启后为什么知道怎么回滚?因为 redo 先把必要的数据页和 undo 页恢复到一致状态,然后 InnoDB 再根据事务状态和 undo 信息回滚未提交事务。
MVCC、undo、redo、binlog 的分工
| 机制 | 解决的问题 | 与 MVCC 的关系 |
|---|---|---|
| MVCC | 普通读如何不阻塞写,还能读到一致版本 | 总体机制 |
| undo log | 历史版本在哪里,事务如何回滚 | MVCC 版本链载体 |
| Read View | 哪个版本对当前事务可见 | MVCC 可见性规则 |
| redo log | 宕机后如何恢复已提交修改和必要 undo 信息 | 保障 undo 和数据页可靠 |
| binlog | 主从复制、增量恢复、审计 | 不直接实现 MVCC |
更完整的事务修改链路见:redo log 与 binlog。
Read View 是什么
Read View 可以理解为“查询开始时,数据库里哪些事务还没提交”的快照。它不是复制一份完整数据,而是一组用于判断可见性的事务信息。
常见字段可以这样理解:
| 字段 | 含义 |
|---|---|
m_ids | 创建 Read View 时仍然活跃的事务 ID 列表 |
min_trx_id | 活跃事务中最小的事务 ID |
max_trx_id | 下一个将要分配的事务 ID |
creator_trx_id | 创建这个 Read View 的事务 ID |
可见性判断简化理解:
flowchart TD
A["读取某个版本 trx_id"] --> B{"是否自己事务修改"}
B -- "是" --> C["可见"]
B -- "否" --> D{"trx_id < min_trx_id"}
D -- "是" --> C
D -- "否" --> E{"trx_id >= max_trx_id"}
E -- "是" --> F["不可见,找旧版本"]
E -- "否" --> G{"trx_id 是否在 m_ids 中"}
G -- "在" --> F
G -- "不在" --> C不用背每个字段的源码细节,但要理解判断方向:
- 已经在快照创建前提交的事务,对我可见。
- 快照创建时还没提交的事务,对我不可见。
- 快照创建后才出现的事务,对我不可见。
- 自己事务里修改的数据,对自己可见。
读取流程
flowchart TD
A["普通 select"] --> B["生成或复用 Read View"]
B --> C["读取当前记录版本"]
C --> D{"当前版本是否可见"}
D -- "是" --> E["返回数据"]
D -- "否" --> F["沿 undo log 找上一版本"]
F --> D这个流程解释了为什么长版本链会影响查询:如果当前版本不可见,查询需要沿 undo log 找旧版本。
RC 与 RR 的区别
| 隔离级别 | Read View 生成时机 | 结果 |
|---|---|---|
| Read Committed | 每次普通 select 都生成新的 Read View | 能读到其他事务已提交的新数据 |
| Repeatable Read | 事务中第一次普通 select 生成 Read View,并复用 | 同一事务多次普通读结果更稳定 |
sequenceDiagram
participant A as "事务 A"
participant B as "事务 B"
participant DB as "InnoDB"
A->>DB: "select,生成 Read View"
B->>DB: "update 并 commit"
A->>DB: "再次 select"
DB-->>A: "RR 复用旧 Read View,仍看旧版本"RC 下第二次 select 会生成新的 Read View,因此可以看到事务 B 已提交的新值。RR 下普通查询复用旧 Read View,因此还是看到旧值。
当前读与快照读
| 类型 | 示例 | 是否走 MVCC 快照 | 是否加锁 |
|---|---|---|---|
| 快照读 | 普通 select | 是 | 通常不加行锁 |
| 当前读 | select ... for update | 否 | 加锁 |
| 当前读 | select ... lock in share mode | 否 | 加共享锁 |
| 当前读 | update、delete | 否 | 加锁 |
示例:
-- 快照读
select *
from mvcc_user
where id = 1;
-- 当前读
select *
from mvcc_user
where id = 1
for update;为什么当前读不能只读历史版本:update 必须基于最新数据修改,否则会把已经提交的新值覆盖掉,造成数据错误。
SQL Demo:观察 RC 和 RR 差异
准备数据:
create table mvcc_user (
id bigint primary key,
name varchar(50) not null
) engine = InnoDB;
insert into mvcc_user(id, name) values (1, 'Tom');事务 A,在 RR 隔离级别下:
set session transaction isolation level repeatable read;
begin;
select * from mvcc_user where id = 1;事务 B:
begin;
update mvcc_user set name = 'Jerry' where id = 1;
commit;事务 A 再查:
select * from mvcc_user where id = 1;
commit;在 RR 下,事务 A 的普通 select 仍然看到第一次 Read View 中的结果,也就是 Tom。
把事务 A 改成 RC:
set session transaction isolation level read committed;
begin;
select * from mvcc_user where id = 1;
-- 等事务 B 修改并提交后
select * from mvcc_user where id = 1;
commit;RC 下第二次普通 select 会看到 Jerry,因为每次查询都会生成新的 Read View。
Demo:当前读会看到最新数据
仍然使用 RR 隔离级别。
事务 A:
set session transaction isolation level repeatable read;
begin;
select * from mvcc_user where id = 1;事务 B:
begin;
update mvcc_user set name = 'Jerry' where id = 1;
commit;事务 A 执行当前读:
select * from mvcc_user where id = 1 for update;这时事务 A 会读取最新已提交版本,并尝试加锁。它和普通快照读结果可能不同。
这也是很多初学者困惑的点:RR 下“可重复读”主要针对普通快照读,不代表所有读都永远返回旧版本。
长事务为什么危险
长事务会长期持有 Read View,导致旧版本不能被清理。
flowchart TD
A["事务 A 开启后长时间不提交"] --> B["Read View 长期存在"]
B --> C["其他事务不断更新数据"]
C --> D["undo log 旧版本不能及时清理"]
D --> E["版本链变长,空间增长,查询变慢"]常见长事务来源:
- 开启事务后执行慢查询。
- 事务里调用远程接口。
- 程序异常但连接没有提交或回滚。
- 手工操作数据库,
begin后忘记commit。
查看事务:
select *
from information_schema.innodb_trx;Purge 线程为什么不能随便清理 undo
undo log 不是事务一提交就立刻删除。InnoDB 后台有 purge 机制,负责清理已经没有事务需要的历史版本。
问题是:能不能清理,不取决于“产生这个 undo 的事务是否已经提交”,还取决于“当前系统里是否还有更早的 Read View 可能要读它”。
flowchart TD
A["事务 A 很早开始"] --> B["生成旧 Read View"]
B --> C["事务 B/C/D 不断更新同一批数据"]
C --> D["产生大量 update undo"]
D --> E{"事务 A 是否结束"}
E -- "否" --> F["旧版本仍可能被 A 读取"]
F --> G["Purge 不能清理相关 undo"]
E -- "是" --> H["旧 Read View 释放"]
H --> I["Purge 可以逐步清理无用版本"]这就是线上常说的“长事务导致 undo 堆积”。它的本质不是连接占用时间长这么简单,而是长事务的 Read View 把历史版本的清理边界卡住了。
常见后果:
| 后果 | 原因 |
|---|---|
| undo 表空间增长 | 旧版本无法及时清理 |
| 查询变慢 | 版本链变长,快照读可能要沿 undo 找很多次 |
| 磁盘压力增加 | undo、redo、脏页都会增加 IO 压力 |
| DDL 或维护变慢 | 长事务可能阻塞元数据锁或清理进度 |
排查长事务时重点看:
select
trx_id,
trx_state,
trx_started,
trx_mysql_thread_id,
trx_query
from information_schema.innodb_trx
order by trx_started;再结合连接状态:
show processlist;生产处理原则:
- 先确认是不是业务长事务、慢查询、人工会话未提交。
- 能让业务自然提交或回滚就优先自然结束。
- 确认无风险后再 kill 对应连接。
- 复盘代码中是否把远程调用、文件处理、大批量循环放进了事务。
为什么当前读不能用旧 undo 版本
普通 select 可以读 undo 里的历史版本,但 update、delete、select ... for update 不能只读历史版本。原因很简单:这些操作要修改或锁定“现在最新的数据”。
假设库存原来是 10:
select stock from sku_stock where sku_id = 1;事务 A 的旧 Read View 看到 stock=10。与此同时,事务 B 已经扣减并提交,当前库存变成 9。
如果事务 A 的扣库存操作还按旧版本 10 去改,就可能覆盖掉事务 B 的提交结果。所以当前读必须读取最新已提交版本,并对记录加锁。
flowchart TD
A["普通 select"] --> B["可以基于 Read View 读旧版本"]
C["update/delete/for update"] --> D["必须读最新版本"]
D --> E["加锁保证修改顺序"]
E --> F["避免覆盖其他已提交事务"]这也是 MVCC 和锁必须配合的原因:MVCC 提升普通读并发,锁保证写入和当前读的正确顺序。
MVCC 和幻读
在 RR 隔离级别下,普通快照读通过复用 Read View,可以避免普通查询看到新增幻影行。但当前读要读取最新数据,为了防止范围内被插入新记录,InnoDB 会使用间隙锁、临键锁。
flowchart TD
A["普通范围 select"] --> B["MVCC Read View"]
B --> C["同一事务内结果稳定"]
D["select ... for update"] --> E["当前读 + 锁"]
E --> F["通过间隙锁防止范围插入"]所以 MVCC 和锁不是互相替代关系:
| 场景 | 主要机制 |
|---|---|
| 普通一致性读 | MVCC |
| 修改数据 | 行锁 |
| 当前读防止幻读 | 临键锁 / 间隙锁 |
常见误区
| 误区 | 正确理解 |
|---|---|
| MVCC 不需要锁 | MVCC 主要优化普通读,写仍然要锁 |
| RR 下所有读都可重复 | 普通快照读可重复,当前读会读最新数据 |
| undo log 只用于回滚 | undo log 也支撑快照读的历史版本 |
| Read View 是复制整张表 | Read View 只是事务可见性判断信息 |
| 长事务只是占连接 | 长事务会阻止旧版本清理,导致 undo 堆积 |
面试标准回答
MVCC 是什么?
MVCC 是多版本并发控制。InnoDB 通过当前记录、undo log 版本链和 Read View 实现普通一致性读。写事务修改数据时,会生成新版本,并把旧版本放到 undo log 中;普通 select 读取时,根据 Read View 判断当前版本是否可见,如果不可见,就沿着 roll_pointer 去 undo log 中找旧版本。因此 MVCC 可以让普通读在很多情况下不阻塞写,写也不阻塞普通读。
MVCC 和 undo log 是什么关系?
undo log 是 MVCC 版本链的重要载体。MVCC 的多版本不是复制多份完整表数据,而是当前记录通过隐藏字段 DB_ROLL_PTR 串到 undo log 中的历史版本。Read View 决定哪个版本可见,undo log 提供可读取的旧版本。undo log 还用于事务回滚,所以它既服务原子性,也服务 MVCC 的一致性读。
undo log 提交后会立刻删除吗?
不会。事务提交只代表这个事务的修改完成了,但如果系统里还有更早的 Read View 可能读取旧版本,相关 undo log 就不能被 purge 清理。只有确认没有事务再需要这些历史版本后,后台 purge 线程才能逐步清理。因此长事务会长期持有旧 Read View,导致 undo 堆积、版本链变长、查询变慢和磁盘压力增加。
undo log 和 redo log 有什么关系?
undo log 记录修改前的数据,用于回滚和 MVCC;redo log 记录页修改,用于崩溃恢复。undo log 本身也是 InnoDB 页数据,对 undo 页的修改也会产生 redo log。宕机恢复时,InnoDB 先通过 redo 恢复数据页和 undo 页,再根据事务状态用 undo 回滚未提交事务。所以 undo 和 redo 不是互斥关系,而是配合保证事务正确性。
小结
- MVCC 用版本链和 Read View 实现一致性读。
- undo log 保存旧版本,既用于回滚,也用于快照读。
- RC 每次普通读生成新的 Read View,RR 第一次普通读生成并复用。
- 普通
select是快照读,select ... for update是当前读。 - 长事务会让旧版本无法清理,是线上数据库的重要风险。
继续学习时建议阅读 锁:MVCC 解释普通读,锁解释写冲突和当前读。
