一文彻底讲清楚 MySQL 的 MVCC 机制

5 阅读17分钟

在学习 MySQL 事务隔离时,很多人都会听到一个关键词:MVCC

不少文章会告诉你:

  • MVCC = 多版本并发控制
  • 能实现“读不加锁”
  • InnoDB 的一致性读靠它完成

这些说法都对,但如果只停留在这个层面,你在真正分析线上问题、理解隔离级别、排查幻读和锁冲突时,还是会感到模糊。

这篇文章,我会从底层原理到执行过程,把 MySQL(准确说是 InnoDB)的 MVCC 机制彻底讲清楚。


1. 什么是 MVCC?

MVCC,全称 Multi-Version Concurrency Control,中文叫 多版本并发控制

它的核心思想非常简单:

数据被修改时,不会立即把旧版本彻底抹掉,而是保留历史版本。 不同事务在不同时间点,可以看到同一行数据的不同版本。

也就是说,MVCC 不是让所有事务都去抢一把锁,而是通过“保存多个版本”的方式,让读操作和写操作尽量不冲突


2. 为什么需要 MVCC?

如果没有 MVCC,数据库并发访问会非常痛苦。

假设有这样一个场景:

  • 事务 A 正在修改一条记录
  • 事务 B 同时要读取这条记录

如果没有多版本机制,就只有几种选择:

  1. 读必须等写完成

    • 会造成大量阻塞,性能差
  2. 写必须等读完成

    • 并发能力低
  3. 允许读到未提交的数据

    • 会产生脏读,数据不可靠

MVCC 的价值就在这里:

在保证一定隔离性的前提下,尽量让读写并发执行。

因此,MVCC 的目标可以概括为两点:

  • 提高并发性能
  • 实现一致性读

3. MVCC 解决了什么问题?

MVCC 主要解决的是 读写冲突,尤其是“普通查询”和“更新操作”之间的并发问题。

它特别适用于:

  • 一个事务在写
  • 另一个事务在读
  • 希望读不阻塞写,写也不阻塞读

但要注意:

MVCC 主要优化的是 快照读,不是所有操作都靠 MVCC 完成。

比如:

  • select ... 普通查询:通常是 快照读
  • select ... for update:是 当前读
  • update/delete/insert:本质上也是 当前读 + 写

也就是说,MVCC 不等于“所有数据库并发控制”,它只是 InnoDB 并发控制体系中的一部分。


4. MySQL 中的 MVCC 是谁实现的?

严格来说,MVCC 不是 MySQL Server 层统一实现的,而是:

InnoDB 存储引擎实现的

所以,当我们讨论 MySQL 的 MVCC 时,实际上讨论的是:

InnoDB 的 MVCC

这点很重要,因为只有支持事务、支持多版本的存储引擎才能实现类似机制。


5. 理解 MVCC 前,先搞清几个前置概念

要彻底理解 MVCC,必须先认识三个关键组件:

  1. 隐藏字段
  2. Undo Log
  3. Read View

这三者共同组成了 InnoDB MVCC 的核心。


6. 隐藏字段:每行记录背后的“身份信息”

InnoDB 的聚簇索引记录中,除了我们自己定义的字段外,还会额外维护一些隐藏列。和 MVCC 关系最密切的是两个:

  • trx_id
  • roll_pointer

有时还会提到 row_id,但它和 MVCC 关系不大,这里重点讲前两个。


6.1 trx_id

trx_id 表示:

最后一次修改这条记录的事务 ID

谁改了这条数据,这条数据当前版本就记住谁的事务 ID。

例如:

id = 1, name = 'Alice', balance = 100

如果事务 100 把 balance 改成了 200,那么这一行当前版本的 trx_id 就会变成 100。


6.2 roll_pointer

roll_pointer 表示:

指向这条记录上一个历史版本的位置

这个指针会把当前版本和旧版本串起来,形成一个版本链

也就是说,一条记录不是只有一个值,而可能有:

  • 当前版本
  • 上一个版本
  • 更早的版本
  • 更更早的版本……

这些旧版本通过 Undo Log 保存,并由 roll_pointer 串联。


7. Undo Log:旧版本存放在哪里?

MVCC 能看到历史版本,前提是旧版本必须被保存下来。

这个保存历史数据的地方,就是 Undo Log


7.1 Undo Log 是什么?

Undo Log,顾名思义,就是“撤销日志”。

它最初的作用是:

当事务回滚时,可以根据 Undo Log 把数据恢复到修改前的状态

但在 InnoDB 中,它还有另一个非常关键的用途:

为 MVCC 提供历史版本数据

所以,Undo Log 有两个核心作用:

  • 事务回滚
  • 支持 MVCC 的一致性读

7.2 版本链是怎么形成的?

举个例子。

假设有一条记录最开始是:

