MySQL死锁怎么排查和解决

文章导读
排查 MySQL 死锁,先不要改隔离级别,也不要急着给表加索引。第一步是从 InnoDB 状态里拿到最近一次死锁的两条事务原文,看清各自持有什么锁、在等什么锁、执行到哪条 SQL,再判断是索引缺失、锁范围过大,还是加锁顺序不一致。多数情况下,问题出在两条业务 SQL 的加锁顺序相反或走了一条没有命中索引的更新上,极少是 MySQL 本身配错了。
📋 目录
  1. A MySQL死锁先定位在哪一层
  2. B MySQL死锁:先看这四个位置
  3. C MySQL死锁:按可能性从高到低排除
  4. D MySQL死锁:改完怎么确认真的解决了
A A

排查 MySQL 死锁,先不要改隔离级别,也不要急着给表加索引。第一步是从 InnoDB 状态里拿到最近一次死锁的两条事务原文,看清各自持有什么锁、在等什么锁、执行到哪条 SQL,再判断是索引缺失、锁范围过大,还是加锁顺序不一致。多数情况下,问题出在两条业务 SQL 的加锁顺序相反或走了一条没有命中索引的更新上,极少是 MySQL 本身配错了。

MySQL 死锁的排查顺序是:先用 SHOW ENGINE INNODB STATUS 的 LATEST DETECTED DEADLOCK 段拿到两条事务的 SQL 和锁等待关系,再看 innodb_print_all_deadlocks 是否开启以便留存历史,然后按“无索引更新、加锁顺序相反、锁范围过大、长事务”的顺序逐一排除。确认修复后不要立刻宣布解决,先观察错误日志里死锁是否还按原 SQL 模式出现。风险边界:在业务高峰修改隔离级别或大批加索引本身会带来新的锁等待,建议在低峰期分批做,并准备回滚脚本。

MySQL死锁先定位在哪一层

死锁报错 Deadlock found when trying to get lock; try restarting transaction 只是 InnoDB 检测到循环等待后主动回滚了一个事务,它不告诉你根因在哪。先分层,再决定看什么。

  • 数据库实例层:判断是不是同一实例上多个库、多个业务共用导致锁冲突。检查位置是 SHOW ENGINE INNODB STATUS 中的 TRANSACTIONS 段和 LATEST DETECTED DEADLOCK 段;判断依据是两条事务是否操作同一张表、是否有跨表更新。
  • 表与索引层:判断更新是否走了索引。检查位置是死锁段里的 SQL 原文,把其中的 WHERE 条件单独拿出来跑 EXPLAIN;判断依据是 type 为 ALL 或 key 为 NULL,说明扫描范围大,加锁范围也跟着变大。
  • 事务与业务代码层:判断两条事务的加锁顺序是否相反。检查位置是应用日志里同一 traceId 下的 SQL 执行顺序,对照死锁段里两条事务的语句顺序;判断依据是 A 先锁表 1 再锁表 2、B 先锁表 2 再锁表 1。
  • 连接与长事务层:判断是否有事务开着却不提交。检查位置是 information_schema.innodb_trx;判断依据是 trx_started 距当前时间过长且 trx_state 为 RUNNING。

这四层里,前三层能解释绝大多数可复现的死锁。如果死锁段里的 SQL 看起来完全无关,才需要往第四层的连接池或框架事务边界去查。

MySQL死锁:先看这四个位置

下面四个位置按“先看什么”排列。命令可以直接在客户端执行,需要 SUPER 或 PROCESS 权限。

1. InnoDB 引擎状态里的最近一次死锁

SHOW ENGINE INNODB STATUS\G

在输出里找 LATEST DETECTED DEADLOCK 这一段,重点读三块:TRANSACTION 1 和 TRANSACTION 2 各自执行过的 SQL、HOLDS THE LOCK(S) 里持有的锁、WAITING FOR THIS LOCK TO BE GRANTED 里在等的锁。行锁会显示索引名和主键值,这决定了你该去看哪条索引。

2. 是否留存了历史死锁

SHOW VARIABLES LIKE 'innodb_print_all_deadlocks';

