MySQL 死锁排查实战:读懂 SHOW ENGINE INNODB STATUS,间隙锁、锁等待超时与重试兜底

一条报错背后的连锁反应:认识 MySQL 死锁

网站后台突然报错 Deadlock found when trying to get lock; try restarting transaction,或者日志里出现 Lock wait timeout exceeded; try restarting transaction,很多站长的第一反应是「是不是数据库坏了」或者「加个索引就好了」。实际上死锁不是故障,而是 InnoDB 在多事务并发下的一种正常保护机制:当两个事务互相持有对方需要等待的锁时,数据库会主动挑一个牺牲掉(回滚),来打破僵局。

真正要做的事情有两件:一是搞懂死锁为什么会形成,二是学会从 SHOW ENGINE INNODB STATUS 里把死锁现场读出来。只盯着「加索引」通常治不了本,因为死锁的成因远不止缺索引一种。本文用个人站长最常见的场景(评论、订单、计数更新)把这件事讲清楚。

死锁是怎么形成的:四个必要条件的通俗版

教科书里讲死锁有四个必要条件:互斥、占有且等待、不可抢占、循环等待。对只关心自己网站的站长来说,只需要记住一句话:两个事务以不同的顺序去锁同一批记录,就极容易死锁。

举个具体的例子。假设你有一个评论表,用户点赞时会对 posts 表里的点赞计数做更新,同时向 post_likes 表写入一条记录。两个用户同时点赞,事务 A 先锁了 posts 再锁 post_likes,事务 B 因为业务代码里两条 SQL 顺序不同,先锁了 post_likes 再锁 posts,两边就形成了循环等待,数据库只能回滚其中一个。

注意这里的核心:锁的不只是你显式写的行,还包括索引间隙(gap lock)和临键锁(next-key lock)。这就解释了为什么很多站长觉得「我明明只更新了一行,怎么会死锁」——在可重复读隔离级别下,InnoDB 为了防止幻读,会锁定一个范围而不只是一行,范围重叠就成了死锁的温床。

第一步:把死锁现场读出来

死锁发生时 MySQL 会自动记录现场,这是排查最重要的证据。用下面这条命令查看,它输出的是 InnoDB 的完整运行状态:

SHOW ENGINE INNODB STATUS\G

输出很长,重点看 LATEST DETECTED DEADLOCK 这一段。它记录了最近一次死锁的完整信息,通常包含这些关键部分:

  • TRANSACTION 1 / TRANSACTION 2:两个互相冲突的事务,各自标明了「持有(HOLDS)」什么锁、「等待(WAITING FOR)」什么锁。
  • WAITING FOR THIS LOCK TO BE GRANTED:这是死锁的关键线索——某事务正在等待对方持有的锁。
  • LOCK MODE:显示锁类型。出现 X locks gap before recX locks rec but not gap 这类字样,说明涉及间隙锁或临键锁。
  • LATEST FOREIGN KEY ERROR / WE ROLL BACK TRANSACTION:后者指明 InnoDB 回滚了哪个事务(通常是修改行数较少的那个)。

读懂这段之后,死锁的成因基本就浮现了。如果两个事务的 SQL 里,锁定顺序和涉及的表顺序不一致,那就是应用层的问题,改代码顺序就能解决;如果都是同一条 SQL 在互相卡,那多半是索引缺失导致锁定范围过大,这才是真正需要加索引的场景。

除了现场,还有两个辅助信息源值得打开。一是把死锁打印到错误日志,方便长期收集:在配置文件里设置 innodb_print_all_deadlocks = ON,之后所有死锁都会写进 MySQL 错误日志,而不是只保留最近一次在内存里。二是关注 SHOW ENGINE INNODB STATUS 里另外一行统计信息:

# 观察死锁与锁等待的整体趋势
SHOW GLOBAL STATUS LIKE '%deadlock%';
SHOW GLOBAL STATUS LIKE 'Innodb_row_lock%';

Innodb_row_lock_waitsInnodb_row_lock_time_avg 能告诉你锁等待到底有多严重、平均等多久。如果平均等待时间在几十毫秒以内、每天几次死锁,其实属于可接受范围,不值得大动干戈;如果平均等待几百毫秒以上,那就是真问题。

第二步:锁定范围过大的三类典型场景

搞清现场之后,绝大多数死锁都能归入下面三类,每类有对应的修法。

