返回首页

17 | 死锁分析与排查

适用版本:MySQL 8.0.x(本文示例基于 InnoDB 存储引擎)

一、背景

在 MySQL 事务并发场景中,“死锁”几乎是绕不开的话题。

很多开发第一次在日志里看到类似报错时会非常紧张:

Deadlock found when trying to get lock; try restarting transaction

然后脑子里通常会冒出三个问题:

  1. 死锁是不是数据库坏了?
  2. 为什么我的 SQL 看起来很简单,也会死锁?
  3. 遇到死锁到底应该改代码、改索引,还是改事务?

实际上,死锁不是数据库“出故障”的表现,而是并发事务竞争资源时的一种正常现象。真正需要做的,不是幻想“永远不发生死锁”,而是:

  • 理解死锁为什么出现;
  • 能快速识别死锁链路;
  • 从 SQL、索引、事务设计上降低死锁概率;
  • 在应用层具备可恢复的重试策略。

这篇文章就围绕死锁的原理、常见场景与排查步骤展开。


二、什么是死锁

死锁可以用一句话概括:

两个或多个事务互相等待对方持有的锁,形成循环依赖,导致谁都无法继续执行。

例如:

  • 事务 A 持有记录 1 的锁,等待记录 2;
  • 事务 B 持有记录 2 的锁,等待记录 1;
  • 两边都不释放,于是形成闭环。

这就叫死锁。

与普通锁等待有什么区别

普通锁等待是“单向等待”:

  • 我等你;
  • 只要你提交或回滚,我还能继续。

死锁则是“循环等待”:

  • 我等你;
  • 你也等我;
  • 系统如果不主动介入,双方都走不下去。

三、核心机制:InnoDB 如何处理死锁

InnoDB 并不是等事务永远卡死,而是会做死锁检测

当系统检测到锁等待形成环时,会从参与死锁的事务中选择一个作为“牺牲者”回滚掉,释放其锁资源,让另一个事务继续执行。

为什么只回滚一个事务

因为死锁的本质是必须打破等待环。只要回滚其中一个事务,环就断开了。

选择谁做牺牲者

InnoDB 通常会尽量选择“代价较小”的事务进行回滚,例如受影响行数更少、回滚成本更低的事务。

所以看到死锁报错时,不代表你的事务一定“写错了”,也可能只是它在死锁冲突中被 MySQL 选中回滚。


四、核心机制:死锁为什么会发生

死锁通常离不开以下几个条件:

  1. 并发事务同时存在
  2. 事务持有锁且继续申请新锁
  3. 申请顺序不一致
  4. 最终形成循环等待

在业务系统里,最常见的根因往往不是“数据库很复杂”,而是:

  • 不同代码路径更新同一批数据,但顺序不同;
  • 范围查询或缺索引导致锁范围扩大;
  • 长事务持锁过久,增加冲突窗口;
  • 批量更新一次锁太多记录。

五、示例:最典型的两行更新死锁

先准备表:

CREATE TABLE account (
    id INT PRIMARY KEY,
    balance INT NOT NULL
);

INSERT INTO account VALUES (1, 1000), (2, 1000);

事务 A

START TRANSACTION;
UPDATE account SET balance = balance - 100 WHERE id = 1;
-- 此时持有 id=1 的排他锁

UPDATE account SET balance = balance + 100 WHERE id = 2;

事务 B

START TRANSACTION;
UPDATE account SET balance = balance - 100 WHERE id = 2;
-- 此时持有 id=2 的排他锁

UPDATE account SET balance = balance + 100 WHERE id = 1;

如果两个事务并发交错执行,就可能出现:

  • A 锁住 1,等 2;
  • B 锁住 2,等 1;
  • 死锁成立。

这个案例揭示了什么

根本问题并不是“更新了两行”,而是:

  • 事务 A 的加锁顺序是 1 -> 2
  • 事务 B 的加锁顺序是 2 -> 1
  • 顺序不一致,最容易形成死锁。

六、示例:范围查询与插入也可能死锁

很多人以为死锁只会发生在 UPDATEUPDATE 之间,其实范围锁、间隙锁、插入操作也可能参与死锁。

例如在 REPEATABLE READ 下,使用当前读:

