MySQL死锁1213错误排查:日志解读与代码级根治方案

发布时间:2026/10/3 14:31:43
MySQL死锁1213错误排查:日志解读与代码级根治方案 凌晨三点被一条报警惊醒屏幕上只有一句话Deadlock found when trying to get lock; try restarting transaction。程序里已经写了自动重试几分钟后数据恢复正常但这个 1213 错误已经不是第一次出现了。作为一个后端工程师MySQL死锁这词我面试的时候背过无数遍什么互斥、持有并等待、不可剥夺、循环等待可真到了生产环境这些理论一点忙都帮不上日志不会告诉我到底是哪段业务代码在凌晨把数据搞成了环路。这篇文章不打算给你再讲一遍八股文定义而是从一次真实的死锁现象出发依次拆开三件事怎么读懂死锁日志里的每一个字段哪些看似合理的 SQL 组合最容易触发死锁以及最终的代码改造方向。无论你是刚接触事务的小白还是已经被死锁折磨过几次的“熟练工”只要照着这个思路走一遍下次再看到 1213至少不会对着日志发呆。1. 死锁不是玄学先看懂 InnoDB 的“证据链”很多人遇到死锁的第一反应是“是不是并发太高了”“是不是 MySQL 参数有问题”然后开始盲目调innodb_lock_wait_timeout。但死锁和锁等待超时完全是两回事。死锁是 InnoDB 主动检测到事务之间存在循环等待选择回滚其中一个事务立刻报错而锁等待超时是一个事务单纯等某把锁等太久系统强制放弃错误码是 1205。这两者的区别特别像现实里的排队1205 是排队排太久不想等了1213 是所有人都拿着对方需要的钥匙谁也走不了。1.1 那个经典的“互相等锁”模型你只需要记住一个最简单的模型。事务 A 拿到了行 1 的锁还想拿行 2 的锁事务 B 拿到了行 2 的锁还想拿行 1 的锁。两个事务谁也不让InnoDB 内部每秒钟都会构建一次“等待图”一旦发现这个环就挑一个代价小的事务直接回滚给你返回ERROR 1213: Deadlock found when trying to get lock。线上大多数死锁都是这个模型的变种区别只在于“行 1”和“行 2”是怎么被锁住的是UPDATE还是SELECT ... FOR UPDATE是记录锁还是间隙锁这决定了你看到的死锁日志长什么样。1.2 刚遇到死锁时你的第一步动作是什么我见过太多人死锁报警后的第一件事是打开 IDE 看业务代码试图用肉眼找出两个同时运行的任务。这不是不行但效率太低。正确的第一步永远是执行这条命令看 InnoDB 自己记录的现场SHOW ENGINE INNODB STATUS\G输出里找到LATEST DETECTED DEADLOCK段落那里是最近一次死锁的完整口供两个事务分别执行了什么 SQL、正在等哪把锁、手里握着哪把锁、最终谁被回滚。InnoDB 已经把答案摆在你面前了只是很多人没耐心看完那段英文。有一点必须提醒你默认配置下SHOW ENGINE INNODB STATUS只保留“最近一次”死锁记录。如果你们的死锁发生频率很高隔一会儿就被新记录覆盖那一定要在 MySQL 配置里打开这个参数innodb_print_all_deadlocks ON打开之后每一次死锁都会完整写入 MySQL 错误日志不会再出现“想看现场但现场被覆盖”的尴尬。这个参数是排查死锁的第一优先级强烈建议所有核心业务库都开。2. 死锁日志详解每一行英文都在指明方向LATEST DETECTED DEADLOCK段落看起来像天书但实际结构非常固定通常由两个TRANSACTION块和一个WE ROLL BACK结尾组成。你不需要背下来所有细节只要按顺序看几个关键点就能还原整个事故现场。2.1 从日志中锁定位“谁等谁”下面是我简化过的死锁日志结构真实环境中字段会更多但核心信息就是这些*** (1) TRANSACTION: TRANSACTION 104752, ACTIVE 3 sec starting index read mysql tables in use 1, locked 1 LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s) MySQL thread id 8341, OS thread handle 140659022335232 UPDATE t_account SET balance balance - 100 WHERE account_id 2 *** (1) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 28 page no 4 n bits 72 index PRIMARY of table test.t_account trx id 104752 lock_mode X locks rec but not gap waiting *** (2) TRANSACTION: TRANSACTION 104753, ACTIVE 1 sec starting index read mysql tables use in 1, locked 1 LOCK WAIT 3 lock struct(s), heap size 1136, 2 row lock(s) MySQL thread id 8342, OS thread handle 140659021996800 UPDATE t_account SET balance balance - 100 WHERE account_id 1 *** (2) HOLDS THE LOCK(S): RECORD LOCKS space id 28 page no 4 n bits 72 index PRIMARY of table test.t_account trx id 104753 lock_mode X locks rec but not gap *** (2) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 28 page no 4 n bits 72 index PRIMARY of table test.t_account trx id 104753 lock_mode X locks rec but not gap waiting *** WE ROLL BACK TRANSACTION (1)别看它长拆开之后就清晰了事务 1 正在执行UPDATE t_account ... WHERE account_id 2它等待的锁是test.t_account表上主键索引里某条记录的排他锁lock_mode X locks rec but not gap而且状态是waiting说明它在等别人释放。事务 2 持有account_id 1这条记录的排他锁HOLDS THE LOCK(S)同时它也在等待另一把锁而这把锁恰好就是事务 1 持有的记录。最后的WE ROLL BACK TRANSACTION (1)告诉你InnoDB 选择了回滚事务 1因为它回滚代价更小。这里有个非常关键的点日志中index PRIMARY of table test.t_account说明锁落在哪个表、哪个索引上。如果看到index idx_status之类的非主键索引那就意味着 SQL 走了二级索引锁也是加在二级索引上的这对后续优化索引有很大参考价值。2.2 如何从日志反推真实业务 SQL死锁日志里会附上每个事务正在执行的 SQL 片段像我上面写的那样。但注意一个坑日志里的 SQL 不一定是事务的完整语句更不一定是导致它持有锁的全部源头。事务可能之前已经查过很多行、更新过很多行日志只会体现它与死锁直接相关的那条 SQL。我遇到过一种情况日志里显示两个事务都在UPDATE t_order SET status 1 WHERE id ?看似更新的行还不同怎么会死锁后来翻代码发现这两个事务在之前分别执行过SELECT ... FOR UPDATE扫了同一个大范围日志里没体现那部分但锁早就在事务里攥着了。所以反推现场时不要把眼睛只盯在日志末尾那条 SQL 上要顺着日志里的MySQL thread id去看对应应用连接的执行历史必要时开启general_log临时抓一段。死锁日志真正向你提供的核心价值就两个死的锁是哪一个谁跟谁形成了环。至于环是怎么绕出来的还要结合代码去还原。3. 高频死锁场景复盘锁类型与 SQL 顺序的组合拳要把死锁场景讲明白逃不开 InnoDB 锁的基本盘。别一听锁的分类就头疼你不需要背所有细节只要把下面这张表吃透线上绝大部分死锁日志你都能看懂。3.1 InnoDB 锁的基本盘速览InnoDB 的锁可以粗略分成两类表级锁和行级锁。表级锁最常见的是意向锁它更像是“我要进这间屋子”的牌子比如IS是“我想锁某些行”IX是“我要写某些行”MySQL 用它来快速判断表级操作会不会和行级操作冲突避免每次都要遍历所有行。行级锁才是死锁的绝对主角锁类型作用范围典型触发 SQL直观理解共享锁 S单条记录SELECT ... LOCK IN SHARE MODE大家一起读谁都不能改排他锁 X单条记录UPDATE、DELETE、SELECT ... FOR UPDATE我锁定了别人读写都别想记录锁 Record Lock单条索引记录精确命中唯一索引/主键锁住这一行很具体间隙锁 Gap Lock两个索引记录之间的区间范围查询或条件未命中索引锁住一段空位谁也不许插入临键锁 Next-Key Lock记录它前面的间隙RR 隔离级别下的范围扫描连人带座位一起锁插入意向锁 Insert Intention间隙内的插入点INSERT我想插队但要看看有没有间隙锁拦着为什么一定要理解间隙锁因为大部分人对死锁的理解停留在“同一行竞争”上实际上 InnoDB 下最高频的死锁反而是由间隙锁和插入意向锁引发的。间隙锁锁住的是一个“范围”它不阻止别的锁但专门阻止新记录插入到该范围内。插入意向锁和间隙锁一冲突就容易形成环。还有一个反直觉的知识点如果UPDATE的WHERE条件没有走索引InnoDB 会扫描全表的聚簇索引给扫描到的每条记录都加上锁表现上就像锁了整个表。这种操作虽然没有名字叫“表锁”但并发效果和锁表没区别是在设计 SQL 时必须回避的。3.2 场景一两个事务按不同顺序更新同一组记录这是教科书里最常见的死锁也是我线上遇到过最多的类型。两个定时任务都在批量处理同一批订单任务 A 的处理逻辑是“先更新订单表再更新客户表”任务 B 的逻辑是“先更新客户表再更新订单表”当它们恰好并发执行并交叉访问时死锁几乎是必然的。代码层面看这两个任务都写了for (id : ids) { UPDATE ... }单看每一条 SQL 都没问题但循环导致锁的获取是“逐步扩大”的。A 拿到了订单 100 的锁B 拿到了客户 200 的锁接着 A 想拿客户 200B 想拿订单 100环瞬间形成。解决办法也很朴素批量更新前把所有数据按统一规则排序比如按主键升序所有任务都必须遵守同一把尺子谁先抢到都不重要重要的是大家排队方向一致。3.3 场景二范围更新与插入之间的间隙锁冲突假设有一个订单表两个事务同时做这些事事务 AUPDATE t_order SET status PAID WHERE status UNPAID这句只要没走到精确索引就会在扫描范围内留下间隙锁或临键锁。事务 BINSERT INTO t_order (order_no, status) VALUES (NO789, UNPAID)插入时需要获取插入意向锁但 A 的间隙锁正好拦在这个插入点上B 开始等待。如果 A 在持有间隙锁的同时还需要更新 B 已经锁住的另一条记录比如 B 先锁定了一个订单号A 恰好也要改它那 A 等 B 释放行锁、B 等 A 释放间隙锁死锁产生。这个场景隐蔽之处在于两边看起来是在操作完全不同的数据一个在改旧数据一个在插入新数据日志里却是清清楚楚的锁环。现实中遇到大量“插入和更新并发”的死锁九成是这类原因。3.4 场景三先查后改导致锁逐步扩大还有一种很常见写法先SELECT ... FOR UPDATE把符合条件的记录查出来再逐条UPDATE。这种模式特别容易在一次事务里积累多把锁。两个并发事务各自查出来的记录集合没有重叠但后续循环更新时发生了交叉死锁就出现了。比如事务 A 先锁了id 100的记录事务 B 先锁了id 50 AND id 200的记录两个范围部分重叠A 往下更新时想拿 B 已经锁住的id80B 往后更新时想拿 A 手里的id30环又成了。处理思路是尽量避免“先查一批再逐条改”要么一次性用一条 SQL 完成条件更新要么对查询结果做排序并限制每次加锁范围。4. 从日志到现场确认死锁、复现问题、继续追踪读完死锁日志只能证明“确实发生了死锁”但要修复还得知道业务里是哪段代码触发。先别急着改代码先用可控手段把现场复现出来。4.1 死锁和锁等待是两回事排查时要分清楚我在 1.1 里提过一次 1205 和 1213 的区别但这里必须再强调一遍因为排查方向完全不同。1213 死锁是被 InnoDB 主动检测后回滚事务通常语句执行很快失败1205 锁等待超时是事务卡在等待锁的环节等满了innodb_lock_wait_timeout默认的 50 秒才报错。如果你们看到的是 1205说明有事务长时间占着锁不放那不是死锁是“一个锁被长时间持有”的问题重点要找长事务和慢 SQL而不是找锁环。判断方法很简单拿到错误码再定位问题。错误码是 1205去performance_schema或information_schema.innodb_trx看谁堵了谁错误码是 1213先SHOW ENGINE INNODB STATUS看最近的死锁日志。4.2 手动复现死锁两个会话就能模拟复现死锁很多时候不需要压测工具直接开两个 MySQL 会话手动按节奏执行就能还原最经典的模式-- 会话 A BEGIN; UPDATE t_account SET balance balance - 100 WHERE account_id 1; -- 会话 B BEGIN; UPDATE t_account SET balance balance - 100 WHERE account_id 2; -- 会话 A 继续执行 UPDATE t_account SET balance balance - 100 WHERE account_id 2; -- 会话 B 继续执行 UPDATE t_account SET balance balance - 100 WHERE account_id 1;第四步执行完InnoDB 会立刻检测出死锁其中一个会话报错 1213另一个事务继续执行并提交。这个复现模型几乎可以套用到任何死锁场景只要把业务里的事务和 SQL 换成这两行就能验证“代码路径是否真的会构成锁环”。4.3 用 performance_schema 捞更完整的事务现场如果只在业务代码里看不到明显问题就需要把数据库当前的“锁等待关系图”拉出来。MySQL 的performance_schema提供了几张很有用的视图直接查sys库就好SELECT * FROM sys.innodb_lock_waits\G这张表实时展示了当前正在等待的锁、阻塞来源、对应 SQL 和用户信息能直观地看到谁堵了谁。如果事务已经结束就只能依赖SHOW ENGINE INNODB STATUS和错误日志了。再配合general_log短时间开启把发生死锁那个时间窗口的完整 SQL 记录抓出来十有八九能定位出是哪两个任务“撞车”。注意general_log对性能有影响生产环境不要长时间开着定位问题的窗口期抓完立刻关闭。5. 根治死锁的代码级改造四个方向最有效很多人走到复现那一步就不往下走了觉得“反正 Error 里有 try restarting transaction我重试就能恢复”。重试只是止血不是根治。真正有效的改动集中在下面四个方向。5.1 统一加锁顺序让所有事务按同一把尺子排队死锁的本质是锁的获取顺序不一致那最简单的解法就是强制顺序统一。业务里如果大批量更新同一个集合先把所有主键取出来排好序再执行如果跨多个表更新也约定表级别的全局顺序比如不管业务逻辑多复杂总是“先订单表、后客户表”。很多人会问排序之后并发性能不是下降了吗排序消耗的 CPU 和死锁回滚带来的性能损耗相比几乎可以忽略不计。尤其是定时任务类的批量更新这一步能直接消灭掉一半以上的死锁。5.2 缩小事务边界长事务是死锁的温床事务里锁持有的时间越长和其他事务碰撞的概率就越大。很多死锁之所以发生是因为一个事务前前后后做了十几件事中间还夹杂着远程调用或慢查询锁被攥了几百毫秒甚至几秒给了别的任务充分的撞车窗口。一个非常有效的改造是把大批量的UPDATE和DELETE拆成小批比如每处理 200 条提交一次。这样即使某两批之间发生死锁损失也只是这一小批重试成本低很多。事务里千万别做“查询后等用户确认”的操作那是最容易把锁时间拉满的反模式。5.3 索引优化让 SQL 精确命中别用“扫全表”的方式加锁我之前提过WHERE条件不走索引时InnoDB 会对扫描记录逐条加锁表现接近锁表。这个问题的修复方式就是给WHERE和UPDATE条件设计合理的索引让锁定范围尽可能缩小。精确主键更新是最好的退一步也要让条件走一个区分度高的二级索引。还有一种比较“根治”的手段如果业务能接受把隔离级别从默认的REPEATABLE READ降到READ COMMITTED。在这个隔离级别下InnoDB 不再使用间隙锁大量由间隙锁引发的死锁会直接消失。代价是可能出现不可重复读所以这个调整要业务评估后决定。另外主从架构中如果 binlog 格式是STATEMENTREAD COMMITTED不被支持需要先切到ROW格式再降级这一点很容易被忽略。5.4 应用层重试机制把 1213 当成“可恢复异常”处理无论怎么优化死锁都不可能 100% 绝迹尤其是高并发下总有突发场景。好的应用代码应该把Deadlock found when trying to get lock当作一种可重试的异常而不是直接抛给用户。以 Java 和 Spring 为例可以在事务方法外层加一个小循环for (int i 0; i 3; i) { try { transactionTemplate.execute(status - { // 你的事务操作 }); break; } catch (DeadlockLoserDataAccessException e) { if (i 2) { throw e; } } }重试时还有个容易忽略的坑事务里如果有“写入后再次读取并依赖”的逻辑事务回滚后整体重试没问题但如果涉及生成唯一编号、外部消息发送等副作用就要保证幂等比如带上批次号等唯一标识防止重试造成重复数据。6. 热点行竞争高并发下的“非典型”死锁与优化思路排查死锁久了你会发现一个规律真正业务量很大的系统死锁反而不常出现在普通记录上而是集中在“热点行”。比如秒杀商品的库存行、热门活动的参与人数计数、大 VIP 用户的账户余额。6.1 大量并发写同一行时死锁检测本身也是成本InnoDB 的死锁检测机制是持续工作的它内部维护着锁等待关系图每秒扫描一次。当大量事务集中等待同一行记录时死锁检测的开销会被急剧放大这就是为什么极端热点场景下 CPU 会莫名飙高。你以为系统是在“干活”实际是在反复构建等待图、判断“有没有环”。遇到这种场景可以把innodb_deadlock_detect关掉让 InnoDB 不主动检测死锁改成靠innodb_lock_wait_timeout超时兜底。关掉之后真正死锁时不会快速报错而是等满超时时间所以这个开关只适合“几乎不存在真正的环形等待、多数是单纯排队”的热点行场景前提是业务能接受故障恢复变慢。这是手术级的参数调整千万别随手关。6.2 扣库存类场景真正的优化方向是减少等待而不是调参数很多人以为扣库存是死锁高发地但其实单行库存UPDATE通常不会形成死锁它更多表现为行锁排队等待是 1205 型问题。真正合理的优化要么是让线程持有事务的时间极短要么是彻底避免在热点行上建立长事务。常见做法有三种在 SQL 层面改成条件更新UPDATE goods SET stock stock - 1 WHERE id ? AND stock 0让失败的请求直接返回“库存不足”事务不纠缠锁占用时间极短。库存分桶把一行库存拆成多个子库存比如stock_1、stock_2写入时按用户 ID 哈希选桶降低单行竞争。引入分布式锁把并发控制在应用层让热点行的写操作串行化MySQL 侧不再受到大量并发的冲击。最后顺带说一下以前很多人会把「死锁是并发导致」和「并发就一定会死锁」画等号这是不对的。并发只是必要条件真正的充分条件是锁获取顺序的错位。顺序问题解决了热点行场景也几乎不怎么死锁剩下的就是排队等待问题这属于性能范畴解法完全不同。数据库的死锁问题说穿了就是「锁顺序」四个字。我现在的习惯是遇到 1213 第一件事永远是SHOW ENGINE INNODB STATUS先看日志里锁落在哪个索引再顺着 SQL 反查业务代码的更新顺序最后落地方案时优先排序、缩小事务、补索引。多数情况下三个方向里至少有一个能直接解决问题。如果你也正在被某个诡异死锁折磨不妨把这篇文章里的排查链路从头走一遍尤其是先打开innodb_print_all_deadlocks别让现场再次被覆盖。