群里有人抛出过一个挺经典的问题: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');
表里目前只有三
