MySQL 锁机制与死锁分析:从死锁日志到根因
InnoDB 的锁加在索引上而不是行上。记录锁、间隙锁、临键锁的加锁规则,死锁日志的四步读法,以及五个高频死锁场景的改法。
一个简单的「扣减库存」接口,压测 200 并发时出现大量死锁:
Deadlock found when trying to get lock; try restarting transaction
业务代码看起来毫无问题——就是 UPDATE stock SET count = count - 1 WHERE sku_id = ?。但如果同时有另一个接口在跑 UPDATE stock SET count = count - 1 WHERE sku_id IN (?, ?, ?),死锁就必然发生。
理解这件事需要先搞清楚 InnoDB 到底加的是什么锁。这篇文章从行锁、间隙锁讲到死锁日志分析,最后是五个高频死锁场景和对应改法。
一、三种锁,两种粒度
InnoDB 的锁按「锁什么」分三种:
| 锁 | 锁的对象 | 用途 |
|---|---|---|
| 记录锁 Record Lock | 单条索引记录 | 锁住某一行 |
| 间隙锁 Gap Lock | 索引记录之间的空隙 | 阻止插入 |
| 临键锁 Next-Key Lock | 记录 + 前面的间隙(左开右闭) | 默认行为,防幻读 |
按「怎么锁」分两种:
- 共享锁 S(
SELECT ... LOCK IN SHARE MODE):读锁,多个事务可共存 - 排他锁 X(
SELECT ... FOR UPDATE/UPDATE/DELETE):写锁,互斥
最关键的一条事实:InnoDB 的锁加在索引上,不是加在行上。 如果查询没走索引,它锁的就不是「一行」,而是「扫过的所有行」——退化到接近表锁。这是死锁和锁等待爆炸的最主要原因。
二、加锁规则:记住这三条
规则本身不复杂,但组合起来的结果需要推演。
规则 1:唯一索引等值命中 → 记录锁
-- id 是主键
SELECT * FROM users WHERE id = 10 FOR UPDATE;
只锁 id=10 这一行,不锁间隙。
规则 2:非唯一索引等值查询 → 临键锁 + 间隙锁
-- age 有普通索引,表里有 age = 25 的三行
SELECT * FROM users WHERE age = 25 FOR UPDATE;
锁住:
age = 25的所有记录(临键锁)age = 25前后的间隙(间隙锁),阻止插入age = 24或26的新记录
这就是为什么「有普通索引的等值查询」在并发下容易互相阻塞。
规则 3:范围查询 → 扫到的全部临键锁
SELECT * FROM users WHERE age > 20 AND age < 30 FOR UPDATE;
锁住这个区间内所有记录 + 区间两端边界。范围越大,锁越多。
推论:范围查询的锁范围取决于扫描的行数,而不是返回的行数。 如果 age 上有索引,只锁索引区间;如果没有索引,全表扫描 → 全表所有记录都被锁。
规则 4(隐藏规则):没有索引 = 锁全表
-- name 没有索引
UPDATE users SET status = 1 WHERE name = 'tom';
InnoDB 无法用索引定位,只能全表扫描,于是扫描到的每一行都加临键锁。哪怕只有一行名字是 tom,也会锁住整张表。这是线上最常见的锁冲突来源。
验证方法:
-- 看锁等待
SELECT * FROM performance_schema.data_locks;
SELECT * FROM performance_schema.data_lock_waits;
三、死锁日志怎么读
MySQL 检测到死锁会自动回滚代价小的事务,并打印日志:
SHOW ENGINE INNODB STATUS\G
输出里的 LATEST DETECTED DEADLOCK 段落:
*** (1) TRANSACTION:
TRANSACTION 28471, ACTIVE 3 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1136, 2 row lock(s)
MySQL thread id 142, query id 8821 localhost app updating
UPDATE stock SET count = count - 1 WHERE sku_id = 'B'
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 42 page no 5 n bits 80 index PRIMARY of table `shop`.`stock`
trx id 28471 lock_mode X locks rec but not gap waiting
Record lock, heap no 3 PHYSICAL RECORD: n_fields 4; compact format; info bits 0
0: len 1; hex 42; asc B;; ← 这一行是 sku_id = 'B'
*** (2) TRANSACTION:
TRANSACTION 28470, ACTIVE 4 sec starting index read
UPDATE stock SET count = count - 1 WHERE sku_id IN ('A','B')
...
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS ... lock_mode X locks rec but not gap
0: len 1; hex 42; asc B;; ← 持有 B 的锁
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS ... lock_mode X
0: len 1; hex 41; asc A;; ← 想要 A 的锁
读法固定四步:
- 事务 (1) 在等什么锁 → 锁在
sku_id='B'上 - 事务 (2) 持有什么锁 → 持有
sku_id='B' - 事务 (2) 在等什么锁 → 等
sku_id='A' - 事务 (1) 持有
sku_id='A'(在 HOLDS 段落里)
环形等待形成: (1) 有 A 等 B,(2) 有 B 等 A。
根因很明确:IN ('A','B') 的批量更新,加锁顺序依赖索引扫描顺序;单条更新顺序由业务代码决定。两边顺序不一致就死锁。
修法:统一加锁顺序。 批量更新前先对 ID 排序:
UPDATE stock SET count = count - 1
WHERE sku_id IN ('A','B')
ORDER BY sku_id; -- 强制按索引顺序加锁
或者在应用层拆分,始终按升序逐个更新。
四、五个高频死锁场景
场景 1:批量 IN + 单条混用(上面这个)
根因:加锁顺序不一致。
修法:ORDER BY 或统一排序后逐个操作。
场景 2:唯一索引冲突导致的死锁
两个事务同时 INSERT 同一个唯一键:
-- 事务 A
INSERT INTO orders (order_no) VALUES ('NO123');
-- 事务 B(同时)
INSERT INTO orders (order_no) VALUES ('NO123');
B 会等 A,A 提交则 B 报 Duplicate entry,A 回滚则 B 成功。单看这个不会死锁。但如果是 INSERT ... ON DUPLICATE KEY UPDATE 或 INSERT IGNORE,事务会先拿 S 锁检查、再升级为 X 锁,两个事务互相等待对方的 S 锁,就死锁了。
修法:用 INSERT ... ON DUPLICATE KEY UPDATE 时控制并发(同一个 key 走同一个队列),或者改用先 SELECT ... FOR UPDATE 再判断更新/插入。
场景 3:间隙锁导致的插入阻塞
-- 事务 A
SELECT * FROM users WHERE age BETWEEN 20 AND 30 FOR UPDATE;
-- 事务 B
INSERT INTO users (name, age) VALUES ('new', 25); -- 阻塞
A 持有 (20, 30) 区间的间隙锁,B 要插入 25 落在区间内,必须等。
如果 A 是个长事务(比如在做批量统计),B 就卡住。多个 B 排队,加上其他表操作,很容易形成死锁。
修法:
- 用
READ COMMITTED隔离级别(RC 下间隙锁基本不生效,只有外键和唯一键检查时才有) - 避免在事务里做
FOR UPDATE范围查询 - 缩短事务
RC 隔离级别是减少死锁最有效的手段之一。 从 RR 换成 RC 后,间隙锁基本消失,锁冲突概率大幅下降。代价是失去「可重复读」保证,可能出现不可重复读——但绝大多数业务并不依赖这个语义,反而在 RR 下被间隙锁坑得更惨。这也是为什么很多互联网公司默认用 RC。
场景 4:索引失效导致的表级锁
-- order_no 是 varchar,传了数字
SELECT * FROM orders WHERE order_no = 20261105 FOR UPDATE;
隐式类型转换,索引失效 → 全表扫描 → 所有行加临键锁 → 几乎是表锁。并发稍高必然死锁。
修法:保证查询条件和索引列类型一致。这类问题在慢查询日志里也看得出来(Rows_examined 远大于 Rows_sent)。
场景 5:SELECT ... FOR UPDATE 用了二级索引,再回表更新
-- status 有索引,id 是主键
SELECT * FROM orders WHERE status = 1 FOR UPDATE; -- 锁二级索引 + 主键
UPDATE orders SET status = 2 WHERE id = 100;
第一个语句同时锁了二级索引记录和对应的主键记录。如果另一个事务先锁主键再锁二级索引,顺序相反就死锁。
修法:尽量用主键做 FOR UPDATE;必须用二级索引时,控制「锁二级索引 + 改主键」这个组合的并发。
五、如何主动发现锁等待
死锁有日志,但长时间的锁等待没有——它只是让请求变慢,不会报错。这是更隐蔽的问题。
-- 当前所有锁等待(MySQL 8.0)
SELECT
r.trx_id AS waiting_trx,
r.trx_mysql_thread_id AS waiting_thread,
r.trx_query AS waiting_query,
b.trx_id AS blocking_trx,
b.trx_mysql_thread_id AS blocking_thread,
b.trx_query AS blocking_query,
TIMESTAMPDIFF(SECOND, r.trx_wait_started, NOW()) AS wait_seconds
FROM performance_schema.data_lock_waits w
JOIN information_schema.innodb_trx b ON b.trx_id = w.BLOCKING_ENGINE_TRANSACTION_ID
JOIN information_schema.innodb_trx r ON r.trx_id = w.REQUESTING_ENGINE_TRANSACTION_ID
ORDER BY wait_seconds DESC;
wait_seconds 超过 5 秒就该告警。如果发现某个事务阻塞了几十个其他事务,直接 KILL 它:
KILL <blocking_thread>;
更好的做法是从源头限制等待时间:
SET SESSION innodb_lock_wait_timeout = 5; -- 默认 50 秒,太长了
50 秒的等待时间意味着一个卡住的事务能让 50 个请求全挂 50 秒。设成 3~5 秒,快速失败 + 重试,整体吞吐更好。
六、减少死锁的六条实践
1. 所有事务保持一致的加锁顺序
按主键或业务 ID 排序后再批量操作。这是最根本的一条。
2. 事务尽可能短,不要在事务里做非 DB 操作
@Transactional
public void createOrder(Order o) {
orderMapper.insert(o);
httpClient.callRemoteService(o); // ← 网络调用,事务被拉长几百毫秒
inventoryMapper.deduct(o);
}
远程调用失败重试、超时,事务就一直开着,锁也就不放。改成先远程调用(拿结果),再开事务写库。
3. 用 RC 隔离级别
SET GLOBAL transaction_isolation = 'READ-COMMITTED';
-- 或 Spring Boot
-- spring.datasource.hikari.transaction-isolation=TRANSACTION_READ_COMMITTED
间隙锁消失,死锁概率大幅下降。
4. 所有 WHERE 条件都走索引
这是防止「意外的表锁」的唯一办法。上线前用 EXPLAIN 确认 type 不是 ALL。
5. 热点行改成乐观锁或排队
秒杀场景下 UPDATE stock SET count = count - 1 WHERE id = 1 单行更新,行锁必然串行化。并发 1000 时这里是瓶颈。
改法:
-- 乐观锁:带版本号,失败重试
UPDATE stock SET count = count - 1, version = version + 1
WHERE id = 1 AND version = #{version} AND count > 0;
或者把一行拆成多行(分段库存),或者用 Redis 预扣减 + 异步落库。
6. 死锁当成可重试错误处理
死锁回滚是 MySQL 的正常行为,不是故障。业务层应该捕获并重试:
@Retryable(
retryFor = { DeadlockLoserDataAccessException.class },
maxAttempts = 3,
backoff = @Backoff(delay = 50, multiplier = 2)
)
@Transactional
public void deductStock(String skuId) { ... }
注意:必须是幂等操作才能重试。扣库存要在同一个事务里做「检查 + 扣减」,重试时重新检查。
七、一个排查清单
遇到死锁,按这个顺序处理:
1. SHOW ENGINE INNODB STATUS\G → 拿到死锁的 SQL 和锁类型
2. 看两条 SQL 的 WHERE 条件 → 是否走了索引?EXPLAIN 确认
3. 看加锁顺序是否一致 → 是否一个 A→B,另一个 B→A
4. 评估锁范围 → 是否因为索引缺失导致锁全表
5. 评估事务长度 → 事务里是否有网络调用/大计算
6. 确定改法 → 统一顺序 / 加索引 / 缩事务 / 改 RC
其中第 2 步(索引)和第 6 步(错序)能解决 80% 的死锁。
小结
死锁的本质是环形等待,而环形等待的来源几乎总是「两个事务以不同顺序获取同一组锁」。
所以要解决死锁,先问三个问题:
- 它们锁的是同一批资源吗? 如果是,为什么顺序不同
- 锁的范围是不是被放大了? 没走索引 → 锁全表
- 事务是不是太长了? 事务开得久,碰撞概率就高
对应的三板斧:统一顺序、确保索引、缩短事务。这三条做到,基本不会再被死锁困扰。
正在加载评论…