多版本并发控制(MVCC,Multi-Version Concurrency Control)是 InnoDB 实现高并发的核心机制,其核心目标是在不加锁(或低锁)的前提下,让不同事务看到符合隔离级别的数据版本,既保证读 - 写、写 - 读并发不阻塞,又能解决脏读、不可重复读、幻读等一致性问题
一、核心概念:一致性非锁定读 vs 锁定读
在讲解 MVCC 实现前,需先明确 InnoDB 的两种读模式,这是 MVCC 落地的基础:
1. 一致性非锁定读(Consistent Non-Locking Read)
- 定义 :InnoDB 默认的读方式,读取数据时不加锁,而是通过读取数据的历史版本(快照)来保证一致性,因此不会阻塞写操作,写操作也不会阻塞该读操作。
- 适用场景:普通 SELECT 语句(无 FOR UPDATE/LOCK IN SHARE MODE)。
- 核心依赖:MVCC 的快照机制(ReadView + undo-log)。
2. 锁定读(Locking Read)
- 定义 :读取数据时会对目标行加锁,强制后续写操作等待,保证读 - 写的强一致性,不依赖 MVCC 的快照。
- 适用场景 :需要保证读取的数据后续能安全修改的场景,如:
SELECT ... FOR UPDATE:加排他锁(X 锁),阻止其他事务修改或加共享锁;SELECT ... LOCK IN SHARE MODE:加共享锁(S 锁),阻止其他事务修改,但允许加共享锁。
- 核心依赖:InnoDB 的行锁机制(Record Lock)、间隙锁(Gap Lock)、Next-key Lock。
二、InnoDB 实现 MVCC 的核心组件
MVCC 的本质是为每行数据维护多个版本,事务根据隔离级别读取 "可见的版本",其实现依赖三大核心组件:隐藏字段 、undo-log 、ReadView。
1. 隐藏字段(Hidden Columns)
InnoDB 会为每张表的每行数据(除主键外)自动添加 3 个隐藏字段,用于标记数据版本和归属事务:
| 隐藏字段 | 数据类型 | 作用说明 |
|---|---|---|
DB_TRX_ID |
6 字节 | 记录最后一次修改该行数据的事务 ID(插入 / 更新 / 删除均算修改)。 |
DB_ROLL_PTR |
7 字节 | 回滚指针,指向该行数据的上一个版本(存储在 undo-log 中),形成版本链。 |
DB_ROW_ID |
6 字节 | 可选字段,仅当表无主键 / 唯一非空键时生成,作为行的唯一标识(类似自增 ID)。 |
示例 :假设表user有显式字段id (PK)、name,则实际存储结构为:
| DB_ROW_ID | id | name | DB_TRX_ID | DB_ROLL_PTR |
|---|---|---|---|---|
| 1 | 1 | 张三 | 100 | 指向 undo-log 版本 1 |
当事务 ID=200 的事务更新name为 "李四" 时,InnoDB 不会直接修改原行,而是:
- 保留原行,
DB_TRX_ID仍为 100; - 新增一行新版本数据,
DB_TRX_ID=200,DB_ROLL_PTR指向原行(版本 1); - 原行的
DB_ROLL_PTR无变化(成为版本链的尾节点)。
2. Undo Log(回滚日志)
Undo Log 是 InnoDB 的事务日志之一,核心作用:
- 事务回滚:若事务执行失败,通过 undo-log 恢复数据到修改前状态;
- MVCC 版本链 :存储数据的历史版本,与
DB_ROLL_PTR配合形成 "版本链"。
(1)Undo Log 的类型
- INSERT Undo Log:仅用于 INSERT 操作,事务提交后可直接删除(因为 INSERT 的行仅当前事务可见,无历史版本共享);
- UPDATE/DELETE Undo Log:用于 UPDATE/DELETE 操作,事务提交后不能立即删除,需保留至所有依赖该版本的事务都已提交(通过 purge 线程清理过期版本)。
(2)版本链的形成
每行数据的多个版本通过DB_ROLL_PTR串联,最新版本在链头,最旧版本在链尾。例如:
最新版本(trx_id=300) → DB_ROLL_PTR → 版本2(trx_id=200) → DB_ROLL_PTR → 版本1(trx_id=100) → null
3. ReadView(读视图)
ReadView 是事务执行一致性非锁定读时生成的 "可见性判断快照",本质是一个数据结构,记录了当前系统中 "活跃的未提交事务 ID",用于判断版本链中的哪个版本对当前事务可见。
ReadView 包含的核心字段
| 字段 | 作用说明 |
|---|---|
m_ids |
当前系统中活跃的未提交事务 ID 列表(升序排列)。 |
min_trx_id |
m_ids中的最小值(最小活跃事务 ID)。 |
max_trx_id |
系统下一个要分配的事务 ID(大于所有已分配的事务 ID)。 |
creator_trx_id |
生成该 ReadView 的事务 ID(当前事务 ID)。 |
数据可见性算法(核心规则)
对于版本链中的某一行版本(记其DB_TRX_ID为trx_id),InnoDB 通过以下规则判断是否对当前事务可见:
- 若
trx_id == creator_trx_id:该版本是当前事务自己修改的,可见; - 若
trx_id < min_trx_id:该版本由已提交的事务生成(事务 ID 小于最小活跃 ID,说明已提交),可见; - 若
trx_id >= max_trx_id:该版本由未来的事务生成(事务 ID 未分配),不可见; - 若
min_trx_id ≤ trx_id < max_trx_id:- 若
trx_id在m_ids中(该事务仍活跃未提交),不可见; - 若
trx_id不在m_ids中(该事务已提交),可见。
- 若
示例 :假设 ReadView 的min_trx_id=200,max_trx_id=500,m_ids=[200,300,400],creator_trx_id=150:
- 版本
trx_id=100:< min_trx_id,可见; - 版本
trx_id=200:在m_ids中,不可见; - 版本
trx_id=450:不在m_ids且< max_trx_id,可见; - 版本
trx_id=500:>= max_trx_id,不可见; - 版本
trx_id=150:等于creator_trx_id,可见。
三、RC 和 RR 隔离级别下 MVCC 的核心差异
InnoDB 的隔离级别(读未提交 RU、读已提交 RC、可重复读 RR、串行化 SERIALIZABLE)中,RU 不使用 MVCC(直接读最新数据),SERIALIZABLE 使用锁定读,只有 RC 和 RR 依赖 MVCC,核心差异在于ReadView 的生成时机。
1. 读已提交(RC,Read Committed)
- ReadView 生成时机 :每次执行 SELECT 语句时都生成新的 ReadView;
- 核心表现 :
- 解决脏读:只能读取已提交事务的版本;
- 无法解决不可重复读:同一事务内多次 SELECT,每次生成新的 ReadView,可能读取到其他事务提交的新版本;
- 幻读未解决:多次 SELECT 可能看到新插入的行(因为每次 ReadView 不同)。
RC 下 MVCC 示例(不可重复读场景)
| 时间 | 事务 A(RC 隔离级) | 事务 B |
|---|---|---|
| T1 | BEGIN; | |
| T2 | SELECT name FROM user WHERE id=1; → 张三(生成 ReadView1:m_ids=[A 的 ID]) | |
| T3 | BEGIN; UPDATE user SET name=' 李四 ' WHERE id=1; COMMIT;(trx_id=B 的 ID) | |
| T4 | SELECT name FROM user WHERE id=1; → 李四(生成 ReadView2:m_ids=[A 的 ID],B 的 ID 已提交,可见) |
2. 可重复读(RR,Repeatable Read)
- ReadView 生成时机 :仅在事务中第一次执行 SELECT(一致性非锁定读)时生成 ReadView,后续所有 SELECT 复用该 ReadView;
- 核心表现 :
- 解决脏读、不可重复读:同一事务内多次 SELECT 复用同一个 ReadView,只能看到第一次 SELECT 时已提交的版本;
- 基础 MVCC 无法完全解决幻读,需结合 Next-key Lock。
RR 下 MVCC 示例(解决不可重复读)
| 时间 | 事务 A(RR 隔离级) | 事务 B |
|---|---|---|
| T1 | BEGIN; | |
| T2 | SELECT name FROM user WHERE id=1; → 张三(生成 ReadView1:m_ids=[A 的 ID],复用至事务结束) | |
| T3 | BEGIN; UPDATE user SET name=' 李四 ' WHERE id=1; COMMIT;(trx_id=B 的 ID) | |
| T4 | SELECT name FROM user WHERE id=1; → 张三(复用 ReadView1,B 的 ID 在 ReadView1 中属于 "未来事务",不可见) |
四、MVCC 解决不可重复读的原理
不可重复读的定义:同一事务内多次读取同一行数据,结果不一致(因其他事务修改并提交)。
MVCC 解决该问题的核心逻辑:
- RR 隔离级:事务第一次 SELECT 生成 ReadView 后,后续所有 SELECT 复用该 ReadView;
- 其他事务提交的新版本,其
trx_id要么大于 ReadView 的max_trx_id(未来事务),要么在m_ids中(活跃事务),根据可见性算法判定为 "不可见"; - 事务始终读取 ReadView 生成时已提交的版本,因此多次读取结果一致,解决不可重复读。
注意:RC 隔离级因每次 SELECT 生成新 ReadView,无法解决不可重复读。
五、MVCC + Next-key Lock 防止幻读
幻读的定义:同一事务内多次执行相同范围的 SELECT,结果集行数不一致(因其他事务插入 / 删除符合条件的行)。
1. 仅 MVCC 无法解决幻读(RR 隔离级)
即使 RR 下复用 ReadView,若其他事务插入新行并提交,新行的trx_id可能小于 ReadView 的min_trx_id(已提交),导致事务再次 SELECT 时看到新行(幻读)。
2. Next-key Lock(临键锁)的补充作用
Next-key Lock 是 InnoDB 在 RR 隔离级下默认的行锁策略,结合了Record Lock(行锁) 和Gap Lock(间隙锁):
- Record Lock:锁定索引行本身;
- Gap Lock:锁定索引行之间的间隙(防止插入新行);
- Next-key Lock:锁定 "索引行 + 间隙",覆盖当前行和下一个间隙。
3. MVCC + Next-key Lock 解决幻读的逻辑
- 读操作(一致性非锁定读):通过 MVCC 的 ReadView 保证多次读取的版本一致;
- 写操作(INSERT/UPDATE/DELETE):通过 Next-key Lock 锁定范围,阻止其他事务插入 / 删除符合条件的行;
- 两者结合:
- 读时:MVCC 保证只能看到事务启动时已提交的版本,看不到其他事务新增的行;
- 写时:Next-key Lock 阻止其他事务在锁定范围内新增 / 删除行,从根本上避免幻读。
示例(RR 隔离级 + Next-key Lock 防幻读)
| 时间 | 事务 A | 事务 B |
|---|---|---|
| T1 | BEGIN; SELECT * FROM user WHERE age > 20;(生成 ReadView1,同时加 Next-key Lock 锁定 age>20 的范围) | |
| T2 | BEGIN; INSERT INTO user (age) VALUES (25);(被 Next-key Lock 阻塞,无法插入) | |
| T3 | SELECT * FROM user WHERE age > 20; → 结果与 T1 一致(无幻读) | |
| T4 | COMMIT;(释放锁) | |
| T5 | INSERT 成功 |
六、总结
InnoDB 的 MVCC 实现是隐藏字段、undo-log、ReadView 三大组件的协同:
- 隐藏字段:标记数据版本和版本链指针;
- undo-log:存储历史版本,形成版本链;
- ReadView:根据隔离级别生成快照,通过可见性算法判断数据版本是否可见;
- 隔离级别差异:RC 每次 SELECT 生成 ReadView(不可重复读),RR 仅第一次生成(可重复读);
- 防幻读:RR 下 MVCC 保证读一致性,Next-key Lock 阻止写操作修改范围数据,两者结合彻底解决幻读。