MVCC 多版本并发控制
一、引言
本文深入剖析 MySQL InnoDB 存储引擎中 MVCC(多版本并发控制)工作原理,涵盖快照读、当前读概念,MVCC 关键构成(隐藏字段、Undo Log、ReadView),及其在不同事务隔离级别下的数据可见性处理与幻读问题解决,且附实例展示读提交和可重复读隔离级别下的 MVCC 操作流程。
二、MVCC 概述
(一)定义
MVCC(Multiversion Concurrency Control)借由管理数据行多版本达成数据库事务并发时的一致性读。当事务读取正被更新行时,可获取更新前版本,有效化解读写冲突,此技术确保 InnoDB 事务隔离级别下一致性读操作顺畅执行,各 DBMS 实现方式有别且无统一标准,本文聚焦 InnoDB 实现机制(MySQL 其他存储引擎不支持)。
(二)快照读与当前读
1. 快照读
- 概念:MVCC 于 MySQL InnoDB 核心应用为快照读,是不加锁非阻塞读,如
SELECT * FROM player WHERE...
,基于提升并发性能考量,以多版本技术实现,可能读取历史版本数据,前提为隔离级别非串行,否则退化为当前读。 - 原理:MVCC 本质是乐观锁思维体现,于事务并发场景,读操作无需等待写事务释放锁,直接读取合适版本数据,减少锁竞争与等待开销,提升系统整体并发性能,其多版本数据存储与管理依赖隐藏字段、Undo Log 与 ReadView 协同。
2. 当前读
- 概念:与快照读相反,当前读为加锁操作,属悲观锁实现,读取记录最新版本且锁定,防并发事务修改,如加锁
SELECT
、增删改操作皆引发当前读,像SELECT * FROM student LOCK IN SHARE MODE;
(共享锁)与SELECT * FROM student FOR UPDATE;
(排他锁)等。 - 原理:数据库执行当前读时,依操作类型精准施加合适锁(共享或排他),确保数据一致性与完整性,此过程需权衡锁粒度与并发性能,粗粒度锁虽易控制数据一致性但并发度受限,细粒度锁并发高但管理复杂、易引发死锁,数据库依业务场景抉择最优锁策略。
1 | SELECT * FROM student LOCK IN SHARE MODE; # 共享锁 |
三、MVCC 三剑客
(一)回顾隔离级别
事务隔离级别调控并发事务对资源访问隔离程度,越高则事务干扰越小、安全性越高,主要级别含读未提交、读提交、可重复读(MySQL 默认)、串行化,各有特性与读问题表现:
- 读未提交:事务可读未提交数据,无锁机制,性能顶尖却易现脏读(读未提交事务修改数据)。
- 读提交:事务仅见已提交事务数据,MVCC 底层支撑,每次快照读生读视图,解脏读之困,如多事务并发场景,一事务读取数据不受未提交事务干扰,保数据有效性。
- 可重复读:事务多次读数据恒定,MVCC 为基,首次快照读制读视图复用,除脏读、克不可重复读(多次读数据变)难题,InnoDB 引擎下可解幻读(读数据集合变),读事务不受其他事务数据变更影响,保障数据稳定性与可预测性。
- 串行化:事务加锁阻塞,读共享锁、写排他锁,性能居末,却根治脏读、不可重复读与幻读,以牺牲并发换高数据一致性,适用对数据准确性严苛场景。
(二)隐藏字段、Undo Log 版本链
1. 隐藏字段
InnoDB 表聚簇索引记录含关键隐藏列:
- trx_id(事务 id):事务改动聚簇索引记录时,自身事务 id 赋于此列,标记数据修改者,助 MVCC 追踪数据版本所属事务,为数据可见性判断奠基。
- roll_pointer(回滚指针):指向 Undo Log 里记录版本链近节点,记录更新时旧版本入 Undo Log,此指针串起历史版本形成单向链表,回溯数据变更轨迹,供 MVCC 按需取历史版本。
2. Undo Log(回滚日志)
事务写操作时,MySQL 先存旧版本数据于 Undo Log,再更新数据库,事务提交前用于回滚,提交后助提供历史版本读取数据,其版本链为链表结构,各版本依时间或事务推进有序排列,头部为最新版本,MVCC 依此定位检索历史版本,保数据完整性与一致性,助事务回滚与多版本并发控制。
(三)ReadView
1. ReadView(读视图)简约版
事务快照读时生成 ReadView,快照记录系统活跃事务 id(递增),主用于数据可见性判定。含关键全局属性:
- creator_trx_id:创建读视图事务 ID,增删改事务有资格分配(读事务 id 为 0),标识读视图所属事务,助判断事务与数据版本关联及可见性。
- trx_ids:生成 ReadView 时系统活跃读写事务 id 列表,反映系统并发事务活跃态势,MVCC 依此对比数据版本事务 id 判断可见性,界定事务能访问的数据版本范围。
- up_limit_id:活跃事务列表最小事务 ID,界定事务生成 ReadView 前已提交事务下限,小于此 id 的数据版本对当前事务可见,因在当前事务启动前已稳定提交。
- low_limit_id:系统应分配给下一事务的 id 值(非 trx_ids 最大值,事务 id 递增),示活跃事务 id 上限,大于等于此 id 的数据版本在当前事务后产生不可见,标志事务启动后新事务修改数据不可访问。
2. 设计思路
- 不同隔离级别处理逻辑
- READ UNCOMMITTED:事务可读未提交记录最新版,因无数据一致性严格要求,无需复杂版本控制,直取最新数据,适对数据时效敏感轻一致性场景,如实时监控数据采集,及时反映最新数据变化,不顾及数据短暂不一致性。
- SERIALIZABLE:InnoDB 强用加锁访问记录,保数据强一致性,多事务串行执行无并发干扰,如金融核心交易处理,数据准确重于性能,确保各交易有序、准确处理,杜绝并发异常致数据错误风险。
- READ COMMITTED 和 REPEATABLE READ:须保证读已提交事务修改数据,核心是精准判断版本链数据版本可见性,ReadView 在此关键,屏蔽未提交事务干扰,READ COMMITTED 每次读新生成 ReadView,REPEATABLE READ 首次读生成后复用,适配不同并发控制与数据一致性平衡需求。
3. ReadView 的规则
依 ReadView 判断记录版本可见性规则如下:
- 若被访问版本
trx_id
与creator_trx_id
同,事务访问自身修改记录,版本可见。 - 被访问版本
trx_id
小于up_limit_id
,此版本在 ReadView 生成前已提交,事务可访问。 - 被访问版本
trx_id
大于等于low_limit_id
,版本于 ReadView 生成后产生,事务不可访问。 - 被访问版本
trx_id
在up_limit_id
与low_limit_id
间,若在trx_ids
列表,版本对应事务活跃,不可访问;若不在,版本事务已提交,事务可访问。
4. MVCC 整体操作流程
MVCC 查询记录流程为:
- 先取事务自身版本号(事务 ID),为后续判断数据版本归属与可见性锚点。
- 获取 ReadView,依事务隔离级别与执行阶段生成或复用,含系统活跃事务关键信息,定数据可见性判断依据。
- 查询数据并与 ReadView 事务版本号比,不符规则从 Undo Log 取历史快照,Undo Log 依 roll_pointer 存历史版本链,MVCC 回溯检索,直至得符合规则数据返回;若版本链遍历完无可见数据,事务对该记录不可见,查询结果排除此记录。
四、举例说明 MVCC 流程
(一)读提交的 MVCC 流程
读提交:事务每次读到的都是最新已提交的数据。底层由MVVC实现 ,每次读取数据前都生成一个ReadView。只解决脏读问题。
读提交的MVCC原理:每次读都生成Read View,不断对比版本链各版本的trx_id,直到发现某版本trx_id比Read View的活跃事务列表里最小trx_id还小,该版本则是读前最新已提交的数据。
现在有两个 事务id 分别为 10 、 20 的事务在执行:
1 | # Transaction 10 |
说明:事务执行过程中,只有在第一次真正修改记录时(比如使用INSERT、DELETE、UPDATE语句),才会被分配一个单独的事务id,这个事务id是递增的。所以我们才在事务2中更新一些别的表的记录,目的是让它分配事务id。
此刻,表student 中 id 为 1 的记录得到的版本链表如下所示:
假设现在有一个使用 读提交READ COMMITTED 隔离级别的事务开始执行:
1 | # 使用READ COMMITTED隔离级别的事务 |
这个 SELECT1 的执行过程如下:
在执行 SELECT 语句(快照读)时会先生成一个 ReadView。ReadView 的 trx_ids 列表的内容就是[10,20]。up_limit_id为10,low_limit_id为21,creator_trx_id为0。
从版本链中挑选可见的记录,从图中看出,最新版本的列 name 的内容是王五,该版本的 trx_id 值为10,在trx_ids 列表内,所以不符合可见性要求,根据 roll_pointer 跳到下一个版本。
下一个版本的列name 的内容是,李四,该版本的 trx_id值也为 10,也在 trx_ids 列表内,所以也不符合要求,继续跳到下一个版本。
下一个版本的列name 的内容是,张三,该版本的trx_id值为 8,小于ReadView中的最小活跃事务up_limit_id值10,说明该版本是生成ReadView 之前的最近版本,所以这个版本是符合要求的,最后返回给用户的版本就是这条列 name 为,张三的记录。
之后,我们把 事务id
为 10
的事务提交一下:
1 | # Transaction 10 |
然后再到 事务id
为 20
的事务中更新一下表 student
中 id
为 1
的记录:
1 | # Transaction 20 |
然后再到刚才使用 READ COMMITTED
隔离级别的事务中继续查找这个 id 为 1 的记录,如下:
1 | # 使用READ COMMITTED隔离级别的事务 |
这个 SELECT2 的执行过程如下
在执行 SELECT 语句时会又会单独生成一个 ReadView,该ReadView的 trx_ids 列表的内容[20],就是up_limit_id为20,low_limit_id为21, creator_trx_id为0。
从版本链中挑选可见的记录,从图中看出,最新版本的列 name 的内容是,宋八,该版本的 trx_id 值为20,在 trx_ids 列表内,所以不符合可见性要求,根据 roll_pointer 跳到下一个版本。
下一个版本的列name 的内容是钱七’,该版本的 trx_id值为 28,也在 trx_ids 列表内,所以也不符合要求,继续跳到下一个版本。
下一个版本的列name 的内容是’王五’,该版本的trx_id值为10,小于ReadView 中的up_limit_id值28,所以这个版本是符合要求的,最后返回给用户的版本就是这条列 name 为,王五’的记录。
(二)可重复读的 MVCC 流程
只在第一次查询时生成ReadView,之后查询用第一次快照读时生成的ReadView。
比如,系统里有两个 事务id
分别为 10
、 20
的事务在执行:
1 | # Transaction 10 |
此刻,表student 中 id 为 1 的记录得到的版本链表如下所示:
假设现在有一个使用 REPEATABLE READ
隔离级别的事务开始执行:
1 | # 使用REPEATABLE READ隔离级别的事务 |
这个SELECT1 的执行过程如下:
在执行 SELECT 语句时会先生成一个Readview,ReadView的 trx_ids 列表的内容就是[10,20].up_limit_id为10,low_limit_id为21,creator_trx_id为0。
然后从版本链中挑选可见的记录,从图中看出,最新版本的列 name 的内容是,王五’,该版本的 trx_id值为 10,在trx_ids 列表内,所以不符合可见性要求,根据 rol1_pointer 跳到下一个版本.
下一个版本的列name 的内容是,李四’,该版本的trx_id值也为 10,也在 trx_ids 列表内,所以也不符合要求,继续跳到下一个版本。
下一个版本的列name 的内容是’张三’,该版本的trx_id值为 8,小于Readview 中的up_limit_id值10,所以这个版本是符合要求的,最后返回给用户的版本就是这条列 name 为,张三’的记录。
之后,我们把 事务id 为 10 的事务提交一下,就像这样:
1 | # Transaction 10 |
然后再到 事务id 为 20 的事务中更新一下表 student 中 id 为 1 的记录:
1 | # Transaction 20 |
此刻,表student 中 id
为 1
的记录的版本链长这样:
然后再到刚才使用 REPEATABLE READ
隔离级别的事务中继续查找这个 id
为 1
的记录,如下:
1 | # 使用REPEATABLE READ隔离级别的事务 |
SELECT2 的执行过程如下:
因为当前事务的隔离级别为 REPEATABLE READ,而之前在执行SELECT1 时已经生成过 ReadView了,所以此时直接复用之前的 ReadView,之前的 ReadView的 trx_ids 列表的内容就是[10,20],up_limit_id为1, low_limit_id为21, creator_trx_id为0
然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列 name 的内容是宋八’,该版本的trx_id值为29,在trx_ds列表内,所以不符合可见性要求,根据ro11 pointer 跳到下一个版本
下一个版本的列name 的内容是’钱七’,该版本的 trx_id值为20,也在 trx_ids 列表内,所以也不符合要求,继续跳到下一个版本。
下一个版本的列name 的内容是,王五’,该版本的trx_id值为10,而trx_ids 列表中是包含值为 10的事务id 的,所以该版本也不符合要求,同理下一个列 name 的内容是,李四的版本也不符合要求。继续跳到下个版本
下一个版本的列name 的内容是’张三’,该版本的trx_id值为 80,小于 ReadView 中的up_limit_id值18,所以这个版本是符合要求的,最后返回给用户的版本就是这条列 c为,张三’的记录。
这次SELECT查询得到的结果是重复的,记录的列c值都是张三,这就是可重复读的含义。如果我们之后再把事务id为20的记录提交了,然后再到刚才使用REPEATABLE READ隔离级别的事务中继续查找这个id为1的记录,得到的结果还是张三,具体执行过程大家可以自己分析一下。
(三)InnoDB 解决幻读问题
接下来说明InnoDB 是如何解决幻读的。
假设现在表 student 中只有一条数据,数据内容中,主键 id=1,隐藏的 trx_id=10,它的 undo log 如下图所示。
假设现在有事务 A 和事务 B 并发执行,事务 A 的事务 id 为 20 , 事务 B 的事务 id 为 30 。
步骤1:事务 A 开始第一次查询数据,查询的 SQL 语句如下。
1 | select * from student where id >= 1; |
在开始查询之前,MySQL 会为事务 A 产生一个 ReadView,此时 ReadView 的内容如下: trx_ids= [20,30] , up_limit_id=20 , low_limit_id=31 , creator_trx_id=20 。
由于此时表 student 中只有一条数据,且符合 where id>=1 条件,因此会查询出来。然后根据 ReadView 机制,发现该行数据的trx_id=10,小于事务 A 的 ReadView 里 up_limit_id,这表示这条数据是事务 A 开启之前,其他事务就已经提交了的数据,因此事务 A 可以读取到。
结论:事务 A 的第一次查询,能读取到一条数据,id=1。
步骤2:接着事务 B(trx_id=30),往表 student 中新插入两条数据,并提交事务。
1 | insert into student(id,name) values(2,'李四'); |
此时表student 中就有三条数据了,对应的 undo 如下图所示:
步骤3:接着事务 A 开启第二次查询,根据可重复读隔离级别的规则,此时事务 A 并不会再重新生成 ReadView。此时表 student 中的 3 条数据都满足 where id>=1 的条件,因此会先查出来。然后根据 ReadView 机制,判断每条数据是不是都可以被事务 A 看到。
1)首先 id=1 的这条数据,前面已经说过了,可以被事务 A 看到。
2)然后是 id=2 的数据,它的 trx_id=30,此时事务 A 发现,这个值处于 up_limit_id 和 low_limit_id 之 间,因此还需要再判断 30 是否处于 trx_ids 数组内。由于事务 A 的 trx_ids=[20,30],因此在数组内,这表 示 id=2 的这条数据是与事务 A 在同一时刻启动的其他事务提交的,所以这条数据不能让事务 A 看到。
3)同理,id=3 的这条数据,trx_id 也为 30,因此也不能被事务 A 看见。
结论:最终事务 A 的第二次查询,只能查询出 id=1 的这条数据。这和事务 A 的第一次查询的结果是一样 的,因此没有出现幻读现象,所以说在 MySQL 的可重复读隔离级别下,不存在幻读问题。
五、总结
这里介绍了 MVCC 在 READ COMMITTD 、 REPEATABLE READ 这两种隔离级别的事务在执行快照读操作时 访问记录的版本链的过程。这样使不同事务的 读-写 、 写-读 操作并发执行,从而提升系统性能。
核心点在于 ReadView 的原理, READ COMMITTD 、 REPEATABLE READ 这两个隔离级别的一个很大不同 就是生成ReadView的时机不同:
READ COMMITTD 在每一次进行普通SELECT操作前都会生成一个ReadView
REPEATABLE READ 只在第一次进行普通SELECT操作前生成一个ReadView,之后的查询操作都重复 使用这个ReadView就好了。
说明: 我们之前说执行DELETE语句或者更新主键的UPDATE语句并不会立即把对应的记录完全从页面中删除而是执行一个所谓的delete mark操作,相当于只是对记录打上了一个删除标志位,这主要就是为MVCC服务的。
通过MVCC我们可以解决:
- 读写之间阻塞的问题。通过 MVCC 可以让读写互相不阻塞,即读不阻塞写,写不阻塞读,这样就可以提升事务并发处理能力。
- 降低了死锁的概率。这是因为 MVCC 采用了乐观锁的方式,读取数据时并不需要加锁,对于写操作,也只锁定必要的行。
- 解决快照读的问题。当我们查询数据库在某个时间点的快照时,只能看到这个时间点之前事务提交更新的结3果,而不能看到这个时间点之后事务提交的更新结果.