场景一:缺索引导致全表扫描加锁。这是最经典也最容易修的一类。当更新或删除语句的 WHERE 条件没有走索引时,InnoDB 只能扫描全表,并对扫过的每一行加锁,相当于锁住了整张表。两个这样的语句并发执行,几乎必然死锁。典型特征是在死锁日志里看到锁定的记录数很多、指向的是同一张表。修法很直接:给 WHERE 条件里用到的列建索引,让锁精确落在目标行上。

-- 排查:先用 EXPLAIN 确认是否走了索引
EXPLAIN SELECT * FROM comments WHERE post_id = 100 AND status = 'approved';

-- 修复:为高频条件建组合索引
ALTER TABLE comments ADD INDEX idx_post_status (post_id, status);

场景二:更新顺序不一致。这是纯应用层问题,跟索引无关。典型表现是同一个业务在不同代码路径里,对多张表的操作顺序不同,或者对同一张表的多行更新顺序不同。修法是统一访问顺序:约定好所有事务都按同一顺序操作表和行,比如永远按主键升序更新,永远先写明细表再更新汇总表。这一点比任何数据库参数都有效。

场景三:间隙锁冲突。做范围更新、或者执行 INSERT ... SELECTINSERT ON DUPLICATE KEY UPDATE 这类语句时,很容易触发间隙锁。如果业务上并不严格要求可重复读,把隔离级别降到 READ COMMITTED 可以显著减少间隙锁,从而降低死锁概率。这是很多高并发场景的标配调整:

# 查看当前隔离级别
SELECT @@transaction_isolation;

# 会话级临时调整(生产调整建议评估后再改全局)
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

需要注意,READ COMMITTED 会改变事务的可见性语义,跟 REPEATABLE READ 在「同一事务内两次读到不同结果」这一点上行为不同。如果你的业务依赖可重复读(比如一个事务里要先统计再决策),就不能随便降级。降级前务必先确认业务逻辑不依赖这个语义。

第三步:让重试成为应用层的兜底机制

即便把上面三类问题都修了,在高并发下死锁仍可能偶发。与其追求「零死锁」,更工程化的做法是让应用能优雅处理死锁——捕获特定错误码并重试。这才是生产环境的标准姿势。

MySQL 对死锁和锁等待超时返回的错误码是固定的:死锁是 1213,锁等待超时是 1205。应用层应当捕获这两个错误码,然后做有限次数的重试(通常 2 到 3 次即可,且要加随机退避,避免重试风暴):

-- 应用层伪代码逻辑(PHP/后端通用)
-- 捕获 errno 1213 或 1205
-- 退避后重试,最多 3 次,超过则返回友好错误

这里有个反直觉但很重要的点:死锁发生时,InnoDB 回滚的是「代价最小」的那个事务,通常不是你的那个。所以你不能假设自己总是受害者,也不能假设重试一定成功。重试逻辑要幂等——也就是说,即使事务被回滚、代码重跑一遍,也不能产生重复数据或重复扣减。对更新操作来说,用绝对值更新(SET count = 10)比增量更新(SET count = count + 1)更难保证幂等,需要结合业务谨慎设计。

预防措施清单

把上面的内容收敛成一份可以直接落地的清单,建议对照自己的项目逐条检查:

  • 所有高频的 UPDATE / DELETE 语句,用 EXPLAIN 确认走了索引。
  • 同一业务跨越多个表或多个行时,约定统一的访问顺序,并在代码注释里写明。
  • 把长事务拆短:事务里不要做网络请求、不要等用户输入、不要做大批量操作,减少持锁时间。
  • 能用唯一索引或应用层幂等解决的并发写入,尽量不用 SELECT 加锁的方式实现。
  • 评估把隔离级别从 REPEATABLE READ 调整为 READ COMMITTED 的可行性,尤其是有大量范围更新的场景。
  • 打开 innodb_print_all_deadlocks,让死锁长期留痕,便于分析趋势。
  • 应用层统一封装 1213 / 1205 的重试与退避,并监控重试次数,把它当成一个业务指标。
  • 定期检查 Innodb_row_lock_waits 和平均等待时间的走势,用数据决定是否需要进一步优化。

小结

死锁不是数据库的「病」,而是并发访问的常态。排查的正确路径是:先从 SHOW ENGINE INNODB STATUSLATEST DETECTED DEADLOCK 读出锁的持有与等待关系,再判断属于「缺索引导致锁范围过大」「更新顺序不一致」还是「间隙锁冲突」中的哪一类,对症下药。最后,无论怎么优化,都要在应用层保留错误码 1213 / 1205 的重试兜底。理解了这个链路,你就能从「看到死锁就慌」变成「看到死锁能定位、能修、能防」。

Last modification:September 22nd, 2026 at 09:24 pm

Leave a Comment