值为 OFF 时,SHOW ENGINE INNODB STATUS 只保留最近一次。需要排查偶发死锁,可以先在配置文件 my.cnf 的 [mysqld] 段改成 innodb_print_all_deadlocks = ON,重启或用 SET GLOBAL innodb_print_all_deadlocks = ON; 生效,之后所有死锁会写进错误日志。错误日志路径用 SHOW VARIABLES LIKE 'log_error'; 确认,别去猜默认路径。

MySQL死锁怎么排查和解决

3. 当前正在运行的事务和锁等待

SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query
FROM information_schema.innodb_trx
ORDER BY trx_started;
SELECT * FROM performance_schema.data_lock_waits;

data_lock_waits 在 MySQL 8.0 可用;5.7 需要改查 information_schema.innodb_lock_waits。先用 innodb_trx 找出跑得最久的 trx_mysql_thread_id,再用这个线程 ID 去对应应用连接,比盲目翻业务日志快。

4. 隔离级别与锁相关配置

SHOW VARIABLES LIKE 'transaction_isolation';
SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';

5.7 及更早版本里这个变量名是 tx_isolation,以 SHOW VARIABLES 实际输出为准。死锁本身由 innodb_lock_wait_timeout 之外的机制触发,改这个超时值只能让普通锁等待更快失败,并不能消除死锁,不要把它当成解决方案。

MySQL死锁:按可能性从高到低排除

  1. 更新或删除没走索引。这是最常见的一类。确认动作:把死锁段里报错的 SQL 拿出来执行 EXPLAIN,看 key 是否为空、rows 是否接近全表行数。处理:给 WHERE 条件补上合适的联合索引,注意把区分度高的列放前面,并用 EXPLAIN 确认执行计划真的走了新索引,而不是只建了却没被选中。

  2. 两个事务加锁顺序相反。确认动作:对照死锁段里两条事务的 SQL 顺序,看是否一张表先更新 A 行再更新 B 行、另一条正好相反。处理:在业务代码里把同一组表的更新顺序统一,例如都按主键升序或都按固定业务顺序,前提是这一步不改变业务语义。

  3. 间隙锁和范围条件导致锁范围过大。确认动作:在 REPEATABLE READ 下,死锁段里出现 lock_mode X locks gap before rec 之类的记录时,说明范围查询或唯一键冲突检查锁住了间隙。处理:优先把范围条件收窄到能命中索引的等值条件;确认业务允许时,再评估是否调整隔离级别,这不是首选动作。

    MySQL死锁怎么排查和解决
  4. 长事务持有锁不释放。确认动作:SELECT trx_id, trx_started, trx_state FROM information_schema.innodb_trx; 查看是否有长时间 RUNNING 的事务。处理:检查应用侧事务里是否夹了远程调用、消息发送或大批量循环;把非数据库操作移出事务边界。

  5. 批量操作一次性处理过多行。确认动作:看死锁 SQL 是否带 IN (...) 大列表或 UPDATE ... WHERE 时间范围。处理:拆批提交,控制每批行数,并在批之间留出间隔。

排除时一次只改一项,否则死锁不再出现也说不清是哪一步起了作用。

MySQL死锁:改完怎么确认真的解决了

验证不是看一次报错消失。建议按下面的顺序确认:

  • 看错误日志是否还有同类死锁。用 SHOW VARIABLES LIKE 'log_error'; 拿到路径,在操作系统上执行 tail -f 错误日志路径,或在日志里按 Deadlock found 关键字检索。观察窗口建议覆盖至少一个完整的业务高峰周期,具体多久要结合你们业务流量分布来定,不能一概而论。
  • 看新 SQL 的执行计划。对改动后的 SQL 再跑一次 EXPLAIN,确认 key 显示了新建的索引、rows 明显下降,这才是加索引真正生效的依据。
  • 看锁等待是否还在堆积。用 SELECT * FROM performance_schema.data_lock_waits; 和 information_schema.innodb_trx 观察高峰期是否还有长事务和大量等待。

回滚办法要提前写好:如果改的是索引,保留建索引语句对应的 DROP INDEX 索引名 ON 表名;;如果改的是隔离级别,先 SELECT @@transaction_isolation; 记下原值,回滚时用 SET GLOBAL transaction_isolation = '原值'; 恢复并同步改配置文件,否则重启后会丢失;如果改的是业务代码里的加锁顺序,回滚就是回退对应版本。索引回滚在大表上同样有代价,建议在低峰期执行,并先确认没有其他查询依赖这个索引。