第 26 章:MVCC 多版本并发控制——"读写不冲突"的秘密
⏱ 阅读时间:约 50 分钟 📖 前置知识:第 24 章(事务与隔离级别)、第 25 章(InnoDB 锁机制) 🎯 读完本章你将:彻底搞懂 MVCC 的原理——行是怎么变成"多个版本"的,ReadView 怎么决定你能看到哪些版本,以及 RC 和 RR 隔离级别的本质区别
一个问题
想象一个场景:
你在双十一写了一个下单接口,逻辑很简单——先查库存,再扣库存。就在你查完库存的那一瞬间,另一个用户的请求也来查库存了。
两个事务同时读同一行数据,然后又同时去改它。
如果用最笨的办法——读的时候加锁,把整行锁住,别人只能等着——那你的系统就像一条单行道:读写排队,性能暴跌。
但 MySQL 没这么干。
在 InnoDB 的默认隔离级别(REPEATABLE READ)下,多个事务可以同时读同一行数据,互不阻塞。读不会被写堵住,写也不会被读堵住。
这个"读写不冲突"的魔法,就叫 MVCC(Multi-Version Concurrency Control,多版本并发控制)。
它是 InnoDB 并发性能的基石。 不夸张地说,没有 MVCC,MySQL 早就被 PostgreSQL 打趴下了。
那么问题来了:
同一行数据,怎么能被多个事务"同时读"而互不干扰?每个事务看到的到底是哪个"版本"?
要回答这个问题,我们得从一行数据的"隐藏身份证"说起。
一行数据的隐藏身份证
你执行 SELECT * FROM account WHERE id = 1,看到的是这样的结果:
| id | name | balance |
|---|---|---|
| 1 | 张三 | 1000 |
干净利落,三个字段。但你看到的只是冰山一角。
InnoDB 在每一行数据的后面,悄悄加了三个隐藏列:
这三个隐藏列各有分工:
| 隐藏列 | 干什么用 | 一句话解释 |
|---|---|---|
| DB_TRX_ID | 记录最后一次修改这行数据的事务 ID | "谁最后动了这一行" |
| DB_ROLL_PTR | 回滚指针,指向这行数据的上一个版本 | "这行数据的旧版本在哪" |
| DB_ROW_ID | 隐藏主键 | "如果你没定义主键,我帮你加一个"(有主键时这个列不存在) |
DB_TRX_ID 和 DB_ROLL_PTR 是 MVCC 的核心。 DB_ROW_ID 跟 MVCC 没关系,它是 InnoDB 给没有主键的表准备的"备胎主键"。
💡 记住这一句就够了: 每一行数据都有隐藏的 DB_TRX_ID(谁改的)和 DB_ROLL_PTR(旧版本在哪),这两个隐藏列是 MVCC 的"身份证"。
一行数据是怎么变成"多个版本"的
光有隐藏列还不够。MVCC 的第二个关键角色是 undo log。
UPDATE 到底做了什么
你以为 UPDATE account SET balance = 900 WHERE id = 1 就是简单地把 1000 改成 900?太天真了。InnoDB 在执行这条 UPDATE 时,内部操作如下:
- 对这行数据加排他锁(X Lock)——防止其他事务同时修改
- 把旧值写入 undo log——
balance=1000, DB_TRX_ID=100被完整拷贝到 undo log - 修改聚簇索引中的当前行——
balance改为 900,DB_TRX_ID改为当前事务 ID,DB_ROLL_PTR指向 undo log 中的旧版本 - 写 redo log——保证崩溃恢复(第 27 章详述)
你看,一次 UPDATE 操作,数据从 1 份变成了 2 份(当前版本 + undo log 中的旧版本)。如果再改一次,就变成 3 份。
场景:三次修改同一行
假设 account 表有一行数据,初始状态是 balance = 1000。这时候这行数据的隐藏列长这样:
id=1, name=张三, balance=1000, DB_TRX_ID=0, DB_ROLL_PTR=null
DB_TRX_ID = 0 表示这条数据是初始化时插入的,DB_ROLL_PTR = null 表示它没有旧版本。
现在,三个事务依次来修改这行数据:
这就是"版本链": 每次修改一行数据,InnoDB 不是直接覆盖旧值,而是把旧值拷贝到 undo log 中,然后在当前行上写新值,同时让 DB_ROLL_PTR 指向旧版本。
就像一本账本——每次改账不是涂掉原来的数字,而是在旁边写上新的,同时把原来的数字记在另一张纸上。如果有人想看"上个月账面上是多少",顺着回滚指针一找就找到了。
undo log 不仅仅是 MVCC 的附属品。 它还承担着两个重要职责:
- 事务回滚:如果事务执行到一半需要 ROLLBACK,InnoDB 就顺着版本链把数据恢复到修改前的状态。所以 undo log 中的"旧版本"其实就是事务回滚的"后悔药"。
- 崩溃恢复:如果 MySQL 在事务执行过程中突然宕机,重启后 InnoDB 会检查哪些事务没有提交,然后利用 undo log 把它们的影响撤销掉。
这也是为什么 undo log 被称为"回滚日志"——它的首要职责是保证事务的原子性(要么全做,要么全不做),MVCC 只是它的"副业"。
💡 记住这一句就够了: 每次修改一行数据,旧版本不会丢失,而是存到 undo log 中,通过 DB_ROLL_PTR 串成一条"版本链"。最新版本在聚簇索引中,历史版本在 undo log 中。undo log 不仅是 MVCC 的基础,还是事务回滚和崩溃恢复的关键。
ReadView:谁能看到哪个版本
有了版本链,一行数据就有了多个版本。但问题来了:
事务 A 正在读这行数据,它应该看到版本链上的哪个版本?
这就需要 ReadView(读视图) 出场了。
ReadView 是什么
ReadView 是一个事务在执行查询的那一刻,拍的一张"快照"——它记录了当时所有活跃事务的 ID 列表。
你可以把它理解成:"在我开始读的这一刻,有哪些事务正在进行中?"
ReadView 包含四个关键字段:
可见性判断算法
当一个事务拿着自己的 ReadView,沿着版本链找数据时,对每个版本做如下判断:
翻译成人话:
- 这个版本是我自己改的 → ✅ 可见
- 修改这行的事务在我之前就提交了(DB_TRX_ID < min_trx_id)→ ✅ 可见
- 修改这行的事务在我之后才开始(DB_TRX_ID >= max_trx_id)→ ❌ 不可见
- 修改这行的事务在我开始读时还活跃着(在 m_ids 中)→ ❌ 不可见
- 修改这行的事务已提交但不在活跃列表中 → ✅ 可见
如果当前版本不可见怎么办?顺着 DB_ROLL_PTR 找旧版本,再重复上述判断,直到找到一个可见的版本为止。
💡 记住这一句就够了: ReadView 就是事务"拍的一张快照",记录了当时哪些事务还活跃。顺着版本链逐个判断——已提交的可见,未提交的不可见,未来的不可见——最终找到一个你能看到的版本。
RC vs RR:ReadView 的生成时机不同
这是面试中 MVCC 部分最核心的区别,也是本章最重要的知识点。
RC(READ COMMITTED)和 RR(REPEATABLE READ)的可见性判断算法完全一样,唯一的区别是:
就这一个区别,导致了完全不同的行为。
- RC 下:每次 SELECT 都会重新生成 ReadView。如果在上次 SELECT 之后有新事务提交了修改,新的 ReadView 就能看到这些修改。
- RR 下:整个事务只生成一次 ReadView。不管中间有多少事务提交了修改,你的 ReadView 始终是"第一次 SELECT 时拍的那张照片",所以每次读到的结果都一样。
这就是为什么 RR 叫"可重复读"——因为你一直用的是同一张"快照"。
实战案例:RC 和 RR 下同一条 SQL 结果不同
光说不练假把式。我们用一组实际 SQL 来演示版本链的工作过程。
准备数据
CREATE TABLE account (
id INT PRIMARY KEY,
name VARCHAR(20),
balance INT
) ENGINE=InnoDB;
INSERT INTO account VALUES (1, '张三', 1000);
初始状态下,id=1 这行数据的 DB_TRX_ID 为某个初始值(假设事务 100 插入的),DB_ROLL_PTR = null。
RC 隔离级别的实验
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
发生了什么? 让我们一步步拆解:
T1:事务 A 第一次 SELECT,生成 ReadView_1。此时事务 B(201)已 BEGIN 但还没提交,所以 m_ids = [201]。当前数据版本的 DB_TRX_ID 不在 m_ids 中(是事务 100 插入的,早已提交),所以 → ✅ 可见,读到 1000。
T2-T3:事务 B 修改了 balance 为 900(写入新版本,DB_TRX_ID=201),然后提交了。
T4:事务 A 第二次 SELECT。RC 级别下,每次 SELECT 都生成新 ReadView。此时事务 B 已提交,所以 ReadView_2 的 m_ids = [](没有活跃事务了)。当前版本 DB_TRX_ID=201,不在 m_ids 中 → ✅ 可见,读到 900。
结果:事务 A 的两次 SELECT 读到了不同的值——这就是"不可重复读"。
RR 隔离级别的实验
同样的操作,把隔离级别换成 RR:
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
关键差异在 T4:
事务 A 第二次 SELECT 时,RR 级别不会生成新的 ReadView,而是复用 T1 时生成的 ReadView(m_ids = [301])。
当前版本 DB_TRX_ID=301(事务 B 改的),检查可见性:301 在 m_ids 中 → ❌ 不可见!
于是顺着 DB_ROLL_PTR 找旧版本:balance=1000, DB_TRX_ID=100。100 < min_trx_id(301) → ✅ 可见!
结果:事务 A 两次 SELECT 都读到 1000——"可重复读"达成。
MVCC + 间隙锁:幻读的终极解法
你可能注意到了,MVCC 解决了不可重复读(同一事务内两次读同一行结果不同),但还有一个更棘手的问题——幻读(Phantom Read):同一事务内两次执行 SELECT ... WHERE balance > 800,第二次多出了一行。
RR 级别下,MVCC 本身并不能完全解决幻读。它解决的是"已有行的版本可见性"问题,但如果另一个事务新插入了一行,这行在你的版本链上根本不存在——你用 ReadView 也找不到它。
那 InnoDB 是怎么解决幻读的?靠间隙锁(Gap Lock)+ MVCC 的组合拳。
间隙锁的工作原理:
当事务 A 执行 SELECT ... FOR UPDATE 或者 UPDATE/DELETE 操作时,InnoDB 不仅会给符合条件的行加行锁(Record Lock),还会给行与行之间的"间隙"加间隙锁(Gap Lock)。
间隙锁不锁具体的行,而是锁住一个开区间。比如 balance=500 和 balance=1000 之间的间隙锁,会阻止其他事务在这个范围内插入任何新行。
这样,其他事务既无法修改已有行(行锁),也无法在间隙中插入新行(间隙锁),幻读就被彻底杜绝了。
但要注意一个细节:快照读和当前读的区别。
快照读用的是 MVCC 的版本链机制,不加锁,读的是 ReadView 中可见的版本。 当前读则是直接读最新版本,并且加锁(行锁 + 间隙锁),阻止其他事务修改。
幻读的完整解决方案就是:
- 快照读:MVCC + ReadView 保证可重复读
- 当前读:行锁 + 间隙锁阻止其他事务插入新行
💡 记住这一句就够了: MVCC 解决的是"读哪个版本"的问题,间隙锁解决的是"别人能不能插新行"的问题。两者配合,才让 RR 级别真正做到了可重复读和无幻读。
undo log 的生命周期与 purge 线程
undo log 不是永远保留的。InnoDB 有一个后台线程叫 purge 线程,专门负责清理"没人需要的"旧版本。
什么时候一个旧版本可以被清理?当没有任何活跃的 ReadView 需要它的时候。
也就是说,如果所有正在运行的事务的 ReadView 都不需要看到某个旧版本,那这个旧版本就可以安全删除了。
这就是为什么长事务是大敌——如果一个事务开了很久不提交,它的 ReadView 就会一直存在,导致大量的 undo log 无法被清理,占用越来越多的磁盘空间,甚至导致整个表空间膨胀。
💡 记住这一句就够了: undo log 的旧版本由 purge 线程清理,但前提是没有活跃的 ReadView 需要它。长事务是 undo log 膨胀的罪魁祸首,生产环境必须严控事务执行时长。
实用技巧:监控 undo log 使用量
在生产环境中,你可以通过以下 SQL 来监控 undo log 的使用情况:
-- 查看 undo tablespace 使用量
SELECT tablespace_name, file_name, status,
engine => 'InnoDB'
FROM information_schema.innodb_tablespaces
WHERE name LIKE 'innodb_undo%';
-- 查看当前运行时间最长的事务(长事务嫌疑人)
SELECT trx_id, trx_state, trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration_seconds
FROM information_schema.innodb_trx
ORDER BY trx_started ASC;
-- 查看 undo log 的历史版本链长度(8.0+)
SHOW ENGINE INNODB STATUS\G
-- 找到 "TRANSACTIONS" 段落中的 "History list length"
History list length 是 undo log 中未清理的版本数量。正常情况下应该在几百以内,如果超过几万甚至几十万,说明有长事务在阻塞 purge 线程,需要立即排查。
MySQL 8.0 还引入了 undo tablespace 的自动 truncate 功能(默认开启),当 undo tablespace 超过阈值时,InnoDB 会自动 truncate 释放空间。但这仍然依赖于 purge 线程能正常工作——如果长事务卡住了 purge,truncate 也无济于事。
本章小结
你学到了什么
| # | 知识点 | 一句话总结 |
|---|---|---|
| 1 | 隐藏列 | 每行数据有 DB_TRX_ID(谁改的)和 DB_ROLL_PTR(旧版本在哪) |
| 2 | undo log 版本链 | 每次修改不覆盖旧值,而是把旧值存到 undo log,用 ROLL_PTR 串成链表 |
| 3 | ReadView | 事务执行 SELECT 时拍的"快照",记录活跃事务列表 |
| 4 | 可见性判断 | 已提交的可见、未提交的不可见、未来的不可见 |
| 5 | RC vs RR | RC 每次 SELECT 新建 ReadView;RR 整个事务复用同一个 ReadView |
| 6 | 快照读 vs 当前读 | 快照读用 MVCC 不加锁;当前读读最新版本 + 加锁 |
| 7 | 幻读解决 | MVCC 解决版本可见性 + 间隙锁阻止插入新行 |
| 8 | purge 线程 | 清理没人需要的旧 undo log,长事务会阻止清理 |
MVCC 整体架构一览

