MySQL MVCC 详解:原理、版本链、Read View 与可见性判断

事务的隔离级别里要解决脏读、不可重复读、幻读这三个问题,最直接的办法是加锁,让读和写互斥,但加锁的代价很大,一个事务在读某行数据的时候会把写操作挡住,写的时候也会把读挡住,而实际的业务里读操作远远多于写操作,并发性能会被压得很低。

MVCC( Multi-Version Concurrency Control,多版本并发控制 ) 走的是另一条路,它不去阻止并发,而是让每个事务读到一份属于自己的数据版本。同一行数据在数据库里保留多个历史版本,事务读的时候只挑自己能看的那个,读操作不加锁,写操作也不会影响正在读的事务。


为什么需要 MVCC

加锁方案的代价

假设不用 MVCC,只靠锁来保证隔离性,那么一条普通的 SELECT 也要给读到的行加锁,防止别的事务在这期间把它改掉。问题是读操作会因此阻塞写操作,写操作也会阻塞读操作,两个本来可以同时进行的事情被迫排队。

更麻烦的是长事务,一个事务如果跑了几秒钟还没提交,它读过的那些行在这几秒内都改不了,线上很容易出现大面积等待。

MVCC 的思路

既然冲突的根源是"同一份数据被读写双方同时盯着",那就让读的人不看那一份数据。写操作产生新版本的时候不动老版本,读操作按自己的需要去读老版本,两边各读各的,自然就不冲突了。

这个思路要落地需要回答三个问题:

  1. 老版本存在哪里
  2. 怎么知道哪一行有哪些版本
  3. 一个事务应该读哪个版本

前两个靠隐藏字段和 undo log 解决,第三个靠 ReadView 解决。

快照读与当前读

需要注意的是 MVCC 只覆盖一部分读操作,SQL 语句要分成两类看:

类型 包含的语句 读的是 走不走 MVCC
快照读 普通的 SELECT ReadView 判定出来的历史版本
当前读 SELECT ... FOR UPDATESELECT ... LOCK IN SHARE MODEUPDATEDELETEINSERT 最新的版本,并加锁 不走

UPDATEDELETE 之所以算当前读,是因为它们要先读到最新的值才能在上面做修改,如果读的是历史版本,就会把别人刚提交的修改覆盖掉。


版本链

三个隐藏字段

InnoDB 的每一行记录除了我们自己定义的字段之外,还会额外存三个隐藏字段:

字段 长度 含义
DB_TRX_ID 6 字节 最后一次插入或更新这一行的事务 ID
DB_ROLL_PTR 7 字节 回滚指针,指向 undo log 里这一行的上一个版本
DB_ROW_ID 6 字节 行 ID,只有在表没有主键也没有唯一索引时才会生成

其中 DB_TRX_IDDB_ROLL_PTR 是 MVCC 的基础,前者标记版本属于哪个事务,后者负责把版本串起来。

undo log 里存了什么

undo log 本来是为回滚准备的日志,事务回滚的时候要用它把数据恢复回去,但 MVCC 顺便复用了它。每次对某行做修改,旧值都会被写进 undo log,新值与旧值之间用 DB_ROLL_PTR 连起来,一条记录的所有历史版本就形成了一条链表,最新的版本在表里,越旧的越往 undo log 深处走。

undo log 分两种,行为不一样:

  • insert undo logINSERT 产生的,只在事务回滚时有用,事务一提交就可以删掉,因为它产生的行本来就只对当前事务可见
  • update undo logUPDATEDELETE 产生的,是版本链的来源,不能提交后就删,只要还有事务可能用到这些旧版本就得留着

DELETE 在 InnoDB 里并不真的删行,只是在记录上打一个删除标记,真正的清理由后台的 purge 线程去做,判断依据就是"再没有事务需要看这个版本了"。


ReadView

ReadView 是事务在执行快照读的时候生成的一个结构,里面记录了生成的那一刻数据库里的事务状态,可见性判断完全依赖它。

四个字段

字段 含义
m_ids 生成 ReadView 时,所有活跃(已启动但未提交)的事务 ID 列表
min_trx_id m_ids 里最小的事务 ID
max_trx_id 系统下一个要分配的事务 ID
creator_trx_id 创建这个 ReadView 的事务自己的 ID

max_trx_id 这里容易记错,它不是 m_ids 里的最大值,而是下一个待分配的 ID ,所以它一定比 m_ids 里最大的那个还要大。因为事务 ID 是递增分配的,凡是 trx_id >= max_trx_id 的版本,都说明它是在这个 ReadView 生成之后才出现的事务写的。

可见性判断

拿到版本链上的一个版本,设它的事务 ID 为 trx_id,规则是四条:

text 复制代码
1. trx_id == creator_trx_id
       → 可见(这个版本就是自己改的)

2. trx_id < min_trx_id
       → 可见(ReadView 生成之前就已经提交了)

3. trx_id >= max_trx_id
       → 不可见(ReadView 生成之后才开启的事务,当时它还不存在)

