1. 一次真实的“诡异”复现:同一事务中两次查询结果不一样
“幻读”这个话题,老实说已经被聊烂了。但凡查过mysql面试题的人,多少都能背出几句“可重复读隔离级别下,InnoDB通过MVCC和间隙锁解决了幻读”。但真正在业务里碰到幻读,并且能一眼认出来的,还真不多。
前段时间一个朋友给我发来一段代码:一个批量统计成绩的存储过程,事务开启后先做了一次COUNT,中间处理了几行数据,再做一次COUNT,两次结果居然不一样。他第一反应是“有人并发改了数据”,排查了半天,发现所有写入操作都有自己的业务状态机,不可能出现这种“凭空多了一条记录”的事情。最后定位到问题根源,就是幻读——而且是被很多人误以为“可重复读级别下不存在”的那种幻读。
所以这篇文章不打算背八股,我把复现过程、底层原理、锁机制、隔离级别关系、还有生产环境里那些容易踩的变体,一条一条拆开讲。
1.1 先搭一个能复现问题的最小环境
为了把问题讲清楚,我建了一张学生课程成绩表。这类表在业务里太常见了,学生、课程、成绩三个要素,拿来做示例再合适不过。
sql复制CREATE TABLE `course_score` (
`id` int NOT NULL AUTO_INCREMENT,
`student_no` varchar(32) NOT NULL COMMENT '学号',
`course_no` varchar(32) NOT NULL COMMENT '课程号',
`score` int DEFAULT NULL COMMENT '成绩',
PRIMARY KEY (`id`),
KEY `idx_student_course` (`student_no`, `course_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学生课程成绩表';
插入几条初始数据:
sql复制INSERT INTO course_score (student_no, course_no, score) VALUES
('S001', 'C001', 92),
('S001', 'C002', 85),
('S002', 'C001', 78);
现在模拟两个并发事务。终端A开启事务,查询学号S001的成绩;终端B插入一条S001的新课程成绩并提交;终端A再次查询同一个条件。
终端A:
sql复制SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT * FROM course_score WHERE student_no = 'S001';
此时能看到两行:C001和C002。
终端B:
sql复制START TRANSACTION;
INSERT INTO course_score (student_no, course_no, score) VALUES ('S001', 'C003', 88);
COMMIT;
终端A再次执行相同的查询:
sql复制SELECT * FROM course_score WHERE student_no = 'S001';
结果还是两行。很多人到这里会得出结论:“RR级别下并没有幻读啊,新插入的行根本看不见。”
这个结论只对了一半。
关键在下一条SQL。如果终端A执行的是当前读,比如 SELECT ... FOR UPDATE,结果就不一样了。
1.2 同一个事务,快照读和当前读看到的东西不一样
终端A继续在同一个事务里执行:
sql复制SELECT * FROM course_score WHERE student_no = 'S001' FOR UPDATE;
返回三行。C003这条新插入的记录,此时出现在终端A面前了。
同一事务内,仅仅因为读的方式不同,查询结果就从两行变成了三行。这就是幻读最直观的面孔——你的事务快照里看不到那行,但当前读却能把最新已提交的数据捞出来。
这个现象让很多人在刚接触时非常困惑:MySQL明明说自己支持可重复读,怎么同一个事务里两次查询结果不一致?原因在于“普通快照读”和“当前读”走的是两套完全不同的逻辑,而这个差异恰恰是幻读问题的核心。
我整理了一个对比表,方便你对照看:
| 操作顺序 | 终端A的观察结果 | 背后的机制 |
|---|---|---|
| 第1步:终端A开启事务并快照读 | 看到2行 | 生成ReadView,后续快照读复用 |
| 第2步:终端B插入C003并提交 | 终端B正常提交,不受阻塞 | 终端A没有持有任何锁 |
| 第3步:终端A再次快照读 | 仍然看到2行 | 快照读复用同一份ReadView |
| 第4步:终端A改用FOR UPDATE当前读 | 看到3行 | 当前读取最新已提交版本数据 |
需要强调的是,如果终端A在第1步就执行 SELECT ... FOR UPDATE,情况完全不同。此时终端A会持有临键锁,终端B的插入会进入锁等待状态,直到终端A提交事务后才会执行。这就是Next-Key Lock“防幻读”的真实工作方式。
但实际开发中,很多人不会一上来就加FOR UPDATE,而是先做普通查询判断,再决定下一步操作,这就为后面那种“先快照读、后当前读”的语义裂缝埋下了伏笔。
1.3 复现过程中的两个细节
我在这里想额外提两个实际操作时容易遇到的问题。
第一,MySQL 8.0默认隔离级别是可重复读,但如果你用的是5.7或更早版本,请先确认一下会话隔离级别,别让配置差异干扰复现结果:
sql复制SELECT @@transaction_isolation;
第二,我上面的示例里给 student_no 建了二级索引 idx_student_course。这个索引不是随便建的。如果这张表完全没有索引,FOR UPDATE 的锁范围会直接退化为全表所有间隙,复现出来的结果会更“暴力”,但同时也更容易掩盖真正的机制。后面第七章我会专门讲这个坑。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 幻读到底是什么:和不可重复读的边界被太多人搞混了
在聊机制之前,先把这个概念彻底掰扯清楚。因为我在排查问题、看别人写的技术总结时,发现有不少人把“不可重复读”和“幻读”混为一谈。面试的时候一紧张,脱口而出“幻读就是两次读不一样”,这句话严格来说不算错,但完全不够精确,也掩盖了幻读真正的危害。
2.1 两个常见错误认识
第一个错误认识:凡是同一事务两次查询结果不一样,都叫幻读。不对。不可重复读针对的是“已存在的行被修改”,比如一行数据从 score=92 变成了 score=95,你两次查询看到同一行的值不一样,这是不可重复读。幻读针对的是“凭空多出原本不存在的行”,重点是INSERT操作带来的新记录。
第二个错误认识:幻读只会在“读已提交”及以下隔离级别出现。MySQL的机制比这个复杂。可重复读级别下,快照读确实不会看到新插入的行,但当前读可以看到。也就是说,幻读的“出现与否”和读的方式强相关,不是单靠一个隔离级别就能一刀切判断的。
2.2 一个生活化的类比:会议室名单
我通常用这个例子解释给团队里的新人。
你在会议室里统计到场人员。第一次数了一圈,5个人。有人中途离开,有人换座位,第二次再数,还是5个人,但其中两张面孔换掉了。这是不可重复读,问题出在“已有的人的状态变了”。
如果会议室本来只有5个人,第二次数的时候却多出第6个人,而且这个人之前完全不在你的名单上,这才是幻读。问题出在“名单以外的实体冒出来了”。
这个类比能直接点出两类问题的本质区别:一个是UPDATE/DELETE对现有记录造成的可见性变化,一个是INSERT对记录集合造成的增量变化。只有INSERT才能制造真正的“幻影”。
2.3 幻读为什么在统计场景里特别危险
幻读影响最大的场景是聚合查询。COUNT、SUM、AVG这类操作,本质是对一个记录集合做整体计算,多一行少一行,结果完全不一样。
典型的业务场景是“先查后写”:比如先查询某个学号是否已经有成绩记录,没有就插入一条新成绩。两个并发事务同时发现“没有记录”,然后各自插入,最终出现两条相同学号+课程号的记录。你需要用唯一索引兜底,或者用分布式锁把判断和插入绑成原子操作。
这里的根子就在幻读:你以为自己面对的集合是固定的,实际上集合边界可以被其他事务插入的新行改变。如果没有锁机制的约束,任何基于“先查询再判断”的并发逻辑都可能有漏洞。
2.4 SQL标准与InnoDB实现之间的差异
还有一个必须澄清的点。SQL标准里对隔离级别的定义是:可重复读级别下不能出现幻读。但InnoDB对这个问题的处理是分了两条路:
- 普通快照读:通过MVCC和ReadView保证事务内的读一致性;
- 当前读(FOR UPDATE、UPDATE、DELETE):通过临键锁(Next-Key Lock)阻塞范围内的插入。
这两条路合在一起,才算是InnoDB在可重复读级别下“基本消除幻读”的完整方案。为什么说“基本消除”,因为两条路之间存在一个微妙的时间窗口:如果你先用快照读判断,再在当前读时发现多了一行,那么严格从“同一事务读一致性”的角度看,你已经遭遇了幻读的影响。
这个差异在实际工程里不是概念问题,而是真实的线上事故。所以我在讲幻读时,从来不会简单地说“RR解决了幻读”,我会补上一句:“MVCC解决快照读场景,临键锁解决当前读场景,两套机制配合,但存在边界。”
3. 为什么会出现幻读:快照读与当前读的底层较量
要真正搞懂幻读,绕不开MVCC和ReadView。很多人知道MVCC,但只知其名不知其运行逻辑,导致遇到问题时无法把现象和机制对上号。这一节我把底层拆开讲。
3.1 ReadView是如何生成一张“时间快照”的
MVCC是“多版本并发控制”的缩写。在InnoDB里,一行记录可以同时存在多个历史版本,每个版本都记录了自己对应的事务ID。当一行数据被修改时,旧版本不会立即删除,而是留在undo log里,供有需要的读事务访问。
ReadView是某个事务发起快照读时生成的一份“视图”,它记录了发起时刻数据库的活跃事务状态。在可重复读隔离级别下,ReadView只生成一次,之后整个事务内的普通读都复用这同一份视图。
ReadView里有几个核心字段:
trx_ids:生成视图时处于活跃状态且未提交的事务ID列表;low_limit_id:当前系统中已经分配过的事务ID上限;up_limit_id:活跃事务列表中最小的ID;creator_trx_id:创建这个ReadView的事务自己的ID。
判断一条记录版本是否可见是有一套规则的,简化后大致如下:
- 如果版本的事务ID等于当前事务ID,说明是自己改的,可见;
- 如果版本的事务ID小于活跃事务列表中的最小ID,说明该版本在ReadView生成时已经提交,可见;
- 如果版本的事务ID大于等于事务ID上限,说明该版本在ReadView生成时还未开始,不可见;
- 如果版本的事务ID落在活跃列表区间内,需要判断是否仍在未提交群体里,是则不可见,否则可见。
正是因为普通SELECT在可重复读级别下复用了第一份ReadView,所以终端B提交的C003记录,对终端A的快照读来说永远是“未来版本”,始终不可见。这是快照读不产生幻读的根本机制。
3.2 FOR UPDATE和UPDATE为什么叫“当前读”
“当前读”这个叫法很形象:读取记录的最新版本,并且对读取的记录加锁。SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE,都属于当前读。
当前读不借助ReadView判断可见性,它直接读取数据库里最新已提交的数据。所以它能看到终端B新插入并提交的那一行。这就是我在第一章里,同一个事务内FOR UPDATE返回三行而普通SELECT返回两行的原因。
有人可能会问:那如果事务A用FOR UPDATE先锁住了范围,事务B还能插入吗?不能。因为事务A的临键锁会把该范围内的所有间隙锁住,事务B的INSERT必须获取插入意向锁才能执行,插入意向锁和间隙锁互斥,于是事务B阻塞,直到事务A提交释放锁。这个机制就是对“当前读场景防幻读”的保障。
3.3 最容易踩的坑:先快照读后UPDATE
现在来复现那个“朋友遇到的诡异场景”。
终端A:
sql复制START TRANSACTION;
SELECT COUNT(*) FROM course_score WHERE student_no = 'S001';
-- 返回 2
终端B:
sql复制INSERT INTO course_score (student_no, course_no, score) VALUES ('S001', 'C003', 88);
COMMIT;
终端A继续:
sql复制UPDATE course_score SET score = score + 1 WHERE student_no = 'S001';
COMMIT;
SELECT COUNT(*) FROM course_score WHERE student_no = 'S001';
-- 返回 3
最终结果:终端A第一次COUNT是2,第二次COUNT是3,而且C003那条记录的score被人为加到了89。注意,这个UPDATE是在终端A事务内部执行的,现实业务里完全可能藏在某个批量处理逻辑中。
为什么会这样?因为UPDATE是当前读,它读取最新已提交版本,所以把C003这条新记录也包含进来了。更新之后,终端A再做普通快照读时——虽然ReadView是复用的,但这条记录在undo log里的旧版本可能已经被清理或者链路变化,读到的结果就和最初的快照不一致了,于是“可重复读”的语义出现了裂缝。
这个场景下的临键锁为什么没拦住?因为终端A一开始只做快照读,并没有持锁;终端B插入并提交后,锁资源已经释放。等终端A再执行UPDATE时,C003已经存在于表里,当前读看到的范围自然包含了它。
这个坑在“先判断后写入”的业务里尤为常见,特别是统计任务、批量清洗、定时补偿这类场景。我的建议是:如果事务内既要读又要写,尽量让所有读都走当前读,或者加锁,保持读方式的一致性,不要混用快照读和当前读。
提示:判断完业务条件后如果打算执行写入,直接使用SELECT ... FOR UPDATE锁定范围内数据,是让“判断+写入”原子化的一种方式,但要注意锁范围和性能。
4. InnoDB用Next-Key Lock交出的答卷,以及它的边界
前面提到锁,这一章就专门把InnoDB的锁体系讲透。很多人觉得Next-Key Lock是个神秘的东西,其实拆开看就是组合拳。
4.1 为什么只有行锁治不了幻读
行锁(Record Lock)只能锁定已经存在的某一行索引记录。它管得住已经在那里的老行,但管不住别人往这个范围内插入新行。你可以理解为:行锁是给已有的椅子上了锁,但挡不住有人再往房间里搬一把新椅子。
要解决幻读,必须把“不存在记录的间隙”也纳入锁定范围,让别的事务无法往这个间隙里插入数据。于是有了间隙锁Gap Lock的用武之地。
4.2 Gap Lock、Next-Key Lock与前开后闭区间
InnoDB的锁有三种主要形态:
- Record Lock(记录锁):锁定一条索引记录本身;
- Gap Lock(间隙锁):锁定两条记录之间的空隙,允许已有记录被读,但阻止其他事务在间隙中插入;
- Next-Key Lock(临键锁):记录锁和间隙锁的组合,锁定一个左开右闭的区间。
以主键id为例,假设表里有id=1、2、3三条记录。临键锁锁定的区间大致是:(-∞,1]、(1,2]、(2,3]、(3,+∞)。注意,Next-Key Lock是“前开后闭”,也就是包含右端点。
如果执行:
sql复制SELECT * FROM course_score WHERE id > 1 FOR UPDATE;
锁定的范围会覆盖(1,2]、(2,3]、(3,+∞)。此时另一个事务尝试插入id=4的行,会被阻塞,因为id=4落在(3,+∞)这个间隙区间里。
如果执行等值查询并且命中唯一索引记录,比如:
sql复制SELECT * FROM course_score WHERE id = 2 FOR UPDATE;
此时只锁定id=2这一条记录,不会对其他间隙加锁。这是因为唯一索引等值查询时,优化器能确定“值如果不存在,才会产生幻读”,既然记录已经存在,就不需要锁间隙。但如果是等值查询未命中,比如 WHERE id = 4,则会锁住3和5(如果下一个值是5)之间不存在的间隙,防止其他事务插入id=4。
非唯一索引的等值查询,即使命中了记录,也会锁范围内的间隙,因为同一个索引值可能对应多条记录,还可能未来插入新的相同键值的记录。这一点在开发里很容易忽略。
4.3 如何亲眼看到这些锁
只讲理论不管用,实际排查问题时你需要在数据库里看到锁。这里分享几个常用命令。
查看当前事务:
sql复制SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id
FROM information_schema.innodb_trx;
查看锁等待明细(MySQL 8.0使用performance_schema):
sql复制SELECT * FROM performance_schema.data_locks\G;
在5.7及更早版本,用的是 information_schema.innodb_lock_waits 和 INNODB_LOCKS,字段名略有不同。
复现锁等待时,事务1执行:
sql复制SELECT * FROM course_score WHERE id > 1 FOR UPDATE;
事务2执行:
sql复制INSERT INTO course_score (student_no, course_no, score) VALUES ('S003', 'C001', 90);
此时事务2会一直卡住,进入锁等待状态。查询 innodb_trx,能看到一个事务状态是RUNNING,另一个是LOCK WAIT。再查 data_locks,能看到事务1持有范围锁,锁类型为X(排他锁),锁模式里包含GAP字样:X,GAP;事务2等待的锁模式是插入意向锁:INSERT_INTENTION。
这种直观的观察方式,能帮你在分析线上死锁或锁等待时迅速定位到是哪条SQL惹的祸。
4.4 RR下用了临键锁,为什么还会有“奇怪”现象
有一个重要事实:临键锁只有在查询条件能走索引时,才会精确锁定对应区间。如果查询条件没有索引,或者优化器选择全表扫描,InnoDB为了在可重复读级别下保证一致性,会给所有扫描过的记录和所有间隙都加上临键锁,实际效果等同于锁表。
所以你会看到一些线上事故:一条 UPDATE 语句因为没走索引,把整张表的所有写入请求全部阻塞。问题不在“间隙锁”,而在“索引设计”。这也是我反复强调UPDATE和DELETE必须使用主键或者索引条件的原因。
另一个现象是“死锁”。两个事务各自持有不同区间的间隙锁,然后因为插入请求互相等待,最终触发InnoDB死锁检测机制,其中一个事务被回滚。下一章我会结合生产场景详细分析。
5. 从隔离级别看幻读:RR为什么能成为MySQL默认选择
把视线拉高一点,从隔离级别演化来看幻读,很多为什么就顺理成章了。
5.1 四档隔离级别逐步消除三类问题
SQL标准定义了四个隔离级别,分别解决脏读、不可重复读、幻读三类问题。用表格总览一下:
| 隔离级别 | 脏读 | 不可重复读 | 幻读(纯SQL标准角度) |
|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 |
| READ COMMITTED | 已消除 | 可能 | 可能 |
| REPEATABLE READ | 已消除 | 已消除 | 理论上应消除 |
| SERIALIZABLE | 已消除 | 已消除 | 已消除 |
在InnoDB里,读已提交隔离级别下,每次普通SELECT都会生成一个新的ReadView,所以两次读到的数据可能不同,不可重复读发生。可重复读级别下,第一个ReadView被整个事务复用,普通SELECT不会看到其他事务提交的新行,这也是很多人把“可重复读”和“不会幻读”画等号的原因。
SERIALIZABLE级别最彻底,它会把普通SELECT也自动转成加锁读,整个事务基本串行执行,幻读彻底没有,但并发能力也大打折扣。
5.2 为什么MySQL默认RR而不是RC
这是一个面试高频追问,也是理解MySQL设计哲学的好入口。
MySQL在主从复制场景下,如果binlog格式是statement,副本库回放的是逻辑SQL。在“读已提交”级别下,主库上的事务提交顺序可能导致同一批SQL在从库上执行的结果和主库不一致。可重复读级别下,事务在整个执行期间使用一致性的快照,配合事务提交顺序,能最大程度保证基于statement的binlog回放结果与主库一致。
后来有了 binlog_format=ROW,从库回放的是行级别的变更,不再依赖隔离级别来保证一致性。理论上生产环境完全可以把隔离级别降到读已提交来提升并发,很多大厂也是这么做的。但MySQL的默认值至今仍是可重复读,一方面是这个历史惯性,另一方面是可重复读对业务开发者来说,读一致性体验更好,踩坑更少。
5.3 业务真的不能容忍幻读,怎么办
如果业务语义上绝对不能出现幻读,比如账户余额统计、兑换码核销、库存扣减,那就要在架构层面做选择。
常见的方案有这几个:
- 将事务隔离级别设为SERIALIZABLE:数据库层面彻底串行化,安全性最高,但并发暴跌,一般不推荐全量使用;
- 事务内使用
SELECT ... FOR UPDATE或LOCK IN SHARE MODE:把快照读变成当前读,配合临键锁防止范围内插入; - 应用层加分布式锁:以业务主键或范围标识为锁粒度,把“查询+判断+写入”整体锁住;
- 数据库唯一索引兜底:即使并发插入,唯一索引会挡住重复数据,让违反约束的事务直接报错。
从成本角度考虑,纯数据库方案最简单,但性能损耗大;应用层锁灵活度高,但需要额外维护中间件。最好是根据业务场景组合使用。我的经验是:能让数据库唯一索引解决的,绝不在应用层写复杂锁逻辑。
6. 面试场上“mysql幻读”怎么答才算加分
说实话,“幻读”在面试里出现的频率非常高,但大多数人的答案都停留在“可重复读解决了幻读”这种一句话水平。面试官追问两句就露馅。这里我给出一套可以实际使用的话术框架。
6.1 一套可以背,但更要能讲清的答题框架
面试官问“聊聊MySQL幻读”,我建议按四层递进回答:
第一层给定义:幻读指同一个事务内执行同一查询,第二次返回的行集中出现了第一次不存在的行,主要是其他事务插入新记录导致的。
第二层讲机制:InnoDB在可重复读级别下,用MVCC的ReadView机制保证普通快照读不会看到其他事务提交的新行;用Next-Key Lock保证当前读的锁范围内,其他事务无法插入新记录。
第三层讲边界:如果事务先执行快照读,再执行UPDATE这类当前读,当前读会看到最新已提交的数据,可能出现“同一个事务里先看到2行,更新后看到3行”的现象。临键锁只在当前读执行期间生效,防不住“先快照读、后当前读”这种组合。
第四层讲工程:完全避免幻读,可以使用SERIALIZABLE、应用层锁、唯一索引兜底。但要注意RR模式下没有索引条件的UPDATE可能锁全表,生产环境要特别谨慎。
这套回答覆盖了概念、原理、边界、工程实践四个维度,既有理论也有实操,面试官基本能判断你是真懂还是背题。
6.2 高频追问:MVCC能替代锁吗
这个追问几乎必出现。答案是:不能完全替代。
MVCC解决的是“读与读之间不互斥”的问题,让快照读无需等待锁就能读到一致性快照;锁解决的是“写与写之间要互斥”的问题,以及“当前读与写之间的一致性”问题。快照读虽然不加锁,但它阻止不了别人写入;反之,当前读必须加锁,才能保证读到的最新数据和之后要做的修改基于同一份数据。
两者是互补关系。把MVCC当成万能药,碰到“先查后写”不加锁,就可能在并发场景下出问题。
6.3 另一个高频追问:RC级别下能避免幻读吗
读已提交级别下,每次普通SELECT都生成新的ReadView,所以其他事务提交的新行,在当前事务的下一次快照读中就可能看到,无法避免幻读。RC下InnoDB只在少数场景保留间隙锁(比如外键检查和重复键检查),因此当前读场景同样无法通过临键锁阻止插入。
如果面试官问到这里,还可以补充:这也是为什么很多团队在改为RC隔离级别时,会同步把binlog格式设为ROW,并且用业务手段保证并发一致性。这句话能体现你对生产环境的理解。
7. 生产环境中的幻读变体:无索引更新、OR条件与锁表
最后一部分讲生产。纸上谈兵的锁机制都很漂亮,但线上真正的坑通常藏在索引失效、条件复杂、长事务这些细节里。
7.1 无索引条件:从精确锁退化为大范围Lock
假设 course_score 表的 student_no 没有索引,执行:
sql复制UPDATE course_score SET score = score + 1 WHERE student_no = 'S001';
由于 student_no 上没有索引,数据库要全表扫描才能定位符合条件的行。为了保证可重复读级别下当前读的一致性,InnoDB会对扫描过程中经过的所有记录和所有间隙加临键锁。结果就是:整个表的写入操作全部被阻塞,包括插入一个完全不同学生的成绩记录。这种问题比显式锁表更隐蔽,因为表没有被LOCK TABLES,但从业务角度看,写入已经完全瘫痪。
排查方法:先看 SHOW PROCESSLIST,确认是否有事务长时间处于等待锁状态;再查 information_schema.innodb_trx,找到 trx_state = 'LOCK WAIT' 的事务;结合 performance_schema.data_locks 定位持有锁的源头SQL。
线上处理时,我一般建议先把肇事SQL找出并终止,再评估是否用主键条件分批更新。
7.2 OR条件走了部分索引,为什么锁范围会爆炸
另一个高频场景是查询条件里出现OR。看看这条SQL:
sql复制UPDATE course_score SET score = score + 1
WHERE student_no = 'S001' OR course_no = 'C002';
即使 student_no 和 course_no 都有单独的索引,OR的存在也可能导致优化器无法同时走两个索引。优化器可能选择其中一个索引回表,再过滤另一个条件;极端情况下干脆全表扫描。无论哪种执行计划,锁的范围都会明显大于实际需要更新的行。
更稳妥的做法是把OR拆开,先定位主键,再按主键更新:
sql复制SELECT id FROM course_score WHERE student_no = 'S001'
UNION ALL
SELECT id FROM course_score WHERE course_no = 'C002';
拿到id集合后,用IN或者临时表去执行UPDATE。这样锁范围可以精确到具体主键,不会有额外的间隙锁膨胀。
7.3 间隙锁加插入意向锁引发死锁:一个实际分析
死锁是当前读场景下最容易碰到的问题之一。经典的死锁场景是这样的:
事务A执行:
sql复制SELECT * FROM course_score WHERE id > 10 FOR UPDATE;
锁住了(10,20]、(20,30]等范围的临键锁。
事务B尝试插入一条id=18的记录:
sql复制INSERT INTO course_score (id, student_no, course_no, score) VALUES (18, 'S010', 'C005', 99);
事务B需要获取id=18所在间隙的插入意向锁,但该间隙已被事务A的间隙锁占据,于是事务B进入锁等待。
此时如果事务A又去更新事务B锁住的某一行(比如事务B在此之前已经更新过id=15的记录并持有行锁),就会形成循环等待,触发InnoDB死锁检测。
SHOW ENGINE INNODB STATUS 的 LATEST DETECTED DEADLOCK 段落会记录这两个事务的SQL、持有的锁和等待的锁,显示核心信息类似于两个事务分别在等对方释放间隙锁和记录锁。遇到死锁时不要慌,看这个段落就够了。
7.4 工程建议
把生产环境的经验沉淀成几条原则:
- UPDATE和DELETE的WHERE条件必须走索引,最好用主键或者唯一索引;
- 大范围更新要分批LIMIT处理,降低单个事务的锁持有时间;
- 长事务要监控,超过阈值自动告警,长事务是锁等待的主要来源;
- 如果业务对可重复读要求不高,可以考虑降到读已提交,并且开启
binlog_format=ROW,可以减少间隙锁带来的锁冲突; - 并发插入的防重,优先用唯一索引兜底,不要在应用层自己写先查后插。
我见过太多线上事故,不是败给高深的分布式理论,而是败在一条没走索引的UPDATE和可重复读默认级别上。幻读带来的问题,本质上都是“读旧版本”和“读新版本”的边界问题。只要记住:先查后写务必加锁,统计结果不要和当前读混着算,大部分幻读坑都能绕开。
这个老话题讲完了,但在实际项目里它永远不嫌旧。希望这篇内容能帮你在面试里讲清楚,更能在生产环境里少踩几个坑。
