并发事务下,MySQL 死锁往往不是数据库随机出错,而是多个事务以不同顺序获取相同资源。本文结合 InnoDB 行锁、Spring 事务和 JPA flush 机制,说明如何定位死锁、统一锁顺序,并设计可控的重试方案。

在订单、库存、账户余额等业务中,偶尔会遇到这样的异常:

Deadlock found when trying to get lock; try restarting transaction

很多团队的第一反应是“数据库不稳定”,随后把事务重试次数加大,或者简单地捕获异常后重新执行整个方法。但死锁通常不是数据库随机发生的故障,而是并发事务之间形成了循环等待。

更麻烦的是,使用 JPA 或 Hibernate 后,代码中的更新顺序不一定等于 SQL 的执行顺序。你在 Java 方法里先修改了账户 A,再修改账户 B,Hibernate 可能在 flush 阶段按照自己的排序策略发送 SQL。排查死锁时,必须同时理解业务代码、ORM flush 和 InnoDB 加锁过程。

一个典型的死锁场景

假设转账业务需要同时修改两个账户余额:

@Service
public class TransferService {

    private final AccountRepository accountRepository;

    public TransferService(AccountRepository accountRepository) {
        this.accountRepository = accountRepository;
    }

    @Transactional
    public void transfer(Long fromId, Long toId, BigDecimal amount) {
        Account from = accountRepository.findByIdForUpdate(fromId)
                .orElseThrow(() -> new IllegalArgumentException("付款账户不存在"));

        Account to = accountRepository.findByIdForUpdate(toId)
                .orElseThrow(() -> new IllegalArgumentException("收款账户不存在"));

        from.decrease(amount);
        to.increase(amount);
    }
}

对应的 Repository:

public interface AccountRepository extends JpaRepository<Account, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select a from Account a where a.id = :id")
    Optional<Account> findByIdForUpdate(@Param("id") Long id);
}

现在同时执行两笔相反方向的转账:

事务 T1:transfer(1, 2, 100)
事务 T2:transfer(2, 1, 50)

执行过程可能是:

T1 锁住账户 1
T2 锁住账户 2
T1 等待账户 2
T2 等待账户 1

这就形成了循环等待。InnoDB 会检测到死锁,并主动回滚其中一个事务,让另一个事务继续执行。

这里有一个容易误解的地方:死锁并不意味着两笔业务都失败。数据库通常只回滚被选中的一个事务,另一个事务可以继续完成。因此应用必须识别被回滚的事务,并在业务允许时重新执行。

先区分死锁、锁等待和连接池耗尽

这三类问题经常被混在一起,但处理方式完全不同。

锁等待

一个事务持有锁较长时间,另一个事务等待锁释放,但不存在循环依赖。超过 innodb_lock_wait_timeout 后,等待方会报锁等待超时。

常见原因包括:

  • 事务中执行了远程 HTTP 调用;
  • 查询或更新没有命中合适索引;
  • 一个事务修改了大量数据;
  • 事务开启后长时间等待用户或消息队列。

死锁

多个事务互相等待,形成环路。InnoDB 会主动选择一个事务回滚,通常很快返回异常,不一定等到锁等待超时。

连接池耗尽

线程都在等待数据库连接,可能根本没有发生数据库锁冲突。此时应查看连接池活动连接数、等待线程数和获取连接耗时,而不是盲目调整锁参数。

InnoDB 到底锁住了什么

select ... for update 锁住的不是抽象意义上的“这一行”,实际锁行为与索引、查询条件和隔离级别有关。

在常见的 InnoDB 场景中,需要关注:

  1. 记录锁:锁住索引记录。
  2. 间隙锁:锁住索引记录之间的范围,主要用于避免幻读。
  3. 临键锁:记录锁和间隙锁的组合。
  4. 意向锁:表级别的标记,用于协调表锁和行锁。

如果查询条件没有合适索引,数据库可能扫描并锁住比预期更多的记录,导致锁竞争范围扩大。比如:

