InnoDB MVCC 和 Read View 是怎么工作的?
简化版
InnoDB 的 MVCC 通过隐藏事务 ID、undo log 版本链和 Read View 实现一致性读,让普通查询在不加锁的情况下读到符合隔离级别的历史版本。RC 是每次快照读生成新的 Read View,RR 是事务第一次快照读生成 Read View 后复用,所以 RR 能保证同一事务内多次快照读结果一致。
详细版
MVCC 主要由三部分组成:
- 隐藏字段:每行记录有事务 ID 和回滚指针等隐藏信息。
- undo log 版本链:记录被修改时,旧版本会通过 undo log 串起来。
- Read View:快照读时生成,用来判断哪个版本对当前事务可见。
快照读读取数据时,不一定读最新版本,而是沿着版本链找到对当前 Read View 可见的版本。
RC 和 RR 的核心区别在 Read View 生成时机:
- RC:每次普通
select都生成新的 Read View,所以能看到其他事务刚提交的数据。 - RR:事务中第一次快照读生成 Read View,后续复用,所以同一事务看到的是稳定快照。
注意,MVCC 主要作用于普通 select 这种快照读。select ... for update、update、delete 属于当前读,会读取最新已提交版本并加锁。
完整版教学
一、MVCC 要解决的核心矛盾
数据库并发里有一个矛盾:如果读都加锁,写会被读阻塞;如果写都阻塞读,读性能会很差。MVCC 的目标就是让普通读和写尽量互不阻塞。
它的思路不是“读最新值”,而是“读一个对当前事务可见的一致版本”。所以一个事务修改某行时,其他事务仍可以通过 undo log 找到旧版本继续读。
例如事务 A 把 balance 从 100 改成 80 但尚未提交,事务 B 做普通 select 时不能读到这个未提交的 80,否则 A 回滚后 B 就读到了脏数据。MVCC 的处理方式不是让 B 等 A 释放锁,而是让 B 沿 undo 版本链找到对自己可见的旧值 100。这样读请求不用阻塞写请求,写请求也不用因为普通读而停下来。
二、版本链是怎么形成的
InnoDB 每行记录有一些隐藏信息,其中包括修改该行的事务 ID,以及指向旧版本的回滚指针。事务更新一行时,会把旧值写入 undo log,并让新记录指向旧版本。
多次更新后,就形成一条版本链:
最新版本 -> 上一个版本 -> 更老版本 -> ...
快照读会根据 Read View 判断最新版本能不能看,不能看就沿着链继续找,直到找到可见版本或发现没有可见版本。
可以用一个小例子看版本链:某行初始值 name='A',事务 10 改成 B,事务 20 又改成 C。最新记录上带着事务 20 的版本,回滚指针指向事务 10 的旧版本,再往前指向更老版本。
记录最新版本: name='C', trx_id=20
|
v
undo 旧版本: name='B', trx_id=10
|
v
undo 更旧版本: name='A', trx_id=5
如果当前 Read View 判断 trx_id=20 不可见,但 trx_id=10 可见,那么快照读就返回 B。这就是“同一行有多个历史版本”的实际含义。
三、Read View 怎么判断可见性
Read View 可以理解成一次读开始时的“事务快照”。它记录当时活跃事务范围,以及当前事务自己的 ID。判断规则可以简化理解为:
- 当前事务自己改的版本,自己可以看;
- 已经在 Read View 创建前提交的事务版本,可以看;
- Read View 创建时仍未提交的事务版本,不可以看;
- Read View 创建后才开始的事务版本,不可以看。
这样,事务就能在并发写入的情况下读到稳定视图。
更具体的 Read View 通常会涉及活跃事务 ID 列表、最小活跃事务 ID、下一个将要分配的事务 ID 等信息。面试不一定要求背字段名,但要会判断可见性:版本的事务如果已经在快照创建前提交,就可见;如果创建快照时还活跃,或者是快照之后才开始的事务,就不可见。
| 版本来源 | 对当前 Read View 是否可见 | 理由 |
|---|---|---|
| 当前事务自己修改 | 可见 | 自己改的自己能读 |
| 快照创建前已提交事务 | 可见 | 已经属于快照里的稳定数据 |
| 快照创建时仍活跃事务 | 不可见 | 不能读未提交或当时不稳定的数据 |
| 快照创建后才开始的事务 | 不可见 | 不属于这次快照 |
记忆钩子:Read View 不是复制一份数据,而是保存一组“事务可见性规则”,读行时再沿版本链挑出符合规则的版本。
四、RC 和 RR 为什么表现不同
RC 每次快照读都会生成新的 Read View。第一次读之后,别的事务提交了修改;第二次读会创建新的 Read View,于是能看到那次提交,所以可能出现不可重复读。
RR 在事务第一次快照读时创建 Read View,后续普通查询复用同一个 Read View。即使别的事务提交了更新,当前事务继续沿着原来的可见性规则读旧版本,所以可以做到可重复读。
这也是面试最常问的点:RC 与 RR 的关键差异不是有没有 MVCC,而是 Read View 的生成时机不同。
用时间线看最清楚:
T1(RR) begin
T1 select balance -> 100,生成 Read View V1
T2 update balance=80 commit
T1 select balance -> 仍按 V1 读,结果还是 100
T1(RC) begin
T1 select balance -> 100,生成 Read View V1
T2 update balance=80 commit
T1 select balance -> 生成 Read View V2,结果可能是 80
所以 RC 允许同一事务内两次普通查询看到不同已提交版本,RR 则让同一事务内普通快照读稳定在第一次快照的视角上。
五、快照读和当前读不要混
普通 select 是快照读,不加锁,读历史可见版本。
这些语句是当前读:
select * from user where id = 1 for update;
update user set age = age + 1 where id = 1;
delete from user where id = 1;
当前读要读最新版本,并且通常要加锁。因为它要参与修改或锁定记录,不能只看历史快照。很多“为什么 RR 下我还能看到新插入数据”的疑问,往往是把快照读和当前读混在一起了。
例如 RR 事务里先普通 select count(*) from orders where user_id=1,之后别的事务插入一条并提交,再普通 select 仍可能看不到新行;但如果执行 select ... for update,它是当前读,会读最新已提交版本并加锁,表现就不同。面试中遇到“RR 是否完全没有幻读”的问题,要先反问或主动说明:你说的是快照读还是当前读。
六、MVCC 的边界
MVCC 提高了读写并发,但不是万能的:
- 它不能替代唯一索引、条件更新这类业务约束;
- 长事务会保留很老的版本,导致 undo 不能及时清理;
- 当前读仍需要锁,冲突时仍会等待或死锁;
- 幻读在当前读场景还要靠 next-key lock 等锁机制配合。
长事务是 MVCC 的典型成本。假设一个事务打开 30 分钟不提交,它的 Read View 可能还需要很早以前的旧版本;这会导致相关 undo 版本不能及时清理,历史链变长,影响存储和查询。MVCC 让读写并发更好,但代价是维护多版本和可见性判断,不是没有成本。
七、常见误区与追问
- 误区:MVCC 会复制整张表快照。 MVCC 不会为每个事务复制全表,而是通过隐藏字段、undo 版本链和 Read View 在读取时判断可见版本。
- 误区:RC 没有 MVCC。 RC 也使用 MVCC,只是每次快照读都创建新的 Read View,所以能看到其他事务新提交的数据。
- 误区:RR 下所有读都只看第一次快照。 普通 select 是快照读会复用 Read View,当前读则读取最新已提交版本并加锁。
- 误区:undo log 只用于事务回滚。 undo log 同时支撑 MVCC 历史版本,快照读可能沿 undo 链查找旧版本。
- 追问:Read View 判断可见性的核心是什么? 看版本对应事务在创建 Read View 时是否已经提交、是否仍活跃、是否是当前事务自己。
- 追问:长事务为什么会影响 MVCC? 长事务持有旧 Read View,会让很早的 undo 版本不能清理,版本链变长,带来存储和性能压力。
八、加强记忆
MVCC 的三件套是隐藏字段、undo 版本链和 Read View。隐藏字段告诉你版本是谁改的,undo 链保存旧版本,Read View 决定哪个版本对当前事务可见。RC 和 RR 的差异主要在 Read View 生成时机:RC 每次普通读新建,RR 第一次普通读后复用;普通 select 是快照读,for update/update/delete 是当前读。把这几条分清,事务隔离、幻读、长事务和 undo 清理就能连起来。