一次「偶发」的 1213 报错
电商中台的订单服务里,下单接口偶尔抛出这样的异常:com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Deadlock found when trying to get lock; try restarting transaction(错误码 1213)。压测时概率不高,但一出现就是整笔事务回滚,用户端表现为下单失败。最头疼的是它不是必现,重启也没用——因为死锁本来就是并发时序问题,跟应用「活没活着」无关。
本文从一次真实排查出发,讲清楚 InnoDB 死锁的成因、复现方法、日志解读,以及根治手段:统一加锁顺序。所有示例以典型电商订单场景为例,库表用 app_db,读者可直接照搬验证。
先搞清楚:InnoDB 到底在锁什么
死锁不是玄学,是锁资源互相等待形成的环。要理解它,先认识 InnoDB 的锁粒度:
- 记录锁(Record Lock):锁住索引上的一条记录,
UPDATE/DELETE以及SELECT ... FOR UPDATE都会加。 - 间隙锁(Gap Lock):锁住索引记录之间的「间隙」,防止其他事务插入,默认隔离级别
REPEATABLE READ下范围查询会加。 - 临键锁(Next-Key Lock):记录锁 + 间隙锁的组合,是 RR 级别下 InnoDB 解决幻读的核心手段。
- 意向锁(Intention Lock):表级别的意向共享/排他锁,用于快速判断表上是否已有行锁,不与行锁冲突。
死锁成立的四个条件缺一不可:互斥、持有并等待、不可剥夺、循环等待。数据库里最常见的就是「两个事务以相反顺序更新同一批资源」。
最小复现:两个事务互相等
假设有两张表:orders(订单主表)和 inventory(库存表)。事务 A 先扣库存再写订单,事务 B 先写订单再扣库存,并发时就可能撞车。用两个终端复现:
-- 终端 1(事务 A)
START TRANSACTION;
UPDATE inventory SET stock = stock - 1 WHERE sku_id = 1001; -- 先拿库存行锁
-- 先不提交,等终端 2 执行第一步
-- 终端 2(事务 B)
START TRANSACTION;
UPDATE orders SET status = 1 WHERE order_no = 'SO20261006001'; -- 先拿订单行锁
-- 再回头去更新库存,此时卡住:
UPDATE inventory SET stock = stock - 1 WHERE sku_id = 1001;
-- ERROR 1213: Deadlock found when trying to get lock; try restarting transaction
回终端 1 补上订单更新再提交,会看到事务 A 成功、事务 B 被选为牺牲者回滚。InnoDB 的死锁检测(innodb_deadlock_detect,默认 ON)会主动发现环,并回滚「代价较小」的事务,把 1213 抛给应用。
现场取证:show engine innodb status
死锁发生当下,最快的取证命令是:
SHOW ENGINE INNODB STATUS\G
输出里的 LATEST DETECTED DEADLOCK 段就是案发现场,重点看三块:
*** (1) TRANSACTION/*** (2) TRANSACTION:两个事务各自在执行的 SQL(ACTIVE ... sec能看到事务存活时长)。*** (1) HOLDS THE LOCK(S)/*** (1) WAITING FOR THIS LOCK TO BE GRANTED:分别列出「我握着什么锁」「我在等什么锁」,lock_mode X locks rec but not gap表示排他记录锁,locks gap before rec表示间隙锁。*** WE ROLL BACK TRANSACTION (2):明确告诉你哪个事务被回滚。
只显示「最近一次」死锁是它的局限。排查偶发问题建议常开:
SET GLOBAL innodb_print_all_deadlocks = ON;
-- 之后每次死锁都会追加写入 MySQL 错误日志,不再只保留最近一次
想查「正在发生的锁等待」(还没死锁但已经卡住),用系统表:
-- 当前活跃事务
SELECT trx_id, trx_state, trx_started, trx_query
FROM information_schema.innodb_trx;
-- 锁等待关系:谁在等谁
SELECT r.trx_id AS waiting_trx, b.trx_id AS blocking_trx,
r.trx_query AS waiting_query
FROM information_schema.innodb_lock_waits w
JOIN information_schema.innodb_trx r ON r.trx_id = w.requesting_trx_id
JOIN information_schema.innodb_trx b ON b.trx_id = w.blocking_trx_id;
这两个查询能帮你在死锁发生之前抓到排队现场,配合 performance_schema.data_locks 还能看到具体锁在哪个索引的哪条记录上。
根治:先统一加锁顺序,再谈其他
死锁日志只能定位「哪两条 SQL 撞了」,真正要修的是代码里的资源获取顺序。我们这次的根因很典型:涉及多表更新的两个方法里,UPDATE 的顺序不一致。修法是把所有路径收敛到同一个顺序:
// 约定:任何事务内,一律「先扣库存,再写订单」
@Transactional
public void createOrder(OrderDO order, Long skuId, int delta) {
// 第 1 步:先锁库存行
inventoryMapper.deductStock(skuId, delta); // UPDATE inventory SET stock = stock - ? WHERE sku_id = ?
// 第 2 步:再写订单
orderMapper.insert(order);
orderMapper.updateStatus(order.getOrderNo(), 1);
}
只要所有入口都遵守同一顺序,环就永远形成不了。除此之外,这几个点同样重要:
- 缩小事务:长事务 = 锁持有时间更长,撞车概率指数上升。把远程调用、消息发送挪到事务提交之后。
- 检查索引:更新/删除条件若没有索引,InnoDB 会退化成锁住大范围的记录(含间隙),死锁和锁等待都会被放大。这是比死锁本身更隐蔽的坑——曾经排查过一起「假死锁」,根因是一张表漏建索引,行锁直接放大成了全表级加锁。
- 隔离级别:业务允许时把
REPEATABLE READ降到READ COMMITTED,间隙锁不再使用,范围更新类的死锁会明显减少;代价是可能出现幻读,需要业务层确认。 - 批量操作统一排序:批量更新多条记录时,先在 Java 里对主键/唯一键
sort,再按序执行,多事务并发批量更新就不会交叉。
应用层兜底:捕获 1213 自动重试
架构上再怎么约定,总有新同学写出新顺序。兜底手段是让应用对 1213 做有限重试。Spring 的 DeadlockLoserDataAccessException 就是为它准备的:
@Component
public class DeadlockRetryHandler {
@Retryable(value = DeadlockLoserDataAccessException.class,
maxAttempts = 3, backoff = @Backoff(delay = 100, multiplier = 2))
public OrderVO createOrder(OrderDO order, Long skuId, int delta) {
return orderService.createOrder(order, skuId, delta);
}
}
注意三个前提:
- 重试必须发生在事务边界之外(即整个事务方法失败回滚后重新进入),否则重试会继承脏上下文。
- 重试次数要有上限,且配合退避时间,避免死锁恢复后的一波重试再引发二次风暴。
- 库存扣减类操作要幂等设计(如用唯一订单号做防重),防止重试造成重复扣减。
最佳实践清单
- 死锁是设计问题,不是运维问题:先统一全局加锁顺序,这是根治。
- 排查靠证据:
SHOW ENGINE INNODB STATUS看最近一次,innodb_print_all_deadlocks=ON看全量,系统表看当下等待。 - 警惕假死锁:先查索引,条件无索引导致的锁放大比真死锁更常见。
- RR 下范围查询会加间隙锁,高并发写密集场景评估降到 RC。
- 应用层对 1213 做有限重试是最后一道保险,但绝不能替代顺序约定。
死锁日志读懂了,你就同时读懂了 InnoDB 的并发模型。下次再看到 1213,先问一句:这两条 SQL 的加锁顺序,是不是反了?