Skip to content

MySQL MVCC 详解

MVCCMulti-Version Concurrency Control,即多版本并发控制。

很多人第一次学 MVCC,会把它记成一句口号:普通 select 不加锁,也能读到一致的数据。

这句话不能说错,但还是太薄了。真正要把 MVCC 讲清楚,至少要把下面几个方面展开:

  1. 一行数据为什么会有“多个版本”
  2. 这些旧版本到底存在哪里
  3. 数据库凭什么知道当前事务该看到哪个版本
  4. Read CommittedRepeatable Read 为什么表现不一样
  5. MVCC 和锁到底是替代关系,还是协同关系

这篇文章就按这个顺序,把 MySQL 里最容易讲散的 MVCC 原理完整串起来。

1. 🌟 MVCC 到底在解决什么问题

先不要急着背术语,看并发读写最原始的矛盾。

假设有两个事务:

  1. 事务 A 正在更新一条订单
  2. 事务 B 同时在查询这条订单

如果数据库只有“锁”这一种手段,就会遇到两个很直接的问题:

  1. 要么读等写,查询容易被阻塞
  2. 要么写等读,并发吞吐会下降

数据库当然可以用更粗暴的锁策略来保证正确性,但代价是:

  1. 延迟更高
  2. 吞吐更低
  3. 高并发场景更容易出现排队

MVCC 的核心思路就是:写操作生成新版本,读操作按自己的事务视角读取合适的旧版本或当前版本。

这样做的价值在于:

  1. 快照读通常不用阻塞写
  2. 写操作也不必因为大量普通查询而停下来
  3. 在合适隔离级别下还能读到一致性视图

所以 MVCC 不是“让数据库完全不加锁”,而是:把原本很多需要靠阻塞式锁解决的读写冲突,改成通过版本可见性判断来解决。

2. MVCC 生效的前提:一行记录不只是一行业务字段

在 InnoDB 里,一条记录除了业务字段外,通常还会带几个隐藏列。

对理解 MVCC 最关键的是下面两个:

  1. DB_TRX_ID:最后一次修改这条记录的事务 ID
  2. DB_ROLL_PTR:指向这条记录旧版本对应 undo log 的指针

如果表没有主键,InnoDB 还可能额外维护一个隐藏主键列 DB_ROW_ID,但它不是 MVCC 的核心。

你可以把一行记录简化理解成下面这样:

text
id=1, name='Alice', balance=200,
DB_TRX_ID=103,
DB_ROLL_PTR -> 指向旧版本

这两个隐藏列的作用分别是:

  1. DB_TRX_ID 用来标记“这是谁改的”
  2. DB_ROLL_PTR 用来找到“改之前长什么样”

也就是说,MVCC 并不是把多个版本都直接堆在主记录里,而是:当前版本留在数据页里,历史版本顺着 roll pointer 到 undo log 里去找。

3. 旧版本从哪来:undo log 和版本链

很多人会说“MVCC 依赖 undo log”,但如果没有版本链这个画面,还是容易悬空。

假设有一行数据最开始是:

text
id=1, balance=100

然后发生两次更新:

  1. 事务 T101balance 改成 150
  2. 事务 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[更早的历史版本]

这里最关键的理解是:

  1. 数据页里通常保留当前版本
  2. 历史版本主要放在 undo log
  3. 读取时如果当前版本对事务不可见,就沿版本链往前找

所以所谓“多版本”,不是说数据库把表复制了很多份,而是:一条记录的历史状态被串成了一个可回溯的版本链。

4. 哪些操作会用到这些旧版本

undo log 不只是为了 MVCC,它至少服务两个核心目标:

  1. 事务回滚
  2. 一致性读

4.1 回滚

如果事务执行到一半失败了,数据库就需要把修改前的状态恢复回来。

这时 undo log 解决的是:怎么把已经改掉的值撤回去。

4.2 一致性读

如果另一个事务在做普通 select,它未必应该看到你刚改但还不该对它可见的数据。

这时 undo log 解决的是:怎么让读请求回到它应该看到的历史版本。

所以你可以把它记成一句话:undo log 既负责“能撤销”,也负责“能回看”。

5. 🌟 Read View:数据库怎么判断哪个版本能看

只有版本链还不够,数据库还得知道:当前事务到底应该看到哪个版本。

这个判断依据就是 Read View,也可以理解成“一致性读视图”。

你不用一开始就死记全部字段,但下面这几个最好知道:

  1. creator_trx_id:创建这个 Read View 的事务 ID
  2. m_ids:创建视图时系统里还活跃的读写事务 ID 列表
  3. min_trx_id:活跃事务里最小的事务 ID
  4. max_trx_id:下一个将被分配的事务 ID

你可以把它想成:事务在做快照读时,给自己拍了一张“当前活跃事务名单”的照片。

后面判断某个版本能不能看,本质就是拿“版本的 trx_id”和这张照片比对。

6. 可见性规则到底怎么判断

这是 MVCC 最容易考原理的部分。

假设当前要读某条记录的一个版本,它的 DB_TRX_ID = x。数据库大体会按下面的逻辑判断这个版本是否可见:

  1. 如果 x 等于当前事务自己的 ID,那么可见
  2. 如果 x < min_trx_id,说明它在 Read View 创建前就已经提交,可见
  3. 如果 x >= max_trx_id,说明它在 Read View 创建后才产生,不可见
  4. 如果 min_trx_id <= x < max_trx_id,就看它是否在 m_ids
  5. 如果在 m_ids 里,说明当时它还活跃,不可见
  6. 如果不在 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 和版本链去找事务可见的数据版本。

它的特点是:

  1. 读到的不一定是最新已写入内存的版本
  2. 读到的是对当前事务来说“合法”的版本
  3. 通常不会因为普通写操作而直接阻塞

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;

