本文面向初学者,从最基础的概念讲起,一步步带你理解 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 不会直接覆盖旧数据,而是:
- 把旧版本存到 Undo Log 里
- 用
DB_ROLL_PTR指向这个旧版本 - 然后才更新当前行
这样就形成了一条版本链:
当前数据(最新版本)
↓ 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
判断过程:
- 读取当前数据:
DB_TRX_ID = 200 - 200 不等于 100(不是自己改的)
- 200 不小于 200(不是在 Read View 之前提交的)
- 200 不大于等于 301
- 200 在 m_ids [200] 中 → 不可见!
- 顺着 ROLL_PTR 找到 Undo Log:
DB_TRX_ID = 50 - 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
判断过程:
- 读取当前数据:
DB_TRX_ID = 200 - 200 不等于 300(不是自己改的)
- 200 < 301(在 max_trx_id 之前)
- 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 UPDATE、INSERT、UPDATE、DELETE这些是当前读,会加锁,不走 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;
有问题欢迎在评论区交流!