凌晨三点被一条报警惊醒,屏幕上只有一句话: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 想拿客户 200,B 想拿订单 100,环瞬间形成。解决办法也很朴素:批量更新前,把所有数据按统一规则排序,比如按主键升序,所有任务都必须遵守同一把尺子,谁先抢到都不重要,重要的是大家排队方向一致。
3.3 场景二:范围更新与插入之间的间隙锁冲突
假设有一个订单表,两个事务同时做这些事:
- 事务 A:
UPDATE t_order SET status = 'PAID' WHERE status = 'UNPAID',这句只要没走到精确索引,就会在扫描范围内留下间隙锁或临键锁。 - 事务 B:
INSERT 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 已经锁住的id=80,B 往后更新时想拿 A 手里的id=30,环又成了。处理思路是尽量避免“先查一批再逐条改”,要么一次性用一条 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 格式是STATEMENT,READ 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,别让现场再次被覆盖。