间隙锁在 RR 隔离级别下如何阻塞 INSERT 并发场景

线上一条订单落库接口偶发 Lock wait timeout exceeded; try restarting transaction,日志里死锁检测输出 insert intention lock 与 gap lock 互相等待。开发同学第一反应是"我插的是新数据,没人锁它"。这正是间隙锁最常见的认知盲区:间隙锁锁的是范围,而 INSERT 需要的插入意向

线上一条订单落库接口偶发 Lock wait timeout exceeded; try restarting transaction,日志里死锁检测输出 insert intention lock 与 gap lock 互相等待。开发同学第一反应是”我插的是新数据,没人锁它”。这正是间隙锁最常见的认知盲区:间隙锁锁的是范围,而 INSERT 需要的插入意向锁必须先在这个范围上”报到”。本文用可复现的 SQL 讲清楚 RR 隔离级别下间隙锁如何阻塞 INSERT,包括加锁规则、复现步骤、死锁日志读法,以及面试中最常追问的边界问题。

一、先厘清三种锁:记录锁、间隙锁、Next-Key Lock

InnoDB 在 RR(REPEATABLE READ)隔离级别下默认使用 Next-Key Lock 解决幻读。三种锁粒度的关系是理解一切的前提:

锁类型 锁住的范围 典型触发场景
Record Lock 索引上的一条具体记录 唯一索引等值命中且记录存在
Gap Lock 两条索引记录之间的开区间,不含端点 非唯一索引等值、范围查询、等值未命中
Next-Key Lock 左开右闭区间 (prev, current] RR 下范围扫描的默认加锁方式

关键点在于:Gap Lock 之间是互相兼容的。两个事务可以同时持有同一个间隙的 gap 锁,这是很多人误以为”间隙锁不阻塞并发”的来源。但 Gap Lock 与 Insert Intention Lock(插入意向锁) 互斥——插入意向锁是 INSERT 在真正写入前必须先拿到的锁,它落在要插入位置之前的那个间隙上。于是”两个 SESSION 都能拿到 gap 锁,但谁都插不进去”的现象就出现了。

面试高频追问:为什么间隙锁设计成互相兼容?因为间隙锁的目的是防止别的会话插入新记录到间隙中产生幻读,而不是排斥其他读操作,互相兼容可以降低读读之间的阻塞。

二、加锁规则:把范围查询拆成 Next-Key + Gap

用一个具体例子说明。建表语句(在 MySQL 8.0.32 实测,5.7 行为一致):