当前读的目标不是拿快照,而是拿当前最新版本,并在需要时加锁。

所以你要把二者分清:

  1. 快照读主要靠 MVCC
  2. 当前读主要看最新版本,并配合锁控制并发

这也是为什么很多人第一次接触时,会疑惑:为什么普通 select 和 select for update 的表现不一样。

因为它们根本不是同一种读。

8. 🌟 RC 和 RR 为什么表现不同

8.1 Read Committed

RC(读已提交)隔离级别下:每次执行快照读,都会生成新的 Read View。

这意味着同一个事务里,两次普通 select 看到的数据可能不同。

例如:

  1. 事务 A 第一次查询时,事务 B 还没提交
  2. 事务 B 提交后
  3. 事务 A 再查一次,会重新生成 Read View
  4. 第二次就可能看到 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 能不能解决幻读

这个问题最容易答得过满。

更准确的说法是:

  1. 对快照读,InnoDB 的 RR 通过 MVCC 能让同一事务看到稳定的一致性视图
  2. 对当前读,仅靠 MVCC 不够,通常还要结合间隙锁、临键锁等锁机制

所以不要把它说成:MVCC 单独彻底解决了所有幻读问题。

更稳妥的表述是:MVCC 主要解决快照读下的一致性视图问题;涉及当前读和范围并发修改时,还需要锁来协同。

10. delete 在 MVCC 里怎么理解

很多人以为 delete 就是把这行立刻物理删除。对 InnoDB 来说,这个理解太粗了。

更常见的过程是:

  1. 当前记录被打上删除标记
  2. 旧版本仍可能要保留一段时间
  3. 只要还有事务可能看见它,就不能急着彻底清理

为什么?

因为某个更早开启的事务,可能还需要通过版本链看到“这条记录尚未被删除时”的历史状态。

也就是说:逻辑上删掉了,不等于物理上立刻彻底消失。

11. 旧版本会一直留着吗:purge 在做什么

不会一直留着。

当数据库确认某些旧版本已经不可能再被任何活跃事务看到后,后台线程会逐步清理这些不再需要的 undo 信息,这个过程通常叫 purge

你可以这样理解:

  1. MVCC 需要历史版本
  2. 但历史版本不能无限堆积
  3. 所以数据库会在“确认再也没人需要看它”之后再清理

这也是为什么长事务很麻烦。

如果一个事务一直不结束,它可能长期持有旧的 Read View,导致:

  1. 很多历史版本不能及时回收
  2. undo 链变长
  3. 存储和查询成本上升

所以工程上常说:长事务是 MVCC 的天敌之一。

12. 一个完整例子:把版本链和 Read View 串起来

假设一行记录初始值为:

text
id=1, balance=100

接下来发生下面的事情:

  1. 事务 T10 开启
  2. 事务 T11 开启,并把 balance100 改成 150,但还未提交
  3. 事务 T10 执行普通 select

这时 T10 形成自己的 Read View。因为 T11 还在活跃事务列表里,所以:

  1. 当前版本 balance=150T10 不可见
  2. T10 会沿版本链往前找
  3. 最终读到旧版本 balance=100

然后如果:

  1. T11 提交
  2. T10RC 下再次查询

由于 RC 会重新创建 Read View,所以第二次查询可能看到 150

但如果:

  1. T10 运行在 RR
  2. 第二次查询仍属于同一事务中的快照读

那它大概率仍然沿着第一次创建的视图去看,结果还是 100

这个例子背后真正要记住的不是数字,而是:看到哪个版本,不取决于“磁盘上最新值是多少”,而取决于“当前事务的 Read View 允不允许你看到它”。

13. 🌟 为什么说 MVCC 和锁是协同关系

一个常见误区是:有了 MVCC,就不需要锁了。

这是不对的。

更准确的关系是:

  1. MVCC 主要优化读写并发,减少不必要阻塞
  2. 锁主要保证当前读、修改冲突控制和更严格的一致性

实际系统里两者会同时存在:

  1. 普通查询大量依赖 MVCC
  2. 更新、删除、select ... for update 依赖当前读和锁
  3. 范围并发控制还要靠间隙锁、临键锁等机制

所以你可以把 InnoDB 的并发控制理解成:MVCC 负责让“读历史版本”这件事变得高效,锁负责让“改当前版本”这件事保持有序。

14. 最容易混淆的几个点

14.1 MVCC 的实现基础是什么

  1. 记录里的隐藏列 DB_TRX_IDDB_ROLL_PTR
  2. undo log 形成的版本链
  3. 一致性读时创建的 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 避免长事务

长事务会让旧版本长期无法回收,带来:

  1. undo 膨胀
  2. 版本链过长
  3. 查询和回收开销增加

16.2 区分普通查询和加锁查询

如果业务明明只是读,却到处写 for update,就会把本来能靠 MVCC 解决的读写并发,重新变成锁竞争。

16.3 不要把隔离级别和可见性想成“越高越好”

隔离级别越高,通常并发代价也越明显。真正重要的是:根据业务一致性要求选择合适的隔离级别和读写模式。

17. 总结

把 MVCC 压缩成最核心的几句话,就是:

  1. 更新不会简单覆盖一切,而是留下可回溯的历史版本
  2. 历史版本主要通过 undo log 串成版本链
  3. 快照读通过 Read View 判断当前事务该看到哪个版本
  4. RCRR 的关键差异,在于 Read View 的创建和复用方式
  5. MVCC 负责提升读写并发,锁负责补足当前读和更强并发控制

如果你能把“隐藏列 -> undo log -> 版本链 -> Read View -> 隔离级别差异”这条线真正讲顺,MVCC 这一块基本就算吃透了。

基于 VitePress 构建的个人技术笔记。