id = 1, balance = 100

然后发生了以下更新:

事务 T1:

update account set balance = 200 where id = 1;

事务 T2:

update account set balance = 300 where id = 1;

事务 T3:

update account set balance = 500 where id = 1;

那么这条记录会形成类似这样的版本链:

当前版本:balance=500, trx_id=T3
   ↓ roll_pointer
旧版本1:balance=300, trx_id=T2
   ↓
旧版本2:balance=200, trx_id=T1
   ↓
旧版本3:balance=100, trx_id=更早事务

当某个事务要读取这条记录时,InnoDB 会根据它自己的可见性规则,决定它应该看到版本链中的哪一个版本。

这就是 MVCC 的核心。


8. Read View:决定“你能看到哪个版本”

如果说 Undo Log 解决的是“旧版本存在哪里”,那么 Read View 解决的就是:

当前事务到底能看见哪些版本?

Read View 可以理解为:

事务在进行一致性读时拿到的一张“可见性快照”

它记录了当前系统中一些事务状态信息,用来判断一条记录的某个版本,是否对当前事务可见。


8.1 Read View 中关键字段

理解原理时,通常抓住这几个值就够了:

  • creator_trx_id:创建这个 Read View 的事务 ID
  • m_ids:创建 Read View 时,当前系统中“活跃的读写事务 ID 列表”
  • min_trx_id:活跃事务中最小的事务 ID
  • max_trx_id:创建 Read View 时,系统将要分配的下一个事务 ID

你不必死记这些字段,但必须理解它们的意义:

  • 哪些事务已经提交了
  • 哪些事务还没提交
  • 哪些事务是在快照生成之后才开始的

这些信息共同决定某个版本是否可见。


9. MVCC 的可见性判断规则

这是整套机制最关键的地方。

当事务去读一条记录时,InnoDB 会拿该记录版本上的 trx_id 和当前事务的 Read View 做比较。

判断规则可以总结为下面几条。


9.1 情况一:版本由当前事务自己创建

如果:

trx_id == creator_trx_id

说明这个版本是当前事务自己改出来的。

那么:

可见

因为事务总是能看到自己做的修改。


9.2 情况二:版本对应的事务已经在 Read View 生成前提交

如果:

trx_id < min_trx_id

说明修改这个版本的事务,在当前 Read View 创建时,已经是“很早之前就提交了”的事务。

那么:

可见

因为它属于历史上已经确定提交的版本。


9.3 情况三:版本对应的事务在 Read View 生成后才开始

如果:

trx_id >= max_trx_id

说明这个版本是 Read View 创建之后才出现的事务生成的。

那么:

不可见

因为对当前事务来说,这属于“未来的数据”。


9.4 情况四:事务 ID 落在中间区间

如果:

min_trx_id <= trx_id < max_trx_id

此时要进一步判断该 trx_id 是否在 m_ids 中。

如果在 m_ids

说明创建 Read View 时,这个事务还活跃,尚未提交。

那么:

不可见

如果不在 m_ids

说明它已经提交了。

那么:

可见


9.5 一句话总结可见性规则

可以把规则压缩成一句话:

当前事务只能看到:在自己快照生成前已经提交的数据,以及自己本事务修改的数据。

看不到的是:

  • 快照生成时还未提交的数据
  • 快照生成后才产生的数据

这就是“一致性读”的本质。


10. 快照读和当前读

要理解 MVCC,一定要区分这两个概念。


10.1 快照读(Snapshot Read)

快照读就是:

读取数据的历史版本,不加锁,依赖 MVCC

典型 SQL:

select * from account where id = 1;

普通 select 在 InnoDB 中一般就是快照读。

它读到的不一定是最新版本,而是当前事务可见的版本


10.2 当前读(Current Read)

当前读就是:

读取记录的最新版本,并且可能对记录加锁

典型 SQL:

select * from account where id = 1 for update;
select * from account where id = 1 lock in share mode;
update account set balance = 200 where id = 1;
delete from account where id = 1;

这些操作读取的是“当前最新版本”,而不是快照里的旧版本。

当前读通常要配合锁机制来保证并发安全,因此:

当前读不完全依赖 MVCC,而是 MVCC + 锁


11. RC 和 RR 隔离级别下,MVCC 有什么不同?

MySQL 中,MVCC 主要在两个隔离级别下发挥作用:

  • Read Committed(读已提交,RC)
  • Repeatable Read(可重复读,RR)

这两个级别最大的差异,核心在于:

Read View 生成的时机不同


11.1 RC:每次 SELECT 都生成新的 Read View

在 RC 隔离级别下:

每执行一次快照读,都会重新生成一个 Read View

这意味着:

  • 第一次 select,看到一个版本
  • 第二次 select,如果别的事务已经提交更新,就可能看到新版本

