并发事务下,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 场景中,需要关注:
- 记录锁:锁住索引记录。
- 间隙锁:锁住索引记录之间的范围,主要用于避免幻读。
- 临键锁:记录锁和间隙锁的组合。
- 意向锁:表级别的标记,用于协调表锁和行锁。
如果查询条件没有合适索引,数据库可能扫描并锁住比预期更多的记录,导致锁竞争范围扩大。比如:
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 标识,避免只看到一句异常信息。
一套可落地的排查顺序
- 确认异常是 deadlock 还是 lock wait timeout。
- 查看 InnoDB 死锁日志,记录双方 SQL 和索引。
- 检查相关查询是否命中预期索引。
- 梳理多个事务获取资源的顺序。
- 确认 ORM 是否延迟到 flush 阶段才执行更新。
- 检查事务中是否包含远程调用、文件操作或长时间计算。
- 缩短事务范围,并统一多资源加锁顺序。
- 对确实可重试的短事务增加有限次数重试。
- 为重试、回滚和最终失败增加监控与日志。
总结
MySQL 死锁的本质,是多个事务以不同顺序争抢相同资源,最终形成循环等待。Spring 的事务注解只负责事务边界,JPA 的实体修改还要经过 flush 才会真正产生 SQL,而 InnoDB 的锁范围又会受到索引和隔离级别影响。
实践中可以优先做好三件事:为查询建立正确索引,统一多个资源的加锁顺序,缩短事务持锁时间。对于仍可能发生的瞬时死锁,再在事务边界外增加有限重试。这样处理的重点不是“让数据库永远不报死锁”,而是让锁冲突可解释、可恢复,并且不会在重试过程中制造新的重复扣款或状态错乱。