一文讲透MVCC

0 阅读12分钟

一、引言

什么是MVCC?

MVCC(Multi-Version Concurrency Control,多版本并发控制)这个词里,其实暗藏了三个关键信息:多版本、并发、控制

并发,相信大家都不陌生,指的是多个事务在同一时间段内同时执行;但多版本究竟指的是什么?这些版本是如何产生和维护的? 而所谓的控制,又是在控制什么?是控制并发读写冲突,还是控制事务之间的可见性?

很多人在学习 MySQL 或 InnoDB 时,都会听到 MVCC,却对它“只闻其名,不知其意”。 这一篇文章,我们就从这三个词本身出发,拆解 MVCC 的设计动机与底层原理,一步步揭开它的神秘面纱。

为什么需要MVCC,它有什么价值?

为什么数据库一定要引入 MVCC?如果没有 MVCC,会发生什么?

在高并发场景下,事务之间的读写冲突是不可避免的。最直观、也最简单的解决方式就是加锁:读加读锁、写加写锁,甚至直接串行执行。 但锁的代价非常高——阻塞、等待、死锁、性能下降,都会随着并发度的提升被无限放大。

MVCC 的价值,正是在于用版本换并发。通过为同一条数据维护多个历史版本,并为每个事务构建一致性视图,数据库可以做到:读不阻塞写,写不阻塞读,从而在保证事务隔离性的同时,大幅提升系统整体吞吐量。也正因为如此,MVCC 成为了 InnoDB 实现 RC、RR 隔离级别 的核心机制之一。

案例演示:

CREATE TABLE `tb_account`  (
  `account` int(11) NULL DEFAULT NULL COMMENT '账号',
  `money` double NULL DEFAULT NULL COMMENT '金额'
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;
​
INSERT INTO `tb_account` VALUES (10087, 2300);
INSERT INTO `tb_account` VALUES (10086, 2000);
INSERT INTO `tb_account` VALUES (10088, 2000);

依靠锁来控制事物之间的读写冲突

image.png

首先,T1 开启事务并修改账号为 10087 的这条数据金额;与此同时,T2 也开启事务,尝试读取同一条账号为 10087 的数据。由于该行记录已被 T1 的写锁(排他锁) 占用,T2 无法获取读锁,只能进入等待状态。

最终导致的结果是:一个看似普通的 SELECT 查询,也可能因为前序写操作而被阻塞,从而使读写操作相互牵制,并发性能急剧下降。

在高并发场景下,这种纯依赖锁的并发控制方式,往往会使数据库逐渐退化为“串行执行”,严重影响系统的整体吞吐能力。

使用MVCC来控制事物之间的读写冲突

image.png

在这个过程中可以看到:

  • T1 在事务中对数据进行更新时,InnoDB 并不会直接覆盖原有记录,而是生成一个新版本的数据,并通过 undo log 将新旧版本串联起来;
  • T2 开启事务并执行 SELECT 时,会创建属于自己的 Read View(一致性视图) ,并根据该视图判断哪些版本的数据对当前事务可见;
  • 当 T1 尚未提交时,由 T1 生成的新版本数据对 T2 不可见,T2 读取到的仍然是旧版本 money = 2300
  • 直到 T1 提交事务,旧事务结束,新开启的事务才会在新的 Read View 下看到最新的数据 money = 2500

也正因为如此,在整个过程中:

  • 写操作不会阻塞读操作
  • 读操作也不会阻塞写操作
  • 并发事务之间既保持了数据一致性,又避免了大量加锁带来的性能损耗

这正是 MVCC 带来的核心价值。

二、MVCC核心原理及其工作机制

MVCC工作的核心思想:为每一行数据保存多个历史版本,事务读取时,根据自身Read View选择“可见的版本”(无需加锁);事务写入时,生成新的版本(不影响旧版本读取),实现“读不加锁、写不阻塞读”,兼顾并发与一致性。

核心三大组件

隐藏字段

为了实现 MVCC,InnoDB 引擎给每一行都加了三个额外的字段 db_trx_id 和 db_roll_ptr以及db_row_id。

  • db_trx_id:事务 ID,也叫做事务版本号。MVCC 里面的 V 指的就是这个数字。每一个事务在开始的时候就会获得一个 ID,然后这个事务内操作的行的事务 ID,都会被修改为这个事务的 ID。
  • db_roll_ptr:回滚指针。InnoDB 通过 roll_ptr 把每一行的历史版本串联在一起。
  • db_row_id:如果你没有设置任何主键,那么这个列就会被当成主键来使用。但是它其实和 MVCC 没太大的关系。

回滚日志 (Undo Log)

在 InnoDB 中,undo log 是实现 MVCC 的核心组件之一。它本质上是一种逻辑日志,记录了数据修改前的状态,既支持事务回滚,也支撑了多版本并发控制机制。可以说,MVCC 让数据库能同时呈现数据的多个版本,而 undo log 正是这些历史版本的存储基础

UndoLog 版本链

版本链的作用就是为每一行数据提供一个完整、可追溯的“历史时间线”

image.png

读视图(Read View)

ReadView的作用:当一个事物发起查询时候,首先它会生成一个ReadView读视图,这个视图确定了当前时刻哪些事务ID是活跃的(即未提交的),从而决定了哪些数据版本对它可见。

ReadView 的几个参数信息

  • up_limit_id:最小事务ID(活跃事务列表中最小的事务ID)

  • low_limit_id:最大事务ID(将要分配的下一个事务ID当前系统已经分配的最大事务ID+1)

  • create_trx_id:当前事务ID

  • trx_ids:事务活跃列表(这里是正在运行的未提交的事务)

ReadView 的读取规则

规则1、 DB_TRX_ID小于最小事务ID可以被读取,因为最小事务Id之前的数据肯定都是已经被提交了的数据。

image.png 规则2、DB_TRX_ID在最大事务ID以后的不读(但是也要看具体的隔离级别RR级别下是不可以被读到的,但是在RC级别下可以读到)

image.png

规则3、DB_TRX_ID和create_trx_id相同,肯定读(一个事务,在自己的事务中生成的数据版本,对自己一定是可见的。)

规则4、DB_TRX_ID 如果大于最小事务Id,但是小于最大的事务Id,然后在活跃列表中不存在的,肯定读(因为它是已经提交的)

image.png

规则5、DB_TRX_ID 如果大于最小事务Id,但是小于最大的事务Id,然后在活跃列表中存在的,肯定不读(因为它是未提交的)

image.png

完整工作机制

接下来用一个案例演示来体验MVCC一整个的工作机制。

数据准备

CREATE TABLE `tb_account`  (
  `account` int(11) NULL DEFAULT NULL COMMENT '账号',
  `money` double NULL DEFAULT NULL COMMENT '金额'
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;
​
INSERT INTO `tb_account` VALUES (10087, 2300);
INSERT INTO `tb_account` VALUES (10086, 2000);
INSERT INTO `tb_account` VALUES (10088, 2000);

这里以MySQL默认的工作隔离级别为例

image.png

Step1:T1事物开启,并执行update更新账号为10087这条数据,生成新的版本链(版本链的指针记录db_trx_id为10)

Step2:T2开启事物,读取账号为10087的这条数据,此时开始创建ReadView读视图

  • ReadView读视图内容

    • up_limit_id(最小活跃事务ID):10

    • low_limit_id(最大事务ID):21

    • create_tx_id(T2自身事务ID):20

    • trx_ids(活跃事务列表):[10]

  • 判断版本可见性

    最新版本 db_trx_id:10 在活跃列表中 → 不可见,通过db_roll_ptr找到上一个版本db_trx_id:8,db_trx_id:8 < up_limit_id所以可见,获取到的结果money = 2300

Step3:T1事物提交,版本链最终版本为db_trx_id = 10

Step4:T2事物尚未提交,在T1事物提交以后再次读取账号为10087的这条数据,获取到的结果仍然为money=2300,这是因为RR隔离级别下面ReadView只在第一次查询的时候生成,后续复用第一次生成的视图。

Step5:T2事物提交,提交以后再次开启事物读取账号为10087的这条数据,此时ReadView会重新生成。

  • ReadView读视图内容

    • up_limit_id(最小活跃事务ID):空

    • low_limit_id(最大事务ID):21

    • create_tx_id(T2自身事务ID):30

    • trx_ids(活跃事务列表):[]

  • 可见性判断

    • 因为db_trx_id不在活跃事物列表中,所以可见,读取到的数据就是更新以后的数据money=2500

到此整个工作流程结束。

补充RC隔离级别下面MVCC是怎么工作的

image.png

Step1:T1事物开启,并执行update更新账号为10087这条数据,生成新的版本链(版本链的指针记录db_trx_id为10)

Step2:T2开启事物,读取账号为10087的这条数据,此时开始创建ReadView读视图

  • ReadView读视图内容

    • up_limit_id(最小活跃事务ID):10

    • low_limit_id(最大事务ID):21

    • create_tx_id(T2自身事务ID):20

    • trx_ids(活跃事务列表):[10]

  • 判断版本可见性

    最新版本 db_trx_id:10 在活跃列表中 → 不可见,通过db_roll_ptr找到上一个版本db_trx_id:8,db_trx_id:8 < up_limit_id所以可见,获取到的结果money = 2300

Step3:T1事物提交,版本链最终版本为db_trx_id = 10

Strp4:T2事物再一次进行select查询,此时会生成新的ReadView读视图

  • ReadView读视图内容

    • up_limit_id(最小活跃事务ID):空

    • low_limit_id(最大事务ID):21

    • create_tx_id(T2自身事务ID):20

    • trx_ids(活跃事务列表):[]

  • 可见性判断

    • 因为db_trx_id不在活跃事物列表中,所以可见,读取到的数据就是更新以后的数据money=2500

到此整个工作流程结束。

RC隔离级别下每一条快照读SQL都会生成新的ReadView,同一个事物内多次SELECT可能看到的数据不同;

RR隔离级别下ReadView只在第一次快照读的时候创建,后续SELECT复用同一个ReadView,同一个事物内,读到的数据始终是一致的

三、可重复读的隔离级别下面有没有彻底解决幻读问题

快照读(普通的select语句),是通过MVCC的方式解决了幻读,在RR级别下,ReadView只是生成一次,select的时候复用这个ReadView,即使中途有其他事物插入数据此时这个时候也是查不到的。

当前读(select ... for update、update、insert、delete 等语句),是通过临界锁的方式解决幻读,执行select ... for update 的时候,会加上next-key lock 如果其他事物在next-key lock这个锁范围内插入了一条记录,此时这个语句会阻塞。

这两个解决方案在很大程度上解决了幻读问题,但是没有办法完全避免幻读

产生幻读情况1:事物A使用快照读(普通的select查询),事物B插入新的数据并进行提交,然后事物A更新事物B提交的那条记录,然后事物A再次使用快照读,就可以看到事物B新增的那条数据

image.png

案例演示

会话1:

image.png

会话2:

image.png

会话1:

image.png

这种情况产生幻读的原因:因为快照读不会加锁,所以事物B可以插入成功,事物A在对id = 4这条记录进行更新以后,这个时候trx_id 的值会变成事物A的id,根据ReadView的读取规则当前事物是可以被读取到的,所以在update之后的快照读取可以看到id=4的这条数据。

产生幻读情况2: 事物A不使用当前读,事物B插入数据以后并提交,事物A在使用当前读,此时就会产生幻读

image.png

案例演示

会话1

image.png

会话2

image.png

会话1

image.png 这种情况产生的原因:快照读不生成next-key lock,导致其他的事物可以插入本次事物查询范围内的行记录,所以,当其他事务插入数据后再执行当前读,就能查到新的记录,从而产生幻读问题。

四、总结

在文章开头,我们提出了三个关键问题:

  • 多版本:数据在 MVCC 中如何产生和维护?
  • 并发:MVCC 如何实现高并发下的高效访问?
  • 控制:MVCC 究竟在控制什么?

多版本的本质是一条数据的更新变化时间线,通过 Undo Log 实现对数据历史版本的完整管理。 并发体现在读写的完美解耦,MVCC 通过 版本可见性规则 实现真正的读写并行,读不阻塞写,写不阻塞读。 控制的核心是数据可见性的精准管理,通过 ReadView(读视图) 及一系列判断规则,决定哪个版本对当前事务可见。需特别注意,在 RR(可重复读)和 RC(读已提交)隔离级别下,ReadView 的生成时机与更新策略不同,这也直接影响了事务的隔离行为。

深入解析了 MVCC 的核心原理与工作机制,重点介绍了其三大组件:隐藏字段、回滚日志(Undo Log)和读视图(ReadView) ,并对比了 RR 与 RC 隔离级别下 MVCC 的工作流程与差异。

最后,我们探讨了一个关键问题:MVCC 是否彻底解决了幻读?答案是否定的——MVCC 并未完全解决幻读,它在快照读层面通过一致性视图缓解了幻读现象,但在当前读或特定更新场景下,幻读仍可能发生。