人人都会AI编程

16.3 死锁排查方法与解决方案

更新时间:2026-07-11

死锁并不是 MySQL 的缺陷,而是高并发下无法完全避免的现象。InnoDB 会自动检测死锁并强制回滚代价较小的事务来解开僵局,但这不代表开发者可以坐视不管。当业务日志里频繁出现 Deadlock found when trying to get lock 错误时,说明表结构、索引或事务设计已经出现了问题,需要主动排查和解决。

16.3.1 死锁是如何被发现的

死锁发生的典型特征是应用端收到 MySQL 返回的错误码 1213,并附带一段显式的错误信息:

ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction

InnoDB 内部有一个背景线程负责死锁检测,其工作原理是将当前所有锁的持有和等待关系构建成一个有向图(wait-for graph)。当图中出现环时,即判定为死锁。此时 InnoDB 会选择一个事务回滚——通常回滚修改行数少、日志量小的事务。被选中的事务的修改会全部撤销,其它事务得以继续。

如果系统真的出现了死锁,你不需要也做不到手动干预死锁本身。InnoDB 会极快自动处理(毫秒级),但问题是为什么死锁会频繁发生,你需要知道怎么排查并减少再次发生。

16.3.2 快速定位死锁:SHOW ENGINE INNODB STATUS

SHOW ENGINE INNODB STATUS 是排查死锁最直接的工具。它输出 InnoDB 引擎的近况报告,其中包含一段完整的死锁记录。

执行命令:

SHOW ENGINE INNODB STATUS\G

在输出中找到 LATEST DETECTED DEADLOCK 部分,它包含了最近一次死锁的详细信息。格式大致如下:

------------------------
LATEST DETECTED DEADLOCK
------------------------
2024-01-15 14:30:00 0x7f8c0000e700
*** (1) TRANSACTION:
TRANSACTION 4212345678, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 4 lock struct(s), heap size 1136, 3 row lock(s)
MySQL thread id 1234, OS thread handle 123456789, query id 5678 localhost root updating
UPDATE orders SET status='paid' WHERE id=10
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 55 page no 3 n bits 72 index PRIMARY of table `shop`.`orders` trx id 4212345678 lock_mode X locks rec but not gap
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 55 page no 3 n bits 72 index PRIMARY of table `shop`.`orders` trx id 4212345678 lock_mode X locks rec but not gap waiting
Record lock, heap no 5 PHYSICAL RECORD...

*** (2) TRANSACTION:
TRANSACTION 4212345679, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
3 lock struct(s), heap size 1136, 2 row lock(s)
MySQL thread id 5678, OS thread handle 987654321, query id 9012 localhost root updating
UPDATE orders SET status='cancel' WHERE id=10
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 55 page no 3 n bits 72 index PRIMARY of table `shop`.`orders` trx id 4212345679 lock_mode X locks rec but not gap
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 55 page no 3 n bits 72 index PRIMARY of table `shop`.`orders` trx id 4212345679 lock_mode X locks rec but not gap waiting

*** WE ROLL BACK TRANSACTION (2)

解读这份死锁日志的要点:

  • 有两个事务(1)和(2),它们都在操作相同的资源(同一行数据或存在间隙锁冲突)。
  • 每个事务都持有一部分锁,同时等待对方释放自己需要的锁。
  • InnoDB 最后回滚了其中代价较小的事务(通常是后者,这里是事务2)。
  • 你需要关注锁定的资源是记录锁(rec but not gap)还是间隙锁(gap),以及发生死锁的索引名称和表。

日志末尾会指明具体是哪一行发生了锁冲突,这对后续优化很有帮助。

16.3.3 持续监控:系统视图与慢日志

单次死锁可以靠 SHOW ENGINE INNODB STATUS 回溯,但如果要监控一段时间内的死锁趋势,需要更自动化的手段。

performance_schema 数据锁表(MySQL 8.0 推荐使用)

MySQL 8.0 提供了更为结构化的锁等待视图,可以实时查询当前正在等待的事务:

-- 查看正在等待的锁关系
SELECT 
    r.trx_id waiting_trx_id,
    r.trx_mysql_thread_id waiting_thread,
    b.trx_id blocking_trx_id,
    b.trx_mysql_thread_id blocking_thread,
    r.requesting_engine_lock_id lock_being_waited
FROM performance_schema.data_lock_waits w
JOIN information_schema.innodb_trx r ON w.requesting_engine_transaction_id = r.trx_id
JOIN information_schema.innodb_trx b ON w.blocking_engine_transaction_id = b.trx_id;

这个查询能直接告诉你:谁正在阻塞谁,具体卡在哪把锁上。配合 innodb_trx 还能查看到阻塞事务执行的 SQL 语句和运行时长,是排查临时锁等待的有效工具。

需要注意,performance_schema.data_locksdata_lock_waits 表默认可能未完全启用,须确认 performance_schema 已设为 ON。

慢查询日志也会暴露死锁

当死锁导致事务回滚时,如果语句执行时间很短,通常不会直接记入慢日志。但你可以把 log_queries_not_using_indexeslog_slow_admin_statements 结合使用,辅助发现没有使用索引的写操作——这类操作极易因为扫描大量行而锁住更多记录,从而增大死锁概率。

16.3.4 死锁常见成因与解法

死锁的根本原因无外乎两个:

  1. 资源访问顺序不一致:事务进入时获取锁的顺序不同。
  2. 锁范围超出预期:原本只打算锁一行,结果因为索引缺失或间隙锁的作用,锁住了一大片区域。