SELECT id, status
FROM order_item
WHERE order_id = 1001
  AND status = 'WAITING'
FOR UPDATE;

如果 order_id 没有索引,即使最终只找到一条记录,也可能扫描大量数据并持有更多锁。至少应该确认索引是否存在,并通过执行计划检查实际访问路径:

EXPLAIN
SELECT id, status
FROM order_item
WHERE order_id = 1001
  AND status = 'WAITING';

索引优化不能保证完全消除死锁,但可以缩小加锁范围、减少持锁时间,从而降低冲突概率。

最有效的手段:统一获取锁的顺序

回到账户转账例子,最直接的修复方式是无论转入还是转出,都按照账户 ID 从小到大的顺序加锁。

@Transactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
    if (fromId.equals(toId)) {
        throw new IllegalArgumentException("付款账户和收款账户不能相同");
    }

    long firstId = Math.min(fromId, toId);
    long secondId = Math.max(fromId, toId);

    Account first = accountRepository.findByIdForUpdate(firstId)
            .orElseThrow(() -> new IllegalArgumentException("账户不存在: " + firstId));

    Account second = accountRepository.findByIdForUpdate(secondId)
            .orElseThrow(() -> new IllegalArgumentException("账户不存在: " + secondId));

    Account from = fromId.equals(first.getId()) ? first : second;
    Account to = toId.equals(first.getId()) ? first : second;

    from.decrease(amount);
    to.increase(amount);
}

这样,无论请求是 1 -> 2 还是 2 -> 1,事务都先锁账户 1,再锁账户 2。多个事务的锁获取方向一致,就不会因为这两个账户形成循环等待。

这个原则不仅适用于账户,也适用于:

  • 一次更新多个库存 SKU;
  • 扣减多个优惠券或配额;
  • 合并多个购物车;
  • 批量修改多个业务实体;
  • 同时写入主表和多个关联表。

如果一次事务需要锁住多个资源,应为资源建立稳定、可比较的排序规则,例如按主键、业务编号或规范化后的组合键排序。

JPA 的 flush 时机不能忽略

JPA 的实体修改通常不会立即生成 UPDATE SQL:

@Transactional
public void changeStatus(Long id) {
    Order order = orderRepository.findById(id).orElseThrow();
    order.changeStatus(OrderStatus.PAID);
    // 这里通常只是修改了持久化上下文中的实体状态
    // SQL 可能在事务提交前的 flush 阶段才发送
}

Hibernate 会在以下时机触发 flush:

  • 事务提交前;
  • 执行查询前,为保证查询结果一致性;
  • 显式调用 entityManager.flush();
  • 某些特定的持久化上下文操作。

因此,代码排列顺序和数据库真正执行 SQL 的顺序不能简单画等号。尤其是一个事务中修改多个实体时,Hibernate 可能进行 SQL 排序和批处理。

如果业务确实要求在继续处理前确认数据库已经执行了更新,可以显式 flush:

@Transactional
public void updateTwoAccounts(Long firstId, Long secondId) {
    Account first = accountRepository.findByIdForUpdate(firstId).orElseThrow();
    Account second = accountRepository.findByIdForUpdate(secondId).orElseThrow();

    first.touchBalanceVersion();
    entityManager.flush();

    second.touchBalanceVersion();
}

但 flush() 不是提交,也不是释放锁。它只是把当前持久化上下文中的变化同步到数据库,事务提交前锁仍然保持。为了减少锁持有时间,通常应先完成必要校验,再尽早写入,避免 flush 之后执行远程调用或复杂计算。

死锁重试应该放在哪里

死锁是并发环境下允许出现的数据库异常之一。统一锁顺序可以显著降低发生概率,但很难保证所有业务路径、索引变更和未来代码都绝对没有死锁。因此,对可安全重试的短事务,可以设计有限次数的重试。

@Service
public class TransferFacade {

    private final TransferService transferService;

    public TransferFacade(TransferService transferService) {
        this.transferService = transferService;
    }

