MySQL 死锁日志怎么读:间隙锁、加锁顺序与四类复现形态的排查次序

MySQL 死锁本身不可怕,它是 InnoDB 检测到循环等待之后主动做的选择:牺牲一个事务,保住其余事务继续跑。真正让人头疼的是两件事:一是业务侧看到「Deadlock found when trying to get lock; try restarting transaction」之后,不知道死锁发生在哪两条 SQL 之间;二是死锁日志只有一份,被下一次死锁覆盖掉之后,就只能凭猜测修 SQL。这篇把我这两年在小站上处理死锁的完整流程写下来:从打开一个能长期用的死锁观测窗口,到读出 LATEST DETECTED DEADLOCK 那一段,再到把结论落成具体的索引改动或者锁顺序调整。

一、先确认它到底是死锁还是锁等待超时

很多人把两种报错混为一谈,结果排查方向完全跑偏。锁等待超时(Lock wait timeout exceeded; try restarting transaction,错误码 1205)是单方向的等待超过了 innodb_lock_wait_timeout,默认 50 秒;死锁(错误码 1213)是出现了环,InnoDB 会立刻动手,不需要等满超时。两者的日志位置、复现方式、修复手段都不一样。

第一条命令永远是看引擎状态,确认死锁计数器有没有在涨:

mysql -e "SHOW GLOBAL STATUS WHERE Variable_name IN
  ('Innodb_deadlocks','Innodb_row_lock_current_waits',
   'Innodb_row_lock_time_avg','Innodb_row_lock_waits')\G"

Innodb_deadlocks 是一个只增不减的累计值,重启才归零。所以「看绝对值」没有意义,正确的是隔一段时间取两次差值:一小时涨了 3 次,说明是一个低频但稳定的模式;一分钟涨几十次,那已经是故障级别,业务都在重试里打转。我习惯把它纳入每小时的巡检,把差值写进日报,涨到阈值再去看日志。

而 Innodb_row_lock_time_avg 高、Innodb_deadlocks 不涨,说明这是排队而不是死锁,问题在长事务或者锁范围过大,跟本文不是一个路子。先把这两类分开,后面所有判断才有意义。

二、打开一个能长期用的死锁窗口

InnoDB 只保存「最近一次」死锁的详细信息,就在 SHOW ENGINE INNODB STATUS 输出的 LATEST DETECTED DEADLOCK 段里。默认这份信息只留在内存,重启就没了,而且被新的一次覆盖。要让死锁能事后复盘,需要三件事同时做到:

第一,把死锁详细信息打到一个独立文件里。innodb_print_all_deadlocks 从 MySQL 5.6.2 起就有,打开之后除了最近一次之外,每一次死锁都会写入错误日志。它的开销是每次死锁多一条日志写入,量很小,小站完全可以常开:

# my.cnf 的 [mysqld] 段
innodb_print_all_deadlocks = 1
log_error_verbosity = 3

第二,把错误日志的路径确认下来。很多发行版把它放在 /var/log/mysql/error.log,也有放在 /var/lib/mysql/<hostname>.err 的。用 SHOW VARIABLES LIKE 'log_error' 查一次,不要让 logrotate 把它转走就找不到了——如果按 2113 那篇里说的用 copytruncate 轮转,记得给这份日志留足保留份数,死锁往往是一周后才有人来查。

第三,给连接加一个能定位来源的标记。应用连库时把进程号或者请求 ID 设进会话变量,死锁日志里会带上 TRANSACTION 段的 information_schema.innodb_trx 里的 trx_mysql_thread_id。有了这个 ID,配合 information_schema.processlist 就能知道是哪个应用、哪个连接池打出来的。这一点比想象中重要:同一张表上,后台定时任务和前台请求的死锁,处理方式截然不同。

三、读懂 LATEST DETECTED DEADLOCK 那一段

死锁日志乍看很长,其实结构是固定的,读法是「先看两个事务,再看最后一句」。下面是一段真实日志的骨架,我按读的顺序拆开:

------------------------
LATEST DETECTED DEADLOCK
------------------------
(星号)(1) TRANSACTION:
TRANSACTION 421880, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1136, 2 row lock(s)
MySQL thread id 8123, OS thread handle 123145, query id 99821
  UPDATE posts SET hits = hits + 1 WHERE id = 100

第一件事是看「谁在等谁」的最终结论。日志最末尾一定有两句:(星号)(1) WAITING FOR THIS LOCK TO BE GRANTED 和 (星号)(2) HOLDS THE LOCK(S)。这里的编号对应前面的 (1) TRANSACTION 与 (2) TRANSACTION。InnoDB 选择回滚的是持有锁较少的那一个,报错会打到对应连接上,所以应用日志里报错的那条 SQL,就是被选中的受害者,而不一定是「错误的一方」。

第二件事是看锁的类型。日志里会出现两种描述:lock_mode X locks rec but not gap 是记录锁,lock_mode X locks gap before rec 是间隙锁,还有 next-key lock 是两者合一。间隙锁只存在于 REPEATABLE READ 隔离级别。这一点在排查里非常关键:很多「莫名其妙的死锁」如果发生在 RR 下、涉及范围条件(WHERE status = 0 AND id > 100),本质上是间隙锁互相咬住;把隔离级别改成 READ COMMITTED 之后间隙锁消失,死锁也就没了。但改隔离级别是全局影响,要同时确认业务没有依赖 RR 的可重复读语义(比如同一事务里两次相同查询希望结果一致),不能为了消死锁随手改。

