☰
MVCC解决不了所有幻读:快照读与当前读的边界与工程兜底
2026/10/2 9:13:40 网站建设 项目流程

如果你去搜索“MVCC解决了什么”,十篇里起码八篇会告诉你:多版本并发控制解决了幻读。这个说法不能说完全错,但很容易让人在线上出事。我印象最深的一次,是批量脚本压测时出现的“幽灵记录”:事务内先用SELECT查询某批流水,一共返回了5行,紧接着执行同条件的UPDATE,结果影响行数是6。开发同学一脸不解,默认隔离级别不是可重复读吗?MVCC不是专门防幻读的吗?

我现场开两个会话复现了这个现象,原因其实不复杂:SELECT走的是快照读,MVCC让它看不到并发插入的那一行;UPDATE走的是当前读,扫描的是最新版本数据,把那一行也纳入了更新范围。更微妙的是,这一行被当前事务修改之后,在后续同事务的SELECT里会因为“自己的修改永远可见”而堂而皇之地出现——两次查询的行集就此不一致。

这就是MVCC的能力边界。本文会从原理、可复现实验、生产兜底三个层面,把这个问题彻底拆开:为什么MVCC只能解决快照读下的幻读,当前读下的幻读靠什么机制兜底,以及这个兜底机制本身有哪些盲区。

1. 再谈幻读的定义:别把“行数变了”和“行内容变了”混为一谈

1.1 那批多出来一行的“幽灵流水”

回到那次压测。业务是一个夜间批量脚本,逻辑很清晰:在事务里先SELECT出某时间段内的流水,然后UPDATE把这些流水标记为已处理。那天凌晨压测时,脚本跑完打出来的日志显示,UPDATE影响行数比SELECT返回行数多了一行。开发同学反复检查SQL,条件完全一样,怎么都想不通。

我让他把两个会话打开,按照同样顺序操作:会话A开启事务并执行SELECT,会话B插入一条符合条件的新流水并提交,会话A再执行UPDATE。结果非常直观:UPDATE的影响行数确实包含那条新插入的流水。问题不在于SQL写错了,而在于SELECT和UPDATE虽然写在同一个事务里,走的却是两条完全不同的读路径。

在InnoDB里,普通SELECT是一致性非锁定读,也就是快照读,它依赖MVCC提供的历史版本;UPDATE、DELETE、SELECT ... FOR UPDATE都是当前读,每次都必须读取记录的最新已提交版本,并且要给读到的记录加锁。同一句条件,在快照读和当前读里看到的行集可以不一样,这是幻读最隐蔽的一种形态。

1.2 幻读、不可重复读、脏读:一张表说清区别

很多同学把幻读和不可重复读搞混,其实它们的区别非常明确:不可重复读是同一行记录的内容发生了变化,幻读是记录本身多出来了,或者少掉了。更准确地说,幻读强调的是“某个范围内的行集在事务内两次查询中不一致”。

异常现象表现典型隔离级别下是否可能出现
脏读读到其他事务未提交的数据READ UNCOMMITTED可能出现
不可重复读同一行内容被其他事务修改,两次读取内容不同READ COMMITTED可能出现
幻读符合条件的行集改变,出现“幽灵行”或缺失行RR下快照读通常规避,当前读依赖锁,部分场景仍可能出现

SQL标准对幻读的定义是从“行集”角度出发的:事务T1执行一个大范围查询,事务T2插入了一行恰好落在该范围内并提交,T1再次执行同样的查询,发现结果里多了一行。这行多出来的记录就像是“幻觉”。上文那个流水场景,UPDATE影响的行数多了一行,本质上就是一次“当前读视角下的幻读”。

1.3 “MVCC解决幻读”这句话,是从哪传开的

这个说法的源头是官方文档和数据库教材里的一句简化表述:InnoDB在默认的REPEATABLE READ隔离级别下,通过MVCC和next-key lock来防止幻读。但大多数二手博客在传播时,把“和next-key lock”这几个字省略了,最后变成“MVCC解决了幻读”。不少人背诵这个结论背了很久,直到被生产环境打脸。

