MVCC为什么不能完全解决幻读问题,这个问题我在面试里被问过很多次,也被刚入行的同事问过很多次。说实话大部分人能答出“MVCC解决快照读的幻读,但解决不了当前读的幻读”,这句话作为结论是对的,但如果只背到这里,等于没真正理解。因为这句话背后牵扯的是版本链、ReadView、可见性判断、当前读与快照读的语义差异、next-key lock 的加锁边界、insert intention lock 的互斥规则,再加上 InnoDB 事务模型的一堆历史设计包袱。这篇文章我不想按教科书顺序讲一遍,而是想按我实际排查线上问题时的思路,把这个坑从原理到案例完整拆开,讲清楚它到底漏在哪里。
1. 先搞清楚 MVCC 到底解决了什么
1.1 多版本并发控制的核心逻辑
MVCC 全称 Multi-Version Concurrency Control,中文叫多版本并发控制,是 InnoDB 在实现事务隔离时最核心的机制。它的基本思路一句话就能说完:数据行不只有一个版本,每个版本都有属于自己的事务信息和回滚段指针,读操作通过判断事务快照来决定能看见哪个版本,写操作通过生成新版本去覆盖旧版本而不直接阻塞读。这个思路说白了就是把时间轴摊开,让读写各看各的版本,减少相互等待。
举个例子,你打开了一个事务,执行了第一条 SELECT,这时候事务拿到一个一致性快照,也就是 ReadView。后续事务对同一行数据的修改,在你这个事务的快照视角里是看不见的。你不需要等它们提交,它们也不需要等你读完,大家并行推进。这就是 MVCC 给 InnoDB 带来的最大好处,也是为什么 InnoDB 默认隔离级别能相对激进地使用 REPEATABLE READ(可重复读)却不至于出现严重的读写阻塞。
数据库里每行记录除了用户数据本身,还有几个隐藏字段。常见的有隐藏主键 DB_ROW_ID、事务 ID 字段 DB_TRX_ID、回滚指针 DB_ROLL_PTR。当一个事务修改某一行时,并不会直接就地覆盖旧值,而是先在 undo log 里记录旧版本,然后生成一个新版本行,把 DB_ROLL_PTR 指向旧版本,把 DB_TRX_ID 更新为自己的事务 ID。这一串版本通过回滚指针串成了一条版本链,最新版本在最上面,最老的版本在链尾。ReadView 在做可见性判断时,就是从这条链上顺着找,找到第一个满足可见性条件的版本返回。
1.2 ReadView 是怎么决定“谁可见”的
ReadView 不是我们平时说的数据库视图,它是事务创建快照时记录的一组信息,核心内容包括创建快照时系统正在活跃的未提交事务集合(m_ids)、这些事务中最小的事务 ID(up_limit_id)、系统已分配事务 ID 中的最大值加一(low_limit_id),以及创建这个 ReadView 的事务自己的 ID(creator_trx_id)。
判断某个版本的 DB_TRX_ID 是否可见时,规则是这样的:如果 DB_TRX_ID 小于 up_limit_id,说明该版本对应的事务已经提交,可见;如果 DB_TRX_ID 大于等于 low_limit_id,说明这个版本是 ReadView 创建之后才产生的事务修改的,不可见;如果 DB_TRX_ID 落在 m_ids 集合里,说明它仍然活跃未提交,不可见;如果不在 m_ids 里但值介于 up_limit_id 和 low_limit_id 之间,说明事务已提交,可见;如果 DB_TRX_ID 等于 creator_trx_id,说明就是自己改的,可见。这套规则我在理解时用的是一个人事档案的类比:你在某个时间点翻员工名册,名册上写明了“这批人还在职、那批人已经离职”,那么在职的人做的事你暂时当没看见,离职的人做的事一笔勾销,新入职的人做的事你也看不见,因为名册在那一刻根本没有他们的名字。
InnoDB 的快照读就是普通 SELECT,不加 FOR UPDATE、不加 LOCK IN SHARE MODE、不在 DELETE/UPDATE 语句里的读操作。在这种读取方式下,ReadView 固定了数据的可见边界,事务内后续所有快照读都基于这个边界,所以事务内多次 SELECT 同一范围内的数据,结果是一致的。这就是可重复读的根基,也是 MVCC 能挡住“快照读视角下幻读”的底层原因。
2. 幻读为什么比不可重复读更难缠
2.1 不可重复读和幻读的本质区别
很多初学者容易把不可重复读和幻读混在一起,其实两者的破坏点是不同的。不可重复读针对的是同一行,是说在一个事务里两次读同一行数据,结果不一致。原因一般是另一个事务在这期间修改了这行,比如把金额从 100 改成了 50。幻读针对的不是某一行,而是某个范围,是说在一个事务里两次用同样的条件查询,结果返回的行集合不一样,多了一行或者少了一行。多出来的那行不是被修改的,是凭空插入的。
用生活化的例子说,你在宿舍清点自己的东西,第一次数了一遍,发现桌上有三本书。过了一会儿你又数了一遍,发现桌上变成四本书,多出来的那本不是你原来有的任何一本,而是别人新放进去的。不可重复读相当于第一遍看见的是《数据库原理》,第二遍看见它变成了《操作系统》,书还是那本书,内容变了。幻读相当于第一遍桌上没有《计算机网络》,第二遍它出现了,数量变了。MVCC 能很好地解决内容变了这种问题,因为它锁定的是版本视角,但对数量变了这种问题,它依赖的是另一套机制,也就是加锁。
2.2 不可重复读能用锁解决,幻读不能只靠行锁
不可重复读,最简单的解法是给那一行加锁,事务 A 读的时候锁住行,事务 B 就不能改它。就算用 MVCC,只要 ReadView 不变,同一行读取结果也稳定。但幻读涉及的是“范围”,范围内可能根本没有行,比如你查询工资大于 5000 的员工,如果现在没有一个人工资大于 5000,那么“范围内一行都没有”,这时行锁根本无从谈起。行锁只能锁已存在的行,锁不住“未来可能插入的行”。
这就是问题的核心:要防止幻读,数据库得锁住一个范围,而不仅仅是几行数据。范围空洞是行锁管不到的,必须有一种“锁住空白区间”的机制。MySQL 的 InnoDB 在 REPEATABLE READ 下会使用间隙锁和 next-key lock 来处理这一点。但这里有个被很多人忽略的关键事实,MVCC 的 ReadView 只对快照读生效,而间隙锁只对当前读生效。两种机制作用在不同场景,一旦事务里既有快照读又有当前读,幻读就找到了突破口。
3. 当前读才是幻读真正冲垮的防线
3.1 快照读与当前读的语义差异
快照读读的是历史版本,不会加锁,也不会被阻塞。当前读读的是最新版本,并且会对扫描到的记录加锁。SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE,这些都是当前读。为什么 InnoDB 要把更新类操作设计成当前读?原因很简单:你要更新数据,必须基于最新值来算,如果基于一个旧的快照去更新,那改出来的结果会覆盖掉别人已经提交的最新修改,产生更新丢失。所以更新时读到的必须是当前已提交的最新版本。
在 REPEATABLE READ 级别下,普通 SELECT 的快照一旦生成,后续 SELECT 都是基于同一个 ReadView,所以看不出数据变化。但如果你在执行快照读之后,又执行了一条 UPDATE 或者 SELECT ... FOR UPDATE,这条当前读会绕过 ReadView,直接读最新已提交版本。问题就在这时出现了:当前读可能会发现范围内多了一行之前快照读时不存在的数据,然后把这行纳入更新范围。站在事务内的视角看,第一次查询没有这行,第二次却在不知情的情况下处理了这行,这就是幻读的原始定义。
3.2 next-key lock 的加锁边界和它的名字
要理解 InnoDB 怎么尝试解决当前读的幻读,就得理解 next-key lock。它本质上是一个记录锁加它前面那个间隙锁的合体,锁定的范围是左开右闭的区间。比如表里有 id 为 5、10、15 三行,执行 SELECT * FROM t WHERE id > 8 FOR UPDATE,InnoDB 扫描时会对 id=10 和 id=15 加 next-key lock,对应的区间分别是 (5,10] 和 (10,15]。间隙锁保护的是 (8,10) 这一段原本没有记录的空隙,记录锁保护的是 10 和 15 这两条已存在的记录。任何其他事务想在 (5,10) 或 (10,15) 之间插入数据,都得等这个锁释放,因为这属于插入意向锁与间隙锁互斥的规则。
所以单看这一条 SQL,它确实把一个范围内既能读到的行锁住了,又把能插入的间隙也锁住了。这就是为什么 MySQL 官方说 REPEATABLE READ 下用 next-key lock 能防止幻读。但这句话有一个隐含前提:这个事务从头到尾都只用当前读,或者从一开始就把范围内所有间隙锁拿全了。如果事务采用的是快照读和当前读混合模式,防线就会出现裂缝。
3.3 RR 级别下“UPDATE 触发当前读”的经典翻车场景
我见过很多项目出现线上问题,最后定位到都是同一个套路。这里用一个具体场景说明。假设表 t 里初始数据如下:
| id | name | age |
|---|---|---|
| 1 | 张三 | 20 |
| 2 | 李四 | 25 |
事务 A 先执行普通 SELECT,这时生成快照 ReadView,看到两行数据。事务 B 此时插入一条 id=3、age=30 的新记录并提交。事务 A 再次执行普通 SELECT,由于 ReadView 没变,它依然只看到两行,这是快照读的稳定表现,没有问题。但接下来事务 A 执行一条 UPDATE,条件是 age > 28。执行计划扫描时走的是当前读,它会发现 id=3 这一行已经提交了,于是把它包含进更新范围。更新后,事务 A 再执行普通 SELECT 查询 age > 28,结果发现现在能看到三行,其中就包括之前快照里根本不存在的 id=3。同一次事务,同一个 WHERE 条件,两次查询的结果集合不同,幻读发生了。
这个场景最关键的一点是,MVCC 的 ReadView 在事务开始时已经固定,但当前读的可见性判断不读 ReadView,而是读“当前已提交的最新版本”。这两种可见性规则在一个事务里并存时,就会产生“先看不见、后处理了、最后又看见了”的矛盾结果。
4. 案例复现:RR 隔离级别下幻读依然会出现
4.1 案例一:唯一索引冲突导致的幻读
稍微有点经验的人可能会说,RR 下不是有间隙锁吗,插入应该被卡住才对。但间隙锁只能挡住“范围内”的插入。如果一个插入操作因为唯一索引冲突绕过了间隙锁,那它的效果等同于是“穿过防线”的。我们来模拟一个场景。
假设表 user 有唯一索引 uni_name,当前有一条记录 name='lisi'。事务 A 开启后执行了 SELECT * FROM user WHERE name='lisi',拿到了这条记录。此时事务 B 尝试插入 name='zhangsan',照理说这个插入如果不属于 A 锁定的间隙,就不受阻塞,直接成功。但如果 A 的 SELECT 是当前读(比如 FOR UPDATE),它锁住了 lisi 这一行以及周边的间隙,那 B 插入 zhangsan 到该间隙时就会阻塞。反过来,如果 A 只是快照读,B 的插入如果落在没有被锁定的间隙,就能成功提交。然后 A 执行一次当前读,比如 UPDATE user SET age=18 WHERE name='zhangsan',因为 zhangsan 这行是刚插入的,不在 A 的快照里,但 A 的 UPDATE 却能把它的 age 改掉。A 再执行普通 SELECT,name='zhangsan' 的记录出现了。整个过程中,A 第一次查询的 WHERE 条件和最后一次查询的完全相同,但结果范围悄悄扩大了一行。
还有一种更隐蔽的情况,事务 A 执行 SELECT ... FOR UPDATE WHERE id > 100,范围内没有记录,所以间隙锁锁住了 (100, +∞) 的区间。但事务 B 插入一条 id 很大的记录时会阻塞。然而如果事务 B 插入一条 id 本来就存在、只是被 A 锁住间隙覆盖的记录,同时这个 id 又走唯一索引,那么 InnoDB 在检查唯一性冲突时一定需要读取当前已提交版本,这个读操作并不受 A 锁的影响,因为唯一键检查需要确认最新状态。实际效果就是,B 的插入在某些路径下可能先检查冲突然后等待锁,等待过程中如果 A 回滚释放锁,B 插入成功,A 后续再次查询就能看到这条“凭空出现”的记录。
4.2 案例二:半一致性读和批量更新的不一致性
MySQL 5.6 开始在 UPDATE 中引入半一致性读,意思是说,如果在执行 UPDATE 时,InnoDB 发现某些记录被其他事务加锁,在这些记录满足 WHERE 条件的情况下,会提前返回记录的主键值,而不是一直等待。这本来是为了减少 UPDATE 时的锁等待和死锁,但它在某种程度上也让快照读和当前读的语义更加混在一起。
举一个批量更新场景,事务 A 开启事务,先执行 SELECT COUNT() FROM orders WHERE status='pending',得到结果是 10。事务 B 插入一条新的 pending 订单并提交。事务 A 执行 UPDATE orders SET status='done' WHERE status='pending',在这个 UPDATE 里因为走当前读,加上半一致性读的优化,A 可能把 B 插入的那一条新订单也一并更新成 done。事务 A 本来以为自己在处理 10 条订单,实际却处理了 11 条。更麻烦的是,A 再执行 SELECT COUNT() FROM orders WHERE status='pending',结果可能是 0,因为那 11 条都已经变成 done 了。要是 A 事务中途发生了回滚,已提交的 B 插入不会回滚,但 A 对它的更新也会跟着回滚,业务逻辑就出现了很难追踪的分叉。
这个案例说明,在当前读场景下,MVCC 根本没有办法把读到的数据集合维持在快照范围之内,因为它的设计目标就不是干这个的。
4.3 案例三:间隙锁之间的“经验之外”路径
还有一种常见的认知偏差,是以为加了间隙锁以后,只要其他事务往范围里插入数据,一定会被阻塞。这不完全对。间隙锁虽然是左开右闭区间,但两个间隙锁在特定情况下可以共存。事务 A 对区间 (10,15] 加锁,事务 B 同样可以对区间 (10,15] 加锁,两个都是间隙锁时它们互相兼容,因为间隙锁的语义是“防止别的事务在这个间隙插入”,但允许别的事务也在同一间隙加锁。真正会让 B 停止的是插入意向锁,插入意向锁会与间隙锁冲突。所以如果事务 A 锁住范围 (10,15),B 想往 12 这个位置插入,B 必须先获取插入意向锁,而这个插入意向锁和 A 的间隙锁互斥,B 阻塞。只要 A 不提交,B 永远插不进去。
但是,如果事务 B 插入的位置不在 A 的间隙覆盖范围里,比如 A 锁住的是 id 小于 10 的范围,B 插入 id=20,那 B 完全不需要等 A,直接成功。A 后续再做当前读扫描,范围如果包含 id=20,就会看到这一条多出来的记录。所以“锁住了某些间隙”不等于“锁住了所有可能成为幻读来源的间隙”,这是所有想在 RR 下完全规避幻读的人都必须记住的一句话。
5. InnoDB 的补救手段和它的实际边界
5.1 间隙锁、插入意向锁的协作机制
InnoDB 为了尽量降低当前读下的幻读概率,设计了一整套锁协作体系。行锁之外,还有间隙锁(Gap Lock)和插入意向锁(Insert Intention Lock)。插入意向锁很有意思,它名字里带“意向”,本质上它是一种特殊的间隙锁,但它的目的是告诉其他事务:我准备往这个间隙里插入数据。插入意向锁之间互相兼容,因为两个事务插入到同一个间隙的不同位置不冲突,比如一个插到 11,另一个插到 13,只要键值不同就没事。但插入意向锁与普通间隙锁冲突,为什么?因为普通间隙锁锁住的是“整个区间的空白”,它不允许这个区间里出现任何新行,否则主从复制时基于语句复制就会出问题。
理解这套协作机制之后就会发现,InnoDB 在 RR 隔离级别下对纯当前读事务的幻读防护确实做得不错,因为事务如果一开始就锁定了主键范围,它基本把自己范围内的所有插入通道都堵死了。可一旦掺入快照读,ReadView 和锁就不在同一套规则里了。快照读看见的是一组旧版本,当前读处理的是最新版本,这两个视角在同一个事务里会交替出现,业务端感受到的“幻读”就是从这种交替中漏出来的。
5.2 为什么 MySQL 的 REPEATABLE READ 和标准 SQL 定义不一样
标准 SQL 定义的四个隔离级别中,REPEATABLE READ 只要求解决不可重复读,不要求解决幻读,解决幻读是 SERIALIZABLE 的职责。但 MySQL InnoDB 的 REPEATABLE READ 实际上通过 next-key lock 顺带解决了一部分幻读问题,所以网上很多资料才会说“MySQL 的 RR 比标准 RR 更强”。但强出来的这部分只覆盖当前读路径,快照读下的范围一致性靠的是 MVCC 快照。两个路径没有统一成一个完整的锁集,所以严格来说,InnoDB 的 RR 没能在所有读写模式下彻底消除幻读,能做到“彻底”的只有把隔离级别升到 SERIALIZABLE,或者把事务里所有读都改成当前读,并靠开发人员保证加锁范围覆盖查询条件。
SERIALIZABLE 之所以能防,是因为它把普通 SELECT 也隐式转换成 SELECT ... LOCK IN SHARE MODE,所有读都变成当前读,都走同一套锁体系,MVCC 的快照视图不再参与范围判断。这样事务 A 一开始读的范围就被锁住,事务 B 往那个范围里插入数据必须等 A 提交。代价是并发度急剧下降,读写互相阻塞,很多业务受不了这种代价。
5.3 唯一索引和外键检查导致串读
这里还想单独提一个很容易被忽视的点:唯一索引冲突检查和外键约束检查,在 InnoDB 内部会执行一次“当前读”。哪怕你只是执行一个普通 INSERT,要插入一条唯一键,InnoDB 必须检查这个键是否已存在,这个检查读的是当前已提交数据,不是事务快照。如果一个事务 A 在快照里看不到某条记录,但它尝试插入同唯一键的数据,InnoDB 在检查时却能读到这条它快照里不存在的记录,于是插入直接报主键冲突。这种“快照看不见,但插入时被它挡住”的情况,本质上也是 MVCC 的可见性规则和 InnoDB 物理实现之间的一种矛盾。
外键检查也类似。你往子表插入一条记录时,InnoDB 需要对父表加锁并读取当前版本,确认父表有对应主键存在。如果父表里刚插入一行并已提交,它不在你的事务快照里,但外键检查能看到它,你的插入就能成功。站在你事务内部视角上,那条父表记录是“不应该存在却发挥了作用”的数据,这可以说是一种广义的幻读。
6. 实战排查建议:如何定位和规避这类问题
6.1 先确认事务里读的类型
遇到跟幻读相关的线上问题时,第一步不是去讨论概念,而是打开 binlog 或者慢查询日志,把有疑问的事务完整 SQL 序列抠出来,逐个标记哪些是快照读,哪些是当前读。标记的方法很简单,普通 SELECT 是快照读,SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE 是当前读。把标记做完之后,再对着事务的时间线看,如果更新操作和 SELECT 的查询条件一样,而且更新结果影响到了快照范围之外的行,基本就能确定是幻读路径被触发了。
我在实际排查时还会额外注意一点:有些框架会自动把 SELECT 包上 FOR UPDATE,比如某些 ORM 的悲观锁语法,很多开发自己都没意识到发出去的是当前读。线上并发一高,这类隐藏的当前读很容易成为批量插入和更新不一致的源头。
6.2 能容忍则用快照读,不能容忍就锁全范围
对于业务来说,解决方式分两种情况。如果业务场景允许基于快照做逻辑判断,那就坚持所有查询都用普通 SELECT,不要在事务中间混入任何当前读。比如生成报表、统计汇总、历史快照查询,这类场景对实时性不敏感,用普通 SELECT 是最省事的。只要一个事务里完全没有当前读,MVCC 的 ReadView 就能保证范围结果稳定,幻读不会出现。此时把隔离级别设成 RR,或者显式 REPEATABLE READ,基本就够了。
如果业务场景必须实时读取最新已提交数据,比如库存扣减、余额变更,那就不能依赖 MVCC 快照了。这时候要么把所有涉及相同范围的读都改成 SELECT ... FOR UPDATE,保证查询和随后更新之间锁不释放;要么加一个业务层面的“对账机制”,把事务范围缩小,让插入和更新在同一个锁周期内完成。我在电商库存系统里踩过不少坑,最有效的方式往往是缩短事务时间间隙,让“检查、扣减、插入”在一条 SQL 或者极短的锁窗口里完成,从根上减少别人插入数据的时间窗口。
6.3 隔离级别不是越高越好
还有一个常见误区,是见问题就无脑把隔离级别升到 SERIALIZABLE。实践下来,这个方案往往引发了更严重的锁等待和死锁。死锁的原因很直白:读全部加共享锁,UPDATE 和 DELETE 要加排他锁,在锁竞争激烈的表上,两个事务互相持有对方的锁是非常容易发生的。每次死锁回滚一个事务,都需要重新执行,对在线业务来说损失比偶发的幻读更大。
我的建议是,先分析业务允许的异常范围。如果只是账单统计场景里偶尔多算了一条,业务可以通过主键去重,那就别加锁,直接用快照读,让漏算的那几条在下一批对账时补上。如果是资金相关的强一致场景,宁可把一次事务拆成多个幂等小事务,也不要试图用一个超长事务加一堆锁来换取完美一致性。长事务自身就是所有锁问题的温床,持锁时间越长,与其他事务的碰撞面就越大,幻读问题的根源是事务边界宽度,而不是单条 SQL 写得多漂亮。
6.4 独家技巧:用同一个 WHERE 条件去加当前读锁
最后分享一个我自己常用的容器技巧。如果你既想利用快照读来降低锁竞争,又必须在事务内保证范围不被插入,可以在事务开始时故意执行一条与业务查询条件完全相同的 SELECT ... FOR UPDATE,把范围内的记录和间隙全部锁住。这条 SQL 通常不返回很多数据,甚至可能一条都不返回,但它锁住的范围就是在告诉所有并发事务:这个区间暂时归我,谁都不要插。
这个做法本质上是把“当前读锁”和“快照读一致性”拧在一起。事务后续的普通 SELECT 依旧走 ReadView,但因为有这条加锁 SQL 开场,任何想往范围内插入新记录的事务都会阻塞,所以从业务视角看,后续快照读和当前读都不会发现多出来的数据。代价是并发插入能力下降,所以只对“插入频率低、范围固定”的场景用,比如按用户 ID 分片处理订单时锁当前用户订单区间。我在账务系统的月末结算任务里就是这么干的,实测下来既避免了重复计算,也没有出现大规模锁等待。
7. 为什么标准答案和实际行为看起来矛盾
7.1 MySQL 官方文档说的和线上表现是两个维度
很多开发在被面试官问到“MVCC 能不能解决幻读”时,会拿 MySQL 官方文档说“REPEATABLE READ 通过 next-key lock 避免了幻读”,然后得出结论“能解决”。等到了线上,实测却发现幻读还是出现,就开始怀疑自己的操作不对。这里其实不是 MySQL 前后矛盾,而是官方文档这句话描述的是“纯当前读场景下的锁效果”,而你实际代码里混用了快照读和当前读。
要理清这件事,建议把问题拆成两半来看。第一半:MVCC 的目标是保证快照读的一致性读,它天然不具备阻止其他事务插入数据的能力,因为它不锁范围。第二半:next-key lock 的目标是保证当前读的加锁扫描范围不被插入,但它只在你执行当前读时生效,不会因为你之前在同一个事务里执行过普通 SELECT 就去锁住某个范围。答案之所以复杂,是因为这两个机制被设计成各管一段,而不是一个统一的全局机制。
7.2 和 Oracle、PostgreSQL 对照之后更容易理解
对比一下其他数据库能更直观理解。Oracle 在默认隔离级别 READ COMMITTED 下,每条语句用一个新的快照,所以读不会阻塞写,但它对“某语句执行过程中其他事务提交的数据”是完全不设防的,所以 Oracle 没有间隙锁概念,幻读问题在 RR 以下大量存在。PostgreSQL 的 REPEATABLE READ 是靠快照实现的,它完全禁止当前读在事务中间发现新数据吗?不是,但要更新时如果发现数据被并发修改或者插入影响到了更新条件,PostgreSQL 会直接报序列化异常,让应用重试。InnoDB 选择了另一条路,用锁去阻止并发插入,而不是用冲突检测。锁的方式好处是应用不需要处理重试,坏处就是锁覆盖范围一旦不全,幻读就从漏掉的范围里钻出来。
理解了这些差异后,再回头看“为什么 MVCC 不能完全解决幻读”这个问题,答案就非常清晰了:MVCC 本身是版本管理工具,它只负责“谁看哪个版本”,不负责“不允许出现新版本”。新版本能否出现,取决于锁,而锁的设计和加锁时机,又是由数据库的实现层决定的。只要实现层允许某条当前读语句在事务中途重新审视最新已提交数据,幻读就不可能被 MVCC 单独挡住。
7.3 这个问题面试时怎么答才有区分度
如果要给这个问题一个既准确又有层次的表达,我习惯这样组织:先说结论,InnoDB 的 MVCC 可以保证一个事务内快照读多次查询结果一致,所以能解决快照读层面的幻读;但对于当前读,普通的多版本机制不参与可见性判断,它直接读最新已提交版本,因此必须有额外机制来防守,这个机制就是 next-key lock。然后补一句核心判断:next-key lock 只有在加锁范围覆盖到了未来插入的所有可能位置时才管用,而业务中快照读与当前读混合使用,或者唯一索引检查、外键检查、半一致性读优化绕过了锁间隙,都会导致防线不完整。最后举一个 UPDATE 触发当前读导致范围多一的例子,就能把面试官想听的几个点全带出来。
我个人在实际操作中的体会是,这个问题背后最值得警醒的不是数据库的缺陷,而是工程师在写事务代码时对“一条 SELECT 到底是哪种读”缺乏敏感。MVCC 给你提供了高性能的快照能力,但快照不是物理隔离墙,它不能替你把所有并发穿透都挡在外面。真正保证数据一致性的,永远是对事务边界的敬畏,是对每条 SQL 加锁效果的完整推演,而不是靠数据库隔离级别的名字去赌安全。