🎯 面试必问 TOP 5
Q1:什么是 MVCC?它的作用是什么?
MVCC(多版本并发控制)是 InnoDB 实现高并发读写的核心技术。它通过 undo log 版本链 + ReadView,让读操作不加锁就能读到一致性快照,实现"读写不冲突",大幅提升并发性能。
Q2:InnoDB 的隐藏列有哪些?各自的作用是什么?
三个隐藏列:DB_TRX_ID 记录最后修改该行的事务 ID;DB_ROLL_PTR 是回滚指针,指向 undo log 中的旧版本,构成版本链;DB_ROW_ID 是隐藏主键,仅在表没有定义主键时存在。MVCC 主要依赖前两个。
Q3:RC 和 RR 隔离级别下 MVCC 有什么区别?
可见性判断算法完全相同,区别在于 ReadView 的生成时机:RC 级别每次执行 SELECT 都会生成新的 ReadView,所以能读到其他事务已提交的最新修改;RR 级别只在事务第一次 SELECT 时生成 ReadView,后续复用,所以整个事务内读到的始终是一致性快照。
Q4:MVCC 能解决幻读吗?
MVCC 本身只能部分解决幻读。对于快照读(普通 SELECT),MVCC 通过 ReadView 保证可重复读;但对于当前读(FOR UPDATE / LOCK IN SHARE MODE / UPDATE / DELETE),MVCC 不加锁,需要配合间隙锁(Gap Lock)来阻止其他事务在间隙中插入新行,从而彻底防止幻读。
Q5:长事务对 MVCC 有什么影响?
长事务会导致其 ReadView 长时间存在,purge 线程无法清理该 ReadView 之后的 undo log 旧版本,造成 undo log 膨胀、磁盘空间浪费、版本链变长导致查询变慢。生产环境应严格监控和控制事务执行时长(建议单事务不超过 500ms)。
📌 下一章预告: [第 27 章:Redo Log 与 Undo Log——MySQL 的"后悔药"和"存档点"] 我们会看看 MySQL 是怎么做到"即使机器突然断电,数据也不丢"的。