InnoDB 间隙锁与死锁:两个 UPDATE 互相等待的 3 个原因

个人主页: > 我不会起名字322 < (欢迎各位大佬莅临😊)
其他栏目: > 技术栈学习笔记 <
其他栏目: > 力扣Hot100题目解析 <
其他栏目: > Go项目学习笔记 <
其他栏目: > redis <
其他栏目: > mysql <




文章目录

死锁日志大概是线上最容易被划过去、又最该点开看的一类告警。下面这段是我按真实格式整理出来的一个典型现场,两个 UPDATE 改的根本不是同一行数据,却一个在等另一个:

text 复制代码
------------------------
LATEST DETECTED DEADLOCK
------------------------
*** (1) TRANSACTION:
TRANSACTION 4821, ACTIVE 3 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s)
MySQL thread id 91, query id 7301 localhost app updating
UPDATE t_order SET status = 2 WHERE user_id = 20
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 7 page no 5 n bits 88 index idx_user_id of table `shop`.`t_order`
trx id 4821 lock_mode X locks gap before rec insert intention waiting

*** (2) TRANSACTION:
TRANSACTION 4822, ACTIVE 4 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 2 lock struct(s), heap size 1136, 2 row lock(s)
UPDATE t_order SET status = 0 WHERE user_id = 10
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 7 page no 5 n bits 88 index idx_user_id of table `shop`.`t_order`
trx id 4822 lock_mode X locks gap before rec
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 7 page no 5 n bits 88 index idx_user_id of table `shop`.`t_order`
trx id 4822 lock_mode X locks gap before rec insert intention waiting

*** WE ROLL BACK TRANSACTION (2)

关键词是 locks gap before rec insert intention waiting:间隙锁 和插入意向锁撞在了一起。这篇就把这类死锁拆开:加锁范围为什么比你以为的大、三个最常见的循环等待是怎么形成的、以及真正能落地的规避手法。

一、先把三种锁分清楚

InnoDB 的行级锁不是"锁一行"这么简单,它在索引上加锁,具体分三种:

锁 加在哪 什么时候出现
记录锁 Record Lock 一条索引记录 唯一索引等值命中
间隙锁 Gap Lock 两条记录之间的空隙 非唯一索引、范围条件、不存在的记录
临键锁 Next-Key Lock 间隙 + 右侧那条记录 RR 隔离级别下的默认形态

准备一张表,后面所有场景都用它:

sql 复制代码
CREATE TABLE t_order (
  id        BIGINT PRIMARY KEY AUTO_INCREMENT,
  user_id   BIGINT NOT NULL,
  status    TINYINT NOT NULL DEFAULT 0,
  amount    DECIMAL(10,2) NOT NULL DEFAULT 0,
  KEY idx_user_id (user_id)
) ENGINE=InnoDB;

INSERT INTO t_order (id, user_id, status) VALUES
  (1, 10, 0), (2, 20, 0), (3, 30, 0), (4, 40, 0);

idx_user_id 是非唯一索引,索引里的顺序是 10 → 20 → 30 → 40。"间隙"就是这个顺序里两条相邻记录之间的空档 :(-∞,10)、(10,20)、(20,30)、(30,40)、(40,+∞)。

二、原因一:非唯一索引会锁住一整个区间

在 RR 隔离级别下开一个事务:

sql 复制代码
-- 会话 A
BEGIN;
UPDATE t_order SET status = 1 WHERE user_id = 20;

直觉上它只锁 user_id = 20 那一行。实际上 InnoDB 会退化成临键锁,把 (10,20] 和 (20,30) 都圈进来。想确认的话,MySQL 8.0 直接查锁表(不用猜):