所以 RC 的特点是:

同一个事务中,多次读取同一条记录,结果可能不同

这就是“不可重复读”可能出现的根源。


例子

事务 A:

begin;
select balance from account where id = 1;

此时读到 100

事务 B:

update account set balance = 200 where id = 1;
commit;

事务 A 再查一次:

select balance from account where id = 1;

在 RC 下,这次可能读到 200

因为第二次查询重新生成了 Read View。


11.2 RR:事务第一次快照读时生成 Read View,后续复用

在 RR 隔离级别下:

一个事务只在第一次快照读时生成 Read View,之后都复用这个 Read View

这意味着,只要事务不结束:

  • 后面的快照读都基于第一次的“快照”来判断可见性

所以 RR 的特点是:

同一个事务中,多次读取同一条记录,结果保持一致

这就是“可重复读”。


例子

事务 A:

begin;
select balance from account where id = 1;

此时读到 100

事务 B:

update account set balance = 200 where id = 1;
commit;

事务 A 再查一次:

select balance from account where id = 1;

在 RR 下,仍然读到 100

因为事务 A 复用了第一次查询生成的 Read View。


12. RR 下为什么还能避免很多“幻读”问题?

这里是 MySQL 面试和实战中最容易混淆的点。

很多人会说:

RR 通过 MVCC 解决了幻读

这句话不完全准确。

更准确地说:

MVCC 让普通快照读场景下,看起来不会出现幻读;而真正要彻底处理当前读下的幻读,还要靠 Next-Key Lock。


12.1 什么是幻读?

幻读指的是:

同一个事务中,前后两次按条件查询,第二次看到了第一次没有看到的“新插入的行”

比如:

select * from user where age > 20;

第一次查出来 10 行,另一个事务插入一条 age=25 的记录后,第二次查出来 11 行,这就是幻读。


12.2 为什么快照读下 RR 看起来没有幻读?

因为 RR 下快照读复用同一个 Read View。

即使别的事务插入了新行,只要那条新行对当前 Read View 不可见,那么当前事务的多次快照读结果就一致。

所以从“普通 select”的表现上看,幻读似乎没有发生。

但这只是:

MVCC 让你在快照层面看不到新插入的数据


12.3 当前读下怎么防止幻读?

如果执行的是:

select * from user where age > 20 for update;

或者 update/delete 这种当前读,事务要读取最新数据并加锁。

这时光靠 MVCC 不够,因为当前读必须面对“最新版本”。

InnoDB 会使用:

Record Lock + Gap Lock + Next-Key Lock

特别是 Next-Key Lock(记录锁 + 间隙锁),用来锁住一个范围,防止其他事务在这个范围内插入新记录,从而避免幻读。

所以,结论是:

  • 快照读的一致性:主要靠 MVCC
  • 当前读避免幻读:主要靠锁机制(Next-Key Lock)

13. MVCC 的执行流程,到底是怎样的?

现在我们把整个流程串起来。

假设事务 A 执行一个普通 select

select * from account where id = 1;

InnoDB 大致会做这些事:

第一步:判断这是快照读还是当前读

普通 select,属于快照读。

第二步:生成或获取 Read View

  • RC:本次查询新建 Read View
  • RR:如果是第一次快照读则创建,否则复用已有 Read View

第三步:拿到该记录的当前版本

先找到聚簇索引中的最新记录。

第四步:检查当前版本的 trx_id 是否可见

用 Read View 的规则判断:

  • 可见:直接返回
  • 不可见:继续沿着 roll_pointer 找旧版本

第五步:遍历版本链

不断回溯 Undo Log 中的历史版本,直到找到一个对当前事务可见的版本。

第六步:返回结果

把最终找到的那个可见版本返回给 SQL 层。

这就是一次 MVCC 快照读的完整过程。


14. 通过一个完整例子理解 MVCC

下面用一个完整例子把原理彻底串起来。

初始数据:

id = 1, balance = 100

假设现在系统事务 ID 递增。


场景过程

事务 T10

begin;
update account set balance = 200 where id = 1;

此时最新版本:

balance = 200, trx_id = 10

旧版本在 Undo Log 中:

balance = 100

但 T10 还没有提交。


事务 T11 开始

begin;
select balance from account where id = 1;

假设当前隔离级别是 RR。

T11 第一次快照读时生成 Read View,发现 T10 还活跃。

因此:

  • 当前版本 trx_id = 10
  • 10 在活跃事务列表中
  • 所以版本 200 不可见

T11 沿着版本链往前找,找到旧版本:

balance = 100

这个版本可见,因此返回 100


T10 提交

commit;

T11 再次查询

select balance from account where id = 1;

因为 RR 下复用第一次的 Read View,所以即使 T10 已经提交:

  • T11 仍然认为事务 10 在它的快照里“当时还没提交”
  • 因此 balance = 200 依然不可见

