分类:数据库与中间件 · 作者:管理员 · 发布于 2026-10-06 · 阅读 0 · 标签:MySQL / 死锁 / InnoDB / 并发 / 电商

一次「偶发」的 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);
}

只要所有入口都遵守同一顺序,环就永远形成不了。除此之外,这几个点同样重要:

  1. 缩小事务:长事务 = 锁持有时间更长,撞车概率指数上升。把远程调用、消息发送挪到事务提交之后。
  2. 检查索引:更新/删除条件若没有索引,InnoDB 会退化成锁住大范围的记录(含间隙),死锁和锁等待都会被放大。这是比死锁本身更隐蔽的坑——曾经排查过一起「假死锁」,根因是一张表漏建索引,行锁直接放大成了全表级加锁。
  3. 隔离级别:业务允许时把 REPEATABLE READ 降到 READ COMMITTED,间隙锁不再使用,范围更新类的死锁会明显减少;代价是可能出现幻读,需要业务层确认。
  4. 批量操作统一排序:批量更新多条记录时,先在 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 的加锁顺序,是不是反了?