在MySQL的事务机制里,“幻读”可能是被误解最严重的一个词。网上的说法经常互相打架:有的人说REPEATABLE READ下根本不会幻读,因为MySQL早就用MVCC解决了;也有的人说MySQL的RR级别下依然会幻读,必须升到SERIALIZABLE才稳妥。两边都遇到过真实案例,所以谁都说服不了谁。我当初刚接触这块时也被绕晕过,后来把快照读、当前读、间隙锁、临键锁这些概念放在一起捋了一遍,才终于理清楚:MySQL的RR隔离级别确实能解决幻读,但它不是靠单一机制解决的,而是靠“MVCC + 行锁+间隙锁”组合完成。这一章就从幻读的定义开始,把我实际验证过和踩过坑的内容全部拆开讲。
1. 幻读为什么是所有隔离级别里最难讲清楚的问题
1.1 幻读的定义:它不是改了数据,而是变出了一行
先明确一个基础概念。
不可重复读和幻读经常被混为一谈,但它们在数据库层面是两回事。不可重复读指的是同一个事务内,两次查询同一行数据,发现这行的某列值变了。例如我在事务A里先查询学生表中张三的分数是60分,另一个事务B把张三的分数改成了90分并提交,事务A再次查询张三,看到的分数变了——这是不可重复读。
幻读不一样。幻读指的是同一个事务内,两次执行同一条查询语句,第二次返回的结果集中多出来了几行之前不存在的记录。比如事务A执行SELECT * FROM student WHERE score > 60,查出来两行;事务B插入了一条score=90的新学生记录并提交;事务A再次执行一模一样的查询,结果变成了三行。多出来的那行像幽灵一样,所以叫“幻读”。
这两个概念的区别一句话就能说清:不可重复读是“同一条记录的内容被改”,幻读是“结果集的记录数量凭空增加”。但痛点在于,隔离级别越高,数据库要做的取舍越复杂,幻读恰好站在了这个复杂度的中心。
从SQL标准的角度看,隔离级别分为四档:READ UNCOMMITTED、READ COMMITTED、REPEATABLE READ和SERIALIZABLE。标准里明确规定:REPEATABLE READ级别只要求解决不可重复读,幻读要到SERIALIZABLE级别才被禁止。也就是说,按照标准文档的字面定义,你工作在RR级别时,理论上还可能出现幻读。
然而InnoDB存储引擎不服这个说法。MySQL把默认隔离级别设置成了REPEATABLE READ,而且在绝大多数场景下,它真的把幻读也一并解决了。这也是数据库圈子里一个经常被拿来讨论的“厂商实现比标准更严格”的典型案例。
1.2 SQL标准与InnoDB实现的差异:标准RR并不解决幻读
为什么标准RR不解决幻读?因为标准的REPEATABLE READ只要求保证“同一事务内的多次读取结果一致”,但没规定数据库用什么手段来实现这种一致。传统数据库往往通过“读已提交”加MVCC或者加锁来近似实现,新插入的行不在当前事务的可见范围里,导致两次查询结果可能不一样。
InnoDB却另辟蹊径,它用了一致性读(Consistent Read)加上行锁组合拳。在InnoDB的RR隔离级别下,普通SELECT走的是快照读,事务内第一次SELECT会生成一个ReadView(一致性视图),后续的普通SELECT都基于这个视图读取数据,其他事务后来插入并提交的新记录,在这个视图里是看不见的。所以你两次普通SELECT的结果天然一致,幻读在快照读层面已经不存在了。
但这只是前半句,后半句更关键:如果你的SQL语句是当前读,比如SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE,这些操作读的是最新已提交数据,而且要加锁。那么数据库就必须用另一套机制来防幻读——套在记录外面的间隙锁和临键锁。
所以准确的说法是:InnoDB在RR级别下,用MVCC解决了快照读的幻读,用锁机制解决了当前读的幻读。两条战线都堵住,才敢说自己处理了幻读。而很多人之所以争论不休,往往是只讲了其中一条战线。
1.3 为什么幻读的“幻觉”在业务中更难察觉
幻读比不可重复读难查,还有一个原因:它只有在某些特定操作组合下才会暴露出来,平时业务SQL根本不会触发。
不可重复读很容易通过日志或者接口返回值的对比发现:同样查一条订单,金额前后不对,那基本就是“不可重复读”或者脏读的问题。幻读却需要一个前置条件——你得有一个“读条件范围”的查询,并且另一个事务同时向这个范围内插入新数据。如果业务上所有查询都是精确主键查询,幻读就永远不出现;一旦遇到“按时间区间查订单”“按分数段查学生”这类范围查询,并且还有并发写入,幻读就可能突然冒出来。
更麻烦的是,MySQL的应用层连接池通常会复用连接,如果你不小心把两次业务请求放在同一个事务里,中间夹着其他事务的提交,数据不一致会表现得非常诡异。排查到最后,可能才发现是事务隔离级别和幻读机制在底层搅局。
2. 先搭一个能稳定复现幻读的实验环境
2.1 建表与初始数据:为什么索引设计决定了实验结果
纸上谈兵用处不大,直接把环境搭起来,亲眼看看到底什么时候幻读,什么时候不幻读。先建一张学生成绩表,这张表会贯穿后面的所有实验。
CREATE TABLE student ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(20), score INT, KEY idx_score (score) ) ENGINE=InnoDB; INSERT INTO student (name, score) VALUES ('张三', 60), ('李四', 72), ('王五', 85);这张表有两个索引:主键索引id和普通索引idx_score。这个设计是有讲究的。后面我会分别演示“走主键索引”“走普通索引”“完全没索引”三种情况下的锁行为,如果一开始就把索引建全,所有场景都能覆盖。
还要注意一点,如果你的MySQL是8.0,上面建表语句没问题;如果是5.7,结果也一样,InnoDB在这两个大版本里的锁机制核心逻辑基本一致。
2.2 两个会话的必要性:关闭autocommit,理解事务边界
复现幻读必须用两个会话,因为幻读本身就是并发事务之间的相互作用。我习惯用两个mysql命令行窗口,左边窗口叫会话A,右边窗口叫会话B。
连接上数据库后,第一步要关闭自动提交。MySQL默认开启autocommit,每条语句执行完就自动提交事务,那样根本没法观察事务内部的状态。
SET autocommit = 0; SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;然后手动开启事务:
START TRANSACTION;这里强调一下:START TRANSACTION和BEGIN在MySQL里基本等价,但START TRANSACTION可以带修饰符,比如START TRANSACTION WITH CONSISTENT SNAPSHOT,后面讲快照读时我会提到。
理解了事务边界,才谈得上复现。幻读的完整复现步骤,其实是三步:
- 事务A执行一条查询,记住结果。
- 事务B插入一条满足A查询条件的新记录并提交。
- 事务A再执行同一条查询,对比两次结果。
只要两次结果集不同,幻读就出现了。接下来所有实验都围绕这个三步模型展开。
3. InnoDB的两条防线:快照读交给MVCC,当前读交给锁
3.1 快照读的ReadView机制:为什么普通SELECT两次结果一定一致
在InnoDB的RR隔离级别下,普通SELECT是快照读,它不会加锁,而是通过MVCC读取一个一致性视图。这个视图在事务中第一次执行普通SELECT时生成,之后整个事务期间都复用同一个视图。
ReadView里记录了三样核心东西:当前活跃事务ID列表、最小活跃事务ID、最大已分配事务ID。一条记录的新版本能否被当前事务看到,取决于提交这条新版本记录的事务ID是否在可见范围内。如果插入新记录的事务ID大于当前事务的ReadView最大ID,说明它是在当前事务开始之后才提交的,这些新记录对当前事务不可见。
所以事务A先查一次SELECT * FROM student WHERE score > 60,生成ReadView;事务B插入一条score=90的记录并提交;事务A再用同样的快照读查询,依旧复用最初的ReadView,自然看不见B插入的新行。两次结果必然一致。
这就能回答很多初学者的疑问:网上说RR能防幻读,说的就是这一层——快照读下,幻读确实被消灭了。
3.2 当前读的三种形态:SELECT FOR UPDATE与UPDATE/INSERT/DELETE
快照读只负责普通SELECT,但数据库不能永远只做快照读。业务要更新数据,要锁定某行防止并发修改,就必须用到当前读。
当前读在MySQL里包含以下几类:
SELECT ... FOR UPDATE:加排他锁SELECT ... LOCK IN SHARE MODE:加共享锁(8.0里也可以用SELECT ... FOR SHARE)UPDATE、DELETE、INSERT语句内部读取数据时,也是当前读
当前读的特点是“读最新已提交版本”,并且读取的同时要加锁。这也是为什么UPDATE语句天然需要看到其他事务最新提交的结果,否则就会基于过期数据做覆盖更新,造成更新丢失。
在RR隔离级别下,当前读就成了一道坎:因为它读最新数据,就不能用ReadView来过滤幻行。如果只靠行锁,锁住已有记录,锁不住“未来可能插入的新记录”,幻读在当前读场景下依然会出现。于是InnoDB必须在当前读的锁机制里加一道屏障——把索引记录之间的间隙也锁住。
3.3 MVCC解决的一半与锁解决的另一半如何衔接
把两条防线放在一起看,InnoDB的思路非常清晰:
- 普通SELECT走快照读,用MVCC保证事务内读取结果一致,不需要锁,读性能不受影响。
- 当前读走加锁读,用临键锁锁住已有记录及间隙,让其他事务无法在这个区间内插入新行,从而保证当前读也不会出现幻读。
- 两条防线互不干扰,普通读不阻塞写,加锁读阻塞并发插入,通过机制分工换来了RR级别下相对高的并发度。
这也是InnoDB设计最巧妙的地方:它没有为了防幻读把所有查询都强制升级成串行化读,而是把“只读查询”和“写前读取”分开对待。前者用MVCC获得无锁的高并发读能力,后者用锁获得严格的一致性。
不少从其他数据库转到MySQL的人,一开始都很难接受这种设计,因为你习惯了“隔离级别决定读一致性”的思维,不容易理解MySQL在同一个RR级别里居然藏着两套读取路径。
4. 间隙锁、临键锁与索引类型:一把钥匙开一把锁
4.1 三种行锁的区间边界
InnoDB在RR隔离级别下使用的锁,主要分为三类:
| 锁类型 | 锁定的范围 | 典型场景 |
|---|---|---|
| 记录锁(Record Lock) | 只锁单条索引记录 | 等值查询命中唯一索引/主键索引 |
| 间隙锁(Gap Lock) | 锁两个索引记录之间的空隙,不锁记录本身 | 等值查询未命中,或范围查询中的区间 |
| 临键锁(Next-Key Lock) | 记录锁 + 间隙锁的组合,左开右闭区间 | 范围查询走索引,RR级别默认锁定方式 |
为什么要用临键锁?因为InnoDB默认在RR级别下,扫描到一行记录时,不仅要锁这行记录,还要锁住这行记录之前的间隙。这样就能保证“间隙里插不进新数据”,从而避免当前读过程中出现新行。
用一个具体的区间例子说明。student表的score索引上有三个值:60、72、85。在RR下执行SELECT * FROM student WHERE score > 60 FOR UPDATE时,InnoDB会扫描到score=72和85的记录,并给它们加临键锁。
锁的区间大致是(60, 72]和(72, 85],同时因为查询条件是> 60,为了封住超过85的区间,还会在最大记录85之后加一个到正无穷的间隙锁。也就是(85, +∞)。这些区间共同阻止其他事务插入score在(60, +∞)范围内的新记录。插不进来,A事务当前读的结果自然稳定,幻读也就无从发生。
4.2 唯一索引等值查询时的锁退化
临键锁并不是在所有场景下都会完整使用。如果当前读走的是唯一索引(包括主键索引)的等值查询,并且命中了一条记录,InnoDB会做一个锁优化:临键锁直接退化成记录锁。
原因很简单。唯一索引保证了某个值在全表只有一个,既然已经锁住了这条记录,那么即使未来有并发事务想插入这个唯一值,也会因为唯一索引约束而失败,没必要再为它锁一个间隙。
比如执行SELECT * FROM student WHERE id = 2 FOR UPDATE,这只是锁定id=2这一行,不会锁住id=1和id=3之间的间隙,其他事务依然可以插入id=4等新记录。这时候你不需要担心幻读,因为当前读查询条件是精确的id=2,新插入的记录只要id不等于2,就不会影响本次查询结果。
但如果等值查询没有命中记录,情况就变了。执行SELECT * FROM student WHERE id = 99 FOR UPDATE,id=99不存在,InnoDB会在相邻索引区间加间隙锁,比如锁住(85, +∞)这个间隙,如果另一个事务插入id=100,就必须等A释放锁。这同样是防幻读——防止“这次查不到,下次却查到了”。
4.3 普通索引范围查询时的锁扩散
普通索引就完全不同了。普通索引不唯一,同一个score值可能对应多行记录,InnoDB只能默认使用临键锁,并且锁的范围会扩散到查询条件涉及的整个索引区间。
还是看score这个索引。初始数据只有60、72、85三个值,如果执行:
SELECT * FROM student WHERE score >= 72 FOR UPDATE;InnoDB会给score=72和85的记录加临键锁,同时锁住60到72之间的间隙、72到85之间的间隙,以及85之后到正无穷的间隙。也就是说,任何试图往score为[72, +∞)范围插入新记录的事务,都会被阻塞,插入操作会一直等待到当前事务提交或回滚。
这里有个非常容易忽略的细节:间隙锁锁定的范围,取决于索引中已有的值,而不是查询条件的字面值。假设索引里最大score是85,那么score >= 72实际上就把85之后的整个正无穷区间都锁住了。如果你在事务里不经意执行了一条范围较大的当前读,竞争对手连插入一条score=100的数据都会被卡住。
生产环境里最常见的锁等待、锁超时,一半以上都和这种锁扩散有关。
5. 五组实测结论:哪些场景还可能在RR下看到幻读
5.1 场景一:快照读——幻读彻底消失
先把复现环境准备好,让两个会话都处于RR级别。会话A执行:
START TRANSACTION; SELECT * FROM student WHERE score > 60;结果返回两行:李四和王五。然后会话B执行:
START TRANSACTION; INSERT INTO student (name, score) VALUES ('赵六', 90); COMMIT;B插入成功并提交。此时会话A再执行一次一模一样的SELECT:
SELECT * FROM student WHERE score > 60;结果依然是两行,看不到赵六。原因就是3.1里说的ReadView复用。这个实验可以反复做,快照读下的RR级别确实是“稳如泰山”,两次查询结果一定一致。
这个结论对你写业务代码非常重要:如果你的场景只需要“查询后展示”,不要求锁定数据,直接使用普通SELECT,在RR级别下完全不用操心幻读,数据库已经帮你处理好了。
5.2 场景二:当前读命中普通索引——临键锁挡住插入
把会话A的回滚补一下,重新走一遍事务。这次A执行的是当前读:
START TRANSACTION; SELECT * FROM student WHERE score > 60 FOR UPDATE;返回两行。接着会话B尝试插入新记录:
INSERT INTO student (name, score) VALUES ('赵六', 90);你会发现这条INSERT语句迟迟不返回,一直处于阻塞状态。这就是临键锁和间隙锁在起作用。因为A对score索引的(60, +∞)区间加了锁,B的插入操作必须等待A释放锁,插入意向锁和间隙锁不能共存。
此时在会话A里再执行一次查询:
SELECT * FROM student WHERE score > 60 FOR UPDATE;结果还是两行,赵六根本没有插进来,自然也没有幻读。等A执行COMMIT后,B的INSERT才会立刻成功。
这类操作在真实项目里经常出现在“查询并锁定一批待处理记录”的场景,比如批量结算、批量扣减库存。如果不注意锁的区间范围,一个事务锁的数据过多,后面所有相关插入操作都会排队,这是性能隐患,后面章节专门讲。
5.3 场景三:当前读未命中任何索引——全表间隙加锁
如果WHERE条件不是索引列,InnoDB会怎么做?比如表里还有一个非索引字段name,执行:
SELECT * FROM student WHERE name = '张三' FOR UPDATE;这条SQL在name上没有索引可用,只能走全表扫描。InnoDB的锁机制会怎么做?它会对扫描过程中碰到的所有索引记录加临键锁,相当于把整个表的记录和所有间隙都锁住了。
这就意味着,在会话A这个事务提交之前,会话B不管想插入什么记录,哪怕是一条WHERE条件完全不相关的记录,也会被阻塞。因为表的所有间隙都被锁了。
这种没有索引的当前读操作,是生产环境中的头号大坑。很多线上锁等待事故,追到根因,都是某条UPDATE或DELETE语句的WHERE条件没有索引,一个事务把整张表锁死。遇到这种情况,优化SQL是第一要务,比如给WHERE条件加上索引,让InnoDB只锁定相关区间,而不是全表间隙。
5.4 场景四:唯一索引等值查询未命中——间隙锁仍生效
这个场景也很容易踩。假设表里id最大是3,执行:
START TRANSACTION; SELECT * FROM student WHERE id = 5 FOR UPDATE;记录不存在,查询结果为空。按理说没锁到任何记录,但InnoDB因为扫描到最大记录id=3后,会给(3, +∞)区间加上间隙锁。接着会话B尝试插入id=4:
INSERT INTO student (id, name, score) VALUES (4, '孙七', 66);这条插入同样会被阻塞。原因是B要插入的位置正好落在A锁定的(3, +∞)间隙里。只有等A提交或回滚,B才能插入。
如果你在业务代码里先查一个不存在的ID,再根据结果决定是否插入,一定要留意这个间隙锁的存在。它不会让你读到幻行,但会把后续想插入该区间的所有事务全部卡住,接口耗时突然飙升,多半就是这个原因。
5.5 场景五:两个间隙锁相遇——死锁离得有多近
间隙锁和间隙锁之间不会互相阻塞,这是InnoDB锁设计里非常反直觉的一点。两个事务可以同时持有同一个间隙的间隙锁,因为它们都不需要修改现有记录,只是禁止别人插入。
但是,如果两个事务各自持有同一个间隙的间隙锁,然后同时向这个间隙插入新记录,就会形成死锁。我复现过的典型流程是这样的:
会话A:
START TRANSACTION; SELECT * FROM student WHERE id >= 10 FOR UPDATE;会话B:
START TRANSACTION; SELECT * FROM student WHERE id <= 20 FOR UPDATE;两条SQL在id的(10, +∞)和(-∞, 20)区间上加了间隙锁,由于间隙锁之间兼容,两边都能执行成功。接着A往间隙里插入id=15,B也往间隙里插入id=16。A的插入尝试会被B持有的间隙锁阻塞,B的插入会被A持有的间隙锁阻塞,MySQL检测到死锁后,会回滚其中一方的事务。
我在实际项目里遇到过的死锁报警,绝大多数都发生在同一批数据上做“先查后插”的并发操作。解决思路无非两条:一是在一个事务里尽早缩小锁范围,二是调整并发操作的数据分布,避免多个事务同时操作同一间隙。
下面的表格把这五组实验的结论汇总起来:
| 实验场景 | 核心操作 | 结果 | 结论 |
|---|---|---|---|
| 场景一 | 两次普通SELECT,中间B插入并提交 | A两次结果一致 | MVCC解决快照读幻读 |
| 场景二 | SELECT FOR UPDATE,B向区间内插入 | B被阻塞直到A提交 | 临键锁解决当前读幻读 |
| 场景三 | 无索引条件当前读,B插入任意记录 | B整表插入被阻塞 | 无索引当前读代价极大 |
| 场景四 | 唯一索引等值未命中,B插入同区间记录 | B被间隙锁阻塞 | 未命中也会锁间隙 |
| 场景五 | 两个事务同时持间隙锁并向间隙插入 | 触发死锁,一方被回滚 | 间隙锁并发需谨慎控制 |
6. 围绕幻读的三个高频误区和性能代价
6.1 误区一:RR下一定不会幻读
把“RR一定不会幻读”这句话当成铁律,早晚会踩坑。准确的说法是:在InnoDB的RR级别下,同一条查询如果始终走快照读,或者始终被合适的间隙锁保护,确实不会幻读;但如果你在一个事务里混合使用快照读和当前读,表现就会变得很微妙。
举个例子。会话A先执行普通SELECT:
START TRANSACTION; SELECT * FROM student WHERE score > 60;此时A看不到任何后来提交的数据。然后会话B插入赵六并提交。接着A执行一条UPDATE语句:
UPDATE student SET score = score + 1 WHERE score > 60;UPDATE是当前读,它读的是最新已提交数据,会把包括赵六在内的所有满足条件记录都更新一遍。更新完之后,A再执行普通SELECT,你会看到什么?
结果是:赵六的行被更新了,但普通SELECT仍然看不到赵六。因为UPDATE获取数据时用的是当前读,而普通SELECT用的还是最初那个ReadView。一个事务内,当前读能看到新提交的幻行,快照读看不到,这就出现了“事务内两种读结果不一致”的诡异情况。
严格来说,这已经不是标准定义的幻读了,但对业务伤害很大。比如你先统计总数再批量更新,统计用的快照读人数少,更新时却多更新了几行,最终结果和预期就对不上。所以不要笼统地说RR没有幻读,要看你走的是哪条读路径。
6.2 误区二:用SERIALIZABLE代替业务约束
有些同学一听说RR可能有边缘情况,干脆把隔离级别调到SERIALIZABLE。这个选择确实简单粗暴,但代价非常大。
在SERIALIZABLE级别下,InnoDB会把普通的SELECT语句也自动转换为SELECT ... LOCK IN SHARE MODE加共享锁,也就是说快照读变成当前读。普通查询也要拿锁,意味着读读并发虽然能并行,但读写之间互相阻塞的概率大幅上升,系统并发能力明显下降。
更值得思考的是:SERIALIZABLE并不能替代业务层面的唯一性约束。如果业务要求“同一订单不能被重复操作”,你仍然要加唯一索引,或者用其他分布式锁的手段。数据库隔离级别只是并发控制的一个层面,不能把全部安全负担都甩给它。
我的建议是,业务上先想清楚哪些SQL是快照读、哪些是当前读,再决定要不要升级隔离级别。大多数场景下,默认RR已经够用,真正要优化的是SQL的索引利用率和事务执行时间。
6.3 从锁的视角设计索引与SQL
理解了幻读和锁机制之后,再回来看索引优化,很多问题就豁然开朗。锁的粒度永远和索引息息相关:走唯一索引的等值查询,锁最小,并发效率最高;走普通索引的范围查询,锁区间变大;不走索引的更新和删除,等于全表加锁。
实际工作中我总结了几条接地气的原则:
第一,事务要短。锁持有的时间越短,其他事务等待的概率就越低。一个事务里尽量只放必要的SQL,没必要的MATCH查询、报表统计别塞进去。
第二,WHERE条件尽量走索引。一条UPDATE或者DELETE语句,如果WHERE条件不能命中索引,InnoDB要对全表加临键锁,这比“读全表”代价大得多。给高频更新的查询条件建好索引,是性价比最高的优化。
第三,避免在事务中做“查是否存在,不存在则插入”的模式。这种模式最容易触发间隙锁和插入意向锁的死锁。更好的方案是直接利用唯一索引做INSERT ... ON DUPLICATE KEY UPDATE,或者插入之前先用短事务检查,再在独立事务里插入。
第四,观察information_schema.innodb_trx和performance_schema.data_lock_waits。线上出现锁等待时,不要猜,直接查这两张表,看当前事务持有哪些锁、正在等哪些锁。从这些系统表里能看到锁的索引名和锁类型,定位问题比靠经验快得多。
我自己的体会是,幻读这个问题,技术原理可能只需要几个小时就能看懂,但真正值钱的是把这些原理转化成日常写SQL时的肌肉记忆:普通读和当前读分开想,锁范围和索引结构紧紧绑定,事务尽量短批量尽量小。把这些习惯刻进脑子里,你在MySQL上踩的坑会少一大半。