sql 复制代码
SELECT ENGINE_TRANSACTION_ID AS trx, INDEX_NAME, LOCK_TYPE, LOCK_MODE, LOCK_DATA
FROM performance_schema.data_locks
WHERE OBJECT_NAME = 't_order';
text 复制代码
+------+-------------+-----------+-----------+-----------+
| trx  | INDEX_NAME  | LOCK_TYPE | LOCK_MODE | LOCK_DATA |
+------+-------------+-----------+-----------+-----------+
| 4821 | idx_user_id | RECORD    | X         | 20        |
| 4821 | idx_user_id | RECORD    | X,GAP     | 30        |
| 4821 | idx_user_id | TABLE     | IX        | NULL      |
+------+-------------+-----------+-----------+-----------+

X,GAP 那一行就是间隙锁,LOCK_DATA = 30 的意思是"锁住 30 之前的那个间隙",也就是 (20,30)。此时另一个会话插一条 user_id = 25 的记录会被卡住:

sql 复制代码
-- 会话 B:会一直等,直到 A 提交或回滚
INSERT INTO t_order (user_id, status) VALUES (25, 0);

这是最反直觉的一点:你只想改一行,却阻止了别人往你的"区间"里插数据。 只要 WHERE 走的不是唯一索引的等值查询,就要做好"锁的是一段范围"的心理准备。

三、原因二:加锁顺序不一致(AB-BA 死锁)

这是最经典、也最容易在业务代码里写出来的一种:

sql 复制代码
-- 会话 A:按 id 升序更新
BEGIN;
UPDATE t_order SET status = 1 WHERE id = 1;
UPDATE t_order SET status = 1 WHERE id = 2;   -- 这里开始等 B

-- 会话 B:按 id 降序更新
BEGIN;
UPDATE t_order SET status = 1 WHERE id = 2;   -- 拿到 id=2
UPDATE t_order SET status = 1 WHERE id = 1;   -- 等 A 释放 id=1

A 拿着 1 等 2,B 拿着 2 等 1,闭环成立,InnoDB 只能回滚代价小的那个。

我在项目里见过的变体是"批量处理同一批订单,但两次调用传进来的列表顺序不同"------一个来自分页查询(ORDER BY id ASC),一个来自前端勾选(勾选顺序),两者一交叉就死锁。根因不在数据库,在你的加锁顺序。

四、原因三:间隙锁 + 插入意向锁

回到开头那段日志。插入意向锁(Insert Intention Lock)是 INSERT 时加的一种特殊间隙锁:它本身不冲突,但和别人的间隙锁冲突------因为"我要往这个空隙里插"和"我把这个空隙锁住了"在语义上就是对立的。

构造一下:

sql 复制代码
-- 会话 A:锁住 (10,20) 这个间隙
BEGIN;
UPDATE t_order SET status = 1 WHERE user_id = 20;

-- 会话 B:锁住 (20,30) 之外的一段,然后往 A 的间隙里插
BEGIN;
UPDATE t_order SET status = 1 WHERE user_id = 30;
INSERT INTO t_order (user_id, status) VALUES (15, 0);   -- 等 A

-- 会话 A:回头往 B 锁住的区间插
INSERT INTO t_order (user_id, status) VALUES (35, 0);   -- 等 B → 死锁

两个事务各自"锁一段、插一段",插入意向锁互相撞上,形成循环等待。日志里就会同时出现 locks gap before rec(持有)和 ... insert intention waiting(等待)。

同类还有一种是唯一索引冲突 :两个事务同时 INSERT 同一个唯一键,先到的拿排他锁,后到的拿共享锁等着,此时如果先到的事务又去改别的行被卡住,同样会死锁。工程上的处理是把 INSERT 换成 INSERT ... ON DUPLICATE KEY UPDATE,或统一"先查后插"的顺序并加分布式锁。

五、出事了怎么定位