SELECT * FROM orders WHERE amount BETWEEN 100 AND 200 FOR UPDATE;

这类语句可能会对索引范围加 next-key lock。此时如果另一个事务尝试在该区间插入新记录,或者从另一个方向锁定相关范围,就可能与前一个事务形成复杂等待关系。

启示

  • 死锁不只与“同一行”有关;
  • 也可能与“同一索引范围”有关;
  • 所以死锁排查不能只盯着某一行主键,还要关注索引和范围条件。

七、如何查看死锁信息

在 MySQL 中,排查死锁最常用的入口是:

SHOW ENGINE INNODB STATUS;

在输出里找到:

LATEST DETECTED DEADLOCK

这一段通常会包含:

  • 参与死锁的两个事务;
  • 每个事务当前执行的 SQL;
  • 它们已经持有的锁;
  • 它们正在等待的锁;
  • MySQL 最终回滚了哪个事务。

你应该重点看什么

1)事务正在执行的 SQL

先定位“到底是哪两条 SQL 打起来了”。

2)等待的是哪类锁

例如:

  • record lock
  • gap lock
  • next-key lock

这能帮助判断是点更新冲突,还是范围锁冲突。

3)锁对应的是哪个索引

如果能看到索引名,就要继续追问:

  • 为什么走了这个索引?
  • 是不是因为缺少更合适索引,导致锁范围扩大?

4)哪个事务被回滚

这通常是受害者,不一定是问题根源,但能帮助你在应用日志中找到对应请求。


八、结合业务日志进行排查的思路

仅看数据库死锁日志,通常只能知道“当时谁和谁撞上了”,但想要真正落地整改,往往还要结合业务日志一起看。

一个比较实用的排查顺序

第一步:拿到死锁发生时间

先确定数据库日志中的死锁时间点。

第二步:找到对应应用请求

在应用日志或链路追踪中定位:

  • 哪些接口在那个时间点执行了相关 SQL;
  • 是否有批量任务、定时任务同时运行;
  • 是否有重试造成放大。

第三步:确认事务边界

看代码时,不要只看某一条 SQL,要确认:

  • 一个事务里到底执行了几条 SQL;
  • 中间有没有额外查询、循环、RPC;
  • 是否存在“先查后改、再查再改”的模式。

第四步:对比并发路径的锁顺序

这是排查死锁最核心的一步。

很多死锁案例最终都能归结成一句话:

同一批资源,被不同事务按不同顺序加锁。


九、常见死锁场景总结

9.1 更新顺序不一致

这是最经典、最常见的死锁来源。

典型表现

  • 一个事务先更新用户表,再更新订单表;
  • 另一个事务先更新订单表,再更新用户表。

结果

两个事务如果并发执行,就可能互相等待。


9.2 缺索引导致锁范围过大

如果更新条件没有合适索引,InnoDB 可能扫描并锁定大量记录,导致本来不该冲突的事务也发生竞争。

典型表现

UPDATE orders SET status = 'DONE' WHERE remark = 'vip';

如果 remark 无索引,那么锁冲突面会明显扩大。


9.3 批量更新一次锁太多记录

例如:

UPDATE orders SET status = 'DONE' WHERE create_time < '2026-06-01';

如果一次更新很多行,且事务执行时间较长,就很容易和其他在线事务形成复杂锁等待。


9.4 范围锁与插入并发

REPEATABLE READ 下,范围当前读会使用 next-key lock。此时并发插入可能被间隙锁阻塞,复杂情况下也能形成死锁。


9.5 外键相关操作

涉及父子表更新、删除时,如果多个事务交错操作,也可能因为检查约束和相关索引加锁而产生死锁。


十、死锁出现后怎么办

先说结论:

死锁不是“彻底避免型问题”,而是“尽量减少 + 正确恢复型问题”。

10.1 应用层必须接受死锁可能发生

只要有并发事务,就很难保证百分之百没有死锁。因此关键业务要对死锁错误具备处理能力。

10.2 对可重试事务进行重试

如果事务是:

  • 短事务;
  • 幂等或可安全重试;
  • 没有外部副作用;

那么遇到死锁后,应在应用层进行有限次数重试。