准确的说法是:MVCC只负责让快照读看到一致的历史版本,从而在“读”的维度上规避幻读;当前读的幻读,靠的是间隙锁和Next-Key Lock这类锁机制;而且这套锁机制本身有生效前提和盲区。下面先把MVCC到底怎么工作的讲清楚,再来看它为什么管不了当前读。

2. InnoDB的MVCC到底保护了谁:隐藏列、undo log与ReadView

2.1 一行记录背后的“时间轴”:DB_TRX_ID、DB_ROLL_PTR与版本链

要理解MVCC的边界,先得知道InnoDB是怎么记录历史的。每一行记录除了用户定义的字段,还藏着几个系统列,其中最核心的是DB_TRX_ID和DB_ROLL_PTR。DB_TRX_ID记录的是最近一次修改这行记录的事务ID;DB_ROLL_PTR指向该记录在undo log里的上一版本,也就是回滚指针。

当一条记录被更新时,InnoDB并不会直接抹掉旧值,而是把旧值写入undo log,形成一条版本链。最新版本在索引上,旧版本沿着DB_ROLL_PTR串起来。事务在读数据时,如果判断最新版本不可见,就沿着版本链往回找,直到找到一个对当前事务可见的版本。

这个机制最直接的价值是“读写不互斥”:一个事务写数据时,其他事务的快照读不会被阻塞,因为读的可以是历史版本。它也直接奠定了快照读防幻读的基础——只要某个事务创建的一致性视图(ReadView)认可旧版本是可见的,那之后即使其他事务插入了新行,只要新行的版本不在视图范围内,读出来就“等于不存在”。

2.2 ReadView是“快照签发处”:生成时机与可见性规则

ReadView可以理解为事务启动时签发的一份“统计数据快照清单”,决定了哪些版本可见、哪些版本不可见。它包含四个关键信息:当前活跃事务ID列表、活跃事务中的最小ID、已分配事务ID的最大值,以及创建这个视图的事务自身ID。

可见性判断规则如下:

  • 若记录版本的事务ID等于当前事务ID,说明是事务自己修改的,必然可见
  • 若该事务ID小于活跃事务列表中的最小值,说明它在当前事务启动前已经提交,可见
  • 若该事务ID大于或等于已分配事务ID的最大值,说明它是当前事务启动之后才产生的新事务,不可见
  • 若事务ID落在两者之间,则看它是否仍在活跃事务列表中:在列表中则不可见,已提交则可见

不同隔离级别下,这个视图的“有效期”完全不同。在RR级别,ReadView在事务内第一次执行快照读时生成,之后整个事务期间的所有快照读都复用同一个视图,这正是“可重复读”的底层实现;在RC级别,每一条SELECT都会重新生成ReadView,视图只对单条语句有效,所以同一事务内两次SELECT之间如果其他事务提交了新数据,第二次就能看到。

2.3 快照读防得了什么,防不了什么

到这里,MVCC的能力范围已经画出来了。它能让一条快照读“看到事务启动时那个时刻的静态世界”,把之后其他事务提交的改动全部屏蔽掉,所以两个纯快照读之间不会出现幻读。但它有一个先天设定:读与写是区分开的,MVCC只为普通的非锁定读服务。

一旦语句需要加锁、需要读取最新已提交版本,比如UPDATE、DELETE、SELECT ... FOR UPDATE、INSERT,MVCC的历史版本机制就不参与了。这些语句必须看到当前真实的数据,否则锁就没法加,版本冲突也没法检测。所以MVCC不是“失效”,而是它的设计目标里根本没有包含当前读。

有个生活化类比很贴切:把事务当作一份文件,ReadView是文件签发时的复印件。别人后来修改文件,你手里的复印件不会变,这是MVCC在保护你。但如果你自己拿起笔去改动文件,你看到的一定是文件当前的真实状态,不可能对着复印件来改。快照读是读复印件,当前写是操作原件,两者面向的东西不同,自然可能观察到不同的世界。

