个人主页: > 我不会起名字322 < (欢迎各位大佬莅临😊)
其他栏目: > 技术栈学习笔记 <
其他栏目: > 力扣Hot100题目解析 <
其他栏目: > Go项目学习笔记 <
其他栏目: > redis <
其他栏目: > mysql <
文章目录
-
- 一、先把三种锁分清楚
- 二、原因一:非唯一索引会锁住一整个区间
- [三、原因二:加锁顺序不一致(AB-BA 死锁)](#三、原因二:加锁顺序不一致(AB-BA 死锁))
- [四、原因三:间隙锁 + 插入意向锁](#四、原因三:间隙锁 + 插入意向锁)
- 五、出事了怎么定位
- [六、5 条能落地的规避手法](#六、5 条能落地的规避手法)
- 小结
死锁日志大概是线上最容易被划过去、又最该点开看的一类告警。下面这段是我按真实格式整理出来的一个典型现场,两个 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,或统一"先查后插"的顺序并加分布式锁。
五、出事了怎么定位
按这个顺序查,基本十分钟内能定位:
-
看最近一次死锁 :
SHOW ENGINE INNODB STATUS\G,翻到LATEST DETECTED DEADLOCK,重点看两个事务的WAITING FOR THIS LOCK TO BE GRANTED和HOLDS THE LOCK(S),找出"锁对象相同、持有方互为等待方"的两条。 -
打开全量记录 (默认只留最后一次,线上必须打开):
sqlSET GLOBAL innodb_print_all_deadlocks = ON;之后每次死锁都会写进
error log,配合日志采集就能统计"哪种 SQL 最常死锁"。 -
实时看等待关系 :
sqlSELECT * FROM performance_schema.data_lock_waits;它直接给出
REQUESTING_ENGINE_TRANSACTION_ID和BLOCKING_ENGINE_TRANSACTION_ID,比读日志快。 -
看事务在干什么 :
SELECT * FROM information_schema.innodb_trx\G,trx_started、trx_rows_locked、trx_query三个字段能告诉你这事务是不是开太久了。
六、5 条能落地的规避手法
- 统一加锁顺序 。批量更新前对主键排序:
SELECT id FROM t_order WHERE ... ORDER BY id ASC,然后按这个顺序逐条更新。这一条能消掉绝大多数 AB-BA 死锁。 - 缩小事务 。事务里不要有 RPC、不要
SELECT SLEEP()、不要发送消息、不要打大批量日志。事务越短,循环等待的窗口越小。 - 尽量用唯一索引等值定位 。
WHERE id = ?这种唯一索引等值查询只加记录锁,不加间隙锁;退而求其次,把频繁更新的条件列建成唯一索引也能避免大量间隙锁。 - 必要时降到 RC 。
SET GLOBAL transaction_isolation = 'READ-COMMITTED';之后间隙锁基本消失(只有唯一键冲突和外键检查还会加),代价是幻读要靠业务自己防(比如加唯一约束)。这是很多互联网业务的默认选择,但要评估好业务语义再改。 - 对死锁做重试,而不是当故障。死锁是并发系统的正常现象,捕获错误码重试比"保证永不发生"现实得多:
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 条件不是唯一索引等值查询,你锁的就是一段范围,而不只是那几行。