第三件事是看 index 那一行。每条 RECORD LOCKS 下面都会写加锁走的索引名,以及锁住的记录 space id ... page no ... n bits。如果看到 index PRIMARY 但 SQL 的 WHERE 条件是一个非主键字段,那基本就确认了「没有走索引,全表扫描逐行加锁」——这是小站死锁最常见的成因,一条 UPDATE 扫了整表,把所有行都锁住,另一条刚好相反方向地扫,环就形成了。

四、最常见的五种死锁形态

第一种,反向更新。事务 A 先更新 id=1 再更新 id=2,事务 B 反过来。这在「批量调整排序」「转移积分」这类业务里极其常见,两条 SQL 都走主键、都很快,照样死锁。修法不是加超时,而是固定顺序:所有涉及多行的更新,按主键升序排列后再逐条执行。这一条改完之后,我们站后台的批量编辑死锁直接归零。

第二种,唯一键冲突引发的插入死锁。两个事务同时 INSERT ... ON DUPLICATE KEY UPDATE 一条不存在的记录时,双方都会先拿间隙锁再尝试插入,形成死锁。典型场景是「文章浏览计数」这种先查后插的逻辑。修法是把它改成单条 INSERT ... ON DUPLICATE KEY UPDATE,或者用 INSERT IGNORE + 再更新,减少「先 SELECT 判断存在性」这一步带来的锁重叠窗口。

第三种,外键与级联删除。删除主表记录时如果带着 ON DELETE CASCADE,InnoDB 会在子表上加锁,而子表的索引如果不完整,加锁范围会放大。小站里更常见的变体是「软删除 + 定时物理删除」,定时任务删数据时的锁和前台查询撞上。修法是把物理删除限速,一批只删几百行,中间 sleep。

第四种,无索引的 UPDATE。前面已经说过,这里补一个判据:在 EXPLAIN 里看到 type=ALL 或者 key=NULL 的 UPDATE / DELETE,就应当视为潜在死锁源。这类语句的危害不只是慢,还会把锁的范围从一行扩大到整表。用 SHOW PROCESSLIST 抓到长事务时顺手 EXPLAIN 一下它的语句,是很划算的习惯。

第五种,主从切换或者备份带来的瞬间冲突。mysqldump 默认会给表加 --lock-tables,加锁期间写入会等待;如果同时有一个长事务在跑,两边一咬就是死锁或者等待超时。这类不需要改业务,把备份改成 --single-transaction(InnoDB 可用)就能消掉绝大部分。

五、一次真实的死锁复盘

去年底站里出现过一阵子「评论提交偶发失败」,错误日志里每隔几小时就有一条 1213。把 innodb_print_all_deadlocks 打开之后,三天收了 11 条,全部是同一个形状:

(星号)(1) TRANSACTION:
UPDATE comments SET status = 1 WHERE post_id = 2170 AND created < '2026-11-01';
(星号)(2) TRANSACTION:
UPDATE comments SET status = 0 WHERE post_id = 2170 AND created > '2026-11-01';

两条语句方向相反、条件互补,走的都是 (post_id, created) 复合索引,索引本身是有的。问题出在清理任务和审核任务并发:清理任务从旧往新扫,审核任务从新往旧扫,两条范围更新的加锁边界正好交叠。EXPLAIN 显示两条都是 range,没有全表扫描,所以「加索引」并不是答案。

最终的处理是三件事:给清理任务加上 id 升序分批(每批 200 行),让加锁边界不会跨越太远;把两个任务的执行时间错开十分钟,从 crontab 层面消除并发;给评论查询加了一条只读从库通道,把统计类查询挪走。改完之后两个月没有再出现过一条死锁日志。这个案例值得记住的地方在于:死锁的解法不一定是索引,有时候只是「不要让两个批量任务同时跑」。

六、死锁之后的业务侧处理

数据库侧改得再干净,也不可能保证零死锁。应用侧必须有正确的兜底,否则一次死锁就会变成用户看到的一次失败。

第一,只对死锁重试,不要对所有异常重试。1213 和 1205 是可以安全重试的(事务已经整体回滚),而 1062 唯一键冲突重试一万次也没用。重试逻辑写在事务边界外面,不要在事务内部 catch 之后继续用同一个连接。

第二,重试次数和退避要有限度。三次够了,间隔用 50ms、150ms、400ms 这类递增,不要固定间隔也不要指数爆炸。超过三次就抛给上层,让用户看到「请稍后重试」,而不是让 PHP 进程在那里转圈。

第三,重试必须是幂等的。这一点最容易被忽略:如果事务里有「插入一条积分流水」,重试之后可能插了两次。要么给流水表加上业务唯一键,要么把重试放在最外层的请求入口,由上层重新走完整流程。我在小站上的做法是给所有流水类表加一个 request_id 唯一索引,让数据库来兜底。

七、把死锁从「事故」变成「指标」

最后说流程。死锁日志如果只是存在服务器上,没有人看,那它和不存在没区别。我现在的做法是每周花十分钟做三件事:

一是数一下这周 Innodb_deadlocks 涨了多少。二是如果涨了,把错误日志里的死锁段抽出来,按「涉及的表 + 语句形状」聚个类,通常会得到一两种主要模式,偶尔才是新问题。三是把结论记到运维文档里,标明日期、触发条件、处理动作。一年之后回头看,这份记录比任何一本 MySQL 书都更适合自己的站。

顺带一句关于参数的话:innodb_lock_wait_timeout 调大不会减少死锁,只会让「非死锁的等待」拖得更久;调小则会制造更多「本来再等两秒就能过」的失败。这个参数正确的用法是配合业务超时来定——如果前端请求超时是 3 秒,把它设成 50 秒毫无意义,设成 3 到 5 秒反而更合适。真正的解法永远在 SQL 和锁的顺序上。

Last modification:November 14th, 2026 at 12:24 pm

Leave a Comment