3. 三个可复现的幻读现场:MVCC失守的边界实验

3.1 实验环境与建表

下面所有实验在MySQL 8.0上验证过,5.7行为一致。准备一张简单的表,建表语句如下:

CREATE TABLE t ( id INT PRIMARY KEY AUTO_INCREMENT, age INT NOT NULL, name VARCHAR(20), KEY idx_age (age) ) ENGINE=InnoDB; INSERT INTO t(age, name) VALUES (10, 'a'), (20, 'b'), (30, 'c');

表里有三条数据,age字段有索引,方便演示范围查询和间隙锁。实验前确认会话隔离级别为RR:

SELECT @@transaction_isolation;

默认情况下InnoDB就是REPEATABLE READ,如果是其他值,可以用SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;调整。

3.2 现场一:先快照读后UPDATE,幽灵行“借尸还魂”

这是最能解释文章开头那个故障的场景。操作顺序如下:

步骤会话1会话2
1BEGIN;BEGIN;
2SELECT * FROM t WHERE age > 15; 返回2行:age=20、age=30,此时生成该事务的ReadView
3INSERT INTO t(age, name) VALUES (25, 'd'); COMMIT; 操作成功,无阻塞
4UPDATE t SET name = 'x' WHERE age > 15; 提示 Query OK, 3 rows affected
5SELECT * FROM t WHERE age > 15; 返回3行,其中包含新插入的age=25记录

关键在于第3步不被阻塞。因为第2步执行的是快照读,没有加任何锁,所以会话2插入age=25时不会碰到锁冲突,直接插入成功并提交。第4步UPDATE执行的是当前读,它扫描的是age索引上的最新记录,age=25那条自然会被读到,于是更新了3行。第5步再次执行快照读时,ReadView还是第2步生成的那个老视图,但版本链上age=25这一条的最新中国版本已经是会话1自己产生的了。

根据ReadView的可见性规则,事务自己修改过的版本永远对自己可见。这就造成了一个诡异的结果:第一次SELECT时这条记录还不存在,第二次SELECT时它却出现了,因为它是“被本事务亲手捞进视野”的。行集在同一个事务内前后不一致,这就是标准的幻读现象。

3.3 现场二:READ COMMITTED下,快照读本身就“残了”

如果把隔离级别降成RC,MVCC连最基本的“事务内可重复读”都保证不了,幻读会以一种更直白的方式裸奔出来:

步骤会话1会话2
1SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; BEGIN;
2SELECT COUNT(*) FROM t WHERE age > 15; 返回2
3INSERT INTO t(age, name) VALUES (25, 'd'); COMMIT;
4SELECT COUNT(*) FROM t WHERE age > 15; 返回3

RC下每一条SELECT都会重新生成ReadView,事务级别的“一致性视图”被降级成语句级别的。第4步看到的,是包含了第3步新插入行的最新已提交状态。如果你所在团队为了降低死锁把隔离级别调成了RC,那事务内多次SELECT的行数不一致,就是这个原因,不必怀疑数据库出了BUG。

3.4 现场三:RC下的SELECT FOR UPDATE,当前读也拦不住幻读

有人会说,那我把普通SELECT换成SELECT ... FOR UPDATE总能防幻读了吧。在RR级别下确实能,但在RC级别下不能。原因是RC下InnoDB只保留记录锁,不再启用间隙锁:

步骤会话1会话2
1SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; BEGIN;
2SELECT * FROM t WHERE age > 15 FOR UPDATE; 返回2行,仅锁住这两条记录
3INSERT INTO t(age, name) VALUES (25, 'd'); COMMIT; 操作成功,无阻塞
4SELECT * FROM t WHERE age > 15 FOR UPDATE; 返回3行

第2步的FOR UPDATE只给age=20和age=30这两条现有记录加了行锁,并没有锁住“15到正无穷”这个范围空间,所以会话2可以在间隙里自由插入新记录。第4步再次执行当前读时,age=25已经存在,自然被扫描并加锁返回。这里的幻读连MVCC的边都没沾上,纯粹是锁机制没有兜住范围。

