数据库运维7 min read次阅读

MySQL 锁和死锁:一次线上死锁的排查记录

那个报错

Deadlock found when trying to get lock; try restarting transaction

第一次见到这行,我的反应和大多数人一样——加个重试。重试确实有用,死锁本来就是数据库自己会挑一个事务回滚掉,业务层补一次通常就过去了。但问题是它隔三差五就来,说明不是偶发撞车,是有固定的冲突模式在反复触发。

真正把它查清楚,靠的是死锁日志,不是猜。

死锁日志怎么看

MySQL 会保留最近一次死锁的详细信息:

SHOW ENGINE INNODB STATUS\G

往下拉找 LATEST DETECTED DEADLOCK 那一段,大概长这样(简化过):

------------------------
LATEST DETECTED DEADLOCK
------------------------
2026-08-12 10:23:41 0x7f8e4c0b9700
*** (1) TRANSACTION:
TRANSACTION 421987, ACTIVE 0 sec starting index read
UPDATE orders SET status='PAID' WHERE user_id=100 AND order_no='A001'

*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 58 page no 4 index idx_user_id of table `shop`.`orders`
lock_mode X locks rec but not gap

*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 58 page no 5 index PRIMARY of table `shop`.`orders`
lock_mode X locks rec but not gap waiting

*** (2) TRANSACTION:
TRANSACTION 421988, ACTIVE 0 sec starting index read
UPDATE orders SET status='CANCEL' WHERE user_id=101 AND order_no='B002'

*** (2) HOLDS THE LOCK(S):  ...
*** (2) WAITING FOR THIS LOCK TO BE GRANTED: ...

*** WE ROLL BACK TRANSACTION (2)

重点看三块:

  • HOLDS THE LOCK(S):这个事务已经拿着什么锁(哪个索引、什么模式)
  • WAITING FOR THIS LOCK:它在什么锁
  • 两个事务的 SQL 原文

把两个事务的"持有"和"等待"对着看,就能画出那个环。我那次的结论很典型:两个事务都先按 idx_user_id 更新、再按主键更新,但顺序是反的——一个先 user_id=100 再 101,另一个反过来。各拿一把锁等对方,环就成了。

注意这个命令只存最近一次死锁。要长期留存得开 innodb_print_all_deadlocks=1,让死锁写进 error log。

行锁、间隙锁、next-key,到底锁了什么

排查死锁绕不开这几个概念,但其实没那么玄。

InnoDB 行锁是加在索引记录上的,不是加在数据行上。这点的直接后果是:不走索引的更新会锁全表

-- status 上没索引 → 扫全表,扫到的每条记录都加锁,等于锁表
UPDATE orders SET status='PAID' WHERE status='WAIT';

记录锁(Record Lock):锁单条索引记录。WHERE id=10(主键)就是这种。

间隙锁(Gap Lock):锁两条记录之间的空隙,防止别的事务往中间插东西。比如已有 id=5 和 id=10,锁住 (5,10) 这个区间,别人插 id=7 会被挡住。

next-key lock:记录锁 + 前面的间隙,也就是左开右闭区间,比如 (5,10]。这是 InnoDB 在**可重复读(RR)**隔离级别下的默认加锁单位,目的是防幻读。

坑在这里:RR 下很多你以为是"锁一行"的操作,实际锁的是一个区间。

-- id 是主键,id=10 存在 → 退化为记录锁,只锁这一行
SELECT * FROM orders WHERE id=10 FOR UPDATE;

-- 范围条件 → next-key lock,锁住 (5,10] 甚至更多
SELECT * FROM orders WHERE id BETWEEN 5 AND 10 FOR UPDATE;

-- id=7 不存在 → 锁住它该在的间隙 (5,10),别的会话插不了 7
SELECT * FROM orders WHERE id=7 FOR UPDATE;

我那次是怎么修的

原因清楚了,改法就很直接:

1. 统一加锁顺序

两个事务改成都按同一个顺序访问资源(比如都按 user_id 升序),环就形成不了。这是最有效的一条。批量更新时先排个序:

-- 批量更新前先按 id 排序,保证加锁顺序一致
UPDATE orders SET status='PAID' WHERE user_id IN (100,101) ORDER BY user_id;

2. 让条件命中唯一索引

WHERE 条件尽量用主键或唯一索引,这样 InnoDB 退化成记录锁,锁范围最小。范围条件能避免就避免。

3. 缩短事务

事务里别夹着 RPC、写文件、sleep。锁是事务提交时才释放的(不是语句执行完),事务拖得越久,撞车窗口越大。这条听起来老生常谈,但线上真死锁的相当一部分都是"事务里干了不该干的事"造成的。

4. 隔离级别能不能降

如果业务能接受,把隔离级别从 RR 降到 RC(读已提交),间隙锁基本就没了(RC 下只有外键约束和唯一性检查还会用),死锁概率能下来不少。代价是失去可重复读,且 binlog 必须用 ROW 格式。

[mysqld]
transaction-isolation = READ-COMMITTED
binlog_format         = ROW

5. 重试别忘了做

前面几条是治本,但死锁在并发系统里不可能完全消灭。应用层重试 + 指数退避该加还是得加,只是别把它当唯一手段。

几句实在话

死锁不是"运气不好",它几乎总能追溯到固定的加锁顺序冲突或者过大的锁范围。查它靠的是 SHOW ENGINE INNODB STATUS 里那段日志,不是靠猜;改它靠的是统一顺序、缩小范围、缩短事务,这几招朴实但真的管用。

最怕的是一直靠重试盖着,直到某天并发上去了,死锁从一天几次变成一分钟几次,那时候再查就被动了。

分享:

相关文章

评论区