按这个顺序查,基本十分钟内能定位:

  1. 看最近一次死锁 :SHOW ENGINE INNODB STATUS\G,翻到 LATEST DETECTED DEADLOCK,重点看两个事务的 WAITING FOR THIS LOCK TO BE GRANTED 和 HOLDS THE LOCK(S),找出"锁对象相同、持有方互为等待方"的两条。

  2. 打开全量记录 (默认只留最后一次,线上必须打开):

    sql 复制代码
    SET GLOBAL innodb_print_all_deadlocks = ON;

    之后每次死锁都会写进 error log,配合日志采集就能统计"哪种 SQL 最常死锁"。

  3. 实时看等待关系 :

    sql 复制代码
    SELECT * FROM performance_schema.data_lock_waits;

    它直接给出 REQUESTING_ENGINE_TRANSACTION_ID 和 BLOCKING_ENGINE_TRANSACTION_ID,比读日志快。

  4. 看事务在干什么 :SELECT * FROM information_schema.innodb_trx\G,trx_started、trx_rows_locked、trx_query 三个字段能告诉你这事务是不是开太久了。

六、5 条能落地的规避手法

  1. 统一加锁顺序 。批量更新前对主键排序:SELECT id FROM t_order WHERE ... ORDER BY id ASC,然后按这个顺序逐条更新。这一条能消掉绝大多数 AB-BA 死锁。
  2. 缩小事务 。事务里不要有 RPC、不要 SELECT SLEEP()、不要发送消息、不要打大批量日志。事务越短,循环等待的窗口越小。
  3. 尽量用唯一索引等值定位 。WHERE id = ? 这种唯一索引等值查询只加记录锁,不加间隙锁;退而求其次,把频繁更新的条件列建成唯一索引也能避免大量间隙锁。
  4. 必要时降到 RC 。SET GLOBAL transaction_isolation = 'READ-COMMITTED'; 之后间隙锁基本消失(只有唯一键冲突和外键检查还会加),代价是幻读要靠业务自己防(比如加唯一约束)。这是很多互联网业务的默认选择,但要评估好业务语义再改。
  5. 对死锁做重试,而不是当故障。死锁是并发系统的正常现象,捕获错误码重试比"保证永不发生"现实得多:
java 复制代码
int retry = 0;
while (true) {
    try {
        orderMapper.updateStatus(orderId, 2);
        break;
    } catch (DeadlockLoserDataAccessException e) {   // MySQL errno 1213
        if (++retry > 3) {
            throw e;
        }
        Thread.sleep(50L * retry);                    // 退避后重试
    }
}

小结

现象 根因 处理
改一行却插不进新数据 非唯一索引退化成临键锁,锁住整个间隙 确认索引、接受范围锁、缩短事务
两个事务互相等待 加锁顺序不一致 按主键排序后统一顺序
日志里 insert intention waiting 间隙锁与插入意向锁冲突 降低批量插入粒度、必要时降到 RC
唯一键并发插入 唯一索引冲突时的 S 锁等待 ON DUPLICATE KEY UPDATE 或入口排队

间隙锁不是 bug,它是 RR 隔离级别为了防幻读付出的代价。写代码时记住一句话就够了:只要 WHERE 条件不是唯一索引等值查询,你锁的就是一段范围,而不只是那几行。

相关推荐
ClickHouseDB1 小时前
ClickHouse Terraform Provider 正式支持 ClickStack 资源管理
网络·数据库·python
步行cgn1 小时前
Spring 事务属性详解
java·数据库·spring
我滴老baby1 小时前
Navidrome:飞牛OS安装、曲库导入与远程收听全流程
开发语言·数据库·php
瀚高PG实验室2 小时前
HGHAC集群VIP-MANAGER挂载的VIP消失
数据库·postgresql·瀚高数据库
野生码农AI实战2 小时前
AI 看不了网页?我用 CDP 重建浏览器采集通道,30 行脚本 2.7 秒读完全文
数据库·人工智能·ai编程
Mortalbreeze2 小时前
MySQL 基础篇(六):聚合查询与分组查询
数据库·mysql
Omics Pro2 小时前
反式感知虚拟细胞!超越线性序列
数据库·人工智能·算法·机器学习·自然语言处理
jianjinwssy2 小时前
PostgreSQL 进阶:索引、MVCC、VACUUM 与 WAL
数据库·postgresql
天空鸟_时光不老2 小时前
10-Agent安全隐患与SQL执行层加固
java·数据库·人工智能·sql·spring·maven·mybatis