10.3 不要无脑无限重试

无限重试可能把数据库打得更忙。通常要设置:

  • 最大重试次数;
  • 退避时间;
  • 日志告警。

十一、常见问题

11.1 死锁和锁等待超时是一回事吗?

不是。

  • 死锁:InnoDB 检测到循环等待,会主动回滚一个事务;
  • 锁等待超时:只是一直没拿到锁,超过等待时间后报错。

死锁一定是循环依赖;超时不一定。


11.2 出现一次死锁,是不是 SQL 一定有问题?

不一定。

并发系统中,偶发死锁本来就是可能出现的。关键要看:

  • 是否频繁出现;
  • 是否影响核心链路;
  • 是否能通过统一顺序、缩短事务、优化索引明显降低概率。

11.3 为什么按主键更新也会死锁?

因为死锁关注的是“多个锁的获取顺序”,不是“SQL 是否简单”。

哪怕每次都只是按主键更新一行,只要一个事务要更新多行,而不同事务更新顺序不一致,仍然会死锁。


11.4 为什么死锁日志里看不懂锁信息?

很正常。

刚开始看 SHOW ENGINE INNODB STATUS 时,很多人会被索引名、页号、锁模式绕晕。实践上可以先抓住三个重点:

  • 谁在等;
  • 等哪条 SQL;
  • 锁的是哪张表、哪个索引、哪个范围。

先建立主干,再补细节。


11.5 可以通过关闭死锁检测来解决问题吗?

通常不应该把“关闭检测”当成常规解决方案。

真正应该优先处理的,还是:

  • 减少冲突;
  • 优化 SQL 与索引;
  • 缩短事务;
  • 统一加锁顺序。

否则只是把“死锁报错”变成“长时间等待”而已。


十二、实践建议

12.1 对同一批资源统一加锁顺序

这是最有效、最经典的死锁治理手段。

例如需要更新多行账户时,统一按 id 从小到大处理,不要不同代码路径各用各的顺序。

12.2 让 SQL 尽量命中索引

索引不只是性能问题,也是并发问题。索引不准,会导致:

  • 扫描更多记录;
  • 锁更多索引项;
  • 冲突面变大;
  • 死锁概率上升。

12.3 缩短事务时间

事务越长,持锁越久,碰撞窗口越大。不要把以下操作放进事务中间:

  • RPC 调用;
  • 复杂计算;
  • 大量循环;
  • 人工等待。

12.4 控制单次批量操作规模

大批量更新、删除最好分批执行,而不是一个事务吃下所有数据。

12.5 为死锁设计重试机制

对关键事务,建议:

  • 捕获死锁错误码;
  • 记录重试日志;
  • 做指数退避或短暂随机退避;
  • 控制最大重试次数。

12.6 定期复盘死锁日志

如果线上出现过死锁,不要只处理当次报错,最好复盘:

  • 高频死锁 SQL 是哪几类;
  • 是否与某次发版有关;
  • 是否与某个新索引或索引缺失有关;
  • 是否与定时任务和在线流量叠加有关。

十三、小结

死锁不是 MySQL 的异常“故障模式”,而是并发事务竞争锁资源时的一种正常结果。真正需要掌握的是:

  • 原理上:死锁就是循环等待;
  • 机制上:InnoDB 会检测死锁并回滚一个事务;
  • 排查上:重点看 SHOW ENGINE INNODB STATUS 中的 LATEST DETECTED DEADLOCK
  • 治理上:统一加锁顺序、优化索引、缩短事务、控制批量规模;
  • 恢复上:应用层对可重试事务做好有限重试。

如果要把本文压缩成一句最实用的经验,那就是:

死锁排查不要只看“谁被回滚了”,而要找到“为什么这些事务会以这样的顺序去争同一批资源”。

只要抓住“资源、顺序、范围、事务时长”这四个关键词,大多数 MySQL 死锁问题都能逐步拆开并定位清楚。


📝 版权声明:本文为原创技术博客,转载请注明出处。

如文章中存在错误或不准确之处,欢迎在评论区指正,感谢您的阅读与支持!

上一篇

16 | InnoDB 锁机制详解

下一篇

18 | InnoDB 存储引擎入门