深入浅出 MVCC —— 从零理解 MySQL 并发控制

本文面向初学者,从最基础的概念讲起,一步步带你理解 MySQL 中 MVCC(多版本并发控制)的工作原理。不需要任何前置知识,看完就能在面试中讲清楚 MVCC。

希望能对大家有帮助!


一、为什么需要 MVCC?从一个故事说起

1.1 没有并发控制的世界

想象一个银行账户系统,张三的账户余额是 1000 元。

场景一:同时读写

时刻 线程A(转账) 线程B(查询)
T1 读取余额:1000
T2 读取余额:1000
T3 扣款200,更新为800
T4 显示余额:1000(旧值!)

线程B看到了一个"过时"的数据。这叫做脏读不可重复读问题。

场景二:同时写

时刻 线程A(转入500) 线程B(扣款200)
T1 读取余额:1000
T2 读取余额:1000
T3 1000+500=1500,写入
T4 1000-200=800,写入(覆盖了A!)

最终余额是800,线程A的转入操作被"丢失"了。这叫做更新丢失问题。

1.2 最简单的解决方案:加锁

最直观的解决方案是加锁:谁在操作数据,其他人都等着。

复制代码
线程A拿到锁 → 读1000 → 改成800 → 释放锁
                                      ↓
                               线程B拿到锁 → 读800 → ...

问题:这太慢了!

  • 读和读之间本来不冲突,也要排队
  • 一个长事务会阻塞所有其他事务
  • 在高并发系统中,性能完全无法接受

1.3 MVCC 的思路:空间换时间

MVCC(Multi-Version Concurrency Control,多版本并发控制)的核心思想是:

不加锁,而是给数据保留多个版本。每个事务看到的是属于自己的"快照",互不干扰。

就像 Git 一样:

  • 你在 feature-A 分支改代码,我在 feature-B 分支改代码
  • 我们各自看到自己版本的代码,互不影响
  • 最终合并时才需要解决冲突

MVCC 让数据库实现了:

  • 读不阻塞写:你在读旧版本,我可以同时写新版本
  • 写不阻塞读:我在写新数据,你照样能读到你该看到的版本
  • 只有写和写之间才需要加锁

二、MVCC 的核心组件

要理解 MVCC 怎么工作,需要先认识三个核心组件:

2.1 隐藏字段:每行数据的"身份证"

InnoDB 在每行数据后面,偷偷加了几个隐藏字段:

字段名 大小 含义
DB_TRX_ID 6 字节 最后修改这行的事务ID
DB_ROLL_PTR 7 字节 回滚指针,指向 undo log 中这行的上一个版本
DB_ROW_ID 6 字节 隐藏主键(如果表没有主键才会有)

重点是前两个

  • DB_TRX_ID:告诉我们"这行是被谁改的"
  • DB_ROLL_PTR:告诉我们"这行的上一个版本在哪"

举个例子,假设有这样一行数据:

复制代码
+----+--------+------------+--------------+
| id | name   | DB_TRX_ID  | DB_ROLL_PTR  |
+----+--------+------------+--------------+
| 1  | 张三   | 100        | 0x12345678   |
+----+--------+------------+--------------+

这行数据是被事务100修改的,DB_ROLL_PTR 指向这行在 undo log 中的上一个版本。

2.2 Undo Log:数据的"历史档案馆"

每当一行数据被修改,InnoDB 不会直接覆盖旧数据,而是:

  1. 旧版本存到 Undo Log 里
  2. DB_ROLL_PTR 指向这个旧版本
  3. 然后才更新当前行

这样就形成了一条版本链

复制代码
当前数据(最新版本)
    ↓ DB_ROLL_PTR
Undo Log(上一个版本)
    ↓ DB_ROLL_PTR
Undo Log(更早的版本)
    ↓ DB_ROLL_PTR
Undo Log(最初版本)
    ↓
  NULL

具体例子

