凌晨两点十七分,值班群被一条告警炸醒:订单支付回写接口耗时从平均 80ms 陡增到 4.8 秒,紧接着出现了一批 Deadlock found when trying to get lock; try restarting transaction 异常。这不是我第一次在电商业务里碰到死锁,但这次的数据很典型——同一张订单表、两个看似毫无关联的接口、三行被锁住的记录,最后把整条支付链路堵到熔断。如果你也维护过订单、库存、账户这类高并发写密集的业务,迟早会遇上类似的场景。这篇就用这个真实案例,把死锁从“看日志”到“找根因”再到“改代码验证”的完整过程过一遍,也说说 MySQL 死锁排查命令怎么用、慢查询和锁等待之间是什么关系。
1. 一次凌晨告警背后:死锁发生在哪条业务链路上
1.1 线上业务场景还原
这个电商项目是典型的订单-支付-库存三角结构。用户下单后会生成一条订单记录,状态是 待支付;用户发起支付后,支付回调接口会把订单改成 已支付,同时扣减库存;如果用户中途取消订单或者支付超时,另一个定时任务会把订单改成 已取消,同时释放预占的库存。
这两个操作看起来不相关——一个是用户主动触发,一个是系统定时触发。但在真实代码里,它们要更新的表却是交叉的:
java复制// 支付回调逻辑(事务A)
@Transactional
public void payOrder(Long orderId, Long userId) {
// 1. 更新订单状态
updateOrderStatus(orderId, "PAID");
// 2. 扣减库存
deductStock(orderId);
}
// 取消订单逻辑(事务B)
@Transactional
public void cancelOrder(Long orderId) {
// 1. 更新订单状态
updateOrderStatus(orderId, "CANCELLED");
// 2. 释放库存
releaseStock(orderId);
}
这两段代码看起来只更新了“自己的”订单和“自己的”库存,但问题恰恰出在更新顺序上。两者都是先更新订单表,再更新库存表,表面看顺序一致,好像不该死锁。可实际线上环境还有个隐藏入口——用户退款。退款流程是先释放库存,再回写订单状态:
java复制// 退款流程(事务C)
@Transactional
public void refundOrder(Long orderId) {
Stock stock = lockStock(orderId); // 1. 先锁库存
updateOrderStatus(orderId, "REFUNDED"); // 2. 再改订单
}
三个事务,三种顺序:订单→库存、订单→库存、库存→订单。前两个没问题,但第三个和第一个并发执行时,就可能出现事务 A 拿到订单行的锁,事务 C 拿到库存行的锁,然后互相等对方释放,形成死锁。
这里我要多解释一句:很多文章讲死锁都说“统一加锁顺序就能解决”,但真实业务里加锁顺序往往藏在多个方法调用之间,不是靠肉眼扫一眼代码就能发现的。这次事故的根子就在于三个事务分别由不同的开发同事提交,代码评审没有人去通盘梳理“所有事务对同一组表的访问顺序”。
1.2 表结构与索引情况
订单表结构简化后大致长这样:
sql复制CREATE TABLE `order_main` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`order_no` varchar(64) NOT NULL COMMENT '订单号',
`user_id` bigint(20) NOT NULL COMMENT '用户ID',
`status` tinyint(4) NOT NULL COMMENT '订单状态',
`amount` decimal(10,2) DEFAULT NULL,
`version` int(11) NOT NULL DEFAULT '0' COMMENT '乐观锁版本号',
`create_time` datetime NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_order_no` (`order_no`),
KEY `idx_user_id` (`user_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
注意这里的细节:主键是 id,业务上使用 order_no 作为唯一键,但业务代码里所有写操作都是通过 order_no 来定位记录的。也就是说,每次更新走的都是 uk_order_no 这个唯一索引。
库存表简化后:
sql复制CREATE TABLE `order_stock` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`order_no` varchar(64) NOT NULL COMMENT '订单号',
`sku_id` bigint(20) NOT NULL COMMENT '商品SKU',
`quantity` int(11) NOT NULL COMMENT '数量',
`status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '库存状态',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_order_sku` (`order_no`, `sku_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
这表设计是一个订单占多条库存记录,每个 SKU 一行。uk_order_sku(order_no, sku_id) 是唯一索引,也是绝大多数更新操作要用到的索引。
表结构本身不算复杂,但问题在于——订单表上的 idx_status 索引是这次事故的帮凶。因为定时取消任务扫描“待支付且超时”的订单时,查询条件是 WHERE status = 0 AND create_time < ?,MySQL 优化器在数据量到了一定规模后,会选择 idx_status 索引来扫描,而不是走主键。这个看似正常的索引选择,会在后面的死锁日志里把锁范围扩大。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 死锁现场复现:两条 SQL 如何互不相让
2.1 从告警到 MySQL 错误日志的定位过程
告警触发后,我第一时间不是去看业务代码,而是先上数据库抓死锁日志。MySQL 里查看死锁信息的命令很直接:
sql复制SHOW ENGINE INNODB STATUS\G
这个命令会输出一大段 InnoDB 的运行状态,其中 LATEST DETECTED DEADLOCK 段落记录的是最近一次死锁的详细信息。我当时把输出重定向到了文件里慢慢看:
bash复制mysql -hxxx -P3306 -uxxx -p'***' -e "SHOW ENGINE INNODB STATUS\G" > /tmp/innodb_status_$(date +%s).txt
如果死锁已经过去很久,SHOW ENGINE INNODB STATUS 只能看到最近一次死锁,这就可能不够用了。另一种更靠谱的办法是开启 MySQL 错误日志的死锁记录:
ini复制# my.cnf
innodb_print_all_deadlocks = ON
这个参数开启后,每一次死锁都会完整记录到 MySQL error log 里,而不是只保留最近一次。建议所有核心交易库都打开,代价极小,排障时价值极大。
2.2 日志里暴露出来的两个事务
死锁日志的核心片段如下(已脱敏并简化):
code复制------------------------
LATEST DETECTED DEADLOCK
------------------------
2025-01-12 02:17:33 0x7f8d0a1b2700
*** (1) TRANSACTION:
TRANSACTION 58690481, ACTIVE 312 ms
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1136, 2 row lock(s)
UPDATE order_stock SET quantity = quantity - 1, status = 1
WHERE order_no = 'SO202501120001' AND sku_id = 10001
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 128 page no 45 n bits 72 index uk_order_sku of table db_order.order_stock
trx id 58690481 lock_mode X locks rec but not gap
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 128 page no 32 n bits 80 index uk_order_no of table db_order.order_main
trx id 58690481 lock_mode X locks rec but not gap
*** (2) TRANSACTION:
TRANSACTION 58690483, ACTIVE 288 ms
mysql tables in use 1, locked 1
LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s)
UPDATE order_main SET status = 1 WHERE order_no = 'SO202501120001'
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 128 page no 32 n bits 80 index uk_order_no of table db_order.order_main
trx id 58690483 lock_mode X locks rec but not gap
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 128 page no 45 n bits 72 index uk_order_sku of table db_order.order_stock
trx id 58690483 lock_mode X locks rec but not gap
*** WE ROLL BACK TRANSACTION (2)
日志把死锁双方写得明明白白:
- 事务 1 先锁住了
order_stock表uk_order_sku索引上的一行,然后想更新order_main表,在等uk_order_no索引上的锁。 - 事务 2 先锁住了
order_main表uk_order_no索引上的一行,然后想更新order_stock表,在等uk_order_sku索引上的锁。
两个事务各自握着一把锁,又在等对方手里的另一把锁。InnoDB 的死锁检测机制在等待时间超过阈值后立即生效,直接回滚了其中一个事务。这里被回滚的是事务 2,也就是支付回调那一边,于是用户在 App 端看到的就是偶发的“支付结果确认失败,请重试”。
2.3 为什么日志里看不到完整的调用链
这里有个让很多人困惑的点:死锁日志只能看到 SQL 本身,看不到是哪个 Java 方法发出来的。当时日志里事务 1 只有一条 UPDATE,事务 2 也只有一条 UPDATE,但真实代码里一个事务可能执行了好几条 SQL。
我后来查业务日志,才发现事务 1 的完整流程是“取消订单 → 释放库存”,事务 2 是“支付回调 → 扣减库存”。只是因为取消订单时,事务 1 里更新订单状态的 SQL UPDATE order_main SET status = 2 WHERE order_no = ? 前一条 SQL 用了 SELECT ... FOR UPDATE 已经锁住了 order_stock 的行,所以在死锁日志里只显示它正在等待的那条 SQL。
这也提醒我们:死锁日志里的 SQL 只是等待锁的那个瞬间,不代表事务的全部操作。要定位真正的死锁源头,必须结合业务日志、代码调用链和这个时刻前后执行的 SQL 一起看。
3. 锁机制拆解:为什么两个“看起来无关”的更新会互相阻塞
3.1 两阶段锁:锁不是用完就释放的
InnoDB 事务采用的是两阶段锁协议,简单说就是:一个事务里的锁分成“加锁阶段”和“释放阶段”,所有加锁操作都集中在前面,所有释放锁的操作都集中在事务提交或回滚时。中间不管有没有执行完某些 SQL,锁都不会提前释放。
拿事务 1 来说,它执行了:
sql复制-- 取消订单事务
UPDATE order_main SET status = 2 WHERE order_no = 'SO202501120001';
-- 这里拿到了 order_main 行的 X 锁,但不释放
UPDATE order_stock SET status = 0, quantity = quantity - 1
WHERE order_no = 'SO202501120001' AND sku_id = 10001;
-- 这里请求 order_stock 行的 X 锁,如果被阻塞,整个事务卡住
同一个事务内,第一条 SQL 加的锁要等整个事务提交才释放。这就是死锁产生的土壤——加锁顺序不同,加上锁等待事务提交才释放,两个事务一旦交叉,就可能在等待中互相卡死。
3.2 当前读与快照读:UPDATE 的加锁规则
有人会问,UPDATE 不是先 SELECT 再改吗?为什么不能读到旧值然后直接写?这里要分清 MySQL 的两种读取模式:
- 快照读:普通的
SELECT,走 MVCC,不加锁,读到的是某个时间点的快照。 - 当前读:
SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE、INSERT都属于当前读,读取的是最新版本,并且对扫描到的记录加锁。
我们的两个事务里的 UPDATE 都是当前读,所以它们必须拿到目标行的 X 锁(排他锁)才能执行写操作。X 锁和 X 锁互斥,这是死锁形成的锁类型基础。
如果业务上允许用乐观锁替代悲观锁,那死锁概率会大幅下降。比如给订单表加一个 version 字段,更新时带上版本号:
sql复制UPDATE order_main SET status = 1, version = version + 1
WHERE order_no = ? AND version = 0;
这种写法不需要先 SELECT FOR UPDATE,也就少了一把锁。坏处是并发冲突时需要重试,但对于很多电商场景,重试的成本远低于死锁带来的熔断代价。
3.3 间隙锁与临键锁:这次事故没有它,但下个事故会有
这次死锁日志里锁类型是 lock_mode X locks rec but not gap,也就是只锁了记录,没有锁间隙。但既然聊到了死锁,就得多说一句间隙锁,因为实际生产环境里,间隙锁引发的死锁比普通记录锁更隐蔽。
当隔离级别是 REPEATABLE READ(MySQL 默认)时,InnoDB 在范围查询时会加间隙锁或临键锁。比如:
sql复制SELECT * FROM order_stock WHERE sku_id = 10001 FOR UPDATE;
如果 sku_id 不是唯一索引,或者查询条件命中多条记录,InnoDB 会在索引区间上加临键锁,把“记录本身”和“记录前面的间隙”都锁住。另一个事务如果想在这个间隙里插入一条新记录,哪怕记录本身不冲突,也会被阻塞。
间隙锁最经典的死锁场景是:
sql复制-- 事务 A
SELECT * FROM t WHERE id BETWEEN 10 AND 20 FOR UPDATE;
-- 事务 B
SELECT * FROM t WHERE id BETWEEN 15 AND 25 FOR UPDATE;
-- 事务 A
INSERT INTO t (id, ...) VALUES (18, ...); -- 被 B 的间隙锁阻塞
-- 事务 B
INSERT INTO t (id, ...) VALUES (18, ...); -- 被 A 的间隙锁阻塞
-- 死锁
所以排查死锁的时候,除了看锁类型,还要看 SQL 的 where 条件是不是范围查询、是不是非唯一索引、是不是在 REPEATABLE READ 下。这几个因素叠加,间隙锁死锁的概率会明显上升。
3.4 隔离级别的选择:READ COMMITTED 能少很多事
如果业务允许,把隔离级别从 REPEATABLE READ 降到 READ COMMITTED,InnoDB 就不会加间隙锁,只加记录锁,死锁概率会小一个量级。很多互联网公司核心交易库直接设置成:
ini复制transaction-isolation = READ-COMMITTED
binlog_format = ROW
binlog_format = ROW 是为了配套 READ COMMITTED,因为 STATEMENT 格式的 binlog 在 READ COMMITTED 下可能无法正确回放。
不过这个修改不是银弹。READ COMMITTED 只解决间隙锁问题,像这次这种纯记录锁交叉等待的死锁,该发生还是会发生。而且 READ COMMITTED 下每个语句都读最新快照,对一致性要求极高的业务(比如账务)需要仔细评估。就这个电商项目而言,订单状态和库存扣减理论上可以接受 READ COMMITTED,但当时 DBA 出于保守考虑没有直接改动,而是先改了代码。
4. 修复方案的设计过程:不止是“统一加锁顺序”那么简单
4.1 方案一:调整退款流程,强制统一加锁顺序
第一个想到的方案很直接——把所有事务对订单表和库存表的加锁顺序统一成“先订单后库存”。取消订单和支付回调本来就是先订单后库存,只要把退款流程改成:
java复制@Transactional
public void refundOrder(Long orderId) {
updateOrderStatus(orderId, "REFUNDED"); // 1. 先改订单
updateStockStatus(orderId); // 2. 再改库存
}
这个方案改造成本最低,一行代码调整就能解决眼前的死锁。但它有个隐患:加锁顺序是隐式约定,靠的是代码评审时人工把关。后续如果有人新加一个流程,不小心先更新库存再更新订单,死锁就又会冒头。而且,如果同一个订单同时被多个分布式节点处理,顺序再统一也挡不住跨行资源竞争。
我自己的判断是:统一加锁顺序可以作为短期止血方案,但不能作为长期防线。它治标不治本,且对团队纪律要求太高。
4.2 方案二:把“多条 SQL 事务”缩成“一条 SQL”
比调整顺序更优雅的思路是减少事务持有锁的条数。说白了,死锁是因为一个事务同时动了多张表、多行记录,导致锁集合之间有交集。如果每个事务只碰一行,死锁概率会急剧下降。
以支付回写为例,原来的逻辑是:
sql复制UPDATE order_main SET status = 1 WHERE order_no = ?;
UPDATE order_stock SET quantity = quantity - 1 WHERE order_no = ? AND sku_id = ?;
两条 SQL 需要锁两行。但如果库存扣减设计成用原子更新,并且允许库存记录不存在时自动插入,那就可以考虑把“更新订单 + 扣减库存”拆成两个独立的小事务,或者用消息队列异步化。
我们最终采用的是折中方案:支付回调里先更新订单状态,把库存扣减放入本地消息表,由异步任务单独执行。这样支付回调事务只锁订单表的一行,库存扣减事务只锁库存表的一行,两个事务之间不再有交叉等待的可能。
异步化带来的额外收益是:库存扣减失败可以重试,不会因为库存服务抖动导致支付回调整体失败。代价是需要处理消息的最终一致性和幂等性。
4.3 方案三:让 UPDATE 走唯一索引,缩小锁范围
还有一个优化点是索引。死锁日志里可以看到,事务 2 的 UPDATE order_main SET status = 1 WHERE order_no = ? 实际加锁走的索引是 uk_order_no,这是没问题的。但我们的定时取消任务里,更新用的是:
sql复制UPDATE order_main SET status = 2
WHERE status = 0 AND create_time < ?
LIMIT 100;
这条 SQL 可能走 idx_status 索引,也可能全表扫描,锁定的行会扩大到所有满足条件的订单。更糟的是,create_time < ? 是范围条件,REPEATABLE READ 下会触发间隙锁。
我们当时的处理是:把定时任务改成先取出待取消订单的主键列表,再逐条更新:
sql复制-- 第一步:查出主键
SELECT id FROM order_main
WHERE status = 0 AND create_time < ?
ORDER BY id
LIMIT 100;
-- 第二步:逐条更新
UPDATE order_main SET status = 2 WHERE id = ?;
这里的关键变化是:第二步 UPDATE 从“按 status 和 create_time 条件更新”变成了“按主键更新”。主键是唯一索引,UPDATE 时只锁对应的一行记录,而且不会产生间隙锁。更重要的是,后续如果还要释放库存,可以先拿着 id 关联到 order_no,再做后续操作。
这个方案不改变业务流程,只是把一个大事务拆成了多个小事务,对并发冲突的容忍度更高,也比单纯统一加锁顺序更耐操。
4.4 方案选型的实际权衡
三个方案我最后都测试了一轮,简单说下结论:
| 方案 | 改动成本 | 彻底解决程度 | 风险点 |
|---|---|---|---|
| 统一加锁顺序 | 低(几行代码) | 仅解决已知场景 | 后续新增流程可能重新引入 |
| 异步化解耦事务 | 中(引入消息表+异步任务) | 高(事务间不再交叉) | 需要处理最终一致性和幂等 |
| 缩小锁范围/主键更新 | 低(改 SQL 写法) | 中(降低锁冲突粒度) | 定时任务批量处理效率略降 |
最终我们三个都上了:先调统一顺序止血,再把支付回调的库存扣减异步化,最后把定时取消任务改成主键逐条处理。三轮改动上线后,死锁告警直接清零。
这里想多说一句:很多文章会告诉你“统一加锁顺序就解决了”,但真实线上环境的数据量、调用链复杂度、团队协作模式,决定了单一方案往往不够稳。层次化防御才是正确姿势——事务粒度要小、加锁范围要准、顺序要有纪律,三者缺一不可。
5. 死锁排查的完整工具链:从手工命令到持续监控
5.1 手工排查三连:SHOW ENGINE INNODB STATUS、information_schema、performance_schema
当场抓死锁日志用 SHOW ENGINE INNODB STATUS 最有名,但实际排查中还有几个命令组合起来更好用。
第一个是看当前正在运行的事务:
sql复制SELECT * FROM information_schema.innodb_trx\G
这个表会列出所有正在执行的事务,包括事务开始时间、锁等待时间、正在执行的 SQL、等待锁的时间等。如果线上有事务长时间不提交,这里一眼就能看出来。
第二个是查锁等待关系:
sql复制SELECT
r.trx_id AS waiting_trx_id,
r.trx_mysql_thread_id AS waiting_thread,
b.trx_id AS blocking_trx_id,
b.trx_mysql_thread_id AS blocking_thread,
b.trx_query AS blocking_query
FROM information_schema.innodb_lock_waits w
JOIN information_schema.innodb_trx r ON w.requesting_trx_id = r.trx_id
JOIN information_schema.innodb_trx b ON w.blocking_trx_id = b.trx_id;
这条 SQL 可以直接告诉你谁在等谁。相比死锁日志只能看“最近一次”,这个查询能看到“此时此刻”的锁等待全貌。
第三个是 performance_schema 下的锁等待明细:
sql复制SELECT * FROM performance_schema.data_lock_waits\G
在 MySQL 8.0 里,data_lock_waits 比 innodb_lock_waits 信息更全,能看到具体锁对象、锁模式、锁类型。如果死锁发生频率高,可以周期性地把这两个表的数据采集下来,留作事后分析。
5.2 慢查询日志与死锁的隐藏关系
这次事故还有一个容易被忽略的点:告警爆发前 20 分钟,监控系统已经提示订单查询接口的慢查询数量在上升。慢查询和死锁之间存在一条隐藏链路——慢查询会导致事务持有锁的时间变长,从而放大死锁概率。
比如支付回调事务里,如果在更新订单状态前执行了一条慢查询(比如查询用户优惠券、计算满减),这个事务持有订单锁的时间就从 1ms 拉长到几百 ms。这时候另一个事务恰好在等同一把锁,冲突的概率就会上升。
排查慢查询的方式大家应该都熟:
sql复制-- 查看当前慢查询日志开关
SHOW VARIABLES LIKE 'slow_query_log%';
SHOW VARIABLES LIKE 'long_query_time';
-- 临时开启
SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 1;
但更有效的做法是监控 information_schema.innodb_trx 里事务的执行时长和锁等待时长。我们当时写了个简单的采集脚本,每分钟拉一次所有事务,把超过 1 秒的事务打点上报到监控系统。这样死锁发生时,可以回溯到“是哪个慢查询拖住了锁”。
5.3 死锁自动监控与告警:别等用户反馈
死锁问题不能只靠人肉排查,得有自动化手段。我们当时做了两层:
第一层是错误日志关键词告警。因为已经开启了 innodb_print_all_deadlocks,死锁会完整记入 MySQL error log。用采集 agent 对 error log 做关键词匹配,匹配到 deadlock 或 Deadlock found 就触发告警。
第二层是基于业务异常率告警。业务代码里对 DeadlockLoserDataAccessException 做了埋点,捕获每一次死锁异常,统计每分钟死锁次数。连续 3 分钟超过阈值就告警。这一层的好处是,即使数据库日志没有及时采集到,业务侧也不会漏。
这里提醒一句:死锁对 InnoDB 来说是正常现象,它通过回滚一个事务来打破死锁,数据库本身不会挂。所以死锁告警应该关注的是“死锁频率”和“对业务的影响”,而不是“有没有死锁”。
6. 真正让死锁“少发生”的日常功课
6.1 事务设计规范:越小越快,越快越安全
从这次事故里提炼出的第一条规范是:一个事务只做一件事。不要把“更新订单状态 + 扣库存 + 发 MQ + 写日志”都塞进一个事务里。事务里的 SQL 越少,持有的锁越少,锁等待时间越短,死锁概率自然越低。
实际操作中我给团队的硬性要求是:
- 事务内禁止远程调用(HTTP/RPC),因为网络等待会无限拉长持锁时间。
- 事务内禁止执行耗时超过 100ms 的查询,必要时提前在事务外查出数据再传入。
- UPDATE、DELETE 必须走主键或唯一索引,禁止无索引条件更新。
这三条里面,第一条尤其重要。我见过太多死锁事故,根因就是事务里调了一个外部接口,外部接口响应慢,锁被拖住,然后一堆事务排队等锁,最后触发死锁检测。
6.2 索引与 SQL 写法:让优化器“少干活”
MySQL 优化器选择索引的规则不是总能如你所愿。像 WHERE status = 0 AND create_time < ? 这类条件,如果 status 的区分度很低(只有 0、1、2 三种值),优化器可能走 idx_status,也可能全表扫描,锁范围随之失控。
一个稳妥的写法是显式告诉优化器用哪个索引,或者干脆改造成以主键为核心的更新方式:
sql复制-- 不推荐:条件范围大,锁范围不确定
UPDATE order_main SET status = 2 WHERE status = 0 AND create_time < NOW() - INTERVAL 30 MINUTE;
-- 推荐:先查主键,再按主键逐条或分批更新
SELECT id FROM order_main
WHERE status = 0 AND create_time < NOW() - INTERVAL 30 MINUTE
ORDER BY id LIMIT 100;
UPDATE order_main SET status = 2 WHERE id IN (...);
这个改造同时解决了两个问题:一是锁范围从“不确定的多行”缩小到“明确的几行”,二是避免了大事务长时间持有大量行锁。
6.3 兜底策略:重试机制是最后的保险丝
哪怕做了所有预防,死锁依然可能发生。InnoDB 本身的设计就是“检测到死锁就回滚一个事务”,所以业务代码必须接受“死锁是可能发生的”这个事实,并做好重试。
Spring 里处理数据库死锁异常的常规写法:
java复制@Retryable(
value = DeadlockLoserDataAccessException.class,
maxAttempts = 3,
backoff = @Backoff(delay = 100, multiplier = 2)
)
@Transactional
public void payOrder(Long orderId, Long userId) {
// 业务逻辑
}
这里有个细节:被死锁回滚的事务,它里面的所有操作都失效了,所以重试必须整个事务重来,不能只重试最后一条 SQL。用 Spring 的 @Retryable 注解时,要把注解标在事务方法的入口上,而不是标在 DAO 层方法上。
如果不想引入 Spring Retry,也可以自己写个简单循环:
java复制@Transactional
public void payOrderWithRetry(Long orderId) {
int retryCount = 0;
while (retryCount < 3) {
try {
payOrder(orderId);
return;
} catch (DeadlockLoserDataAccessException e) {
retryCount++;
if (retryCount >= 3) {
throw e;
}
Thread.sleep(50L * retryCount);
}
}
}
不过自己写重试要特别注意两点:一是事务边界,@Transactional 如果标在外层方法上,重试会失效(因为事务已经标记为 rollback-only);二是要捕获准确的异常类型,别把业务异常也当死锁重试。
6.4 复盘沉淀:把死锁案例变成团队的“排障手册”
这次事故处理完,我让团队把整个排查过程写成了一份内部文档,包含:
- 死锁环境的系统架构图、涉及的表结构、关键 SQL;
- 死锁日志的截图与逐行解释;
- 根因分析(加锁顺序 + 事务交叉 + 索引选择);
- 三套修复方案的具体 diff;
- 以及线上验证的数据对比(部署前后死锁次数、慢查询数量、接口 P99 耗时)。
这份文档后来成了团队所有后端同学入职必读。原因很简单:死锁这种问题,靠背概念是学不会的,一定要基于真实场景反复看日志、推演锁的获取顺序,才能形成肌肉记忆。下次再遇到类似告警,新人也能第一时间知道先看什么、后看什么。
我个人在实际操作中还有个习惯:每次处理完死锁,都会把死锁日志和当天的慢查询记录归档到一起,按月份整理。因为死锁很少是孤立的,它往往是系统性能劣化、索引设计不合理、事务粒度过大的一个“综合投影”。把这些数据放在一起,过几个月再回头看,往往能发现更深层的设计问题——比如某个接口的并发量已经远远超出当初的预期,或者某张表的索引区分度已经低到必须重新设计。这种视角,比单纯解决一次告警有价值得多。
