线上业务偶尔抛出Deadlock found when trying to get lock; try restarting transaction这类错误,涉及的仅仅是几条普通的Update语句,表面上看它们更新的行并不相同,却依然产生了死锁。很多同学面对这种情况第一反应是重启服务或者重试了事,但如果不弄清楚加锁的底层逻辑,同样的死锁会反复出现。排查MySQL死锁,最直接的证据来源就是Show Engine InnoDB Status命令输出的LATEST DETECTED DEADLOCK段落,本文结合一个具体案例,完整讲解如何拿到日志、如何读懂日志、以及如何从日志反推出业务代码里的问题。

一、复现死锁并获取Show Engine日志
排查死锁的第一步是让MySQL把死锁信息记录下来。默认情况下,死锁信息只保留最近一次,通过Show Engine InnoDB Status查看即可。如果需要持续收集每一次死锁,可以打开全局参数innodb_print_all_deadlocks,打开后所有死锁都会写入错误日志,方便事后追溯:
-- 打开后每一次死锁都会写入 err log SET GLOBAL innodb_print_all_deadlocks = ON; -- 查看最近一次死锁详情 SHOW ENGINE INNODB STATUS\G
执行后在输出中找到LATEST DETECTED DEADLOCK这一段,它是InnoDB存储引擎保存的最近一次死锁现场,包含两个事务各自的持有锁、等待锁,以及事务正在执行的SQL语句。如果死锁频率很低难以捕捉,还可以配合general log和慢日志,先根据应用报错时间点锁定相关SQL,再回到死锁日志中交叉验证。
接下来准备一张简单的表来复现问题:
CREATE TABLE `account` (
`id` bigint NOT NULL AUTO_INCREMENT,
`user_no` varchar(32) NOT NULL,
`balance` decimal(12,2) NOT NULL DEFAULT '0.00',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_no` (`user_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT INTO account(user_no, balance) VALUES
('U001', 100), ('U002', 200), ('U003', 300);开两个会话按特定顺序执行Update,就可以制造出一次典型死锁:会话A先更新U001再更新U002,会话B先更新U002再更新U001,双方各持有一把行锁又互相等待对方释放,InnoDB的死锁检测机制会立刻发现环路并主动回滚其中代价较小的事务,此时另一个会话就能继续执行。下面从日志层面看看这次死锁长什么样。
二、逐行解读死锁日志的关键信息
拿到日志后不要被大段英文吓到,真正需要关注的核心字段其实不多。一段典型的死锁日志如下(节选):
------------------------ LATEST DETECTED DEADLOCK ------------------------ *** (1) TRANSACTION: TRANSACTION 421984, ACTIVE 3 sec starting index read mysql tables in use 1, locked 1 LOCK WAIT 3 lock struct(s), heap size 1136, 2 row lock(s) UPDATE account SET balance = balance + 50 WHERE user_no = 'U001' *** (1) HOLDS THE LOCK(S): RECORD LOCKS space id 245 page no 4 n bits 72 index uk_user_no of table `test`.`account` lock_mode X locks rec but not gap *** (1) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS ... index uk_user_no of table `test`.`account` lock_mode X locks rec but not gap waiting *** (2) TRANSACTION: TRANSACTION 421985, ACTIVE 2 sec starting index read UPDATE account SET balance = balance + 30 WHERE user_no = 'U002' *** (2) HOLDS THE LOCK(S): ... index uk_user_no ... lock_mode X locks rec but not gap *** (2) WAITING FOR THIS LOCK TO BE GRANTED: ... index uk_user_no ... lock_mode X locks rec but not gap waiting *** WE ROLL BACK TRANSACTION (2)
读这段日志有一个固定套路:先看两个TRANSACTION块里各自执行的SQL,确认是哪两条业务语句撞在一起;再看HOLDS THE LOCK(S)部分,它说明事务当前持有哪些锁,其中index字段指明锁加在哪个索引上,lock_mode说明锁的类型和范围;最后看WAITING FOR THIS LOCK TO BE GRANTED,也就是事务正在等待的锁。把两个事务的持有与等待交叉对照,就能画出一张环形等待图:事务一持有U002的锁却在等U001,事务二正好相反,环路形成,死锁成立。
日志末尾的WE ROLL BACK TRANSACTION (2)表示InnoDB选择了回滚事务二,选择依据是事务回滚代价的大小,undo量少的一方会被牺牲。应用层收到1213错误码的连接就是被回滚的那个,因此业务代码里针对这个错误做有限次重试是标准做法。
另外要留意lock_mode的细节。X locks rec but not gap表示仅锁记录本身;如果出现locks gap before rec字样,说明加的是间隙锁,通常意味着Update的WHERE条件走了普通二级索引或者没有命中索引,这时锁的范围远大于那一行数据,多个事务看似更新不同的行也会因为间隙重叠而死锁,这是Update死锁里最高频的原因之一。
三、从加锁原理分析Update死锁的常见成因
Update语句的加锁范围由WHERE条件命中的索引决定,理解这一点是排查死锁的根本。在RR隔离级别下,如果WHERE条件能命中唯一索引且记录存在,InnoDB只对该记录加行锁;如果命中的是普通二级索引,除了锁二级索引记录,还会对对应的主键记录加锁,并且会对索引记录前的间隙加间隙锁,组合成临键锁,锁定范围从上一条记录延伸到当前记录。
更危险的情况是WHERE条件没有索引可用。此时InnoDB必须全表扫描,会给扫描过的每一行都加上锁,效果近似锁全表,并发事务只要更新顺序稍有不同就必然互相等待。所以排查时要先用EXPLAIN确认Update的执行计划,一旦看到type=ALL,基本可以断定死锁根源就是缺失索引。
-- 观察Update的访问方式 EXPLAIN UPDATE account SET balance = balance - 10 WHERE user_no = 'U009'; -- 实时观察锁等待(8.0提供的数据字典视图) SELECT * FROM performance_schema.data_lock_waits; SELECT * FROM performance_schema.data_locks WHERE OBJECT_NAME = 'account';
MySQL 8.0推荐用performance_schema.data_locks和data_lock_waits两张视图实时观察锁状态,比5.7时代的information_schema.innodb_locks信息更完整,可以直接查到锁模式、锁数据等内容,和死锁日志相互印证。此外还有一个容易被忽视的坑:如果Update修改的列涉及二级索引,加锁顺序会先锁主键再维护二级索引,与其他语句先走二级索引加锁的顺序相反,这种顺序差异正是死锁日志里两个事务锁的索引不一致时的常见解释。
间隙锁导致的死锁还有一个经典场景:两个事务同时用UPDATE ... SET c = c + 1 WHERE id > 100这类范围条件更新,各自先拿到相邻的间隙锁,间隙锁之间彼此兼容,随后各自尝试对相邻记录加行锁时才发现互不相让。遇到日志中出现locks gap before rec且SQL带范围条件,优先考虑把范围拆小、改用等值条件,或者评估将隔离级别调整为RC来消除间隙锁的可行性。
四、业务侧预防死锁的实用措施
定位问题之后,预防比反复重试更重要。第一原则是统一加锁顺序:所有涉及批量更新的代码,无论在哪个服务、哪个接口里,都按照相同的字段排序后再执行,比如统一按主键升序逐条更新,这样环形等待就无从形成。前面案例中两个会话分别按U001、U002和U002、U001的顺序更新,只要约定都先处理编号小的用户,死锁自然消失。
第二是缩小事务粒度。把不必要的查询、RPC调用移出事务,事务里只保留必要的写操作,持锁时间越短,与其他事务交叉的概率越低。大批量更新尽量拆成小批次提交,避免单条事务长时间持锁。同时需要唯一性判断的场景,优先使用INSERT ... ON DUPLICATE KEY UPDATE这类原子写法,而不是先查再改的两步操作,减少加锁的时间窗口。
第三是索引与隔离级别的权衡。确保Update的WHERE条件始终有合适的索引可用,避免全表扫描导致的锁放大;在业务允许的前提下,将隔离级别设置为READ COMMITTED可以消除间隙锁,配合binlog_format=ROW使用,能显著降低死锁概率。最后,应用层对错误码1213实现指数退避重试作为兜底。整套排查思路概括起来就是:先拿日志、再读锁信息、后查执行计划,掌握了它,绝大多数Update死锁都能在一两个小时内定位到根因。
MySQL死锁排查Update死锁Show Engine InnoDB Status修改时间:2026-09-06 13:17:02