最终 T11 还是读到:

100

这就是 RR 下“可重复读”的来源。


如果隔离级别是 RC 呢?

如果 T11 是 RC 隔离级别,那么第二次查询会重新生成 Read View。

这时 T10 已提交,因此版本 balance = 200 可见。

于是第二次查询会返回:

200

这就是 RC 和 RR 的本质差别。


15. MVCC 是否完全不加锁?

不是。

这是一个非常常见的误解。

很多人以为:

MVCC = 完全无锁

其实不对。

更准确地说:

MVCC 让“快照读”尽量不加锁,但数据库并发控制仍然离不开锁。

MySQL/InnoDB 中:

  • 普通 select:可能通过 MVCC 实现无锁读
  • select ... for update / update / delete:仍然要加锁
  • 插入、唯一性检查、外键检查等场景:也涉及锁

所以 MVCC 是“减少锁冲突”,不是“消灭锁”。


16. MVCC 的优点

MVCC 的优势非常明显。


16.1 提高并发性能

读操作不需要等写操作释放锁,写操作也不必因为普通读而阻塞。

这对读多写少或读写并发高的系统非常重要。


16.2 实现一致性读

事务可以基于某个时间点的快照来读取数据,保证结果逻辑一致。


16.3 减少锁竞争

相比完全依赖锁的机制,MVCC 大大降低了锁等待和死锁概率。


17. MVCC 的代价和局限

任何机制都有成本,MVCC 也不例外。


17.1 需要额外存储历史版本

Undo Log 要保存旧版本,因此会带来存储开销。


17.2 版本链过长会影响查询性能

如果某条记录被频繁更新,而长事务迟迟不提交,就可能导致 Undo Log 迟迟无法清理,版本链变长。

这样快照读在回溯版本链时,性能就会下降。


17.3 长事务是 MVCC 的天敌

这是线上最常见的问题之一。

如果一个事务长时间不结束,它持有的 Read View 会导致很多旧版本不能被 purge 清理。

结果可能是:

  • Undo 空间膨胀
  • 查询变慢
  • 系统负担加重

所以实际开发中要特别注意:

避免长事务


18. MVCC 和锁的关系

可以这样理解:

  • MVCC 解决的是读写并发中的“读”问题
  • 锁机制解决的是写写冲突、当前读、范围并发控制等问题

二者不是替代关系,而是协作关系。

InnoDB 的并发控制,本质上是:

MVCC + 锁

这才是完整答案。


19. 面试中关于 MVCC 的高频问题

下面顺手总结几个高频问题。


19.1 MVCC 是什么?

MVCC 是多版本并发控制,通过保存数据历史版本,让事务能够读取符合自己可见性规则的数据版本,从而实现一致性读并提升并发性能。


19.2 MVCC 的实现依赖什么?

InnoDB 的 MVCC 主要依赖:

  • 隐藏字段 trx_id
  • 隐藏字段 roll_pointer
  • Undo Log
  • Read View

19.3 哪些操作是快照读?

普通 select 一般是快照读。


19.4 哪些操作是当前读?

  • select ... for update
  • select ... lock in share mode
  • update
  • delete
  • insert(会涉及当前最新数据和锁控制)

19.5 RC 和 RR 下 MVCC 的区别是什么?

  • RC:每次快照读都生成新的 Read View
  • RR:第一次快照读生成 Read View,后续复用

19.6 MVCC 能解决幻读吗?

要分场景回答:

  • 对于 快照读,RR 下通过 Read View 复用,表现上能避免幻读
  • 对于 当前读,还要依靠 Next-Key Lock 防止幻读

20. 最后做一个总结

我们把这篇文章压缩成几个关键点:

1)MVCC 是什么

MVCC 是 InnoDB 的多版本并发控制机制,通过保存一行数据的多个历史版本,实现读写并发和一致性读。

2)MVCC 依赖什么

核心依赖四部分:

  • trx_id
  • roll_pointer
  • Undo Log
  • Read View

3)工作原理是什么

读取数据时,不一定读最新版本,而是根据 Read View 的可见性规则,在版本链中找到当前事务可见的那个版本。

4)适用于什么场景

主要用于 快照读,让普通 select 在多数情况下不加锁。

5)RC 和 RR 的区别

区别在于 Read View 的生成时机:

  • RC:每次查询都生成
  • RR:事务第一次快照读生成并复用

6)MVCC 不是万能的

它不能替代锁。InnoDB 的完整并发控制依赖:

MVCC + Lock


21. 放在文末的一句话

MySQL 的 MVCC,本质上不是“读最新的数据”,而是“读对当前事务来说应该看到的数据”。

理解了这句话,你就真正理解了 MVCC。