生产环境的 MySQL 死锁 往往来得突然:监控里一闪而过的 Deadlock found when trying to get lock,业务日志零星报错,复现却困难。死锁本质是并发事务互相持有对方需要的锁、形成循环等待。本文用一个可复现的交叉更新案例,带你从现场复现、日志解读到根因定位,系统掌握 MySQL 死锁排查与预防方法。
一、什么是死锁:等待图与 InnoDB 检测机制
InnoDB 通过等待图(wait-for graph)检测死锁:当事务 A 等待事务 B 的锁、而 B 又等待 A 的锁时,就形成环。InnoDB 默认开启 innodb_deadlock_detect,会在事务请求锁时实时扫描等待图,一旦成环立即挑一个”代价最小”的事务回滚,抛出 1213 Deadlock found 错误,被回滚方需重试。
要分清两个易混概念:死锁是循环等待、必须靠回滚打破;锁等待超时是单向等待超过 innodb_lock_wait_timeout(默认 50s)后放弃。后者不会成环,调大超时并不能解决死锁,反而可能拖垮连接池。
隔离级别也直接影响死锁概率:在默认的 REPEATABLE READ 下,辅助索引的等值或范围更新会加间隙锁(gap lock),锁住记录之间的”空隙”,更容易与并发插入形成环;改为 READ COMMITTED 可去掉间隙锁、降低死锁,却牺牲了幻读防护。选择隔离级别本质是在并发度与一致性之间做权衡,并非越低越好。
二、复现一个经典死锁:两事务交叉更新
最常见的死锁来自不同事务以不同顺序更新同一批行。下面两张表模拟订单与账户,用两个会话交叉执行即可稳定复现。
-- 会话 1
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1; -- 持有 id=1 行锁
UPDATE accounts SET balance = balance + 100 WHERE id = 2; -- 等待 id=2
-- 会话 2(几乎同时)
BEGIN;
UPDATE accounts SET balance = balance - 50 WHERE id = 2; -- 持有 id=2 行锁
UPDATE accounts SET balance = balance + 50 WHERE id = 1; -- 等待 id=1 → 与会话1成环
当会话 1 已锁 id=1、会话 2 已锁 id=2,二者分别去申请对方已持有的锁,InnoDB 立即检测到环,回滚其中一个并返错。这正是”相同两行、相反顺序”导致的死锁。
三、SHOW ENGINE INNODB STATUS 解读死锁日志
出事后第一手现场在 SHOW ENGINE INNODB STATUS 的 LATEST DETECTED DEADLOCK 段。线上短暂,建议立刻捞取或启用 pt-deadlock-logger 持久化。
SHOW ENGINE INNODB STATUS\G
-- 重点关注:
-- (1) *** (1) TRANSACTION / (2) TRANSACTION 各自持有的锁
-- (2) RECORD LOCKS ... index PRIMARY 标明锁在哪一索引
-- (3) waiting for this lock / holds the locks 给出等待关系
日志会列出哪个事务持有哪些记录锁、又在等哪个锁。若看到 index PRIMARY 上的 lock_mode X locks rec but not gap,说明是行锁而非间隙锁——通常意味着 SQL 走的是主键更新;若显示 locks gap before rec,则涉及间隙锁,常见于范围更新或隔离级别为 RR 的辅助索引命中。
四、定位根因:事务顺序与索引缺失
死锁根因通常只有两类。第一类是访问顺序不一致:上文两个事务更新顺序相反。解法是对所有调用方约定统一的更新顺序(如始终按 id 升序批量更新)。第二类是索引缺失导致锁升级:若更新条件未命中索引,InnoDB 会退化为全表扫描并锁住所有行(或 RR 下的间隙锁覆盖整段),极大放大冲突面。这和 MySQL 索引底层原理 中强调的”最左前缀命中”直接相关。
-- 危险:status 无索引 → 锁放大为全表/大范围
UPDATE orders SET flag = 1 WHERE status = 'pending';
-- 安全:加复合索引 (status, id),让更新只锁命中行
ALTER TABLE orders ADD INDEX idx_status (status);
EXPLAIN UPDATE orders SET flag = 1 WHERE status = 'pending'; -- 确认 rows 大幅下降
五、六类预防手段对照
| 手段 | 适用场景 | 代价 / 注意 |
|---|---|---|
| 统一更新顺序 | 多行交叉更新 | 需约束所有调用方,成本最低 |
| 补全索引避免锁放大 | 更新条件无索引 | 写放大,参考 MySQL 深度调优 |
| 减小事务粒度 | 长事务持锁久 | 拆分业务,缩短持锁窗口 |
| 降低隔离级别到 RC | 间隙锁惹祸 | 可能丢幻读防护,需评估 |
| 应用层重试 | 偶发死锁 | 必须幂等,配合退避 |
| 乐观锁 version 字段 | 冲突频繁的热点行 | 改表结构,重试逻辑上移 |
六、监控与告警:把死锁抓进可观测体系
被动等报错太慢,应主动监控。Percona 的 pt-deadlock-logger 可把每次死锁落库,便于事后聚合。自建方案可定时采样 information_schema 与全局状态计数。
-- 死锁累计次数(归零重启后重新计算)
SHOW GLOBAL STATUS LIKE 'Innodb_deadlocks';
-- 最近一次死锁的时间点(MySQL 8.0+)
SELECT * FROM performance_schema.data_lock_waits;
-- pt-deadlock-logger 持续记录到表
pt-deadlock-logger h=127.0.0.1,u=monitor,p=xxx,D=diag,t=deadlocks --daemonize
把 Innodb_deadlocks 接入 MySQL 主从复制与读写分离 那样的监控面板,设阈值告警,就能在死锁从偶发变频繁时提前介入,而不是等线上大量报错才反应。
若已用 Prometheus,配合 mysqld_exporter 暴露的 mysql_global_status_innodb_deadlocks 指标直接画图告警,比定时采样更实时;再叠加 Grafana 阈值规则,就能在死锁次数突增时秒级通知值班,把”事后救火”变成”事前预警”。
七、实战落地清单
总结一套可立即执行的死锁应对流程:
- 统一多行更新的顺序(按主键升序),从根上消除反向环;
- 为所有 UPDATE/DELETE 的 WHERE 条件建索引,避免锁放大;
- 缩短事务、尽快提交,不在事务里嵌外部调用;
- 应用层对 1213 错误做幂等重试(指数退避);
- 用 pt-deadlock-logger 持久化日志,结合 MySQL 字符集与时区踩坑 的巡检习惯做定期复盘;
- 热点行改用乐观锁 version,把冲突检测上移到应用层。
死锁不可怕,可怕的是没有现场与复盘。把”复现—读日志—定根因—改顺序/补索引—加监控”六步固化进发布前检查,配合 缓存与数据库一致性实战 的稳健写法,多数 MySQL 死锁都能在上线前被拦下。
小结
MySQL 死锁排查的核心不是记住命令,而是理解等待图与访问顺序。复现交叉更新、读懂 SHOW ENGINE INNODB STATUS、补齐索引与统一顺序,再用监控把偶发变成可观测,就能把死锁从”玄学报错”变成可治理的常规问题。记住一句口诀:统一顺序、补索引、缩事务、加重试与监控,死锁就不再是深夜告警的噩梦。下一轮遇到 1213,按本文清单逐项对照即可。




