在MySQL数据库的并发控制中,InnoDB存储引擎的多版本并发控制(MVCC) 是保障读写性能的核心机制。它解决了传统锁机制下"读写互斥"的痛点,实现了"读不加锁、读写不冲突",极大提升了数据库的并发处理能力。本文将从实验现象出发,逐步拆解MVCC的底层原理,带你彻底搞懂这一关键技术。
一、从实验切入:MVCC的直观现象
要理解MVCC,我们先从一个真实的数据库实验开始。通过观察不同事务对同一数据的访问结果,感受MVCC的作用。
1.1 实验环境准备
首先创建测试表t1并插入初始数据(使用InnoDB引擎,避免MyISAM无事务支持的问题):
sql
-- 切换数据库(若不存在需先创建)
use martin;
-- 删除旧表(避免干扰)
drop table if exists t1;
-- 创建测试表
CREATE TABLE `t1` (
`id` int NOT NULL AUTO_INCREMENT,
`a` int NOT NULL,
`b` int NOT NULL,
PRIMARY KEY (`id`), -- 主键索引
KEY `idx_a` (`a`) -- 普通索引
) ENGINE=InnoDB CHARSET=utf8mb4;
-- 插入初始数据
insert into t1(a,b) values (1,1);
1.2 实验步骤与现象
我们开启两个事务(session1和session2),均使用读已提交(READ-COMMITTED) 隔离级别,执行如下操作:
| 步骤 | session1(事务1) | session2(事务2) |
|---|---|---|
| 1 | set session transaction_isolation='READ-COMMITTED'; |
set session transaction_isolation='READ-COMMITTED'; |
| 2 | select * from t1; → 结果:(1,1,1) |
- |
| 3 | begin;(开启事务) |
- |
| 4 | update t1 set b=3 where a=1;(修改未提交) |
- |
| 5 | - | begin;(开启事务) |
| 6 | select * from t1 where a=1; → 结果:(1,1,3) |
select * from t1 where a=1; → 结果:(1,1,1) |
| 7 | commit;(提交事务) |
- |
| 8 | - | select * from t1 where a=1; → 结果:(1,1,3) |
| 9 | - | commit;(提交事务) |
1.3 实验现象分析
这个实验暴露了一个关键问题:
- 步骤6中,session1修改数据后未提交,自身能看到修改后的
b=3,但session2看到的仍是原始值b=1; - 步骤8中,session1提交后,session2才能看到
b=3。
为什么同一时间对同一数据的访问会出现"版本差异"?答案就是MVCC------InnoDB通过保存数据的"历史版本快照",让不同事务看到对应时间点的一致数据。
二、MVCC的核心组成:三大基石
MVCC的实现依赖三个关键组件:隐藏列 、Undo Log(撤销日志) 和Read View(读取视图)。这三者共同构成了MVCC的底层逻辑。
2.1 隐藏列:数据的"身份档案"
InnoDB存储引擎会为表中的每一行记录自动添加3个隐藏列,用于追踪数据的版本和事务信息:
| 隐藏列名 | 作用说明 |
|---|---|
DB_ROW_ID |
隐藏自增ID。若表未定义主键,InnoDB会用该列作为聚集索引的键值 |
DB_TRX_ID |
事务ID。记录最后一次修改该记录的事务ID(包括INSERT/UPDATE/DELETE) |
DB_ROLL_PTR |
回滚指针。指向该记录的"历史版本"在Undo Log中的存储地址,形成版本链 |
以实验中的初始数据(1,1,1)为例,其隐藏列初始状态如下:
| id | a | b | DB_TRX_ID | DB_ROLL_PTR |
|---|---|---|---|---|
| 1 | 1 | 1 | 1(插入事务ID) | NULL(无历史版本) |
2.2 Undo Log:历史版本的"仓库"
Undo Log(撤销日志)的核心作用有两个:
- 事务回滚:若事务执行失败,通过Undo Log恢复数据到修改前的状态;
- MVCC版本存储:当数据被修改时,InnoDB会将修改前的"历史版本"存入Undo Log,供其他事务读取。
版本链的形成过程
当同一行数据被多次修改时,Undo Log会形成一条版本链 ,通过DB_ROLL_PTR串联:
-
初始状态:只有当前版本(b=1),DB_ROLL_PTR=NULL;
-
第一次修改(session1的
update b=3):- 原记录(
b=1)被存入Undo Log,DB_TRX_ID=1; - 新记录(
b=3)的DB_TRX_ID=2(session1的事务ID),DB_ROLL_PTR指向Undo Log中的原记录;
- 原记录(
-
若再次修改(如
update b=4):- 上一次的新记录(
b=3)存入Undo Log,DB_TRX_ID=2; - 最新记录(
b=4)的DB_TRX_ID=3,DB_ROLL_PTR指向Undo Log中的b=3版本。
- 上一次的新记录(
版本链的结构如下:
plain
当前版本(b=4, DB_TRX_ID=3)
↓(DB_ROLL_PTR指向)
Undo Log中的版本(b=3, DB_TRX_ID=2)
↓(DB_ROLL_PTR指向)
Undo Log中的版本(b=1, DB_TRX_ID=1)
2.3 Read View:版本选择的"裁判"
当事务需要读取数据时,面对版本链中的多个历史版本,如何确定"哪个版本可见"?这就需要Read View(读取视图) 来判断。
Read View的核心属性
Read View包含4个关键信息,用于判断版本可见性:
| 属性名 | 含义 |
|---|---|
trx_ids |
生成Read View时,数据库中当前活跃的事务ID集合 |
low_limit_id |
生成Read View时,系统"下一个要分配的事务ID"(即当前最大事务ID+1) |
up_limit_id |
生成Read View时,活跃事务中的最小事务ID |
creator_trx_id |
生成该Read View的"当前事务ID" |
Read View的可见性判断规则
对于版本链中的某一历史版本(DB_TRX_ID表示最后一次修改这行记录的事务ID ,其DB_TRX_ID为trx_id),Read View按以下规则判断是否可见:
- 若
trx_id == creator_trx_id:当前事务修改的数据,可见; - 若
trx_id < up_limit_id:修改该版本的事务已提交(早于所有活跃事务),可见; - 若
trx_id >= low_limit_id:修改该版本的事务未开启(晚于当前Read View生成),不可见,需沿版本链找前一个版本; - 若
up_limit_id <= trx_id < low_limit_id:- 若
trx_id在trx_ids中:事务未提交,不可见,找前一个版本; - 若
trx_id不在trx_ids中:事务已提交,可见。
- 若
| 满足的条件 | 查询哪个版本 |
|---|---|
| DB_TRX_ID= creator_trx_id | 说明当前事务是这行数据的创建者。自然这一行记录对该事务是可见的 |
| DB_TRX_ID < up_limit_id | 说明这行记录在这些活跃的事务创建之前就已经提交了,那么这一行记录对该事务是可见的 |
| DB_TRX_ID >= low_limit_id | 说明这行记录在这些活跃的事务开始之后创建的,那么这一行记录对该事物是不可见的,则再确定这一行记录的前一个版本对该事务是否可见 |
| up_limit_id <= DB_TRX_ID < low_limit_id 并且 DB_TRX_ID 在 trx_ids 中 | 说明 DB_TRX_ID 还未提交,那么这一行记录对该事物是不可见的,则再确定这一行记录的前一个版本对该事务是否可见 |
| up_limit_id <= DB_TRX_ID < low_limit_id 并且 DB_TRX_ID 不在 trx_ids 中 | 说明事务 DB_TRX_ID 已经提交了,那么这一行记录对该事物是可见的 |
ReadView判断哪个版本可用的举例
我们通过模拟多事务操作,结合ReadView的核心参数(trx_ids、low_limit_id、up_limit_id、creator_trx_id),分析不同场景下版本可见性的判断逻辑。
实验背景
初始化测试表:
bash
truncate table t1;
set session transaction_isolation='READ-COMMITTED';
| 步骤 | session1(DB_TRX_ID=1) | session2(DB_TRX_ID=2) | session3(DB_TRX_ID=3) | session4(DB_TRX_ID=4) | session5 | a=1这行记录的DB_TRX_ID | 满足条件 |
|---|---|---|---|---|---|---|---|
| 1 | begin insert into t1(a,b) values (1,1); select * from t1 where a=1; commit;(此时的Read View trx_ids:1 low_limit_id:2 up_limit_id:1 creator_trx_id:1) | 1 | DB_TRX_ID = creator_trx_id | ||||
| 2 | begin; update t1 set b=2 where a=1; | 2 | |||||
| 3 | select * from t1 where a=1;(此时的Read View trx_ids:2 low_limit_id:3 up_limit_id:2 creator_trx_id:0) | 2 | up_limit_id <= DB_TRX_ID < low_limit_id 并且DB_TRX_ID也在trx_ids中 | ||||
| 4 | begin; insert into t1(a,b) values (2,2); | 2 | |||||
| 5 | commit; | 2 | |||||
| 6 | begin; update t1 set b=3 where a=1; commit; | 4 | |||||
| 7 | select * from t1 where a=1;(此时的Read View trx_ids:3 low_limit_id:5 up_limit_id:5 creator_trx_id:0) | 4 | up_limit_id <= DB_TRX_ID < low_limit_id 并且DB_TRX_ID不在trx_ids中 | ||||
| 8 | commit; |
场景分析
(1)步骤1:session1的查询
- ReadView参数 :
trx_ids = {1}(生成Read View时,仅当前事务活跃);low_limit_id = 2(下一个待分配的事务ID);up_limit_id = 1(活跃事务中最小的事务ID);creator_trx_id = 1(创建Read View的事务ID)。
- 匹配规则 :
DB_TRX_ID = creator_trx_id - 结论 :当前事务是该行数据的创建者,记录对该事务可见 ,查询结果为
a=1, b=1。
(2)步骤3:session3的查询
- ReadView参数 :
trx_ids = {2}(生成Read View时,session2的事务活跃);low_limit_id = 3(下一个待分配的事务ID);up_limit_id = 2(活跃事务中最小的事务ID);creator_trx_id = 0(只读事务默认ID)。
- 匹配规则 :
up_limit_id <= DB_TRX_ID < low_limit_id 并且 DB_TRX_ID 在 trx_ids 中 - 结论 :事务
DB_TRX_ID=2未提交,记录对当前事务不可见 ,需通过Undo Log找前一版本(DB_TRX_ID=1的版本),查询结果为a=1, b=1。
(3)步骤7:session5的查询
- ReadView参数 :
trx_ids = {3}(生成Read View时,session3的事务活跃);low_limit_id = 5(因session4已用事务ID4,下一个事务ID为5);up_limit_id = 3(活跃事务中最小的事务ID);creator_trx_id = 0(只读事务默认ID)。
- 匹配规则 :
up_limit_id <= DB_TRX_ID < low_limit_id 并且 DB_TRX_ID 不在 trx_ids 中 - 结论 :事务
DB_TRX_ID=4已提交,记录对当前事务可见 ,查询结果为a=1, b=3。
通过以上实验和分析,可以清晰看到ReadView如何通过事务ID的规则判断,确定数据版本的可见性。
三、关键差异:RC与RR隔离级别的Read View时机
MVCC仅在读已提交(RC) 和可重复读(RR) 两个隔离级别下生效,但两者的核心差异在于:Read View的生成时机不同。这直接导致了"可重复读"和"不可重复读"的现象。
3.1 实验对比:RC与RR的Read View差异
我们将之前的实验改为可重复读(REPEATABLE-READ) 隔离级别,观察步骤8的结果:
| 步骤 | session1(事务1) | session2(事务2) |
|---|---|---|
| 1 | set session transaction_isolation='REPEATABLE-READ'; |
set session transaction_isolation='REPEATABLE-READ'; |
| 2 | select * from t1; → (1,1,1) |
- |
| 3 | begin; → update t1 set b=3 where a=1; |
- |
| 5 | - | begin; → select * from t1 where a=1; → (1,1,1) |
| 7 | commit;(提交事务) |
- |
| 8 | - | select * from t1 where a=1; → 仍为 (1,1,1) |
| 9 | - | commit; |
| 10 | - | select * from t1; → (1,1,3) |
3.2 差异原因:Read View生成时机
- 读已提交(RC) :同一事务中,每次执行查询都会重新生成Read View。因此步骤8中,session1提交后,session2的新Read View能看到提交后的版本;
- 可重复读(RR) :同一事务中,仅在第一次查询时生成Read View,后续查询复用该Read View。因此步骤8中,session2仍用初始Read View,看不到session1提交的新版本,实现了"可重复读"。
四、MVCC的完整工作流程
结合上述组件,我们梳理MVCC的完整执行流程:
- 事务开启,执行
select查询; - InnoDB生成当前事务的Read View,记录活跃事务ID、最小/最大事务ID等;
- 读取目标记录的最新版本,检查其
DB_TRX_ID是否符合Read View的可见性规则; - 若符合规则,直接返回该版本;若不符合,通过
DB_ROLL_PTR沿Undo Log版本链向前查找,直到找到可见版本; - 返回可见版本给事务。
获取事务ID 获取Read View 查询数据 比较事务ID 符合规则的数据 返回数据
五、MVCC的核心价值与适用场景
5.1 MVCC的核心优势
- 读写不冲突:读操作无需加锁,写操作仅加行级排他锁,极大提升并发性能;
- 保障隔离性:通过版本链和Read View,实现RC和RR级别的事务隔离性,满足ACID中的"I";
- 避免幻读(RR级别):RR级别下复用Read View,同一事务多次查询看到一致数据,避免幻读(注:InnoDB通过"间隙锁"彻底解决幻读,MVCC是辅助)。
5.2 MVCC的适用范围
- 仅支持 InnoDB:MyISAM 等其他存储引擎不支持 MVCC;
- 仅支持 RC 和 RR 隔离级别 :
- 读未提交(RU):直接读最新版本,无需 MVCC;
- 串行化(SERIALIZABLE):通过锁互斥实现隔离,不依赖 MVCC;
- 仅支持 "快照读":普通 SELECT 是快照读(读历史版本),而 UPDATE/DELETE/SELECT ... FOR UPDATE 是 "当前读"(读最新版本,需加锁)。
六、总结
MVCC是InnoDB并发控制的灵魂,其本质是"通过保存数据的历史版本,结合Read View的可见性判断,实现读写分离"。核心要点可归纳为:
- 三大组件:隐藏列(身份)、Undo Log(版本仓库)、Read View(裁判);
- 关键差异:RC每次查询生成Read View,RR仅第一次生成;
- 核心价值:读不加锁、读写不冲突,平衡并发与数据一致性。
建议大家动手复现文中的实验,通过实际操作感受MVCC的现象,再结合原理拆解,就能真正掌握这一MySQL核心技术。