假设 name 字段经历了三次修改:

复制代码
版本链:
┌─────────────────────────────────────┐
│ 当前数据: name='王五', TRX_ID=300   │
└─────────────┬───────────────────────┘
              ↓ ROLL_PTR
┌─────────────────────────────────────┐
│ Undo Log:  name='李四', TRX_ID=200  │
└─────────────┬───────────────────────┘
              ↓ ROLL_PTR
┌─────────────────────────────────────┐
│ Undo Log:  name='张三', TRX_ID=100  │
└─────────────┴───────────────────────┘
              ↓ ROLL_PTR = NULL(最初版本)

为什么叫 Undo Log?

因为它最初的作用是支持回滚(Rollback):如果事务执行到一半失败了,可以根据 Undo Log 恢复到修改前的状态。后来发现它还能用来实现 MVCC,一举两得。

2.3 Read View:事务的"快照时刻"

这是 MVCC 最核心的概念!

当一个事务开始读取数据 时(准确说是执行第一条 SELECT 时),InnoDB 会给这个事务创建一个 Read View(读视图)。

Read View 记录了这一瞬间的事务状态:

字段 含义
m_ids 当前所有活跃(未提交)事务的 ID 列表
min_trx_id m_ids 中的最小值(最老的活跃事务)
max_trx_id 下一个将要分配的事务 ID(当前最大事务ID + 1)
creator_trx_id 创建这个 Read View 的事务自己的 ID

举个例子

假设现在有以下事务正在运行:

  • 事务 100:已提交
  • 事务 200:正在执行(未提交)
  • 事务 300:正在执行(未提交)
  • 事务 400:刚开始,要创建 Read View

那么事务 400 的 Read View 是:

复制代码
m_ids = [200, 300]      // 当前活跃的事务
min_trx_id = 200        // 活跃事务中最小的
max_trx_id = 401        // 下一个要分配的事务ID
creator_trx_id = 400    // 自己的ID

三、MVCC 的可见性判断(核心!)

有了 Read View 和版本链,MVCC 就可以判断:当前事务能看到哪个版本的数据?

3.1 判断规则

拿到一行数据的 DB_TRX_ID(修改这行的事务ID),按以下规则判断:

规则一:自己修改的,肯定能看到

复制代码
如果 DB_TRX_ID == creator_trx_id
    → 可见(是我自己改的)

规则二:在我之前就已经提交的,能看到

复制代码
如果 DB_TRX_ID < min_trx_id
    → 可见(这个事务在我创建 Read View 之前就提交了)

规则三:在我之后才开始的,看不到

复制代码
如果 DB_TRX_ID >= max_trx_id
    → 不可见(这个事务是在我之后才开始的)

规则四:在 min 和 max 之间的,要看是否在活跃列表中

复制代码
如果 min_trx_id <= DB_TRX_ID < max_trx_id
    如果 DB_TRX_ID 在 m_ids 列表中
        → 不可见(这个事务还没提交)
    否则
        → 可见(这个事务已经提交了)

3.2 完整的判断流程图

复制代码
                    读取一行数据
                         ↓
              获取该行的 DB_TRX_ID
                         ↓
         ┌───────────────┴───────────────┐
         ↓                               ↓
   DB_TRX_ID == 自己?              DB_TRX_ID < min_trx_id?
         ↓ 是                            ↓ 是
      【可见】                        【可见】
         ↓ 否                            ↓ 否
         └───────────────┬───────────────┘
                         ↓
              DB_TRX_ID >= max_trx_id?
                         ↓ 是
                     【不可见】
                         ↓ 否
              DB_TRX_ID 在 m_ids 中?
                         ↓ 是
                     【不可见】
                         ↓ 否
                      【可见】

3.3 如果不可见怎么办?

如果当前版本不可见,就顺着 DB_ROLL_PTR 找到 Undo Log 中的上一个版本,重新判断。

