应用日志里蹦出一行:
Deadlock found when trying to get lock; try restarting transaction
很多人第一反应是加个重试就完事。重试确实能让业务不报错,但死锁频繁说明并发控制的设计有问题,光重试是把火药桶盖住了,没拆引信。
死锁为什么会发生
两个事务互相握着对方要的锁,谁也不放手,InnoDB 检测到环就挑一个代价小的回滚(通常是改动行数少的那个)。
最常见的是加锁顺序不一致:
-- 事务 A
UPDATE account SET balance = balance - 100 WHERE id = 1;
UPDATE account SET balance = balance + 100 WHERE id = 2;
-- 事务 B(顺序反了)
UPDATE account SET balance = balance + 50 WHERE id = 2;
UPDATE account SET balance = balance - 50 WHERE id = 1;
A 锁住 1 等 2,B 锁住 2 等 1,环就形成了。解决办法朴素得让人意外:让所有业务按同一个顺序加锁(比如都按 id 升序)。这一条能消掉一大半死锁。
InnoDB 到底锁了什么
理解锁的类型,才知道为什么"改一行"会锁住一片。
- 记录锁(Record Lock):锁单条索引记录。
WHERE id = 1(id 是主键/唯一索引)时就是这个。 - 间隙锁(Gap Lock):锁两条记录之间的空隙,防止别的事务往中间插数据。比如已有 id=1,5,锁住 (1,5) 这个区间,别人插 id=3 会被挡住。
- 临键锁(Next-Key Lock):记录锁 + 间隙锁,锁的是左开右闭区间,比如 (1,5]。这是 RR(可重复读)隔离级别下的默认加锁单位。
关键点:InnoDB 的行锁是加在索引上的,不是加在行上。如果你的 WHERE 条件没走索引,就会退化成锁全表(其实是锁所有记录 + 所有间隙),并发直接归零,死锁概率飙升。
-- 危险:type 列没索引,这条更新会锁住全表所有记录和间隙
UPDATE orders SET status = 'PAID' WHERE type = 'VIP';
-- 相对安全:走主键,只锁这一行
UPDATE orders SET status = 'PAID' WHERE id = 1001;
RR 隔离级别下的坑
MySQL 默认是 RR(可重复读),靠间隙锁来防止幻读。代价是:RR 下间隙锁的范围比 RC 大得多,死锁率高不少。
很多高并发业务会选择改成 RC(读已提交) + binlog_format=ROW:
[mysqld]
transaction-isolation = READ-COMMITTED
binlog_format = ROW # RC 下 binlog 必须用 ROW,否则主从数据会不一致
RC 下基本没有间隙锁(除了外键检查和唯一性检查),锁范围小、并发高、死锁少。代价是同一事务内两次 SELECT 可能看到不同结果(不可重复读),但对大多数 OLTP 业务来说这点无所谓——本来一个事务里也不会查两遍还要求结果一模一样。
要不要改 RC 得看业务:金融、对账这类对一致性读要求严格的,老实用 RR;普通互联网高并发写入场景,RC + ROW 基本是标配。
死锁日志怎么读
出事了看这里:
SHOW ENGINE INNODB STATUS\G
找到 LATEST DETECTED DEADLOCK 这一段,典型长这样(简化过):
------------------------
LATEST DETECTED DEADLOCK
------------------------
*** (1) TRANSACTION:
TRANSACTION 421312, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1136, 2 row lock(s)
UPDATE orders SET status='PAID' WHERE user_id=100 AND sku_id=7
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 58 page no 4 n bits 80 index PRIMARY of table `shop`.`orders`
trx id 421312 lock_mode X locks rec but not gap
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS ... index idx_user of table `shop`.`orders`
trx id 421312 lock_mode X locks gap before rec
*** (2) TRANSACTION:
TRANSACTION 421313, ...
UPDATE orders SET status='CANCEL' WHERE user_id=101 AND sku_id=7
*** (2) HOLDS THE LOCK(S): ...
*** (2) WAITING FOR THIS LOCK TO BE GRANTED: ...
*** WE ROLL BACK TRANSACTION (2)
逐行看什么:
(1) TRANSACTION/(2) TRANSACTION:两个互相等待的事务,各自的 SQL 会列出来——先确认是哪些语句在打架。HOLDS THE LOCK(S):这个事务已经拿着什么锁。lock_mode X是排他锁,locks rec but not gap是纯记录锁,locks gap before rec是间隙锁。WAITING FOR THIS LOCK:它在等什么锁。配合上面一条就能画出等待环。index PRIMARY/index idx_user:锁在哪个索引上。如果显示的是你没想到的索引,说明优化器选的索引和你预期不一致,可能要加 hint 或建更合适的索引。WE ROLL BACK TRANSACTION (2):InnoDB 决定回滚谁。如果总是回滚你不想回滚的那个(比如回滚了重要业务),可以考虑在事务里减少改动行数、或者拆事务。
注意:
SHOW ENGINE INNODB STATUS只保留最近一次死锁。要看历史死锁,得开innodb_print_all_deadlocks,让死锁写进 error log,或者靠监控定期采集。
# 让所有死锁都写进 error log,方便事后追溯
innodb_print_all_deadlocks = 1
减少死锁的几个实操手段
- 固定加锁顺序:批量更新时先
ORDER BY id排个序,让所有事务按同一顺序拿锁。 - 缩短事务:事务里别掺 RPC、别发邮件、别等用户输入。锁持有时间越短,撞车概率越低。
- 让
WHERE走索引:没索引 = 锁全表,这是死锁的头号元凶。用EXPLAIN确认。 - 用主键/唯一索引精准命中:
WHERE id = ?只锁一行;范围条件会触发 next-key 锁锁住一片。 - 降低隔离级别到 RC(配合 ROW binlog):能大幅减少间隙锁。
- 拆分大事务:一次更新 10 万行拆成每批 1000 行,锁范围和时间都变小。
- 热点行排队:秒杀扣库存这种,考虑用 Redis 预扣或者把一行的更新拆成多行(分段库存)减少争抢。
排查死锁的标准流程
- 抓死锁 SQL(
SHOW ENGINE INNODB STATUS或开了innodb_print_all_deadlocks后的 error log); - 看两个事务各自持有什么、等什么、锁在哪个索引;
EXPLAIN那几条 SQL,确认走了预期的索引;- 看加锁顺序是否一致、事务是否过大;
- 针对性改:调整索引、统一顺序、拆事务、降隔离级别。
最后
死锁不是"重试就行了"的小毛病,它是并发设计的一面镜子。偶尔一次可能是运气,一天几十次一定是代码或索引的问题。真正治本的办法就那几个:索引建对、顺序统一、事务做小。把这三件事做好,死锁日志会安静很久。