4. min_trx_id <= trx_id < max_trx_id
       若 trx_id 在 m_ids 里   → 不可见(生成 ReadView 时它还没提交)
       若 trx_id 不在 m_ids 里 → 可见(生成 ReadView 时它已经提交了)

对照着图里面的顺序来看会更好理解一点

第 1 条是为了让自己能看到自己的修改,否则事务里改完再读会读不到。第 2、3 条是两个边界,分别对应"早就结束了"和"还没出生"。第 4 条是中间地带,事务已经启动但状态不定,所以要去 m_ids 里查一下当时它提交没有。

如果当前版本不可见,就顺着 DB_ROLL_PTR 找上一个版本,用同样的规则再判一次,一直找到可见的为止。

走一遍完整的例子

text 复制代码
事务 98 把 name 改成 '王五' 
事务 100 把 name 改成 '李四' 
事务 102 把 name 改成 '张三' 
表里的当前记录: name='张三' DB_TRX_ID=102 
                    ↓ DB_ROLL_PTR 
    undo log: name='李四' DB_TRX_ID=100 
                    ↓ DB_ROLL_PTR 
	undo log: name='王五' DB_TRX_ID=98 

用上面那条版本链。现在事务 103 执行第一次 SELECT,生成 ReadView,此刻活跃的事务是 100 和 102,系统下一个要分配的 ID 是 104:

text 复制代码
m_ids          = [100, 102]
min_trx_id     = 100
max_trx_id     = 104
creator_trx_id = 103

从最新的版本开始判断:

text 复制代码
版本 trx_id=102:
    落在 [100, 104) 区间内,去 m_ids 里查,102 在 → 不可见

版本 trx_id=100:
    落在 [100, 104) 区间内,去 m_ids 里查,100 在 → 不可见

版本 trx_id=98:
    98 < min_trx_id(100),走第 2 条 → 可见

所以事务 103 读到的是 '王五',也就是事务 98 提交的那个版本。事务 100 和 102 虽然对数据的修改已经写进了表里,但在这个事务眼里它们根本不存在。


RC 与 RR 的区别

前面说 ReadView 里记的是"生成那一刻"的事务状态,那么什么时候生成 ReadView,就直接决定了这个事务能看到什么。InnoDB 的两种隔离级别差别只在这里:

隔离级别 ReadView 的生成时机 结果
Read Committed 每次 SELECT 都重新生成一个 两次 SELECT 之间别人提交的数据能看到,出现不可重复读
Repeatable Read 只在事务里第一次 SELECT 时生成,之后复用 整个事务看的是同一份快照,两次读结果一致

可见性判断的算法、版本链的结构,这些在两种级别下都是一样的,唯一的变量就是快照什么时候拍。RC 每次拍新的,所以别人提交的变更会被它看见,RR 只拍一次,所以整个事务被冻结在那一刻。

MySQL 的默认隔离级别是 Repeatable Read,这也是为什么日常写业务的时候很少遇到不可重复读的问题。


MVCC 处理不了的情况

MVCC 只是解决并发读写的其中一环,有两件事它管不了。

当前读不走 MVCC

UPDATEDELETESELECT ... FOR UPDATE 这类语句读的是最新版本,加的是锁,和版本链没有关系。所以一个事务里如果混着快照读和当前读,看到的数据可能对不上:前面的 SELECT 读的是历史版本,后面的 UPDATE 却是在最新版本上改。

幻读

在 RR 级别下,快照读确实不会幻读,因为整个事务用的都是同一个 ReadView,别的事务插入的行不在这个快照里。但如果事务里做了当前读,比如先 SELECT 查一遍有没有某条记录,再用 SELECT ... FOR UPDATE 去确认,后一条是当前读,能读到别的事务新插入并提交的行,幻读照样会出现。

InnoDB 在这里靠的是 Next-Key Lock,也就是记录锁加上间隙锁,把查询范围内的区间一起锁住,让别的事务没法在这个区间里插入新行。这部分是纯锁机制,和 MVCC 无关。

相关推荐
foolishlee1 小时前
SCRAM-SHA-256
数据库·算法·postgresql
Java成神之路-1 小时前
MySQL 约束与索引完全指南
mysql
BJ_Bonree1 小时前
博睿数据加入ITSS分会,成为国家级信息技术服务标准化体系单位成员!
大数据·运维·数据库·人工智能·可观测性
自传.3 小时前
B 站更新|软考架构师案例篇・数据库篇第 1-2 集上线|数据库规范化|索引视图物化视图真题精讲
数据库·软考高级·系统架构师·索引·案例分析·2026软考·软考系统架构师
jnrjian4 小时前
Oracle 没有drop any job 只需要create any job Session_Privs
数据库
+VX:Fegn089510 小时前
计算机毕业设计|基于springboot + vue蛋糕店管理系统(源码+数据库+文档)
前端·数据库·vue.js·spring boot·课程设计
哈__12 小时前
KES-Operator重塑Kubernetes环境下的KES数据库集群管理
数据库·kubernetes·operator·kes
IvorySQL14 小时前
IvorySQL 5.6 发布:PG 18.6 内核升级,沙盒即开即用
数据库·人工智能·postgresql