4. Next-Key Lock:真正挡住INSERT的防线,以及它的三块盲区

4.1 三种锁的定位差异

既然MVCC管不了当前读,那当前读在RR级别下靠什么防幻读?答案是Next-Key Lock。它是记录锁和间隙锁的组合体,先把三类锁看明白:

锁类型锁定的范围典型作用
记录锁索引上的单条记录保护某一行
间隙锁索引记录之间的开放区间阻止其他事务往该间隙插入记录
Next-Key Lock记录本身加上它前面的间隙,前开后闭区间行锁与间隙锁的组合,范围扫描时的主要防幻读手段

拿前面的表举例,age索引里已有10、20、30三条记录,会话执行SELECT * FROM t WHERE age > 15 FOR UPDATE时,InnoDB会对找到的age=20和age=30加Next-Key Lock,同时对age=30之后的开放区间加间隙锁。这样age=25想插入,必须与age=20与age=30之间的间隙锁协调,无法获得插入意向锁,只能等待。

4.2 为什么MySQL要在RR下默认启用间隙锁:一段复制历史

这是很多人忽略的背景知识。早期的MySQL主从复制,binlog默认以语句格式记录,也就是把SQL语句原文写到binlog,从库照着语句重放。RR级别下启用Next-Key Lock,最直接的好处是:在范围条件上执行的语句,其覆盖范围在执行期间被锁住,那么无论主库还是从库,按同一顺序执行语句得到的结果集合是一致的。

如果取消间隙锁,比如允许RC级别,两个会话交叉执行INSERT和DELETE,产生的数据变更在不同节点上回放时可能因为执行顺序、锁等待等因素出现结果不一致。这是MySQL在默认的RR级别就启用Next-Key Lock的历史原因。后来binlog有了ROW格式,按行复制对语句顺序不再敏感,RC+ROW才成为很多生产环境的标准化配置。

所以严格来说,RR级别下“当前读防幻读”的功臣是锁,不是MVCC。MVCC负责快照读的一致视图,Next-Key Lock负责把当前读的扫描范围锁住,两者是互补关系,不是同一件事。

4.3 盲区一:快照读不唤起锁

很多业务代码里,事务内先SELECT判断、再INSERT或UPDATE,这个SELECT是快照读,它不会申请任何锁,更不会阻止其他事务插入新行。前面现场一的实验已经证明了这一点。数据库并不知道你接下来要做什么,它只负责执行当前这句SQL,而快照读在InnoDB的设计里就是“不设防”的读。

这意味着,如果你以为“开了RR就万事大吉”,事务里先查后写,其他会话照样可以在你查询后的间隙里插入记录,并在你后续的UPDATE里改变影响行数。MVCC给你的是一个稳定的读视角,但当你切换到写操作时,一切回到最新现实。

4.4 盲区二:锁范围可能过大,也可能退化

间隙锁并不是一个“精确制导”的机制。如果查询条件没有走索引,InnoDB对全表所有记录都加Next-Key Lock,锁的范围会波及大量不相关的间隙,这会显著放大锁冲突和死锁概率。如果走的是唯一索引等值查询且命中记录,InnoDB会把Next-Key Lock优化退化为记录锁,能防住单行的重复操作,却防不住恰好落在旁边的其他范围插入。

所以我一直提醒团队:FOR UPDATE不是万灵丹。它在某些查询路径下锁得过多,在某些路径下锁得不够,具体行为取决于索引结构、查询条件和隔离级别。用之前最好先在测试环境用两个会话把锁范围实际验证一遍。

4.5 盲区三:RC下间隙锁直接失效

落到生产实践,很多团队为了降低死锁率和锁等待,把隔离级别调整为RC。这么做确实减少了Next-Key Lock带来的锁冲突,但代价就是彻底放弃了范围锁。RC下只保留记录锁,当前读之间不再保护范围区间,幻读从“需要构造条件的偶发场景”变成“稍微并发就会碰到的高概率事件”。如果你在RC级别做分页、统计、范围更新,请务必在业务前方加好唯一约束或幂等控制,否则踩雷只是时间问题。

