MySQL MVCC 多版本并发控制原理
深入解析 MySQL InnoDB 的 MVCC 机制。核心组件包括隐藏字段(DB_TRX_ID、DB_ROLL_PTR)、Undo Log 版本链及 Read View 快照。文章详细说明了可见性判断规则,区分了 RC 与 RR 隔离级别下 Read View 的生成时机差异,并对比了快照读与当前读的行为。此外,还阐述了 MVCC 对脏读、不可重复读的解决能力及其在幻读场景下的局限性。

深入解析 MySQL InnoDB 的 MVCC 机制。核心组件包括隐藏字段(DB_TRX_ID、DB_ROLL_PTR)、Undo Log 版本链及 Read View 快照。文章详细说明了可见性判断规则,区分了 RC 与 RR 隔离级别下 Read View 的生成时机差异,并对比了快照读与当前读的行为。此外,还阐述了 MVCC 对脏读、不可重复读的解决能力及其在幻读场景下的局限性。

想象一个银行账户系统,张三的账户余额是 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 的转入操作被"丢失"了。这叫做更新丢失问题。
最直观的解决方案是加锁:谁在操作数据,其他人都等着。
线程 A 拿到锁 → 读 1000 → 改成 800 → 释放锁 ↓ 线程 B 拿到锁 → 读 800 → ...
问题:这太慢了!
MVCC(Multi-Version Concurrency Control,多版本并发控制)的核心思想是:
不加锁,而是给数据保留多个版本。每个事务看到的是属于自己的"快照",互不干扰。
就像 Git 一样:
feature-A 分支改代码,我在 feature-B 分支改代码MVCC 让数据库实现了:
要理解 MVCC 怎么工作,需要先认识三个核心组件:
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 中的上一个版本。
每当一行数据被修改,InnoDB 不会直接覆盖旧数据,而是:
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,一举两得。
这是 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 |
举个例子:
假设现在有以下事务正在运行:
那么事务 400 的 Read View 是:
m_ids = [200, 300] // 当前活跃的事务
min_trx_id = 200 // 活跃事务中最小的
max_trx_id = 401 // 下一个要分配的事务 ID
creator_trx_id = 400 // 自己的 ID
有了 Read View 和版本链,MVCC 就可以判断:当前事务能看到哪个版本的数据?
拿到一行数据的 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 列表中 → 不可见(这个事务还没提交)
否则 → 可见(这个事务已经提交了)
读取一行数据
↓
获取该行的 DB_TRX_ID
↓
┌───────────────┴───────────────┐
↓ ↓
DB_TRX_ID == 自己? DB_TRX_ID < min_trx_id?
↓ ↓
是 是
【可见】 【可见】
↓ ↓
否 否
└───────────────┬───────────────┘
↓
DB_TRX_ID >= max_trx_id?
↓
是
【不可见】
↓
否
DB_TRX_ID 在 m_ids 中?
↓
是
【不可见】
↓
否
【可见】
如果当前版本不可见,就顺着 DB_ROLL_PTR 找到 Undo Log 中的上一个版本,重新判断。
一直往前找,直到找到一个可见的版本,或者找到 NULL(说明这行数据对当前事务来说"不存在")。
初始状态:表中有一行数据
id=1, name='张三', DB_TRX_ID=50, DB_ROLL_PTR=NULL
(事务 50 很久以前就提交了)
现在有三个事务并发执行:
| 事务 | 操作 |
|---|---|
| 事务 100 | 读取 id=1 |
| 事务 200 | 修改 name='李四' |
| 事务 300 | 读取 id=1 |
T1:事务 200 开始,修改数据
-- 事务 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 开始读取
-- 事务 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 = 200m_ids [200] 中 → 不可见!ROLL_PTR 找到 Undo Log:DB_TRX_ID = 50结果:事务 100 读到的是 name='张三'
T3:事务 200 提交
-- 事务 200
COMMIT;
T4:事务 300 开始读取
-- 事务 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 = 200max_trx_id 之前)m_ids 为空,200 不在其中 → 可见!结果:事务 300 读到的是 name='李四'
| 事务 | 读取时机 | 看到的值 | 原因 |
|---|---|---|---|
| 事务 100 | 事务 200 未提交时 | 张三 | 200 在活跃列表中,不可见 |
| 事务 300 | 事务 200 已提交后 | 李四 | 200 不在活跃列表中,可见 |
这就是 MVCC 的魔法:不同事务根据自己的 Read View,看到不同版本的数据!
MVCC 的行为在不同隔离级别下有所不同,关键区别在于 Read View 什么时候生成。
每次 SELECT 都生成新的 Read View
-- 事务 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 时生成 Read View,后续复用
-- 事务 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 隔离级别
| 隔离级别 | Read View 生成时机 | 同一事务内多次读 |
|---|---|---|
| READ COMMITTED | 每次 SELECT 都生成新的 | 可能读到不同值 |
| REPEATABLE READ | 第一次 SELECT 生成,后续复用 | 保证读到相同值 |
| 问题 | 是否解决 | 说明 |
|---|---|---|
| 脏读 | ✅ 解决 | 未提交的事务对其他事务不可见 |
| 不可重复读 | ✅ 解决(RR 级别) | Read View 锁定快照 |
| 读阻塞写 | ✅ 解决 | 读的是历史版本,写的是当前版本 |
| 写阻塞读 | ✅ 解决 | 同上 |
幻读(Phantom Read):MVCC 不能完全解决幻读。
什么是幻读?
-- 事务 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 之外的锁机制。
MVCC 不解决写 - 写冲突,两个事务同时写同一行时,还是需要加锁:
这叫做当前读(Current Read),会读取最新版本并加锁。
使用 MVCC 机制,读取的是历史快照版本,不加锁。
-- 普通的 SELECT 就是快照读
SELECT * FROM user WHERE id = 1;
读取的是数据的最新版本,并且会加锁。
-- 以下都是当前读,会加锁
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 |
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。
不会。InnoDB 有一个 Purge 线程,专门负责清理不再需要的 Undo Log。
清理条件:当没有任何活跃的 Read View 需要访问某个历史版本时,这个版本就可以被清理了。
| 维度 | 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 只建一次 |

微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
在线格式化和美化您的 SQL 查询(它支持各种 SQL 方言)。 在线工具,SQL 美化和格式化在线工具,online
解析 INSERT 等受限 SQL,导出为 CSV、JSON、XML、YAML、HTML 表格(见页内语法说明)。 在线工具,SQL 转 CSV/JSON/XML在线工具,online
CSV 与 JSON/XML/HTML/TSV/SQL 等互转,单页多 Tab。 在线工具,CSV 工具包在线工具,online
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online