一直往前找,直到找到一个可见的版本,或者找到 NULL(说明这行数据对当前事务来说"不存在")。


四、实战举例:一步步模拟 MVCC

场景设定

初始状态:表中有一行数据

sql 复制代码
id=1, name='张三', DB_TRX_ID=50, DB_ROLL_PTR=NULL

(事务50很久以前就提交了)

现在有三个事务并发执行:

事务 操作
事务100 读取 id=1
事务200 修改 name='李四'
事务300 读取 id=1

执行过程

T1:事务200 开始,修改数据

sql 复制代码
-- 事务200
BEGIN;
UPDATE user SET name = '李四' WHERE id = 1;
-- 注意:还没有 COMMIT!

执行后,数据变成:

复制代码
当前数据: name='李四', DB_TRX_ID=200, DB_ROLL_PTR → Undo Log
                                                      ↓
Undo Log: name='张三', DB_TRX_ID=50, DB_ROLL_PTR=NULL

T2:事务100 开始读取

sql 复制代码
-- 事务100
BEGIN;
SELECT name FROM user WHERE id = 1;

事务100 创建 Read View:

复制代码
m_ids = [200]        // 事务200正在活跃
min_trx_id = 200
max_trx_id = 301     // 下一个事务ID
creator_trx_id = 100

判断过程:

  1. 读取当前数据:DB_TRX_ID = 200
  2. 200 不等于 100(不是自己改的)
  3. 200 不小于 200(不是在 Read View 之前提交的)
  4. 200 不大于等于 301
  5. 200 在 m_ids [200] 中 → 不可见!
  6. 顺着 ROLL_PTR 找到 Undo Log:DB_TRX_ID = 50
  7. 50 < 200 → 可见!

结果:事务100 读到的是 name='张三'

T3:事务200 提交

sql 复制代码
-- 事务200
COMMIT;

T4:事务300 开始读取

sql 复制代码
-- 事务300
BEGIN;
SELECT name FROM user WHERE id = 1;

事务300 创建 Read View:

复制代码
m_ids = []           // 事务200已经提交,没有活跃事务了
min_trx_id = ∞       // m_ids为空,设为无穷大(简化理解)
max_trx_id = 301
creator_trx_id = 300

判断过程:

  1. 读取当前数据:DB_TRX_ID = 200
  2. 200 不等于 300(不是自己改的)
  3. 200 < 301(在 max_trx_id 之前)
  4. m_ids 为空,200 不在其中 → 可见!

结果:事务300 读到的是 name='李四'

总结

事务 读取时机 看到的值 原因
事务100 事务200未提交时 张三 200在活跃列表中,不可见
事务300 事务200已提交后 李四 200不在活跃列表中,可见

这就是 MVCC 的魔法:不同事务根据自己的 Read View,看到不同版本的数据!


五、Read View 的生成时机:RC vs RR

MVCC 的行为在不同隔离级别下有所不同,关键区别在于 Read View 什么时候生成

5.1 READ COMMITTED(读已提交,RC)

每次 SELECT 都生成新的 Read View

sql 复制代码
-- 事务A
BEGIN;
SELECT name FROM user WHERE id = 1;  -- 生成 Read View #1
-- ... 等一会儿,事务B提交了 ...
SELECT name FROM user WHERE id = 1;  -- 生成 Read View #2(新的!)
COMMIT;

因为每次读都用新的 Read View,所以:

  • 如果在两次 SELECT 之间,其他事务提交了修改
  • 第二次 SELECT 能看到新提交的数据
  • 这就是"读已提交"的含义

问题:两次读可能得到不同的结果(不可重复读)

5.2 REPEATABLE READ(可重复读,RR)

只在事务第一次 SELECT 时生成 Read View,后续复用

sql 复制代码
-- 事务A
BEGIN;
SELECT name FROM user WHERE id = 1;  -- 生成 Read View #1
-- ... 事务B提交了修改 ...
SELECT name FROM user WHERE id = 1;  -- 复用 Read View #1(不是新的!)
COMMIT;

