SELECT ... FOR UPDATE 会锁住不存在的行,因为间隙锁(Gap Lock)锁定的是索引区间而非具体记录;例如 id 主键存在 (1,'a') 和 (5,'b') 时,WHERE id>2 AND id<4 会锁住 (1,5) 之间的间隙,防止幻读。

为什么 SELECT ... FOR UPDATE 会锁住不存在的行
间隙锁(Gap Lock)不是锁记录,而是锁索引区间。比如 id 是主键,当前有记录 (1, 'a') 和 (5, 'b'),执行 SELECT * FROM t WHERE id > 2 AND id ,即使没查到任何行,MySQL 也会在 <code>(1, 5) 这个间隙上加锁——后续所有 INSERT INTO t VALUES (3, 'c') 都会被阻塞。
这不是 bug,是 RR 隔离级别下防止幻读的机制。但业务里常误以为“没查到数据就等于没加锁”,结果插入卡住还查不出原因。
- 只在
REPEATABLE READ隔离级别下默认启用间隙锁;READ COMMITTED下会禁用(但要配合 binlog_format=ROW 才安全) - 唯一索引等值查询(如
WHERE id = 3)不会触发间隙锁,但范围查询(>、<、BETWEEN)、非唯一索引查询、LIKE 'abc%'都可能触发 -
SELECT ... LOCK IN SHARE MODE同样受间隙锁影响,不只FOR UPDATE
如何用 SELECT ... FOR UPDATE 避开间隙锁
核心思路:让查询走**唯一索引 + 等值匹配**,MySQL 就只加记录锁(Record Lock),不加间隙锁。
- 把
WHERE status = 'pending' AND create_time > '2024-01-01'改成先查出具体id(走主键或唯一索引),再用WHERE id = ?加锁 - 避免在非唯一索引字段上做范围查询;如果必须查状态+时间,考虑给
(status, create_time)建联合唯一索引(需业务逻辑保证组合唯一) - 用
SELECT ... FOR UPDATE NOWAIT替代默认阻塞,能立刻报Lock wait timeout exceeded错误,方便定位哪条 SQL 卡住了锁
示例:原写法 SELECT * FROM order WHERE user_id = 123 AND status = 'unpaid' FOR UPDATE(user_id 是普通索引)→ 改为先 SELECT id FROM order WHERE user_id = 123 AND status = 'unpaid' LIMIT 1,再 SELECT * FROM order WHERE id = 456 FOR UPDATE。
修改查询条件后插入仍被阻塞?检查是否漏了隐式锁升级
有时改了 SELECT 条件,INSERT 还是卡住,大概率是其他事务还在持有更宽泛的锁,或者你自己的事务里混用了不同条件的加锁语句。
- 用
SELECT * FROM performance_schema.data_locks查当前锁信息(MySQL 8.0+),重点关注LOCK_MODE是否含GAP,LOCK_DATA显示锁住的索引值范围 - 确认没有在同一个事务里先执行了
SELECT ... WHERE status IN ('a','b') FOR UPDATE,又执行INSERT——前者可能已锁住整个status索引段 - 临时关闭间隙锁最直接的方式是设会话级隔离级别:
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED,但要注意主从复制和一致性风险
业务层绕过间隙锁的务实做法
不总靠调数据库参数或改 SQL,有些场景更适合在应用层收敛。
- 插入前先
SELECT COUNT(*)判断是否存在冲突(需搭配唯一约束兜底),避免对“可能不存在”的范围加锁 - 用
INSERT ... ON DUPLICATE KEY UPDATE替代“先查再插”,只要冲突字段有唯一索引,就完全绕过 SELECT 加锁环节 - 对高并发插入同一类数据(如订单号生成),改用独立号段服务或 Redis 自增,不走 MySQL 行锁路径
间隙锁的边界模糊、表现隐蔽,尤其在复合条件和多索引共存时,单看 SQL 很难预判锁范围。线上遇到插入卡住,优先抓 data_locks 和 innodb_trx,别只盯着自己写的那条 INSERT。


