CREATE TABLE t_order (
  id INT UNSIGNED NOT NULL AUTO_INCREMENT,
  order_no VARCHAR(32) NOT NULL,
  user_id INT UNSIGNED NOT NULL,
  amount DECIMAL(10,2) NOT NULL,
  PRIMARY KEY (id),
  UNIQUE KEY uk_order_no (order_no),
  KEY idx_user_id (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

INSERT INTO t_order (order_no, user_id, amount) VALUES
('A001', 10, 100.00),
('A002', 20, 200.00),
('A003', 30, 300.00),
('A004', 40, 400.00);

此时 idx_user_id 上的索引值为 10、20、30、40。InnoDB 会把它抽象成带两条伪记录的链:(-∞, 10, 20, 30, 40, +∞)。

事务 A 执行:

-- SESSION 1
BEGIN;
SELECT * FROM t_order WHERE user_id = 20 FOR UPDATE;

user_id 是非唯一索引,等值命中 1 条记录,但 RR 下加锁会退化:由于索引不唯一,InnoDB 无法确定是否还有别的 user_id = 20 的行,于是把 (10, 20] 与 (20, 30) 都锁住,本质是 (10, 30) 这个 Next-Key Lock 拆开后的 record lock + 两个 gap lock。

此时事务 B:

-- SESSION 2
BEGIN;
INSERT INTO t_order (order_no, user_id, amount) VALUES ('A005', 25, 500.00);  -- 阻塞

插入值 25 落在间隙 (20, 30) 内,INSERT 申请插入意向锁时与 SESSION 1 的 gap lock 冲突,进入 Waiting for lock。查询锁等待情况能看到:

SELECT * FROM performance_schema.data_locks WHERE OBJECT_NAME = 't_order'\G

输出中会有两条关键记录:

*************************** 1. row ***************************
       ENGINE: INNODB
  OBJECT_NAME: t_order
  INDEX_NAME: idx_user_id
  LOCK_TYPE: RECORD
  LOCK_MODE: X
  LOCK_STATUS: GRANTED
  LOCK_DATA: 20
*************************** 2. row ***************************
  LOCK_TYPE: RECORD
  LOCK_MODE: X,GAP
  LOCK_STATUS: WAITING
  LOCK_DATA: 25

注意 LOCK_DATA: 25 表示插入意向锁的位置,LOCK_MODE: X,GAP 表示它是落在间隙上的插入意向锁,状态 WAITING。这就是”看不到行被锁住,却插不进去”的直接证据。

三、可复现的完整并发现场

用两个终端模拟,注意先确认隔离级别:

SELECT @@transaction_isolation;  -- MySQL 8.0 变量名,5.7 用 @@tx_isolation
-- 输出 REPEATABLE-READ

完整流程按时间轴排布:

时刻 SESSION 1(读事务) SESSION 2(写事务) 状态
T1 BEGIN; – –
T2 SELECT * FROM t_order WHERE user_id = 20 FOR UPDATE; – S1 持有 (10, 30) 的 Next-Key Lock
T3 – BEGIN; INSERT … user_id=25; S2 等待插入意向锁
T4 – INSERT … user_id=35; S2 可直接执行 — 35 在 (30, 40) 间隙内,未被锁
T5 COMMIT; – S2 T3 的 INSERT 在 50s 内成功,否则超时

测试时可以调小超时,快速观察报错:

SET SESSION innodb_lock_wait_timeout = 5;
-- 在 SESSION 2 中执行插入,5 秒后得到:
-- ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

如果 SESSION 1 迟迟不提交,日志里还会出现另一种更隐蔽的现象:两个事务各插入一个值,互相等待对方持有的 gap,形成死锁而非单纯超时。

四、用死锁日志验证”间隙锁互斥 INSERT”的真实形态

设计一个更贴近线上的场景:对不存在的行做等值加锁,再并发插入。

-- SESSION 1
BEGIN;
SELECT * FROM t_order WHERE user_id = 50 FOR UPDATE;  -- 50 不存在,锁住 (40, +∞)

-- SESSION 2
BEGIN;
SELECT * FROM t_order WHERE user_id = 60 FOR UPDATE;  -- 60 同样落在 (40, +∞),gap 兼容,不阻塞

-- SESSION 1
INSERT INTO t_order (order_no, user_id, amount) VALUES ('A006', 55, 550.00);

-- SESSION 2
INSERT INTO t_order (order_no, user_id, amount) VALUES ('A007', 65, 650.00);

由于两者都持有 (40, +∞) 的 gap lock,而各自插入都需要在 (40, +∞) 上申请插入意向锁,形成交叉等待。执行 SHOW ENGINE INNODB STATUS\G 看 LATEST DETECTED DEADLOCK 段:

*** (1) TRANSACTION:
TRANSACTION 2318, ACTIVE 12 sec
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1136, 3 row lock(s)
MySQL thread id 9, query id 88 localhost root update
INSERT INTO t_order (order_no, user_id, amount) VALUES ('A006', 55, 550.00)
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 2 page no 4 n bits 80 index idx_user_id of table `demo`.`t_order`
trx id 2318 lock_mode X
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 2 page no 4 n bits 80 index idx_user_id of table `demo`.`t_order`
trx id 2318 lock_mode X insert intention waiting

*** (2) TRANSACTION:
TRANSACTION 2319, ACTIVE 9 sec
INSERT INTO t_order (order_no, user_id, amount) VALUES ('A007', 65, 650.00)
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS ... lock_mode X
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS ... lock_mode X insert intention waiting

读日志的关键词:lock_mode X 是持锁(此处为 gap 锁,日志不区分 gap 与 next-key 时会省写),insert intention waiting 是当前等待。两个事务都是”持有 X 锁等待 insert intention”,即可判定为间隙锁互斥导致的死锁,而不是主键冲突。MySQL 会回滚其中一个事务,报错 ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction。

五、打破阻塞的六种工程手段

知道原理后,解决方案要落到代码上。以下按优先级排列,均在生产中验证过:

  1. 把唯一索引作为加锁入口。唯一索引等值命中且记录存在时退化为 Record Lock,不产生 gap。例如根据 uk_order_no 更新单个订单,锁范围就是一条记录,不会阻塞别的插入。
  2. 缩短事务粒度。把 SELECT ... FOR UPDATE 与后续 RPC 调用拆开,任何跨网络调用都不应放在持有 gap 锁的事务里。实测中 60% 的 Lock wait timeout 都是事务过长导致的。
  3. 降低隔离级别。RC(READ COMMITTED)下间隙锁基本不生效(仅在唯一键冲突检查时短暂持有),淘宝、美团等大量核心交易系统运行在 RC。代价是需要在业务层处理不可重复读,或用乐观锁替代 FOR UPDATE。
  4. 用 innodb_lock_wait_timeout 做熔断。默认 50 秒太长,建议按接口 P99 设为 3~5 秒,让失败快速暴露而非拖垮连接池。
  5. 唯一索引 + INSERT ... ON DUPLICATE KEY UPDATE 或 INSERT IGNORE,把”先查后插”的两段式改为单语句,天然缩短加锁窗口。
  6. 业务唯一约束前置。用 Redis SET NX 或数据库唯一索引兜底幂等,避免用 SELECT ... FOR UPDATE 做分布式锁的轮子。

一个典型的错误改法要单独提:把 SELECT ... FOR UPDATE 换成普通 SELECT。在 RR 下普通快照读不加锁,确实不阻塞插入,但会读到旧版本,且 UPDATE 语句自身仍会在写时加锁。这不是修复,只是把阻塞点挪到了别处。

六、加锁行为速查表

SQL 形态(user_id 有普通索引) RR 下加锁 是否阻塞插入
WHERE user_id = 20 命中 1 行 (10, 30) Next-Key Lock 插入 21~29 会阻塞
WHERE user_id = 20 未命中 退化 gap lock 插入到该 gap 内会阻塞
WHERE user_id BETWEEN 20 AND 30 (10, 40) 范围 Next-Key 插入 21~39 会阻塞
WHERE user_id = 20 且走唯一索引、命中 Record Lock 不阻塞(除主键冲突)
WHERE id = 2(主键,命中) Record Lock 不阻塞
无索引字段更新 全表所有记录 + 全部 gap 整表插入都会阻塞

最后一行是线上事故高发项:在 WHERE status = 1 这类无索引字段上执行 UPDATE,InnoDB 只能全表扫描并锁住所有扫描过的记录及其间隙,等同于把表锁住。补上索引是最直接的止损手段,可以在 information_schema.INNODB_TRX 与 performance_schema.data_locks 关联后精确定位。

小结

间隙锁阻塞 INSERT 的本质是三层约束叠加:RR 需要 Next-Key Lock 防幻读 → Next-Key 拆开后留下 gap lock → INSERT 必须先申请与该 gap 互斥的插入意向锁。面试里能把这三点讲清楚,并补充”gap 之间兼容、gap 与插入意向锁互斥”这一对关系,基本就能满分。工程上则牢记:能用唯一索引就别用普通索引等值加锁,能缩短事务就别让事务跨网络,能用 RC 就不要在 RR 上硬扛。

FAQ

Q1:为什么我的 SELECT ... FOR UPDATE 明明没查到数据,也阻塞了插入?
等值查询未命中时,InnoDB 会加 gap lock 到”该值应该所在位置”的间隙上。例如 WHERE user_id = 25 FOR UPDATE,锁的是 (20, 30) 这个 gap,插入 25 或 26 都会等待。用 performance_schema.data_locks 看 LOCK_MODE 为 X,GAP 即可确认。

Q2:RC 隔离级别下真的没有间隙锁吗?
不是完全没有。RC 下普通 SELECT ... FOR UPDATE 不加 gap;但 INSERT 遇到唯一键冲突做重复检查时会短暂持有 gap,除此之外基本可以认为没有间隙锁。另外 RC 下 binlog 需要 ROW 格式才能保证主从一致。

Q3:插入时 Primary Key 冲突和间隙锁阻塞的报错一样吗?
不一样。主键冲突是 ERROR 1062 (23000): Duplicate entry '2' for key 'PRIMARY',立即返回;间隙锁阻塞表现为 ERROR 1205 (HY000): Lock wait timeout exceeded 或 1213 死锁,需要等超时或对方释放。这两个错误码是排查方向的第一判断依据。

Q4:会阻塞 SELECT 吗?
间隙锁只锁住”不允许插入”这一语义,普通快照读(SELECT)不加锁,不受影响。当前读(FOR UPDATE、LOCK IN SHARE MODE)如果请求的是 gap 锁,因 gap 互相兼容也不阻塞;请求 record lock 时才阻塞。

Q5:调整 innodb_lock_wait_timeout 能解决死锁吗?
不能。innodb_lock_wait_timeout 只影响超时回滚的速度;死锁由 InnoDB 的死锁检测在环形等待出现时立即触发,与超时时间无关。调小它的作用是缩短阻塞时间、保护连接池,属于止血而非治本,根因仍要回到加锁范围和事务长度。