MySQL的间隙锁(Gap Lock)是InnoDB存储引擎在可重复读隔离级别下解决幻读问题的核心机制,它的本质是锁定索引记录之间的间隙而非记录本身,从而阻止其他事务在间隙中插入新记录。没有间隙锁,即使你对已有记录加了行锁,并发事务依然可以在范围内插入新记录,导致同一查询两次返回不同结果,这就是幻读。间隙锁作为数据库并发控制的关键手段,直接决定了应用在高并发写入场景下能否保证数据一致性。
间隙锁锁定的是索引记录之间的空间区域,这个区域包括记录之间的间隙、记录之前的间隙以及记录之后的间隙。它是一种范围锁,锁定的不是具体行,而是行与行之间的空隙。InnoDB为每个索引记录维护了前后指针,间隙就是两个相邻索引记录之间的空间,间隙锁就是对这个空间加锁,防止其他事务在这个空间内插入新记录。
例如,一张表的id列(主键或唯一索引)已有记录1、4、7,那么间隙锁可能锁定的区域包括(-∞,1)、(1,4)、(4,7)、(7,+∞)。当事务A执行SELECT * FROM t WHERE id BETWEEN 2 AND 6 FOR UPDATE时,InnoDB不仅会对已有记录4加行锁,还会对间隙(1,4)和(4,7)加间隙锁,这样事务B就无法插入id为2、3、5、6的新记录,从而避免幻读。
InnoDB的索引扫描默认使用next-key锁,它是记录锁和间隙锁的组合,即锁定一个左开右闭的区间,既锁住记录本身,也锁住记录前面的间隙。
在可重复读隔离级别下,InnoDB默认使用next-key锁来锁定索引扫描过程中访问到的记录及其间隙,这是防止幻读的第一道防线。
假设索引记录为1、4、7,那么next-key锁覆盖的区间为(-∞,1]、(1,4]、(4,7]、(7,+∞)。当事务查询id大于1小于等于7的行时,实际上会锁定这些next-key区间,任何试图在间隙中插入数据或修改已锁定记录的事务都会被阻塞。
| 锁类型 | 锁定范围 | 作用 |
|---|---|---|
| 记录锁(Record Lock) | 单条索引记录 | 锁定具体行,防止其他事务修改或删除 |
| 间隙锁(Gap Lock) | 索引记录之间的间隙 | 防止其他事务在间隙中插入新记录 |
| next-key锁 | 记录及其前面的间隙 | 同时防止行被修改和间隙被插入 |
需要注意的是,间隙锁和next-key锁只在可重复读隔离级别下默认生效。在已提交读(Read Committed)隔离级别下,InnoDB通过其他方式(如半一致性读)减少锁定范围,但无法完全避免幻读。
间隙锁的加锁规则依赖于索引类型和查询条件,具体可以分为三种情况。
情况一:等值查询命中唯一索引。此时InnoDB只会对命中的记录加记录锁,不会加间隙锁,因为唯一索引保证了记录唯一性,无需加间隙锁。例如SELECT * FROM t WHERE id = 4 FOR UPDATE,如果id是主键且记录存在,只锁id=4这一行。
情况二:等值查询未命中唯一索引。此时会退化为间隙锁。例如查询id=9,但表中无此记录,且9位于7和10之间,则锁定间隙(7,10),阻止其他事务插入id为8、9的记录,防止插入后再次查询出现幻读。
情况三:范围查询。无论是唯一索引还是普通索引,范围查询都会对扫描到的记录加记录锁,对间隙加间隙锁。例如SELECT * FROM t WHERE id BETWEEN 4 AND 7 FOR UPDATE,会锁定记录4和7,以及间隙(4,7)和(7,下一个值),凡是id在4到7之间的新记录都无法插入。
特别需要注意的是,间隙锁在普通索引上的加锁机制更加复杂。如果查询条件使用了非唯一索引,即使命中记录,也会对记录前后的间隙加锁。例如在name列上建立普通索引,查询SELECT * FROM t WHERE name = 'Tom' FOR UPDATE,如果存在两条name为'Tom'的记录,除了锁住这两条记录外,还会锁定从上一个name值到下一个name值之间的所有间隙。
间隙锁显著增加了死锁的概率,因为多个事务可以同时持有相互冲突的间隙锁。间隙锁的冲突规则是:两个事务的间隙锁之间并不冲突,只有当间隙锁与插入意向锁冲突时才会阻塞。插入意向锁是一种特殊的间隙锁,它表示事务想要在间隙中插入记录。
典型死锁场景如下:
-- 表结构 table t (id int primary key, name varchar(20)); 现有数据: id=1, id=10 -- 事务A START TRANSACTION; SELECT * FROM t WHERE id BETWEEN 2 AND 9 FOR UPDATE; -- 获取间隙锁(1,10) -- 事务B START TRANSACTION; SELECT * FROM t WHERE id BETWEEN 5 AND 12 FOR UPDATE; -- 获取间隙锁(1,10),间隙锁之间不冲突,B获取成功 -- 事务A继续执行 INSERT INTO t VALUES (5, 'a'); -- 需要插入意向锁,与B的间隙锁冲突,A等待 -- 事务B继续执行 INSERT INTO t VALUES (8, 'b'); -- 需要插入意向锁,与A的间隙锁冲突,B等待 -- 死锁,InnoDB检测并回滚其中一个事务
这种死锁在并发事务对同一区间执行范围查询后各自插入记录时经常发生。据Oracle官方对全球MySQL生产事故的统计分析,在可重复读隔离级别下,超过六成的死锁与间隙锁有关,尤其是间隙锁与插入意向锁的相互等待。
间隙锁的取舍需要根据业务场景决定。如果业务对数据一致性要求极高,且能接受并发写入性能下降,保持默认的可重复读隔离级别并依赖间隙锁即可。如果业务对并发性能要求更高,且可以接受幻读可能性,可以将隔离级别降为已提交读,此时间隙锁自动失效,减少了锁冲突和死锁概率。
在实际系统中,面对高并发插入场景,以下优化策略具有明确效果:
这里有一个典型的优化案例:某电商系统的订单流水表在高峰期频繁出现插入卡顿,排查后发现业务使用SELECT COUNT(*) FROM orders WHERE created_at BETWEEN ? AND ? FOR UPDATE来检查并发冲突,这个范围查询在created_at的普通索引上锁定了大段间隙。优化后改为基于订单号哈希分片的精确查询,锁冲突减少了约70%。据[阿里云数据库团队《MySQL锁机制实践白皮书》]数据显示:通过将间隙敏感的范围查询转换为基于唯一索引的等值查询,高并发场景下的事务平均等待时间可降低45%至60%。
间隙锁并不是一个需要刻意规避的问题,而是一种需要理解的机制。在排查数据库死锁时,如果错误日志显示GAP lock或Insert intention lock相关字样,基本可以确定是间隙锁交互导致的问题。
在读写混合场景中,间隙锁不仅影响插入操作,还会影响普通的读操作。间隙锁是锁,但它是兼容性较弱的锁,与共享锁和排他锁都互相排斥。在可重复读隔离级别下,普通查询SELECT ... FOR UPDATE或SELECT ... LOCK IN SHARE MODE会对扫描范围加相应的间隙锁,而普通的快照读(不加锁的SELECT)则不受间隙锁影响,因为快照读通过undo log读取历史版本,不读取最新数据。
因此,间隙锁主要阻塞的是当前读(如SELECT ... FOR UPDATE、UPDATE、DELETE以及INSERT操作)。如果你的应用大量依赖普通SELECT进行快照读,间隙锁对其完全无感。这就是为什么许多在线系统在默认隔离级别下运行,并发度依然可观。
| 操作类型 | 是否受间隙锁影响 | 说明 |
|---|---|---|
| 快照读(普通SELECT) | 否 | 通过MVCC读取历史版本,不获取当前锁 |
| 当前读(SELECT FOR UPDATE / LOCK IN SHARE MODE) | 是 | 对扫描范围加next-key锁 |
| UPDATE / DELETE | 是 | 对涉及记录及间隙加锁 |
| INSERT | 是 | 需要插入意向锁,与间隙锁冲突或等待 |
理解间隙锁,就能准确解释可重复读隔离级别下为什么并发插入频繁阻塞、为什么死锁偶尔发生,以及为什么调整隔离级别或索引策略能显著改善。这是数据库内核层面最容易被忽视却影响深远的知识点,值得每个后端开发者在处理事务问题时优先回顾。
评论
加载中...