先交代一个背景,我最近面了一家电商平台企业,技术面第一轮,面试官坐下之后几乎没有寒暄,上来就是一句:“你讲讲 MVCC 的原理吧。”我当时心里“咯噔”一下,不是因为不会,而是这个问题太像八股了。如果只背“MVCC 就是多版本并发控制,解决读写冲突”,那基本就凉了。面试官要听的绝不是这个。他要听的是版本链怎么组织、ReadView 怎么判断可见性、RR 和 RC 在 MVCC 上到底有什么差别,甚至还要你结合一条具体的 SQL 推演一遍。
这篇文章就基于那场面试,把“MVCC 原理”从底到上完整拆一遍。我不打算给你速背口诀,而是把它讲成一套能推导的逻辑。懂了这套逻辑,不管面试官怎么换着问,你都能顺下来。
1. 开场:第一道题就把我按在板凳上
说实话,数据库并发控制里,MVCC 算是“既高频又容易讲浅”的知识点。很多人能说出“通过多版本解决读写冲突”,但后来被问到“版本存放在哪里”“怎么判断哪个版本对当前事务可见”“为什么 RR 不会出现不可重复读”时,当场卡壳。
那天的面试官就踩着这个套路走。他先问“MVCC 解决了什么问题”,我答“读写不阻塞”。他接着问“读和写分别是读什么、写什么”,我答“读是快照读,写是当前读”。他点头,然后立刻追问:“那这个快照是怎么构建的?ReadView 什么时候生成?”
这里其实已经脱离了背题,进入原理验证阶段。为了不再写“面试官问完就没了下文”的流水账,我把那次对话里涉及的技术点,重新整理成一套完整的脉络。先讲底层结构,再讲判断逻辑,最后讲隔离级别如何驱动不同的可见性行为。这套顺序,也是你在面试中“被问下去”时最安全的展开路径。
1.1 面试官不是要你背“多版本”
MVCC 里的“版本”是有物理载体的。在 MySQL InnoDB 引擎里,一行数据上除了业务字段,还藏着几个对用户不可见的字段:事务 ID、回滚指针,以及可能用到的隐藏主键。每次事务修改记录之前,先把旧值写到 undo log,再通过回滚指针把它串成一条链条。
所以你随便一搜“MVCC”,搜出来的版本链示意图,本质上就是“当前记录 + undo log 里的旧版本”。这个链条不是为查询历史数据准备的,而是为了在某个事务读取时,能沿着指针找到一个“它应该看见的版本”。
1.2 理解 MVCC 的三个关键词
真正要理解 MVCC,需要抓住三块东西:隐藏字段、undo log、ReadView。隐藏字段提供了定位版本的能力,undo log 承载了历史版本的数据,ReadView 决定了“哪些版本可见、哪些版本不可见”。
很多讲解把它们割裂开讲,导致背完规则却不知道数据从哪来。其实你可以把 MVCC 看作一套“时间旅行”机制:每条记录都有修改时间线,ReadView 就是当前事务的“时间旅行滤镜”,滤镜只允许它看到那个时刻已经尘埃落定的版本。脏读、不可重复读这些异常,本质上都是“滤镜”太宽松造成的。
2. 先把版本链和隐藏字段拼出来
MVCC 的第一步,是理解 InnoDB 中一行记录到底长什么样。我经常跟人开玩笑,你以为存进表里的只有字段,其实引擎在暗地里给记录“贴了标签”。
2.1 一行数据里不只有业务字段
以这张表举例:
CREATE TABLE `account` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `balance` DECIMAL(10,2) ) ENGINE=InnoDB;当有一条记录(id=1, balance=100)时,实际在 InnoDB 内部,它至少还包含三个隐藏字段:
| 隐藏字段 | 含义 |
|---|---|
| DB_TRX_ID | 最近一次修改(插入或更新)这条记录的事务 ID |
| DB_ROLL_PTR | 回滚指针,指向当前版本在 undo log 中的上一个版本位置 |
| DB_ROW_ID | 如果没有定义主键,InnoDB 会生成一个隐藏的自增主键,用于聚簇索引定位 |
其中前两个字段是 MVCC 的核心。你可以这样理解:每一条记录身上都刻着“最后一次动我的人是谁”(事务 ID)和“我上一个版本在哪里”(回滚指针)。插入操作会生成一个初始版本,版本链长度为 1;更新操作会在链头加一个新版本,旧版本继续留在 undo log 里。
2.2 一次 UPDATE 在背后做了什么
假设事务 A(事务 ID 为 100)执行UPDATE account SET balance = 200 WHERE id = 1;,InnoDB 的流程并不是直接覆盖:
- 给记录加排他锁。
- 将修改前的
(id=1, balance=100)连同事务信息写入 undo log。 - 更新内存中的数据行,将
balance改为 200,同时把这一行的DB_TRX_ID改成 100,DB_ROLL_PTR指向刚才写入 undo log 的那个旧版本。
于是内存中当前版本是(200, trx_id=100, roll_ptr->旧版本),undo log 里躺着旧版本(100, trx_id=上一次事务id, roll_ptr->更早版本)。这条记录在逻辑上形成了一条从新到旧的链表:最新版本 → undo 版本1 → undo 版本2……
这也是为什么说“多版本”并不是把多份数据都放在数据页里,而是“数据页只放最新值,历史值全部依赖 undo log”。搞清楚这一点,面试时再被问“版本链在哪里”,你就知道答案不是“在数据表”,而是“最新值在聚簇索引,历史值在 undo log”。
2.3 版本链解决了读写不互锁的问题
读写互斥的痛点在哪里?如果写操作直接覆盖数据,那正在执行读操作的事务就会看到变化中的值,读的原子性就无法保证。一个很朴素的办法是读操作加共享锁,写操作加排他锁,但这样读和写就完全串行化,高并发性能无从谈起。
MVCC 的思路是“写的时候不覆盖旧版本,而是新增版本”。读操作如果不需要最新值,就去选择版本链上某个对它可见的旧版本,这样读和写操作在物理上都不需要互相等待。写操作持有锁只锁住“最新版本”本身;快照读走 undo log,完全绕开锁。这句话就是 MVCC 解决“读写阻塞”的本质。
3. ReadView 是可见性判断的“裁判”
版本链摆在那里,链路上一大串版本,哪一个是当前事务应该看到的?这就轮到 ReadView 登场。很多面试题喜欢只让背 ReadView 的字段和规则,其实完全可以当成一个“资格判定问题”来推。
3.1 ReadView 里都有什么
ReadView 是 InnoDB 在事务执行快照读时生成的一份“快照视图”,里面记录了生成时刻所有未提交事务的集合。它的核心字段包括:
creator_trx_id:创建这个 ReadView 的事务 ID,也就是“当前事务自己”。m_ids:生成 ReadView 时,系统中所有仍处于活跃状态(未提交)的事务 ID 列表。min_trx_id:m_ids中最小的那个事务 ID。max_trx_id:生成 ReadView 时,系统为下一个事务准备的事务 ID,一般来说是“最大事务 ID + 1”。
你可以把 ReadView 想象成一个班里的学生名单。min_trx_id是学号最小编号,max_trx_id是下一位新生的预设编号,m_ids是当前没有交卷的考生列表,creator_trx_id则是你本人。
3.2 判断规则不是死记硬背,是比大小
当某个事务读取一条记录时,它会拿出记录上的DB_TRX_ID,把这个事务 ID 和 ReadView 做四步比较。
| 条件 | 判定结果 |
|---|---|
DB_TRX_ID == creator_trx_id | 可见。这是自己修改的版本 |
DB_TRX_ID < min_trx_id | 可见。这个事务已提交,且早于所有活跃事务 |
DB_TRX_ID >= max_trx_id | 不可见。这个事务是在 ReadView 生成之后才开始的,还没提交 |
DB_TRX_ID在m_ids列表中 | 不可见。这个事务在 ReadView 生成时仍活跃,没有提交 |
DB_TRX_ID不在m_ids中,且大于min_trx_id、小于max_trx_id | 可见。事务在生成快照前就已提交 |
看到没有,规则的核心就是“我是否可以确定这个版本对应的修改者已经尘埃落定”。如果修改者已经被标记为提交,而且它在我的快照之前就已经完成,那么我就看得到;如果它还没提交,或者是在我快照之后才开始的新事务,那对不起,我看不到。
3.3 一条具体记录的可见性推导
光说规则太抽象,我现场推一遍。
假设事务 A 的事务 ID 为 20,事务 B 的事务 ID 为 21。初始数据balance=100的版本事务 ID 为 10,已提交。
执行顺序如下:
- 事务 A 开启,但还没操作,此时它执行第一次快照读,生成 ReadView。当前没有其他活跃事务,所以
m_ids = [],min_trx_id无数值或空集,max_trx_id为 22(下一个分配的事务 ID)。 - 事务 B 开启,执行
UPDATE account SET balance=200 WHERE id=1;。此时版本链为:最新版本trx_id=21,旧版本trx_id=10。 - 事务 B 尚未提交。事务 A 再次执行
SELECT * FROM account WHERE id=1;,此时它继续使用之前生成的 ReadView 判断:最新版本的DB_TRX_ID=21不等于 creator(20),也不小于 min(21 不小于),所以不看它,沿着回滚指针找到trx_id=10的旧版本。10 小于 min_trx_id,可见。事务 A 读到100。 - 事务 B 提交后,事务 A 再次执行同一查询,因为 RR 隔离级别下它还是用同一个 ReadView,判断逻辑不变,依然读到
100。这就是可重复读。
但如果换成 RC 隔离级别,第 3 步会重新生成一个新的 ReadView,此时事务 B 已提交,m_ids不再包含 21,那么最新版本trx_id=21落在 m_ids 之外且小于 max,可见。因此事务 A 会读到200,从而造成不可重复读。
这个例子你如果能自己在纸上画一遍,面试时基本就稳了。
4. 隔离级别怎么“指挥” ReadView
ReadView 的判断规则是固定的,真正让隔离级别产生差异的,是 ReadView 的生成时机和复用策略。
4.1 两个隔离级别下 ReadView 的生命周期
在 MySQL InnoDB 中:
- READ COMMITTED(RC):每次执行快照读都会生成一个新的 ReadView。也就是说,一个事务里,第一次 SELECT 用一个快照,第二次 SELECT 再生成一个新的快照。所以这个隔离级别能避免脏读,但不能避免不可重复读。
- REPEATABLE READ(RR):只在事务第一次执行快照读时生成 ReadView,之后整个事务内都复用这一个 ReadView。后面的所有查询都用同一把“时间尺子”去衡量版本,所以不会出现不可重复读。
| 对比项 | READ COMMITTED | REPEATABLE READ |
|---|---|---|
| ReadView 生成时机 | 每条 SQL 执行前都新建 | 事务中第一条快照读语句执行时生成 |
| 同一个事务两次 SELECT 是否一致 | 可能不一致 | 一定一致 |
| 能否避免脏读 | 能 | 能 |
| 能否避免不可重复读 | 不能 | 能 |
这里要注意一个细节:快照读本身并不主动加锁,它在生成 ReadView 时就“冻结”了当时的事务状态。RC 之所以每次 SQL 都要重新冻结,是因为业务上往往需要看到“最近已提交”的数据;RR 之所以只冻结一次,是为了提供一个稳定的、可重复的读视图。两者的取舍就藏在 ReadView 的“冻结频率”里。
4.2 实例模拟:脏读和不可重复读是如何被挡住的
脏读场景:事务 A 修改一行数据但未提交,事务 B 去读。假设使用 RC 或 RR,事务 B 在生成 ReadView 时,发现这行最新版本的DB_TRX_ID仍在该快照活跃事务列表中,它读不到最新值,于是沿版本链找上一次已提交版本。因此脏读被挡住。
不可重复读场景:事务 A 读取某值后,事务 B 修改并提交,事务 A 再读。如果使用 RR,因为 ReadView 没变,事务 A 依然认为事务 B 的修改“不可见”,自然重复读到旧值。如果使用 RC,事务 A 第二次 SELECT 会生成新 ReadView,事务 B 已经提交,因此新修改变得可见,从而读到新值。
想深入理解的话,可以自己建表开两个 MySQL 会话,分别执行SELECT和UPDATE,观察 RC 与 RR 的结果差异。眼见为实,比死记结论牢靠得多。
4.3 RR 还有幻读?MVCC + 当前读的边界
好多人背过“RR 解决不可重复读,但可能幻读”,可面试官一追问“为什么 MVCC 能防幻读又不能防幻读”就懵了。
其实 MVCC 主要解决的是快照读场景下的幻读。在 RR 隔离级别下,事务第一次 SELECT 生成 ReadView,后续相同条件的快照查询结果保持一致,因此自动获得了一致的快照,也就看不到后来插入的新行。从这个角度说,MVCC 消除了快照读的幻读。
但是,如果业务里执行的是当前读,比如SELECT ... FOR UPDATE、UPDATE、DELETE,这些语句必须读取最新版本,MVCC 的版本链帮不上忙,InnoDB 就用了另一种武器:next-key lock(记录锁 + 间隙锁的组合)。它会锁住查询范围里的已有记录,也锁住范围内的“空隙”,防止在加锁期间新记录插入。所以 InnoDB 在 RR 下能真正防住当前读幻读。
面试的加分点是你要主动提到:MVCC 对付的是一致性快照读,next-key lock 对付的是当前读。两者合起来,才能覆盖 RR 隔离级别下的一致性。
5. 面试追问链:从原理到场景
除了上面这些主脉络,面试官还会从各种角度追问,试图考察你到底有没有真正理解。我把那天被追问的几类问题整理一下。
5.1 删除操作和更新操作本质相同
有面试官会问:“删除了的记录在版本链里怎么体现?”很多人以为删除是直接在物理层面抹掉记录,其实不是。
在 InnoDB 中,删除操作更像是“打上标记的更新”。它会先写一条 undo log,把记录标记为已删除,同时将该记录的DB_TRX_ID更新为删除事务的 ID。这个删除版本照样在版本链上。之后某个事务如果通过 MVCC 判断该删除事务不可见,就会沿回滚指针找到更早的版本,在它眼中记录依然存在。只有当删除事务已经提交,而且没有其他需要引用该版本的事务时,后台清理线程才会真正执行物理删除。
这就是为什么在一个 RR 事务里,先删除某行再查询,数据仍然可能是可见的,因为判断依据不是物理状态,而是“删除事务是否在当前快照可见”。
5.2 当前读和快照读的区分再解释
再强调一遍,“读”被拆成两类:
- 快照读:普通的
SELECT,不加 FOR UPDATE / LOCK IN SHARE MODE。它读的是版本链上符合 ReadView 的版本,不加锁。 - 当前读:
SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE。它读的是记录的最新版本,并且要对读到的记录加锁。
MVCC 只管快照读。当前读不用版本链,它直接用锁定机制保证并发安全。面试时如果能把两种读拆分清楚,再结合场景说“当前读为什么不能用 ReadView 判断”,就显示你已经把 InnoDB 并发控制的整体框架装进脑子里了。
5.3 “版本号”为什么用事务 ID
还有一个很容易被忽略的问题:为什么可见性判断用事务 ID 而不是时间戳?其实事务 ID 在 InnoDB 里可以看作一种“逻辑时间戳”。它单调递增,创建 ReadView 时记录活跃事务集合,其实就是记录该时刻的“事务时间截面”。
对比物理时间戳,事务 ID 有个天然优势:它严格遵循事务提交顺序。事务可能长时间不提交,物理时间早的事务未必先提交成功。如果按物理时间判断可见性,两个事务交错提交时结果会产生错乱。而事务 ID 配合活跃列表,可以从逻辑上保证“提交顺序与 ID 顺序一致的前提下,只有已提交事务才可见”,这一判断是确定性且可复现的。
6. 我自己准备 MVCC 的三个笨办法
最后分享一点个人的备考经验。我能把 MVCC 讲得比较透,靠的其实不是读更多原理文章,而是三个笨办法。
第一个办法,画版本链。我找了一张只有 few 条记录的表,手动模拟 3 个事务并发操作,把每次更新后记录上的DB_TRX_ID、DB_ROLL_PTR以及 undo log 中每个版本的 ID 全部写出来。画上几遍后,你对“多版本到底多在哪里”就有了不可磨灭的记忆。
第二个办法,手工推 ReadView。我给自己出题:假设事务 ID 1 到 8 按某种顺序开启、提交、修改同一行,在某个时刻事务 6 执行 SELECT,判断它会看到哪个版本,并写出判断过程。这比背规则有效得多,因为每推一次,就是重新理解一遍规则背后的排序思想。
第三个办法,结合隔离级别做实验。在本地起一个 MySQL,开两个会话,把默认隔离级别分别切成 RC 和 RR,反复执行“A 读,B 改,A 再读”的脚本。结论不需要背诵,实验看得多了自然就内化了。
面试那天被问到“起手 MVCC”,我顺着版本链、ReadView、隔离级别这条线一口气讲完,面试官后续才把问题转到了 next-key lock 和实际业务里的死锁排查上。我后来复盘发现,真正有用的不是我把概念背得多熟,而是我能在脑内快速画出一条事务生命周期的时间线,再用这条时间线去解释所有并发问题。这个思路,才是 MVCC 问不倒的本质。