以下是几种生产中最常见的死锁场景及其解决方案。

场景一:多表更新顺序不一致

事务A先更新订单表,再更新库存表;事务B先更新库存表,再更新订单表。两个事务拿到第一把锁后,等第二把锁时相互死等。

解决方案:强制所有涉及这些表的写入操作都采用相同的资源访问顺序。在代码层面规定一个规范:永远先操作订单表,后操作库存表;或者按表名排序写入。如果是同一张表的多行,同理按主键增序操作。

场景二:共享锁升级排他锁

事务A执行 SELECT ... FOR SHARE 读取一行并持有共享锁,事务B也对该行请求共享锁(不互斥),随后事务A试图将该行改为排他锁 UPDATE,必须等待B释放共享锁;同时事务B也想升级为排他锁——死锁发生。

解决方案:如果后续必然要修改,从一开始就直接使用 SELECT ... FOR UPDATE 获取排他锁,避免“先共享后升级”造成的锁冲突。这种模式在 select for update 然后 update 的场景中很常见。

场景三:唯一索引重复键冲突

事务A插入一条记录(获取插入意向锁),事务B也插入一条相同的唯一键值。此时B会等待A的插入意向锁,并额外加一个共享锁等待。如果A之后又因为其他原因需要获取B已持有的其他锁,就可能产生死锁。这种情况在批量导入数据且可能导致唯一键冲突时尤其频繁。

解决方案

  • 确保插入的数据在应用层已经处理好唯一键冲突,避免让 InnoDB 承担重复键检测带来的复杂锁。
  • 使用 INSERT ... ON DUPLICATE KEY UPDATE 语句将冲突转化为更新操作,减少额外的锁等待。

场景四:间隙锁(Gap Lock)恶意膨胀

在可重复读隔离级别下,InnoDB 为了防止幻读,使用间隙锁和临键锁。当你执行 SELECT ... FOR UPDATEUPDATE ... WHERE 条件涉及一个范围,而这个范围又恰好没有命中一条记录时,InnoDB 会给所选范围的“间隙”加上锁。这在类似 WHERE order_id BETWEEN 100 AND 200 却没有精确索引时,可能锁住一大片。

如果事务A锁住间隙 G1,事务B锁住间隙 G2,然后事务A想去插入 G2 内的行,事务B想去插入 G1 内的行——死锁。

解决方案

  • 尽量缩小锁范围:利用精确的唯一索引或主键来限制间隙锁的覆盖区域。
  • 适当降低隔离级别:如果业务确实不需要防止幻读(比如只靠唯一索引防重),可以将隔离级别设为读已提交(READ COMMITTED)。该级别下不使用间隙锁,可以大幅减少死锁,但必须由业务逻辑保证不会因为幻读导致数据不一致。
  • 优化索引:确保 WHERE 条件能用上合适的索引,避免锁蔓延。

场景五:大事务长锁定

一个事务一直不提交,持有锁的时间过长,其他事务被阻塞等待,出现锁堆积,死锁概率陡然上升。

解决方案

  • 缩小事务粒度:避免在事务中做远程调用、发邮件等耗时操作。
  • 尽早提交:拆分大型批处理,如一次处理 1000 行改为每次处理 200 行并分批提交。
  • 监控长事务:定期查询 information_schema.innodb_trx,设置 innodb_lock_wait_timeout(默认 50 秒)防止个别事务无限期阻塞。

16.3.5 应用层兜底:死锁重试

无论排查和优化得多彻底,死锁在高并发场景下仍有极低概率发生。所以应用层必须做好重试机制。

典型的处理方式(以 Java 伪代码为例):

int retryCount = 3;
while (retryCount-- > 0) {
    try {
        // 开启事务,执行业务操作
        executeTransaction();
        break;
    } catch (DeadlockLoserDataAccessException e) {
        if (retryCount == 0) throw e;
        Thread.sleep(50); // 等待一小段时间再重试
    }
}

重试时不应该简单地反复执行相同代码而不加延迟。一个短暂的随机等待可以有效避免两个冲突事务同时重试再次发生死锁。

此外,重试的事务需要保证幂等性——第二次执行不能造成重复数据。这就要求业务表有唯一索引或状态机来兼容重复执行。

16.3.6 预防死锁的清单

在实际工作中,与其出问题后被动排查,不如从开发阶段就做好预防。下面是一些可直接落地的规范:

  • 规定资源访问顺序:所有更新多张表或多行记录的代码,按固定顺序(如主键顺序)执行。
  • 精简化事务:一个事务中包含尽可能少的语句,不要放无关的查询或外部调用。
  • 为写操作建立精准索引:确保 UPDATEDELETE 语句能用上索引,避免锁范围扩大。这是减少死锁最有效的手段之一。
  • 优先选择读已提交隔离级别:如果业务允许,使用读已提交可以去掉间隙锁,大幅降低死锁风险。但仍需确保业务的正确性。
  • 使用乐观锁:对于更新同一行的高并发场景,可改用版本号机制,读时不加锁,更新时通过版本号校验,减少锁竞争。
  • 监控与告警:设置监控项,若短时间内死锁错误频繁(如每分钟超过 5 次),立即告警通知开发介入。
  • 定期审查执行计划:迭代过程中不断用 EXPLAIN 分析查询是否正确使用索引,避免锁膨胀。

死锁是不可完全消除的,但它不应该成为业务的常态。通过排查工具定位具体冲突点,结合索引优化和事务设计调整,绝大多数死锁问题都能被有效控制。当死锁异常从每天上百次降到每周零次时,说明你的处理是成功的。