群里有人抛出过一个挺经典的问题:MySQL 在可重复读(RR)隔离级别下,记录锁加间隙锁,到底能不能防住删除操作导致的幻读?问的人还带了个很常见的直觉:删除只是把行变少,怎么可能会“多”出幻觉数据来?这个直觉恰恰是很多并发问题的起点。要回答这个问题,不是简单说一句“能”或“不能”,而是得把 delete 在 InnoDB 里到底怎么加锁、锁在哪些范围、在什么条件下会退化、什么条件下直接失效,完整过一遍。这篇文章我就打算从幻读定义本身开始拆,再把删除场景下的加锁行为一层层摊开,最后给出一套可以实际操作的排查和验证方法。
1. 幻读定义里被大多数人漏掉的那半句话
1.1 “多出来一行”只是幻读的一种形式
很多人对幻读的记忆还停留在教科书上的经典描述:事务 T1 查询了一批行,事务 T2 插入了一行,T1 再次查询时看到了多出来的这一行,于是产生“幻觉”。这个描述本身没有错,但它把重点放在了“插入新行”上,导致大家下意识认为只有 insert 才会产生幻读,delete 不会。
实际上幻读的标准语义比这宽得多:同一个事务里,用同一条查询条件执行两次,第二次得到的结果集和第一次不一致,无论这个不一致是“多了一行”还是“少了一行”,在业务层面都可能造成不可预期的问题。只不过 MySQL 的可重复读隔离级别加上 MVCC 之后,普通快照读天然消除了“少一行”的感知,所以 delete 造成的那些行数变化,大多数时候被掩盖掉了。
但关键在于,delete 本身是一条当前读操作,它的执行方式是实时读取已经提交的最新数据,并且要加锁。如果 delete 在扫描某个范围的过程中,另一个事务往这个范围内插入了一条新记录,那么这条新记录就会成为“本不该出现在这个删除操作结果集里的行”,从结果集变化的角度看,这就是一种幻读。所以“删除操作会不会导致幻读”这个问题,本质上不是问 commit 之后数据少了多少,而是问:一个正在按条件删除数据的事务,能不能保证自己两次读取或者完整扫描到的是同一批数据。
1.2 快照读和当前读,是两套并行运行的逻辑
要把这个问题讲清楚,必须先把 InnoDB 里“读”的两种类型分开。
普通 SELECT 语句走的是快照读,也叫一致性读。它不会加锁,读取的是事务开始后某个时间点生成的 read view 对应的数据版本。在这个机制下,RR 级别里同一个事务的多次普通 SELECT 看到的都是同一个快照,所以其他事务删了行、插了行,只要没有提交或者提交时间晚于快照点,当前事务都看不见。这部分“防幻读”能力来自 undo log 版本链和 read view,和锁没关系。
但 DELETE、UPDATE、SELECT ... FOR UPDATE 这些语句走的是当前读,它们每次读取的是最新已提交版本,并且需要加锁。当前读无法靠 MVCC 的快照机制来保证两次读取结果一致,所以 InnoDB 必须用锁来保护正在扫描的区间,防止其他事务插入或修改会改变结果集的数据。
把这两套机制分开之后就能明白:记录锁加间隙锁要解决的是“当前读”这个层面的幻读风险;而普通 SELECT 的幻读风险已经被快照机制解决了。所以当我们问“锁能不能防删除导致的幻读”时,真正要考察的是 delete 这条当前读语句在并发写入下能不能锁住自己关心的整个区间。
1.3 delete 产生幻读错觉的根源
直觉上认为 delete 不会产生幻读,其实是因为我们默认了一个前提:两次查询之间,只有“其他事务插入新行”才会让结果集变大,而 delete 是在删行,只会让表里现存的行变少,变少的结果不会让查询突然多出数据。
这个默认前提放在快照读场景下大体成立,毕竟你看不到别人提交的新增数据。但 delete 操作本身是会扫描索引的,它的扫描范围取决于 where 条件。一个事务执行范围删除时,另一个事务在同一个范围内插入新行,这次 delete 的扫描结果和它最终要删除的数据集合就可能不一致。更隐蔽的情况是,当前事务第一次用当前读确认了某批数据不存在,然后另一个事务插入并提交,当前事务第二次执行时发现这批数据出现了——对业务来说,这就和幻读没有区别。
这其实是一个非常经典的并发更新问题:判断“数据是否存在然后删除”并不是原子的,如果判断和删除之间没有一把足够宽的锁,缝隙里就会长出数据来。记录锁加间隙锁的组合,正是为了堵住这个缝隙而设计的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从零复现:delete 范围内插入一行,到底会被锁多久
2.1 准备好实验环境和初始数据
先说实验环境,我这边用的是 MySQL 8.0.32,InnoDB 引擎,事务隔离级别是默认的 REPEATABLE READ。为了观察加锁细节,建议把 autocommit 关掉,用两个终端会话分别模拟两个事务。
建表和数据如下:
sql复制CREATE TABLE t (
id INT PRIMARY KEY,
name VARCHAR(20)
) ENGINE=InnoDB;
INSERT INTO t(id, name) VALUES (1, 'a'), (5, 'b'), (10, 'c');
表里目前只有三行,主键分别是 1、5、10,中间天然存在 (1,5) 和 (5,10) 两个间隙。后面所有实验都围绕这张表展开。
2.2 事务 A 删除一个不存在的单点,事务 B 插入同一点
第一个实验场景是最容易引起兴趣的:事务 A 删除一个当前并不存在的 id,比如 id=7,事务 B 紧接着插入 id=7,B 会不会被阻塞?
操作过程如下。
终端 A 开启事务并执行删除:
sql复制BEGIN;
DELETE FROM t WHERE id = 7;
终端 B 开启事务并尝试插入:
sql复制BEGIN;
INSERT INTO t(id, name) VALUES (7, 'x');
你会看到这个 INSERT 语句卡住了,一直处于锁等待状态。原因很直接:id=7 这条记录当前不存在,delete 的内部语义和“等值查询未命中”是一样的,InnoDB 不能只锁一条不存在的记录,于是它会在 id=7 应该存在的逻辑位置上加一把间隙锁。具体来说,扫描索引时会定位到第一条大于 7 的记录,也就是 id=10,然后给它加上一个 next-key lock,锁定的区间是 (5,10]。
此时事务 B 想插入 id=7,这个值落在 (5,10) 这个间隙里,插入动作需要先获取插入意向锁,而插入意向锁和已经存在的间隙锁是互斥的,于是 B 只能原地等待 A 提交或回滚。
为了确认锁确实加在这个位置,可以在第三个会话里查询 performance_schema 的锁信息:
sql复制SELECT ENGINE_TRANSACTION_ID, OBJECT_NAME, INDEX_NAME,
LOCK_TYPE, LOCK_MODE, LOCK_STATUS, LOCK_DATA
FROM performance_schema.data_locks;
如果一切正常,能看到事务 A 对应的事务持有 id=10 这条记录上的 X 锁,而且锁模式是 X,GAP 或者 X 加在 rec_not_gap 之外的形式,具体呈现和版本有关,但都能看出锁数据指向 id=10。
然后让事务 A 回滚:
sql复制ROLLBACK;
事务 B 的 INSERT 会立刻执行成功。这说明什么?说明间隙锁不是一个永久的路障,它只是在事务 A 的执行周期内,阻止其他事务把新数据插进它扫描过的间隙。
2.3 事务 A 范围删除,事务 B 往区间内插入
单点不存在的场景验证完,再来看更常见的范围删除场景。
事务 A 执行:
sql复制BEGIN;
DELETE FROM t WHERE id BETWEEN 4 AND 6;
表里满足条件的只有 id=5 这一行。如果只在 id=5 上加记录锁,那事务 B 插入 id=4、id=6 或者 id=7 这些范围边界附近的记录时可能不会受阻碍,这就会留下幻读隐患。
实际执行时,事务 B 插入 id=6:
sql复制BEGIN;
INSERT INTO t(id, name) VALUES (6, 'y');
同样会被卡住。为什么?因为事务 A 的删除不只是锁定了 id=5 这行,而是从索引定位开始扫描,把扫描沿途遇到的索引区间都加了 next-key lock。id=5 这条记录本身有记录锁,它前面的间隙 (1,5) 也被锁住;为了确定范围边界,InnoDB 还会继续向右扫描到第一条不在范围内的记录 id=10,并在 id=10 上加 next-key lock,相当于把 (5,10] 也一起锁住。这样一来,id=4、6、7、8、9 这些位置的插入请求都得排队等 A 结束。
这个现象暴露了一个非常重要的规律:delete 的加锁范围不是用肉眼从 where 条件里画出来的,而是由索引扫描实际走过的轨迹决定的。范围条件意味着扫描不会在范围结束的那一条记录上戛然而止,它会多走一步,锁定下一个记录及其前面的间隙,以确保“范围边界之外紧挨着的位置”不会突然冒出新行。
2.4 锁等待和死锁是两码事
上面的锁等待超过默认 50 秒后,终端 B 会报这样一个错误:
sql复制ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
这是锁等待超时,不是死锁。InnoDB 的死锁检测机制正常情况下会在两个事务互相持有对方需要的资源时,立刻让其中一个事务回滚,报错码通常是 1213。实验里 B 单纯在等 A,A 并没有反过来等 B,所以系统不会把它判定为死锁,只会让 B 一直等到超时。
这个区分在生产环境排查时非常重要。一看到“Lock wait timeout”就以为发生了死锁,是很多刚接触锁机制的人最容易犯的错。锁等待处理起来通常比较简单:找到持有锁的事务,让它尽快提交或者回滚;死锁则需要从日志里分析事务之间的资源竞争关系,处理策略完全不同。
3. 记录锁加间隙锁在删除时的真实加锁范围拆解
3.1 把 next-key lock 当成一个整体来看
在 InnoDB 里,记录锁和间隙锁经常不是独立出现的,它们组合成一种叫 next-key lock 的锁,也就是临键锁。记录锁锁住的是索引记录本身,间隙锁锁住的是两个索引记录之间不存在的那些位置。一个 next-key lock 可以表示成一个左开右闭的区间,比如 (5,10],它的含义是:间隙 (5,10) 不能被插入新值,同时索引记录 10 本身被锁住。为什么记录 10 也要锁?因为如果不锁住它,其他事务可能把 id=10 删掉,或者修改它的索引值,导致当前事务的区间参考点漂移。
在默认 RR 隔离级别下,InnoDB 做范围查询和删除时,默认用的就是 next-key lock。当你看到某条 delete 语句的锁范围是一串连续的 next-key lock 时,其实不用惊讶,这是 InnoDB 为了保证结果集稳定所采取的标准做法。
前面那个范围删除的例子就可以用这个区间表示法还原:表里初始有 1、5、10,事务 A 执行 DELETE FROM t WHERE id BETWEEN 4 AND 6,扫描过程中会对 id=1 和 id=5 之间的区域、id=5 和 id=10 之间的区域分别处理,最终留下的锁分布大致是 id=1 上的 next-key lock、id=5 上的 next-key lock、id=10 上的 next-key lock。由于范围只到 6,id=1 对应的那个区间是否保留取决于优化器的具体扫描路径,但 id=10 上的锁一定会出现。
3.2 等值删除且记录存在时,锁会退化成记录锁
这里有个细节值得单独拿出来讲:如果 delete 的 where 条件是一个唯一索引的等值条件,而且目标记录恰好存在,那么 InnoDB 不会在这个位置加间隙锁,只会加记录锁。
举个例子:
sql复制BEGIN;
DELETE FROM t WHERE id = 5;
id=5 存在,delete 会直接定位到这行并加 X 锁。此时另一个事务尝试插入 id=6,并不会被阻塞。这看起来是不是很危险?其实不危险,因为这次删除的查询条件是等值 id=5,查询结果只可能包含 id=5 这一行,其他事务即使插入 id=6,也不会改变“id=5 存在与否”这个结果集。唯一能改变这个结果集的插入动作是插入另一个 id=5 的新行,但这种插入会被主键唯一约束挡住,插入事务会等待当前事务释放 id=5 上的记录锁。
这就是为什么唯一索引等值命中时 next-key lock 可以退化成 record lock:不是随便退的,是因为唯一约束已经替你挡住了“产生幻读的行”进入当前区间的路径,不需要再用间隙锁重拳出击。
但请注意,这个退化只对唯一索引等值命中成立。如果 where 条件用的是普通索引列,即使等值命中,InnoDB 也不会把 next-key lock 完全退掉。普通索引允许相同值出现在不同主键上,某个正在被删除的普通索引值附近,随时可能出现一个新的相同索引值,所以必须保留间隙锁,把可能的插入位置全部封死。
3.3 等值删除且记录不存在时,锁的是间隙而不是记录
等值删除存在时锁记录,那等值删除不存在的地方锁什么?前面实验里已经验证过了,锁的是目标记录“如果存在就应该在的那个间隙”。
还是用 id=7 这个例子,表里没有 7,delete 扫描时会向后再找一个下一条记录 id=10,在 id=10 上加 next-key lock,锁住区间 (5,10]。这个区间里的所有 id 值,6、7、8、9,在事务 A 结束前都无法插入。
这个设计意图很好理解:事务 A 执行了一个条件为 id=7 的删除,它意味着“这个位置应该没有数据”。但如果事务 A 提交前,另一个事务在同一个位置插入了一条新记录,那么事务 A 第二次执行同样的查询时,就会突然看到一条新数据。为了防止这种结果集变化,InnoDB 必须把 id=7 可能被插入的整个间隙都保护起来。由于 id=7 本身没有记录可锁,只能锁住它所在的间隙以及间隙的右边界记录。
正因为如此,gap lock 也被称为“防幻读锁”,它的主要工作对象从来不是已经存在的行,而是还没出现、但将来可能出现的行。只要存在这种可能性,就必须在间隙层面设卡拦截。
4. 真正会让这套锁“防不住”的边界场景
4.1 隔离级别一旦降到 RC,间隙锁直接失效
记录锁加间隙锁能防幻读,有一个暗含前提:事务隔离级别必须是 RR 或以上。MySQL 在 READ COMMITTED(RC)隔离级别下会关闭间隙锁,delete 语句只对扫描过程中实际匹配到的记录加记录锁,范围两侧的间隙完全不加锁。
还是那个场景,如果隔离级别是 RC,事务 A 执行 DELETE FROM t WHERE id BETWEEN 4 AND 6,那么表里已有记录 id=5 会被锁定,但 id=10 上不会有 next-key lock,(5,10) 这个间隙也空着。事务 B 此时插入 id=7 甚至 id=6,都不会被阻塞,只要新插入的主键值不直接撞上已经被锁的 id=5,就能立即成功。
这导致的结果是:事务 A 的删除扫描可能没结束,事务 B 插入的新行已经进入同一范围。在 RC 级别下,这不算违约,因为 RC 本身只保证读已提交数据,不要求可重复读,更不会保证范围结果集稳定。但业务代码如果是在 RR 默认配置下写出来的,换到 RC 环境后,删除逻辑就可能出现“删了又冒出新数据”的怪现象。
用一张表简单对比 RC 和 RR 下的差异:
| 隔离级别 | 记录锁 | 间隙锁 | delete 能否防止范围内的并发插入 | 结果集稳定性 |
|---|---|---|---|---|
| RR | 有 | 有 | 能 | 稳定 |
| RC | 有 | 没有 | 不能 | 不稳定,允许已提交新行插入 |
不少团队为了降低死锁概率、提升并发性能,会把生产环境切到 RC。这种切换在大多数读多写少场景下收益明显,但如果有业务依赖范围删除或范围更新的原子性,那就必须重新审视。毕竟 RC 下 delete 防不了幻读不是 bug,而是隔离级别的既定语义。
4.2 where 条件不走索引时,防是防住了,但锁的范围完全失控
还有一个场景在失效的边缘疯狂试探:delete 的 where 条件没有索引,或者索引因为函数、隐式转换等原因失效。
表里如果没有 name 索引,执行:
sql复制BEGIN;
DELETE FROM t WHERE name = 'a';
InnoDB 只能走聚集索引全表扫描,把所有记录逐条读出来判断 name 是否匹配。在扫描过程中,InnoDB 会对每一条读到的聚集索引记录都加 next-key lock。由于聚集索引就是主键本身,所有记录都在扫描范围内,记录之间的所有间隙也都会被锁住。
结果就是:在这个事务结束之前,这张表上几乎所有插入操作都会被阻塞,写并发基本为零。从防幻读角度看,确实防住了,任何事务都没法往表里插新行,结果集当然不会变。但这种“防住”是以牺牲整个表的并发能力为代价的,在实际生产中,一个没走索引的大范围 delete,瞬间就可能拖垮线上业务。
这里有个特别容易踩的坑:delete 语句加了 LIMIT 也救不了。
sql复制DELETE FROM t WHERE name = 'a' LIMIT 100;
不少人以为加 LIMIT 会减少锁定的行数,实际上 InnoDB 在确定哪些记录满足条件之前必须扫描并加锁,LIMIT 只能限制最终删除的行数,不能限制扫描过程中加锁的记录数。只要扫描路径走的是全表,锁的范围就是全表。
4.3 快照读和当前读混用,会造成“刀切豆腐两面光”的假象
还有一个让人困惑的场景,和锁本身没有直接关系,但经常被误认为锁失效。
事务 A 开启 RR 事务后,先用普通 SELECT 查了一遍 id=7,查不到。此时事务 B 插入 id=7 并提交。事务 A 再用普通 SELECT 查一遍 id=7,仍然查不到。这没问题,快照读读的是旧快照。但如果事务 A 接着执行 DELETE FROM t WHERE id = 7,会发现它能删掉 id=7——因为 delete 是当前读,读取的是最新已提交数据,它能看到事务 B 插入的那一行。
这算不算幻读?严格说不算,因为事务 A 的两次普通 SELECT 结果并没有变化。但在业务逻辑上,这个现象经常导致困惑:明明前面查不到,后面却删到了。理解“快照读负责普通 SELECT,当前读负责 DML”这条界线,比死记硬背任何锁规则都重要。锁解决的是当前读下的并发一致性,快照解决的是普通 SELECT 下的一致性,两者各管一段,不能混为一谈。
同样道理,如果一个事务先 delete 了 id=7,然后再用普通 SELECT 查一下,自己依然看不到 id=7,这也不是锁出了问题,而是当前事务的修改在快照读里有自己的可见性处理。分析这类问题时应先分清语句走的是哪类读,再去讨论锁范围,否则很容易被表面现象带偏。
5. 实际生产里写删除语句,我是这样判断和排查的
5.1 任何 delete 并发问题先问自己三句话
现在遇到“这条 delete 会不会被并发插入影响”“要不要手动加锁”这类问题,我会习惯性地问三个问题。
第一问:当前事务隔离级别是不是 RR 或以上?如果不是,删除范围结果集不稳定的问题就是无解的,记录锁加间隙锁这套机制根本不生效。
第二问:where 条件是等值还是范围?如果走的是唯一索引等值且记录存在,那锁会退化成记录锁,不需要担心范围内插入同主键值,因为唯一约束已经兜底;如果走的是范围条件或者普通索引,那就要按 next-key lock 覆盖的区间来分析。
第三问:执行计划实际走的是哪个索引,扫描区间有多宽?这是最容易被漏掉的问题。delete 加锁只认执行计划,不认 where 里的字面意思。同一个 where 条件,可能因为统计信息变化而选择不同的索引,加锁范围随之剧烈变化。业务高峰期如果发现一条平时很快的 delete 突然锁了一大片,第一件事应该是看执行计划是不是变了。
这三问的顺序不能颠倒,隔离级别是总开关,条件形状决定锁是否会退化,执行计划决定锁的实际覆盖范围。任何一层出现问题,都可能得到错误的结论。
5.2 批量修复数据时,先用 SELECT ... FOR UPDATE 探一遍锁
线上做数据订正经常会执行大范围 delete,比如清掉一批过期订单、修复一批状态异常的数据。直接执行 delete 的风险在于,语句一跑起来锁就生效了,万一扫描范围超出预期,会把线上的正常写入挡住。更稳妥的做法是先用当前读探路。
在一个事务里执行:
sql复制BEGIN;
SELECT * FROM t WHERE status = 'expired' FOR UPDATE;
此时已经能模拟出 delete 的大致锁范围。接下来可以查询 performance_schema.data_locks,看看锁到底覆盖了哪些记录和间隙。确认无误之后再执行真正的 delete,或者把事务提交、让 select 的锁先释放,再单独执行 delete。
这个习惯让排查成本低很多。delete 一旦执行,回滚也要付出代价;select for update 则可以在发现问题后直接 rollback,损失的只是一点点锁等待时间。确认锁范围是第一步,之后还应该确认影响行数是否符合预期,两者都确认完了,再真正走向生产环境的删除。
5.3 看锁状态的实用命令,别再靠猜
排查锁问题最直接的工具是 performance_schema.data_locks 表。在 MySQL 8.0 里,information_schema.innodb_locks 已经废弃,不建议再用。常用的查询语句如下:
sql复制SELECT
ENGINE_TRANSACTION_ID,
OBJECT_NAME,
INDEX_NAME,
LOCK_TYPE,
LOCK_MODE,
LOCK_STATUS,
LOCK_DATA
FROM performance_schema.data_locks;
LOCK_TYPE 分为 TABLE 和 RECORD 两种,TABLE 锁通常来自 DDL 或外键检查,RECORD 锁才是分析重点。LOCK_MODE 里的标识含义大概可以这样对应:
| LOCK_MODE 标识 | 含义 |
|---|---|
| X, REC_NOT_GAP | 仅记录锁,锁住索引记录本身,不锁间隙 |
| X, GAP | 仅间隙锁,锁住两个记录之间的间隙,不锁记录本身 |
| X | 通常是 next-key lock,即记录锁加间隙锁的组合 |
| X, INSERT_INTENTION | 插入意向锁,表示事务准备插入,需要和已有间隙锁竞争 |
LOCK_DATA 字段要结合 INDEX_NAME 看。如果是主键索引,它就表示被锁住的主键值;如果是二级索引,会显示二级索引值加主键值。排查时先找到持有锁的事务 ID,再去 performance_schema.data_lock_waits 里看谁在等谁,基本就能还原整个阻塞链路。
5.4 大范围删除的拆批操作要懂得重新算边界
最后聊一个生产实战里非常常见的问题:千万级大表要清理历史数据,一条 delete 语句直接跑,即使走对了索引,也可能因为持有的间隙锁太多、事务执行时间太长而拖垮复制延迟或阻塞其他业务。
常见拆批做法是每次只删一小段主键范围,比如:
sql复制DELETE FROM t
WHERE id BETWEEN 100000 AND 200000
AND create_time < '2023-01-01'
LIMIT 5000;
这种方式看起来简单,但有个坑:如果主键范围划分在删除过程中不连续,比如上一批删完后,下一批回表扫描的成本会变高;如果每条 delete 事务还包裹了额外业务逻辑,锁的持有时间会被拉长。拆批时要关注的不只是“一次删多少行”,还有“这次范围是否在索引上连续、锁大概覆盖多大区间、事务大概执行多久”。
我的经验是拆批后每批 commit 一次,且每批之间加一个短暂 sleep,给主从复制和应用层一点缓冲时间。真正严苛的场景下,我会先按时间升序取出需要删除的最小主键和最大主键,再按主键分片,避免二次扫描造成的锁区间扩大。这个操作的要点不是每批删除多少行,而是让每一批 delete 都能快速开始、快速结束,把锁持有时间压到最短。
还有一个容易被忽视的细节:拆批删除时如果业务上允许,可以显式把事务隔离级别保持 RR,但通过缩小 where 范围来控制锁。毕竟 RR 下防幻读的语义更完整,真正的风险从来不是间隙锁本身,而是间隙锁覆盖的面积太大、持有时间太长。控制好这两个变量,delete 在并发环境里就能既安全又高效地运行。
我自己在实际操作中最深的体会是:MySQL 的锁机制并不会按照直觉工作,它严格遵循索引扫描的轨迹。所以排查这种问题时,我最先做的事不是马上写 delete,而是把执行计划拉出来,看优化器到底选了哪条路。先有执行计划,再谈锁范围,最后才谈幻读能不能防住——这个顺序帮我避免了很多次线上故障,也希望对你有点用。