    public void transferWithRetry(Long fromId, Long toId, BigDecimal amount) {
        int maxAttempts = 3;

        for (int attempt = 1; attempt <= maxAttempts; attempt++) {
            try {
                transferService.transfer(fromId, toId, amount);
                return;
            } catch (RuntimeException ex) {
                if (!isDeadlock(ex) || attempt == maxAttempts) {
                    throw ex;
                }

                long delayMillis = 20L * attempt;
                sleep(delayMillis);
            }
        }
    }

    private boolean isDeadlock(Throwable ex) {
        Throwable current = ex;
        while (current != null) {
            String message = current.getMessage();
            if (message != null && message.toLowerCase(Locale.ROOT)
                    .contains("deadlock")) {
                return true;
            }
            current = current.getCause();
        }
        return false;
    }

    private void sleep(long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException ex) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException("重试等待被中断", ex);
        }
    }
}

示例中的字符串判断只是为了说明结构,生产环境更适合根据实际数据库驱动和 Spring 异常类型做更准确的分类,例如区分死锁、锁等待超时、唯一键冲突和连接异常。

重试必须放在事务边界之外。不能在一个已经标记为回滚的事务内部捕获异常后继续执行,因为当前事务的状态通常已经不可恢复。上例中 TransferService 和 TransferFacade 分成两个 Bean,也是为了让每次重试都通过新的事务代理进入 transfer 方法。

常见错误做法

在事务里调用远程服务

@Transactional
public void pay(Long orderId) {
    Order order = lockOrder(orderId);
    paymentClient.requestPayment(order);
    order.markPaid();
}

远程服务的延迟、超时和重试都会延长数据库事务持锁时间。更稳妥的方式通常是先完成本地状态变更,再通过可靠消息或任务表推进外部调用,具体方案取决于业务一致性要求。

只增加锁等待超时时间

增大 innodb_lock_wait_timeout 不能解决循环等待,反而可能让请求等待更久。应先确认是死锁还是普通锁等待,再分别处理。

认为 @Transactional 会自动保证业务正确

事务只能保证边界内的原子性、隔离性等语义,不能替代正确的锁设计、唯一约束和幂等方案。事务方法如果包含不稳定的外部操作,边界本身就可能设计不合理。

只看应用异常,不保留数据库现场

发生死锁后,应及时查看数据库提供的死锁信息。例如 MySQL 可以执行:

SHOW ENGINE INNODB STATUS;

重点关注最近一次死锁中的两个事务、持有的锁、等待的锁、涉及的索引以及最终被回滚的事务。生产环境还应记录事务类型、业务主键、SQL 模板和 trace 标识,避免只看到一句异常信息。

一套可落地的排查顺序

  1. 确认异常是 deadlock 还是 lock wait timeout。
  2. 查看 InnoDB 死锁日志,记录双方 SQL 和索引。
  3. 检查相关查询是否命中预期索引。
  4. 梳理多个事务获取资源的顺序。
  5. 确认 ORM 是否延迟到 flush 阶段才执行更新。
  6. 检查事务中是否包含远程调用、文件操作或长时间计算。
  7. 缩短事务范围,并统一多资源加锁顺序。
  8. 对确实可重试的短事务增加有限次数重试。
  9. 为重试、回滚和最终失败增加监控与日志。

总结

MySQL 死锁的本质,是多个事务以不同顺序争抢相同资源,最终形成循环等待。Spring 的事务注解只负责事务边界,JPA 的实体修改还要经过 flush 才会真正产生 SQL,而 InnoDB 的锁范围又会受到索引和隔离级别影响。

实践中可以优先做好三件事:为查询建立正确索引,统一多个资源的加锁顺序,缩短事务持锁时间。对于仍可能发生的瞬时死锁,再在事务边界外增加有限重试。这样处理的重点不是“让数据库永远不报死锁”,而是让锁冲突可解释、可恢复,并且不会在重试过程中制造新的重复扣款或状态错乱。

最后修改:2026 年 10 月 08 日
如果觉得我的文章对你有用,请随意赞赏