Appearance
MySQL MVCC 详解
MVCC 是 Multi-Version Concurrency Control,即多版本并发控制。
很多人第一次学 MVCC,会把它记成一句口号:普通 select 不加锁,也能读到一致的数据。
这句话不能说错,但还是太薄了。真正要把 MVCC 讲清楚,至少要把下面几个方面展开:
- 一行数据为什么会有“多个版本”
- 这些旧版本到底存在哪里
- 数据库凭什么知道当前事务该看到哪个版本
Read Committed和Repeatable Read为什么表现不一样- MVCC 和锁到底是替代关系,还是协同关系
这篇文章就按这个顺序,把 MySQL 里最容易讲散的 MVCC 原理完整串起来。
1. 🌟 MVCC 到底在解决什么问题
先不要急着背术语,看并发读写最原始的矛盾。
假设有两个事务:
- 事务 A 正在更新一条订单
- 事务 B 同时在查询这条订单
如果数据库只有“锁”这一种手段,就会遇到两个很直接的问题:
- 要么读等写,查询容易被阻塞
- 要么写等读,并发吞吐会下降
数据库当然可以用更粗暴的锁策略来保证正确性,但代价是:
- 延迟更高
- 吞吐更低
- 高并发场景更容易出现排队
MVCC 的核心思路就是:写操作生成新版本,读操作按自己的事务视角读取合适的旧版本或当前版本。
这样做的价值在于:
- 快照读通常不用阻塞写
- 写操作也不必因为大量普通查询而停下来
- 在合适隔离级别下还能读到一致性视图
所以 MVCC 不是“让数据库完全不加锁”,而是:把原本很多需要靠阻塞式锁解决的读写冲突,改成通过版本可见性判断来解决。
2. MVCC 生效的前提:一行记录不只是一行业务字段
在 InnoDB 里,一条记录除了业务字段外,通常还会带几个隐藏列。
对理解 MVCC 最关键的是下面两个:
DB_TRX_ID:最后一次修改这条记录的事务 IDDB_ROLL_PTR:指向这条记录旧版本对应undo log的指针
如果表没有主键,InnoDB 还可能额外维护一个隐藏主键列 DB_ROW_ID,但它不是 MVCC 的核心。
你可以把一行记录简化理解成下面这样:
text
id=1, name='Alice', balance=200,
DB_TRX_ID=103,
DB_ROLL_PTR -> 指向旧版本这两个隐藏列的作用分别是:
DB_TRX_ID用来标记“这是谁改的”DB_ROLL_PTR用来找到“改之前长什么样”
也就是说,MVCC 并不是把多个版本都直接堆在主记录里,而是:当前版本留在数据页里,历史版本顺着 roll pointer 到 undo log 里去找。
3. 旧版本从哪来:undo log 和版本链
很多人会说“MVCC 依赖 undo log”,但如果没有版本链这个画面,还是容易悬空。
假设有一行数据最开始是:
text
id=1, balance=100然后发生两次更新:
- 事务
T101把balance改成150 - 事务
T102又把balance改成200
这时数据库不会把所有版本都直接并排放在数据页里,而是形成一条“当前版本 -> 更老版本 -> 更老版本”的链。
mermaid
flowchart TD
A[当前记录<br/>balance=200<br/>DB_TRX_ID=102] --> B[undo 版本 1<br/>balance=150<br/>trx_id=101]
B --> C[undo 版本 2<br/>balance=100<br/>更早版本]
C --> D[更早的历史版本]这里最关键的理解是:
- 数据页里通常保留当前版本
- 历史版本主要放在
undo log - 读取时如果当前版本对事务不可见,就沿版本链往前找
所以所谓“多版本”,不是说数据库把表复制了很多份,而是:一条记录的历史状态被串成了一个可回溯的版本链。
4. 哪些操作会用到这些旧版本
undo log 不只是为了 MVCC,它至少服务两个核心目标:
- 事务回滚
- 一致性读
4.1 回滚
如果事务执行到一半失败了,数据库就需要把修改前的状态恢复回来。
这时 undo log 解决的是:怎么把已经改掉的值撤回去。
4.2 一致性读
如果另一个事务在做普通 select,它未必应该看到你刚改但还不该对它可见的数据。
这时 undo log 解决的是:怎么让读请求回到它应该看到的历史版本。
所以你可以把它记成一句话:undo log 既负责“能撤销”,也负责“能回看”。
5. 🌟 Read View:数据库怎么判断哪个版本能看
只有版本链还不够,数据库还得知道:当前事务到底应该看到哪个版本。
这个判断依据就是 Read View,也可以理解成“一致性读视图”。
你不用一开始就死记全部字段,但下面这几个最好知道:
creator_trx_id:创建这个 Read View 的事务 IDm_ids:创建视图时系统里还活跃的读写事务 ID 列表min_trx_id:活跃事务里最小的事务 IDmax_trx_id:下一个将被分配的事务 ID
你可以把它想成:事务在做快照读时,给自己拍了一张“当前活跃事务名单”的照片。
后面判断某个版本能不能看,本质就是拿“版本的 trx_id”和这张照片比对。
6. 可见性规则到底怎么判断
这是 MVCC 最容易考原理的部分。
假设当前要读某条记录的一个版本,它的 DB_TRX_ID = x。数据库大体会按下面的逻辑判断这个版本是否可见:
- 如果
x等于当前事务自己的 ID,那么可见 - 如果
x < min_trx_id,说明它在 Read View 创建前就已经提交,可见 - 如果
x >= max_trx_id,说明它在 Read View 创建后才产生,不可见 - 如果
min_trx_id <= x < max_trx_id,就看它是否在m_ids里 - 如果在
m_ids里,说明当时它还活跃,不可见 - 如果不在
m_ids里,说明它在创建 Read View 前已经提交,可见
可以把它浓缩成一句话:对当前事务可见的版本,要么是自己改的,要么是在自己视图形成前就已经提交的。
下面这张图更直观:
mermaid
flowchart TD
A[拿到当前版本的 trx_id] --> B{是否等于当前事务 ID}
B -- 是 --> C[可见]
B -- 否 --> D{trx_id < min_trx_id}
D -- 是 --> C
D -- 否 --> E{trx_id >= max_trx_id}
E -- 是 --> F[不可见]
E -- 否 --> G{trx_id 在 m_ids 中吗}
G -- 是 --> F
G -- 否 --> C
F --> H[沿 undo 版本链继续找更老版本]如果当前版本不可见,就不会立刻报错,而是继续顺着 DB_ROLL_PTR 去找更老的版本,直到找到一个可见版本,或者整条链都找不到为止。
7. 🌟 快照读和当前读:别把这两个混在一起
很多人学 MVCC 时最大的问题,不是没背定义,而是把“普通查询”和“加锁读取”混成一件事。
7.1 快照读
快照读通常指不加锁的普通 select,例如:
sql
select * from account where id = 1;这种读更多依赖 MVCC,会根据 Read View 和版本链去找事务可见的数据版本。
它的特点是:
- 读到的不一定是最新已写入内存的版本
- 读到的是对当前事务来说“合法”的版本
- 通常不会因为普通写操作而直接阻塞
7.2 当前读
当前读关注的是“此刻最新且准备加锁/修改的数据”,典型语句包括:
sql
select * from account where id = 1 for update;
select * from account where id = 1 lock in share mode;
update account set balance = balance - 100 where id = 1;
delete from account where id = 1;当前读的目标不是拿快照,而是拿当前最新版本,并在需要时加锁。
所以你要把二者分清:
- 快照读主要靠 MVCC
- 当前读主要看最新版本,并配合锁控制并发
这也是为什么很多人第一次接触时,会疑惑:为什么普通 select 和 select for update 的表现不一样。
因为它们根本不是同一种读。
8. 🌟 RC 和 RR 为什么表现不同
8.1 Read Committed
在 RC(读已提交)隔离级别下:每次执行快照读,都会生成新的 Read View。
这意味着同一个事务里,两次普通 select 看到的数据可能不同。
例如:
- 事务 A 第一次查询时,事务 B 还没提交
- 事务 B 提交后
- 事务 A 再查一次,会重新生成 Read View
- 第二次就可能看到 B 提交后的新版本
所以 RC 更容易出现不可重复读。
8.2 Repeatable Read
在 InnoDB 默认的 RR(可重复读)隔离级别下:事务中的第一次一致性读会生成 Read View,后续快照读通常复用同一个 Read View。
这意味着只要你做的是同一事务内的快照读,即使别的事务已经提交了更新,你后续看到的结果仍然可以保持一致。
这就是“可重复读”的底层原因。
下面这个时间线很适合帮助记忆:
mermaid
sequenceDiagram
participant A as 事务 A(RR)
participant B as 事务 B
A->>A: 第一次 select,创建 Read View
B->>B: update balance = 200
B->>B: commit
A->>A: 第二次普通 select
Note over A: 继续沿旧 Read View 读到旧版本如果把 RR 换成 RC,关键变化就在于第二次 select 会重新生成 Read View,于是更可能看到事务 B 刚提交的新值。
9. 🌟 MVCC 能不能解决幻读
这个问题最容易答得过满。
更准确的说法是:
- 对快照读,InnoDB 的
RR通过 MVCC 能让同一事务看到稳定的一致性视图 - 对当前读,仅靠 MVCC 不够,通常还要结合间隙锁、临键锁等锁机制
所以不要把它说成:MVCC 单独彻底解决了所有幻读问题。
更稳妥的表述是:MVCC 主要解决快照读下的一致性视图问题;涉及当前读和范围并发修改时,还需要锁来协同。
10. delete 在 MVCC 里怎么理解
很多人以为 delete 就是把这行立刻物理删除。对 InnoDB 来说,这个理解太粗了。
更常见的过程是:
- 当前记录被打上删除标记
- 旧版本仍可能要保留一段时间
- 只要还有事务可能看见它,就不能急着彻底清理
为什么?
因为某个更早开启的事务,可能还需要通过版本链看到“这条记录尚未被删除时”的历史状态。
也就是说:逻辑上删掉了,不等于物理上立刻彻底消失。
11. 旧版本会一直留着吗:purge 在做什么
不会一直留着。
当数据库确认某些旧版本已经不可能再被任何活跃事务看到后,后台线程会逐步清理这些不再需要的 undo 信息,这个过程通常叫 purge。
你可以这样理解:
- MVCC 需要历史版本
- 但历史版本不能无限堆积
- 所以数据库会在“确认再也没人需要看它”之后再清理
这也是为什么长事务很麻烦。
如果一个事务一直不结束,它可能长期持有旧的 Read View,导致:
- 很多历史版本不能及时回收
- undo 链变长
- 存储和查询成本上升
所以工程上常说:长事务是 MVCC 的天敌之一。
12. 一个完整例子:把版本链和 Read View 串起来
假设一行记录初始值为:
text
id=1, balance=100接下来发生下面的事情:
- 事务
T10开启 - 事务
T11开启,并把balance从100改成150,但还未提交 - 事务
T10执行普通select
这时 T10 形成自己的 Read View。因为 T11 还在活跃事务列表里,所以:
- 当前版本
balance=150对T10不可见 T10会沿版本链往前找- 最终读到旧版本
balance=100
然后如果:
T11提交T10在RC下再次查询
由于 RC 会重新创建 Read View,所以第二次查询可能看到 150。
但如果:
T10运行在RR- 第二次查询仍属于同一事务中的快照读
那它大概率仍然沿着第一次创建的视图去看,结果还是 100。
这个例子背后真正要记住的不是数字,而是:看到哪个版本,不取决于“磁盘上最新值是多少”,而取决于“当前事务的 Read View 允不允许你看到它”。
13. 🌟 为什么说 MVCC 和锁是协同关系
一个常见误区是:有了 MVCC,就不需要锁了。
这是不对的。
更准确的关系是:
- MVCC 主要优化读写并发,减少不必要阻塞
- 锁主要保证当前读、修改冲突控制和更严格的一致性
实际系统里两者会同时存在:
- 普通查询大量依赖 MVCC
- 更新、删除、
select ... for update依赖当前读和锁 - 范围并发控制还要靠间隙锁、临键锁等机制
所以你可以把 InnoDB 的并发控制理解成:MVCC 负责让“读历史版本”这件事变得高效,锁负责让“改当前版本”这件事保持有序。
14. 最容易混淆的几个点
14.1 MVCC 的实现基础是什么
- 记录里的隐藏列
DB_TRX_ID和DB_ROLL_PTR undo log形成的版本链- 一致性读时创建的
Read View
14.2 🌟 为什么 RR 能做到可重复读
因为 InnoDB 在 RR 下,事务里的快照读通常会复用第一次一致性读生成的 Read View,所以后续普通 select 仍然沿着同一个可见性规则读取旧版本。
14.3 🌟 为什么普通 select 不加锁也能读
因为它走的是快照读,不是强行去抢当前最新版本,而是根据 Read View 从当前版本或 undo 版本链里找到对自己可见的那个版本。
14.4 🌟 MVCC 能不能完全解决幻读
不能这么绝对地说。MVCC 主要解决快照读的一致性视图问题;当前读场景下还要配合间隙锁、临键锁等机制控制幻读。
15. 一段压缩版说明
如果想用一段话把 MVCC 串起来,可以这样概括:
MVCC 是 InnoDB 用来提升并发读写能力的一套多版本机制。它的核心不是简单加锁,而是让一条记录保留版本信息。记录里会有隐藏列,比如最后修改该行的事务 ID 和指向 undo log 的指针;更新时旧版本会进入 undo log,并形成版本链。普通 select 做快照读时,会基于 Read View 判断当前版本是否可见;如果不可见,就沿版本链找更早但可见的版本。在 RC 下,每次快照读通常生成新的 Read View,所以可能出现不可重复读;在 RR 下,事务中的快照读通常复用第一次生成的 Read View,因此能做到可重复读。MVCC 不是替代锁,当前读和范围并发控制仍然要靠锁来配合。
16. 工程上怎么避免把 MVCC 用“坏”
最后补几个很实际的经验点。
16.1 避免长事务
长事务会让旧版本长期无法回收,带来:
- undo 膨胀
- 版本链过长
- 查询和回收开销增加
16.2 区分普通查询和加锁查询
如果业务明明只是读,却到处写 for update,就会把本来能靠 MVCC 解决的读写并发,重新变成锁竞争。
16.3 不要把隔离级别和可见性想成“越高越好”
隔离级别越高,通常并发代价也越明显。真正重要的是:根据业务一致性要求选择合适的隔离级别和读写模式。
17. 总结
把 MVCC 压缩成最核心的几句话,就是:
- 更新不会简单覆盖一切,而是留下可回溯的历史版本
- 历史版本主要通过
undo log串成版本链 - 快照读通过
Read View判断当前事务该看到哪个版本 RC和RR的关键差异,在于 Read View 的创建和复用方式- MVCC 负责提升读写并发,锁负责补足当前读和更强并发控制
如果你能把“隐藏列 -> undo log -> 版本链 -> Read View -> 隔离级别差异”这条线真正讲顺,MVCC 这一块基本就算吃透了。