因为始终用同一个 Read View,所以:

  • 无论其他事务怎么修改和提交
  • 在同一个事务内,多次读同一行数据,结果始终一致
  • 这就是"可重复读"的含义

MySQL InnoDB 默认使用 REPEATABLE READ 隔离级别

5.3 对比表格

隔离级别 Read View 生成时机 同一事务内多次读
READ COMMITTED 每次 SELECT 都生成新的 可能读到不同值
REPEATABLE READ 第一次 SELECT 生成,后续复用 保证读到相同值

六、MVCC 解决了哪些问题?没解决哪些?

6.1 MVCC 解决的问题

问题 是否解决 说明
脏读 ✅ 解决 未提交的事务对其他事务不可见
不可重复读 ✅ 解决(RR级别) Read View 锁定快照
读阻塞写 ✅ 解决 读的是历史版本,写的是当前版本
写阻塞读 ✅ 解决 同上

6.2 MVCC 没有解决的问题

幻读(Phantom Read) :MVCC 不能完全解决幻读。

什么是幻读?

sql 复制代码
-- 事务A
BEGIN;
SELECT COUNT(*) FROM user WHERE age > 20;  -- 结果:5条

-- 事务B 插入一条 age=25 的新数据并提交

SELECT COUNT(*) FROM user WHERE age > 20;  -- 结果可能还是5条(MVCC保护)

-- 但如果事务A执行 UPDATE:
UPDATE user SET status = 1 WHERE age > 20;  -- 会更新6条!包括事务B插入的

SELECT COUNT(*) FROM user WHERE age > 20;  -- 结果变成6条了!

这就是幻读:同一个事务内,同样的查询条件,前后读到的行数不一样。

MySQL InnoDB 的解决方案 :用 Next-Key Lock(临键锁)来防止幻读,这是在 MVCC 之外的锁机制。

6.3 写-写冲突

MVCC 不解决写-写冲突,两个事务同时写同一行时,还是需要加锁

  • 先到的事务获得行锁
  • 后到的事务等待

这叫做当前读(Current Read),会读取最新版本并加锁。


七、快照读 vs 当前读

7.1 快照读(Snapshot Read)

使用 MVCC 机制,读取的是历史快照版本,不加锁。

sql 复制代码
-- 普通的 SELECT 就是快照读
SELECT * FROM user WHERE id = 1;

7.2 当前读(Current Read)

读取的是数据的最新版本,并且会加锁。

sql 复制代码
-- 以下都是当前读,会加锁
SELECT * FROM user WHERE id = 1 FOR UPDATE;      -- 加排他锁
SELECT * FROM user WHERE id = 1 LOCK IN SHARE MODE;  -- 加共享锁

INSERT INTO user VALUES (...);   -- 加排他锁
UPDATE user SET name = 'x' WHERE id = 1;  -- 加排他锁
DELETE FROM user WHERE id = 1;   -- 加排他锁

关键区别

类型 读取版本 是否加锁 典型语句
快照读 历史快照 不加锁 SELECT ...
当前读 最新版本 加锁 SELECT ... FOR UPDATE, INSERT, UPDATE, DELETE

八、面试答案模板(直接背诵版)

问题:请解释一下 MySQL 的 MVCC 机制?

MVCC 是多版本并发控制,InnoDB 用它来实现读写不阻塞 。核心思想是:不删除旧数据,而是保留多个版本,每个事务根据自己的"快照"来决定能看到哪个版本

MVCC 有三个核心组件:

第一是隐藏字段 :每行数据都有 DB_TRX_ID(最后修改的事务ID)和 DB_ROLL_PTR(指向 Undo Log 的指针)。

第二是 Undo Log :每次修改数据时,旧版本会存到 Undo Log 里,通过 ROLL_PTR 串成一条版本链。

