MySQL 死锁是怎么产生的?如何排查和避免?
简化版
死锁是两个或多个事务互相持有对方需要的锁,形成循环等待。InnoDB 会自动检测死锁并回滚其中一个事务;排查时看死锁日志、SQL 顺序、索引命中和锁范围,避免时要保持固定访问顺序、缩短事务、建立合适索引并做好失败重试。
详细版
典型死锁:
事务 A:锁住 id=1,等待 id=2
事务 B:锁住 id=2,等待 id=1
双方都不释放已持有的锁,就形成死锁。InnoDB 通常会检测到循环等待,选择一个代价较小的事务回滚,让另一个事务继续执行。
处理思路:
- 用
SHOW ENGINE INNODB STATUS查看最近一次死锁信息; - 分析涉及的 SQL、索引、锁模式和等待关系;
- 检查是否因为没有索引导致锁范围扩大;
- 让多个事务按固定顺序访问资源;
- 缩短事务,减少持锁时间;
- 应用层捕获死锁错误并重试。
完整版教学
一、死锁不是数据库坏了
死锁是并发系统里的正常风险,不代表 MySQL 出 bug。只要多个事务同时更新多份资源,并且访问顺序不一致,就可能出现循环等待。
比如转账业务:
事务 A:先扣账户 1,再加账户 2
事务 B:先扣账户 2,再加账户 1
如果两个事务同时执行,就可能 A 拿到账户 1 的锁,B 拿到账户 2 的锁,然后互相等待。
死锁成立通常要具备几个条件:互斥资源、持有并等待、不能强制抢占、形成循环等待。数据库里的行锁天然是互斥的,事务又会在持有已有锁的同时继续申请新锁,所以只要访问顺序不一致,就很容易凑出循环等待。把它理解成并发访问顺序问题,比简单说“两个事务互相等”更扎实。
时间线:
T1: lock account 1 成功
T2: lock account 2 成功
T1: 申请 account 2,等待 T2
T2: 申请 account 1,等待 T1
等待图:T1 -> T2 -> T1,形成环
二、InnoDB 如何处理死锁
如果只是普通锁等待,事务可能一直等到超时。死锁不同,继续等没有意义,因为等待关系成环了。
InnoDB 有死锁检测机制,发现循环等待后,会选择一个事务作为牺牲者回滚,释放它持有的锁。应用会收到死锁相关错误,此时正确处理方式通常是重试整个事务,而不是只重试失败 SQL。
InnoDB 的死锁检测会维护锁等待关系,发现环后主动打破。被回滚的事务通常会报类似 deadlock 的错误,业务层要把这类错误视为可重试的并发异常。注意,回滚的是事务,不是单条 SQL 的局部失败;如果事务前面已经做了多步数据库修改,必须从事务入口重新执行,且业务操作要具备幂等性。
| 情况 | 数据库行为 | 应用处理 |
|---|---|---|
| 普通锁等待 | 等到锁释放或超时 | 可设置合理超时并告警 |
| 死锁 | 检测到环后回滚一个事务 | 捕获异常,重试整个事务 |
| 热点频繁冲突 | 死锁或超时都可能增多 | 降低并发冲突,调整访问顺序 |
三、怎么排查死锁
常用入口:
SHOW ENGINE INNODB STATUS;
输出中会包含最近一次死锁信息,包括事务、SQL、持有什么锁、等待什么锁。排查时重点看:
- 两个事务分别执行了什么 SQL;
- 是否访问同一批资源但顺序不同;
- where 条件是否命中索引;
- 是记录锁、间隙锁还是 next-key lock;
- 是否有范围更新扩大了锁范围。
如果线上死锁频繁,还要结合慢日志、应用日志、事务调用链和表结构一起看。
排查时不要只看最后报错的 SQL。死锁日志里通常会展示两个事务各自“持有什么锁、等什么锁”,真正的问题可能在事务前面已经执行过的语句。比如最后一条 SQL 是更新订单状态,但前面先更新库存或账户表,顺序不同才是根因。
排查路径:
死锁日志 -> 找两个事务 SQL -> 还原事务内访问顺序
-> 看 where 是否命中索引 -> 判断锁范围
-> 对照业务调用链 -> 修改顺序/索引/事务边界
记忆钩子:死锁排查不是找“哪条 SQL 报错”,而是还原“两个事务怎样一步步拿锁,最后形成等待环”。
四、没有索引为什么会放大死锁概率
InnoDB 锁和索引强相关。如果更新条件没有合适索引,数据库可能扫描更多记录,并对扫描到的记录加锁或产生更大的锁范围。
例如:
update orders set status = 'CANCELLED' where user_id = ? and status = 'INIT';
如果缺少 (user_id, status) 这类合适索引,扫描范围可能很大,锁住的记录更多,和其他事务冲突的概率也更高。
用数字感受一下:订单表 500 万行,某个用户的 INIT 订单只有 3 条。如果没有合适索引,更新可能扫描大量记录;如果有 (user_id,status),扫描范围能缩到这 3 条附近。锁住 3 条记录和扫描数万条候选记录,冲突概率完全不是一个量级。
索引不只是性能工具,也是并发控制工具。它让数据库更精确地找到要修改的记录,减少无关记录被扫描和锁住。很多线上“偶发死锁”,最后根因不是业务一定复杂,而是某条更新 SQL 缺了合适索引。
五、避免死锁的常用手段
工程上不会追求“永远没有死锁”,而是降低概率并能恢复:
- 所有事务按固定顺序访问资源,比如账户 ID 从小到大加锁;
- 把事务做短,避免事务里夹杂慢查询、远程调用、用户交互;
- 为更新条件建立合适索引;
- 大批量更新分批提交;
- 热点资源考虑队列化、乐观锁或业务拆分;
- 应用层对死锁和锁等待超时做幂等重试。
固定访问顺序是最经典也最有效的办法。比如转账时,不管是 A 转 B 还是 B 转 A,都先锁较小账户 ID,再锁较大账户 ID;这样两个事务不会一个先拿 1、另一个先拿 2。批量更新也类似,先把要操作的主键排好序,再按固定顺序更新。
-- 先查出需要加锁的账号,并按 id 排序
select id from account
where id in (1001, 2002)
order by id
for update;
六、死锁重试要注意什么
重试不是简单 while true。应用应限制重试次数,例如最多 3 次,并加入短暂退避,避免高峰期所有请求同时重试再次撞上。事务内部如果包含外部调用、发消息、扣优惠券等副作用,还要保证幂等,避免数据库事务回滚了,外部副作用却已经发生。
一个更稳妥的事务边界是:先完成必要校验,进入事务后只做数据库强一致修改,提交后再发异步消息;如果必须在事务里写多张表,就固定表顺序和行顺序。这样既减少持锁时间,也降低重试带来的业务复杂度。
七、常见误区与追问
- 误区:死锁一定是 MySQL 或 InnoDB 的 bug。 死锁是并发事务循环等待的结果,数据库负责检测和打破环,业务负责降低概率和重试恢复。
- 误区:只要设置更长锁等待超时就能解决死锁。 死锁等待关系成环,继续等没有意义;应排查访问顺序、索引和事务边界。
- 误区:死锁只发生在两条简单 update 之间。 多表事务、范围锁、二级索引、外键检查、唯一键冲突都可能参与死锁。
- 误区:被回滚后只重试最后失败那条 SQL 就行。 死锁牺牲者回滚的是事务,应用通常要重试整个事务流程,并保证幂等。
- 追问:为什么固定加锁顺序能减少死锁? 所有事务按同一顺序申请资源,就很难形成 A 等 B、B 又等 A 的环形等待。
- 追问:没有索引为什么会导致死锁变多? 缺索引会扩大扫描和锁范围,更多事务更容易碰到同一批记录或间隙,冲突概率上升。
八、加强记忆
死锁的本质是等待图成环:我拿着你要的锁,你拿着我要的锁,双方都无法继续。排查要从 SHOW ENGINE INNODB STATUS 还原事务、SQL、索引和锁范围;预防靠固定资源访问顺序、缩短事务、命中索引、分批更新和热点削峰。InnoDB 会自动回滚一个事务打破死锁,但业务层必须把它当成可重试异常,并保证重试不会制造重复副作用。