5. 从RR到RC再到业务层:幻读兜底的现实答案

5.1 为什么生产环境普遍“降级”到RC

我见过不少团队,上线前把隔离级别从RR改成RC,主要理由集中在两点。一是间隙锁导致大量锁等待和死锁。比如批量更新场景,两个事务同时扫描同一范围,一个更新了前一段,一个更新了后一段,最终因为互相等待释放间隙而报死锁,应用层只好重试。二是很多业务本质上不需要“整个事务期间的跨语句一致性”,只需要单条语句的原子性和一致性,RC已经足够。

这里要提一个容易被忽略的细节:在RC级别下,如果业务里还有“事务内先SELECT再UPDATE”的流程,两次操作看到的数据状态可能不同,尤其要注意UPDATE可能触发半一致性读——它在判定记录是否需要更新时,先读取最新已提交版本做判断,如果条件不满足就走历史版本,这种优化让受影响行数更不可预测。同时,务必把binlog_format设置为ROW,否则从库复制一致性也会成为隐患。

5.2 应用层的三条防线:唯一键、幂等控制、显式锁

如果MVCC和间隙锁在RC下都兜不住,业务层就要自己上保险。我最推荐的是加唯一索引。重复插入的场景,比如同一用户同一个月不能重复开票,直接在表上建UNIQUE KEY uk_user_month(user_id, month),两个并发事务同时插入时,数据库会拒绝其中一条,让它报主键冲突,应用捕获后做重试或提示即可。这是最便宜、最可靠的做法。

如果业务场景不允许加唯一索引,可以做幂等控制。每次请求生成一个唯一业务号,插入时把业务号和请求类型放在唯一索引里,重复请求直接命中冲突被丢弃。还有一种是显式锁,比如对用户主键行执行SELECT ... FOR UPDATE,把并发的“先查后写”串行化,虽然体验不算最优,但能把逻辑控制权拿在数据库里,避免应用层各自判断造成不一致。

我实际处理过的一个案例,是付款回调重复入账。业务本身带着payment_no,最初没有唯一约束,并发回调时两条事务都先查“这个payment_no处理过没有”,都没查到,然后各入一笔账。修复就是在表上加payment_no的唯一索引,同时在更新状态时使用条件更新,通过”影响行数是否为1”来判断是否抢占成功,从那以后再没出现过重复入账。

5.3 面试或协作中,怎么把这道题讲完整

以后再有同事或者面试官问“MVCC为什么不能完全解决幻读”,可以先给一句话版本:MVCC只保护快照读的一致性视图,不参与当前读;当前读的幻读在RR级别靠Next-Key Lock兜底,隔离级别降到RC后连锁都兜不住,业务层必须自己保证。

如果对方愿意听,再给三句话展开。第一句,RR下普通SELECT用ReadView屏蔽其他事务已提交的新行,所以两次快照读之间不会出现幻行。第二句,UPDATE、DELETE、SELECT ... FOR UPDATE是当前读,读最新版本并加锁,RR下靠Next-Key Lock阻止新行插入到锁定的间隙。第三句,一旦快照读和当前读在同一个事务里混用,或者隔离级别降到RC,MVCC和间隙锁各自的盲区都会被踩中,幻读就会穿透这层防御真实出现。

把这个逻辑链讲清楚,比单纯背一句结论有用得多。

最后再分享一个排查技巧。想验证某个业务场景是否会被幻读影响,不要只看文档,直接开两个客户端,按场景里的事务顺序执行一遍SQL,配合SELECT * FROM performance_schema.data_locks观察锁类型和锁范围,几分钟就能定位是快照读的问题,还是锁失效的问题。如果现在再有人问我“MVCC能不能完全解决幻读”,我会先反问一句:你的事务里,下一次读是快照读还是当前读?问完这句,大半人自己就找到答案了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询