第三是 Read View :事务读数据时会创建一个 Read View,记录当前有哪些事务正在活跃(未提交)。然后根据版本链上每个版本的 TRX_ID,判断这个版本是否对当前事务可见。

判断规则简单说就是:已提交的能看到,未提交的看不到,自己改的能看到

RC 和 RR 隔离级别的区别在于 Read View 的生成时机:

  • RC:每次 SELECT 都生成新的 Read View,所以能读到其他事务新提交的数据
  • RR:只在第一次 SELECT 时生成,后续复用,所以同一事务内多次读结果一致

需要注意的是,MVCC 只用于快照读 (普通 SELECT)。SELECT FOR UPDATEINSERTUPDATEDELETE 这些是当前读,会加锁,不走 MVCC。


九、常见面试追问

Q1:Undo Log 会无限增长吗?什么时候清理?

不会。InnoDB 有一个 Purge 线程,专门负责清理不再需要的 Undo Log。

清理条件:当没有任何活跃的 Read View 需要访问某个历史版本时,这个版本就可以被清理了。

Q2:MVCC 和锁是什么关系?

  • MVCC 解决读写并发问题:读不阻塞写,写不阻塞读
  • 锁解决写写并发问题:两个事务同时写同一行时加锁
  • 两者是互补的,不是替代关系

Q3:为什么 InnoDB 默认用 RR 而不是 RC?

  • RR 提供更强的一致性保证(可重复读)
  • 配合 Next-Key Lock 可以解决幻读
  • 对大多数业务场景来说,RR 的行为更符合直觉

Q4:MVCC 和乐观锁有什么区别?

维度 MVCC 乐观锁
层面 数据库引擎层实现 应用层实现
冲突检测 通过版本链判断可见性 通过版本号/时间戳检测
用途 读写并发控制 写写冲突检测
代码 无需修改业务代码 需要在代码中加版本判断

十、总结

概念 一句话解释
MVCC 多版本并发控制,读写不阻塞
DB_TRX_ID 每行数据记录"谁最后改的我"
DB_ROLL_PTR 指向 Undo Log 中的上一个版本
Undo Log 存储数据的历史版本,形成版本链
Read View 事务的快照,记录活跃事务列表
快照读 普通 SELECT,走 MVCC,不加锁
当前读 FOR UPDATE/INSERT/UPDATE/DELETE,加锁
RC vs RR RC 每次 SELECT 新建 Read View;RR 只建一次

恭喜你看完了! 如果你能把上面的面试答案模板讲清楚,MVCC 这个知识点就算过关了。

建议配合动手实验加深理解:

sql 复制代码
-- 开两个 MySQL 客户端,分别执行事务,观察隔离级别的效果
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;

有问题欢迎在评论区交流!

相关推荐
小杜的生信筆記2 小时前
生信技能技巧小知识,Linux多线程压缩/解压工具
linux·数据库·redis
Smoothcloud润云2 小时前
Google DeepMind 学习系列笔记(3):Design And Train Neural Networks
数据库·人工智能·笔记·深度学习·学习·数据分析·googlecloud
银发控、2 小时前
MySQL覆盖索引与索引下推
数据库·mysql·面试
NEXT062 小时前
数组转树与树转数组
前端·数据结构·面试
We་ct2 小时前
浏览器 Reflow(重排)与Repaint(重绘)全解析
前端·面试·edge·edge浏览器
DolphinDB智臾科技2 小时前
DolphinDB 与英方软件达成兼容互认,共筑高效数据新底座
数据库·时序数据库·dolphindb
ZJun_Ocean2 小时前
add_columns
数据库·sql
ID_180079054732 小时前
淘宝商品详情 API 接口 item_get: 高效获取商品数据的技术方案
java·前端·数据库
ssshooter2 小时前
看完就懂 useLayoutEffect
前端·react.js·面试