凌晨一点,业务群里突然炸开一张截图:Deadlock found when trying to get lock; try restarting transaction。旁边还有一句运营的追问:“线上怎么又报错了?”如果你经历过这种场景,应该能体会那种头皮发麻的感觉。MySQL死锁并不是什么罕见的疑难杂症,恰恰相反,它在高并发业务里几乎是必然出现的“日常事故”。但正因为常见,很多人反而处理得特别随意——把错误日志发群里问一圈,或者干脆让代码自动重试一次,下次遇到接着懵。
这篇文章我打算从一次真实死锁的发现、定位、修复和预防讲起,把InnoDB锁机制、死锁日志的读法、system view的用法、以及我在生产环境里踩过的坑全部串起来。内容主要面向需要自己排查线上问题的后端开发、DBA和运维同学,也适合准备Java后端面试时被问到“线上数据库怎么避免死锁”的应试者。看完之后你至少能独立完成一次死锁分析,并且知道该从哪里下手去预防。
1. 死锁的本质:先搞清楚InnoDB为什么会“卡死”
1.1 死锁到底是怎么发生的:以转账场景做一次现场还原
死锁这个概念,操作系统课程里会讲,数据库面试里也会问,但真正在MySQL里见过的同学往往不多。其实用一句话概括就是:两个或多个事务互相持有对方需要的锁资源,彼此都在等对方释放,结果谁都没法继续往下走。
我习惯用一个转账场景来模拟这个过程。假设有一张账户表t_account,两个事务分别做转账:
- 事务A:从账户1转出1000,转入账户2
- 事务B:从账户2转出1000,转入账户1
如果两个事务严格按顺序执行,一点问题都没有。但线上是并发执行的,可能出现这种交错序列:
- 事务A执行第一条UPDATE,锁住了账户1的行
- 事务B执行第一条UPDATE,锁住了账户2的行
- 事务A接着执行第二条UPDATE,想要锁账户2,发现账户2已经被事务B锁住了,于是它进入等待状态
- 事务B接着执行第二条UPDATE,想要锁账户1,发现账户1已经被事务A锁住了,于是它也进入等待状态
走到这里,事务A等事务B释放账户2,事务B等事务A释放账户1,两边谁也不肯先放手。这就是死锁的现场还原。
死锁的成立通常依赖于操作系统教材里说的四个必要条件:互斥、持有并等待、不可剥夺、循环等待。在MySQL的InnoDB引擎里,互斥和不可剥夺是锁机制自带的天性,开发者基本上改变不了;能主动控制的是“持有并等待”和“循环等待”这两条。所以我们后面聊的所有预防方案,本质上都是在打破“持有并等待”或者“循环等待”这两条路径。
InnoDB对死锁的处理也很有意思:它默认开启死锁检测,每次加锁排队都会检查是否存在循环等待,一旦发现死锁,就立刻把其中一个事务整个回滚掉,让另一个事务继续执行。MySQL返回给客户端的错误码是1213,对应的SQLSTATE是40001。注意,“死锁检测把其中一个事务回滚了”是因为它判定“必须牺牲一个才能解开局面”,这和单纯的锁等待超时是两个完全不同的机制。
1.2 InnoDB的锁类型与兼容规则:X锁、S锁、意向锁
要真正看懂死锁日志,必须先把InnoDB的锁类型和兼容规则弄清楚。InnoDB的锁可以按照两个维度去分:一个是从读写上区分,一个是从粒度上区分。
从读写角度,InnoDB提供了两种基本行锁:
- 共享锁(S锁):读的时候加,多个事务可以同时持有同一行的S锁,谁都能读,但谁都不能写。
- 排他锁(X锁):写的时候加,持有X锁的事务可以读也可以写,其他事务既不能读也不能写。
这两种锁的兼容关系很简单:S锁和S锁兼容,S锁和X锁不兼容,X锁和任何锁都不兼容。你可以把它类比成教室座位:S锁是“占了个座位看书”,别人还能坐同一张桌子看自己的书;X锁是“这个位置我要用白板写写画画”,其他人只能站着等。
从锁粒度上,InnoDB在表级别还有意向锁(Intention Lock)。意向锁本身不是直接锁住某一行数据,它更像是“我准备在这个表里的某个行加锁”的声明。其中意向共享锁(IS锁)表示事务准备对某些行加S锁,意向排他锁(IX锁)表示事务准备对某些行加X锁。
InnoDB在加行锁之前,会先在表上加上对应的意向锁。意向锁与意向锁之间互不冲突,但意向锁与表级锁之间是有兼容性规则的。下面的矩阵是面试里很容易考到的:
| 锁类型 | X | S | IX | IS |
|---|---|---|---|---|
| X | 不兼容 | 不兼容 | 不兼容 | 不兼容 |
| S | 不兼容 | 兼容 | 不兼容 | 兼容 |
| IX | 不兼容 | 不兼容 | 兼容 | 兼容 |
| IS | 不兼容 | 兼容 | 兼容 | 兼容 |
这张表记忆起来有个技巧:只要看到X,几乎就等于“压倒性不兼容”,因为排他锁不允许任何其他锁同时存在;剩下的格子基本遵循“相同性质才兼容”的直觉。
光知道行锁和表锁还不够。InnoDB在可重复读(REPEATABLE READ)隔离级别下,范围查询会额外使用间隙锁(Gap Lock)和Next-Key Lock。间隙锁锁的是一个区间,而不是具体某一行;Next-Key Lock则是行锁和间隙锁的组合,锁住的是“从某个键值到下一个键值之间的左开右闭区间”。间隙锁存在的意义是解决幻读,但也正因为有了间隙锁,死锁的可能性被放大了——很多看起来很普通的范围更新,互相覆盖的间隙就会造成等待链。
2. 现场排查三板斧:日志、状态表、进程列表
2.1 第一板斧:快速拿到死锁现场
排查死锁的第一步,是先拿到现场。MySQL里最常见的查看死锁信息命令是:
SHOW ENGINE INNODB STATUS\G注意这个命令的输出内容非常多,其中有一节叫LATEST DETECTED DEADLOCK,这里记录了最近一次死锁的完整信息,包括两个事务各自的SQL、持有锁情况、等待锁情况,以及InnoDB最终选择了回滚哪个事务。
但这里有个很麻烦的问题:SHOW ENGINE INNODB STATUS只保留最近一次死锁的信息。如果线上死锁发生得频繁,你跑到现场的时候,看到的可能已经是五分钟前另一次死锁的记录,甚至被后续其他事务的信息覆盖掉了。
所以我的建议是:在排查之前,先把参数innodb_print_all_deadlocks打开。这个参数默认值是OFF,改成ON之后,每次死锁都会直接打印到MySQL的错误日志文件里,不会再被覆盖。
SET GLOBAL innodb_print_all_deadlocks = ON;这个参数是动态的,不需要重启MySQL,在线就能生效。错误日志的路径可以通过SHOW VARIABLES LIKE 'log_error';查看。实测在有死锁预警的环境里,这个参数几乎是必开的,它能帮你把死锁发生的时序还原出来,而不是只看到最后一次。
拿到错误日志之后,建议立刻做两件事:一是把死锁发生前后的业务日志、慢SQL日志一起拉出来,二是在information_schema里查一下当时的活跃事务和锁等待情况。虽然事后查询可能拿不到历史数据,但如果死锁当前仍在持续,这些视图能给出现场快照。
2.2 第二板斧:逐段解析死锁日志
很多人打开死锁日志时一脸懵,因为格式确实不算友好。我来做个逐段拆解。假设你执行SHOW ENGINE INNODB STATUS\G后,在LATEST DETECTED DEADLOCK一节看到类似下面的内容:
*** (1) TRANSACTION: TRANSACTION 28329, ACTIVE 1 sec mysql tables in use 1, locked 1 LOCK WAIT 2 lock struct(s), heap size 1136, 2 row lock(s) MySQL thread id 11, OS thread handle 13977, query id 133 localhost 127.0.0.1 root /* ApplicationName=DBeaver */ UPDATE t_account SET balance = balance + 1000 WHERE id = 2 *** (1) HOLDS THE LOCK(S): RECORD LOCKS space id 3 page no 5 n bits 72 index PRIMARY of table `test`.`t_account` ... *** (1) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 3 page no 6 n bits 72 index PRIMARY of table `test`.`t_account` ... *** (2) TRANSACTION: TRANSACTION 28330, ACTIVE 1 sec mysql tables in use 1, locked 1 LOCK WAIT 2 lock struct(s), heap size 1136, 2 row lock(s) MySQL thread id 12, OS thread handle 13978, query id 134 localhost 127.0.0.1 root /* ApplicationName=DBeaver */ UPDATE t_account SET balance = balance + 1000 WHERE id = 1 *** (2) HOLDS THE LOCK(S): RECORD LOCKS space id 3 page no 6 n bits 72 index PRIMARY of table `test`.`t_account` ... *** (2) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 3 page no 5 n bits 72 index PRIMARY of table `test`.`t_account` ... *** WE ROLL BACK TRANSACTION (2)看上去很长,但我们需要抓的核心信息就四个。
第一,看TRANSACTION后面的ID。28329和28330分别是两个事务的ID,后续如果要在information_schema里继续追踪,这个ID就是关联线索。
第二,看每个事务对应的SQL。日志里会直接打印出正在执行的SQL语句。比如这个例子,事务1执行的SQL是UPDATE t_account SET balance = balance + 1000 WHERE id = 2,事务2执行的SQL是UPDATE t_account SET balance = balance + 1000 WHERE id = 1。结合业务代码,马上就能知道这两个操作分别来自哪个服务、哪个接口。
第三,看HOLDS THE LOCK(S)和WAITING FOR THIS LOCK TO BE GRANTED两段。HOLDS THE LOCK(S)表示这个事务已经拿到了哪些锁,WAITING FOR ...表示它在等哪一行锁。这两个小节下面会注明锁的索引类型和空间范围。比如space id 3 page no 5,配合index PRIMARY,说明锁落在主键索引的某个页上。
第四,看最后一行WE ROLL BACK TRANSACTION (2)。这一行告诉你InnoDB最后牺牲了哪个事务。在这个例子里被回滚的是事务2。知道这个信息之后,可以回看业务日志,确认是否有部分请求因为死锁报错而需要重试。
日志读完之后,整个链条基本就清晰了:事务1持有了账户1的行锁,想要账户2;事务2持有了账户2的行锁,想要账户1。两边互等,InnoDB选择回滚事务2让事务1继续。
2.3 第三板斧:结合系统表与进程列表还原全链路
死锁日志本身能解决“哪两个事务互锁”的问题,但往往还不能回答“是哪些业务代码引发的”。如果业务系统做了分库分表或者连接池复用,同一张表可能被很多接口操作,这时就需要用系统表来还原完整链路。
MySQL的information_schema里有一张很关键的视图叫INNODB_TRX,存的是当前正在运行的事务。你可以用事务ID去关联:
SELECT * FROM information_schema.INNODB_TRX\G这里重点关注trx_id、trx_mysql_thread_id、trx_query、trx_started、trx_rows_locked这几个字段。trx_mysql_thread_id可以直接对应到SHOW PROCESSLIST里的Id,再用这个Id查当前连接正在执行的SQL,就能定位到代码入口。
如果死锁事故还在持续,sys.innodb_lock_waits这个视图也很有用:
SELECT * FROM sys.innodb_lock_waits\G它能列出当前所有锁等待关系,包括等待的事务、被等待的事务、等待的锁类型、等待时长。当死锁不只涉及两个事务而是形成了更复杂的等待链时,这个视图比一份日志更直观。
性能相关的辅助排查也可以补上慢查询日志。开启慢查询并记录没有使用索引的语句:
SET GLOBAL slow_query_log = ON; SET GLOBAL long_query_time = 1; SET GLOBAL log_queries_not_using_indexes = ON;长时间运行的大事务、全表扫描的UPDATE语句,很容易出现在慢日志里,这些往往是死锁的温床。把死锁日志、活跃事务视图、慢查询日志三份数据交叉对比,基本就能把一次线上死锁从“现象”还原到“代码行”了。
3. 三类高发死锁场景的成因与修复
3.1 场景一:交换更新顺序引发的循环等待
这应该是死锁里最典型的教科书案例。两个事务操作同一批数据,但加锁顺序相反,形成了循环等待。前面转账场景就是最标准的例子。实际业务里这样的问题比想象的普遍,常见于订单改状态、账户加减积分、库存扣减这类涉及“先操作A再操作B”的接口。
比如有一个接口是“订单支付成功,给买家加积分,给卖家扣平台代金券”,代码里先更新订单表,再更新账户表。如果另一个接口是“订单退款,卖家账户加回代金券,买家账户扣减积分”,那么两个接口同时跑的时候,就可能出现一个事务先锁买家账户、另一个事务先锁卖家账户的交叉等待。
这种问题的修复方案很直接:让所有事务都按相同的顺序加锁。定一个全局的“加锁顺序规范”,比如“固定先更新买家人相关的表,再更新卖家人相关的表”,或者按表名字母序、按主键ID升序,总之要保证所有访问路径一致性。
我在实践里还有一个额外建议:对于账户、库存这类高频热点数据,把“更新操作”做一次排序再执行。比如Java里可以用Stream把要更新的ID排序后再逐个处理。这样做的好处是彻底消除“代码路径不同导致加锁顺序不同”的可能,代价只是排序的CPU开销,几乎可以忽略。
3.2 场景二:范围批量更新引发的间隙锁冲突
比单行锁交叉更隐蔽的是批量更新引发的死锁。在可重复读隔离级别下,InnoDB对范围查询会加间隙锁或Next-Key Lock,这本身是为了防止幻读,但也导致两个范围并不完全重合的更新操作互相锁住对方的“间隙”。
举个实际例子。电商后台有定时任务批量更新订单状态:
UPDATE orders SET status = 'shipped' WHERE status = 'pending';另一个并发任务在按订单ID更新某些字段:
UPDATE orders SET remark = 'vip' WHERE id IN (1001, 1002, 1003);虽然这两个UPDATE目标看起来不相关,但第一个UPDATE会锁定所有status为pending的订单行及其间隙,第二个UPDATE需要锁定的行如果恰好落在第一个UPDATE覆盖的区间里,就可能要等待。如果两个任务的加锁顺序有交错,死锁就出现了。
这种问题的应对思路比单行锁场景复杂一些。最常见的手段是给批量任务加“分片处理”逻辑,不要一个事务更新太大数据量,每次只处理一定数量的主键ID,处理完一批提交一批,避免锁范围覆盖过大。另一种思路是把批量任务串行化:通过分布式锁或者MQ消费队列保证同一时刻只有一个任务在跑。
如果这些方案都改不动,还可以考虑把隔离级别从REPEATABLE READ降到READ COMMITTED,可以显著减少间隙锁导致死锁的可能。但是隔离级别是全局配置,改动影响面很大,需要DBA牵头评估,不能只为了一个场景就拍板切换。
3.3 场景三:唯一键冲突引发的“隐蔽死锁”
唯一键冲突死锁是新手最容易忽略的,因为表面SQL看起来是在“INSERT”,而你一般不会把插入操作和死锁联系到一起。但在高并发插入相同唯一键的场景下,死锁特别容易出现。
InnoDB在插入数据前需要做唯一性检查。如果两条并发事务同时插入相同的唯一键,比如相同用户名注册、相同订单号落库,它们都会先尝试加一个S锁用于检查,发现唯一键已存在之后,S锁需要升级为X锁再去更新或报错,这个过程就可能形成循环等待:事务A持有S锁等待X锁,事务B也持有S锁等待X锁,两边互相等。
典型SQL就像这样:
INSERT INTO t_user (id, mobile) VALUES (1001, '13800138000') ON DUPLICATE KEY UPDATE mobile = VALUES(mobile);两个事务同时执行,参数相同,就可能触发死锁。日志里通常能看到两个事务的SQL几乎一模一样,这也是判断“唯一键冲突死锁”的关键特征。
解决思路有几个方向。第一个思路是尽量使用INSERT IGNORE或先查后插,减少重复键冲突的概率;第二个思路是在业务层面对关键唯一键做去重保护,比如注册场景加分布式锁或幂等表;第三个思路是用REPLACE INTO替代ON DUPLICATE KEY UPDATE,但要注意REPLACE是先删后插,可能带来其他副作用。优先推荐前两种,因为它们不改变数据语义,风险更可控。
4. 预防死锁的落地手段:从表设计到事务编排
4.1 索引与锁粒度:让锁尽量小、尽量准
死锁的预防从来不是只在“出事之后调参数”,更重要的战场在表结构和索引设计阶段。锁的粒度直接由语句命中的索引决定:Explain分析里如果出现了ALL类型的扫描,说明这条UPDATE语句很可能把整张表的所有行锁都扫了一遍。锁的范围越广,死锁的概率就指数上升。
所以第一条预防原则是:所有UPDATE和DELETE语句的WHERE条件必须能命中索引。为高频过滤字段建立合适的二级索引,尽量把锁锁定在少数几行上。如果一张表经常按status批量扫描,但status区分度又特别低,可以改写为“先通过主键查出来需要更新的主键ID列表,再按ID逐条更新”,从根上避免全表扫描。
第二条原则是尽量用主键聚簇索引做点查。点查只锁一行,是最理想的加锁方式。即使一个事务要更新多行,也要保证这些行来自同一个索引范围且能提前排序,减少不同事务之间加锁顺序冲突的可能。
还有一点容易被忽视:给索引字段的冗余设计留好空间。有些表为了省空间把字段类型设置得很短,导致维护成本高、索引区分度低,反而在更新数据时扩大了锁范围。这个权衡需要业务经验来把握,但在设计评审阶段多花五分钟看索引,往往能省掉后面无数个深夜。
4.2 事务编排:统一加锁顺序并缩短持有时间
就算索引建得再好,事务编排出了问题,死锁照样防不住。我在生产环境里见到的多数死锁,其实都跟“一个事务里做的事情太多”有关。很多后端同学会把事务当成万能工具,在一个事务里同时更新订单、扣库存、加积分、记录日志,最后还要调一次远程服务。锁的持有时间被拉得很长,另一个事务进来等待的概率自然就高。
事务编排上我总结了几个比较实用的经验。
第一,能拆的小事务绝不合并。每个事务只围绕一个业务用例做必要的数据变更,明确列出“必须在一个事务里的操作”和“可以放到事务外的操作”。日志、通知、消息发送这些尽量放到事务提交之后,不要占用连接和锁资源。
第二,所有事务内使用的数据都按固定顺序去访问。你可以把它理解成数据库层面的“交通规则”,所有人都从左往右走,才不会堵在路口。建立一张“表访问顺序表”,任何新功能上线前对照检查。
第三,事务内不要做远程调用。这个错误我见过太多次了。事务未提交时调用外部接口,外部接口响应超时,连接一直不返回,锁一直不释放,其他请求全部堵住,最终引发连锁死锁甚至连接池耗尽。远程调用必须挪到事务提交之后执行。
第四,控制单个事务的执行时间。线上测量下来,一个普通事务从begin到commit在100毫秒内是比较健康的。超过1秒的事务,极有可能已经锁了比较多的资源,需要重点排查慢查询或者是不是在事务里做了不该做的事。
4.3 参数与监控:该开的开、该调的调、别乱关
排查预防都做完了,还可以通过参数把“死锁的影响面”压到最小。这里我按重要程度排序说几个高频参数。
innodb_print_all_deadlocks必须打开,这个前面已经强调过,不再赘述。它不直接影响死锁会不会发生,但决定了你下回能不能快速定位问题。
innodb_lock_wait_timeout控制的是普通锁等待超时时间,默认是50秒。如果业务请求本身对响应时间要求很高,比如网关超时设置是3秒,那么50秒的锁等待显然过长。很多团队会把它调成5秒甚至3秒。但要注意,这个参数是把双刃剑:调太小会让正常的锁等待也被误杀,调太大又会让请求长时间挂起。一般来说,建议结合业务接口的P99耗时间去设定,不要拍脑袋。
innodb_deadlock_detect默认是ON,一般不建议关闭。关闭死锁检测后,InnoDB不再主动检测循环等待,死锁会退化成普通的锁等待,最终由innodb_lock_wait_timeout兜底。也就是说,原本能秒回一个1213错误的死锁,关闭检测后可能变成一个几十秒才返回的锁等待超时,对用户体验的伤害更大。只有在极高并发场景、死锁检测本身成为瓶颈、且业务完全能容忍锁等待超时的前提下,才考虑关闭。
监控告警层面,推荐在错误日志里持续采集死锁关键字,配合Zabbix、Prometheus或者云厂商的日志服务做告警。也可以使用Percona Toolkit里的pt-deadlock-logger,它能把死锁日志解析成结构化字段输出,长期观察哪些表、哪些SQL是死锁高发点。有了数据,预防才能精准发力。
5. 常见问题速查表与踩坑实录
5.1 死锁相关高频问题速查表
日常帮同事排查死锁时,大家问得最多的问题基本可以汇总成下面这张速查表,先按场景对照着查,能少走很多弯路。
| 现象 | 可能原因 | 快速确认方式 | 处理建议 |
|---|---|---|---|
| 两个事务互相等对方持有的行锁 | 加锁顺序不一致 | 死锁日志中HOLDS和WAITING对应不同行 | 全局统一加锁顺序 |
| 批量UPDATE任务并发时频繁死锁 | 间隙锁冲突 | 日志中出现GAP锁 | 分批处理、串行化任务、评估RR隔离级别 |
| INSERT语句也报死锁 | 唯一键冲突 | 日志中两个事务SQL相同,均有S锁等待X锁 | 应用层去重、幂等表、INSERT IGNORE |
| 死锁日志只有最近一次 | 未开启全量打印 | SHOW VARIABLES LIKE 'innodb_print_all_deadlocks' | SET GLOBAL innodb_print_all_deadlocks = ON |
| 锁等待超时和死锁同时出现 | 长事务持锁过久 | 结合慢日志定位长时间事务 | 拆分事务、远程调用移出事务 |
| 同样的SQL手动执行正常,线上死锁 | 并发量触发 | 检查并发测试结果 | 从业务入口做并发控制 |
这张表不是万能的,但能帮你在脑子里形成一个基本方向。遇到一个死锁报错,先判断它属于“顺序问题”还是“范围锁问题”还是“唯一键问题”,再决定往哪个方向深挖。
5.2 我踩过的坑与几条实在建议
第一,别一上来就关死锁检测。我见过一个项目组为了提升吞吐,直接把innodb_deadlock_detect关掉了,结果线上原本几毫秒就能报错的死锁全部变成几十秒的锁等待,堆积的请求把连接池拖垮,最后花了两个小时才恢复。死锁检测本身是保护机制,关掉它的代价远比想象中高。
第二,事务里远程调用是死锁放大器。这个错误我在不同公司都见过不止一次。事务里调用外部接口,外部接口又回查这个系统的数据库,而回查的SQL恰好需要已经被锁住的行,就形成了跨系统的隐式死锁。这类问题排查起来非常费劲,与其事后痛苦,不如在设计阶段就强制规定事务内不允许远程调用。
第三,重试机制别乱写。MySQL死锁错误码是1213,SQLSTATE是40001。应用层可以针对这个错误码写重试,但重试的前提是:整个事务是做过的,需要完整回滚后重新执行。如果只是在报错的那一条SQL上重试,事务里前序操作可能已经造成数据不一致。重试逻辑必须是“事务级重试”,不是“语句级重试”。
第四,监控一定要先于事故发生。很多团队是出了几次线上问题之后才想起把死锁日志接进告警平台,而那时候已经被折腾了好几轮。真正靠谱的做法是上线第一天就把死锁指标接到监控大屏上,哪怕频率是“一周一次”也要告警出来看一下,防微杜渐比事后救火轻松得多。
5.3 应用层补偿:如何写一个靠谱的重试
最后分享一个我常用的应用层补偿模板,很多项目里我都会放这样一个工具方法。核心思路是捕获DeadlockLoserDataAccessException或者MySQL错误码1213后,对整个事务做有限次重试。
public <T> T executeWithDeadlockRetry(Supplier<T> businessOperation, int maxRetries) { int retryCount = 0; while (true) { try { return businessOperation.get(); } catch (DeadlockLoserDataAccessException ex) { if (++retryCount >= maxRetries) { throw ex; } // 退避一小段时间,降低再次碰撞的概率 try { Thread.sleep(ThreadLocalRandom.current().nextLong(10, 50)); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); throw ex; } } } }上面的写法用的是Spring的异常抽象,如果项目用的是原生JDBC,可以把异常判断改成检查SQLException的errorCode是否为1213或者SQLSTATE是否为40001。重试次数我一般建议不超过3次,因为死锁检测本身是毫秒级的,如果连续3次都还死锁,说明大概率不是偶发,而是程序结构有问题,盲目重试只会掩盖真实故障。
回滚策略也要提前约定:事务内部任何异常都以RuntimeException向外抛,让Spring或业务框架统一回滚。死锁发生后,被牺牲事务里所有已经执行的UPDATE、INSERT都会回滚干净,所以重试时整个事务重新走一遍是安全的。把重试放在事务边界之外,千万别写在事务方法内部,否则重试的每一次尝试都会把上一个未提交的半成品事务混进来。
我在实际使用中还会把每次死锁重试都打印成一条WARN日志,带上事务涉及的业务参数。因为死锁日志描述的是数据库内部状态,而业务参数能告诉我们用户路径。两者结合,下一次再做预防优化的时候就有据可依。死锁这件事,光祈祷不发生没有意义,真正有意义的是每次发生后都能快速“破案”,并且让同类问题在后续版本里越来越少。