线上一条订单落库接口偶发 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。
五、打破阻塞的六种工程手段
知道原理后,解决方案要落到代码上。以下按优先级排列,均在生产中验证过:
- 把唯一索引作为加锁入口。唯一索引等值命中且记录存在时退化为 Record Lock,不产生 gap。例如根据
uk_order_no更新单个订单,锁范围就是一条记录,不会阻塞别的插入。 - 缩短事务粒度。把
SELECT ... FOR UPDATE与后续 RPC 调用拆开,任何跨网络调用都不应放在持有 gap 锁的事务里。实测中 60% 的Lock wait timeout都是事务过长导致的。 - 降低隔离级别。RC(READ COMMITTED)下间隙锁基本不生效(仅在唯一键冲突检查时短暂持有),淘宝、美团等大量核心交易系统运行在 RC。代价是需要在业务层处理不可重复读,或用乐观锁替代
FOR UPDATE。 - 用
innodb_lock_wait_timeout做熔断。默认 50 秒太长,建议按接口 P99 设为 3~5 秒,让失败快速暴露而非拖垮连接池。 - 唯一索引 +
INSERT ... ON DUPLICATE KEY UPDATE或INSERT IGNORE,把”先查后插”的两段式改为单语句,天然缩短加锁窗口。 - 业务唯一约束前置。用 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 的死锁检测在环形等待出现时立即触发,与超时时间无关。调小它的作用是缩短阻塞时间、保护连接池,属于止血而非治本,根因仍要回到加锁范围和事务长度。