应捕获 DeadlockLoserDataAccessException(Spring 封装)或 SQLException(检查错误码 1213/40001 或 40P01),配合指数退避重试(含随机抖动,最多 3 次),并用 TransactionTemplate 手动控制事务重试边界。

捕获 DeadlockLoserDataAccessException 还是 SQLException?
Spring 事务中死锁异常不是统一类型,具体取决于底层 JDBC 驱动和 Spring 版本。MySQL + Spring Boot 2.7+ 默认抛出 DeadlockLoserDataAccessException(继承自 DataAccessException),而老版本或手动 JdbcTemplate 可能直接暴露 SQLException,错误码为 1213(Deadlock found when trying to get lock)。
别只 catch RuntimeException —— 这会吞掉真正需要人工干预的异常(比如空指针)。应该明确区分:
- 对 MySQL:检查
ex.getSQLState().equals("40001")或ex.getErrorCode() == 1213 - 对 PostgreSQL:错误码是
40P01 - 用 Spring 时优先 catch
DeadlockLoserDataAccessException,它已做封装,语义清晰
为什么不能用固定间隔重试?
并发事务同时重试,等于把死锁从“偶发”变成“必现”。两个线程在 100ms 后一起冲回数据库,大概率再次撞上相同资源顺序,第三次死锁。
指数退避(Exponential Backoff)的核心是引入随机性与错峰:
- 基础延迟从
50ms开始,每次失败 ×2(50 → 100 → 200 → 400) - 再叠加
0–50ms随机抖动(ThreadLocalRandom.current().nextLong(0, 50)),避免同步重试 - 最多重试 3 次 —— 再多大概率是逻辑问题(比如事务粒度太大、索引缺失),不是靠重试能解决的
@Retryable 在事务方法里为什么无效?
Spring Retry 的 @Retryable 注解基于代理,而事务注解 @Transactional 也是代理。如果两者套在同一方法上,且没配置 mode = AdviceMode.ASPECTJ,默认 JDK 代理下 @Retryable 会包裹在事务外层 —— 也就是说,第一次执行失败 rollback 了,但重试时又开一个新事务,可能读到过期数据,甚至重复提交。
正确做法只有两种:
- 把重试逻辑下沉到 service 内部:用
while (retries-- > 0)手动控制,每次循环显式调用transactionTemplate.execute(...) - 或者改用
TransactionTemplate+ 自定义 retry 循环,确保每次重试都是独立、干净的事务边界
别依赖 @Retryable 和 @Transactional 共存 —— 它们代理层级打架,行为不可控。
重试时读到脏数据?查不到刚插入的记录?
这不是重试机制的问题,是隔离级别和事务可见性导致的。默认 REPEATABLE_READ 下,事务开始后看不到其他事务的提交;而重试时新事务看不到前一次未提交就 rollback 的变更。
关键点在于:重试必须假设“上次什么都没发生”,所有状态检查(比如查订单是否存在、余额是否足够)都要在重试的事务内重新做。
- 不要在重试外缓存数据库状态(比如先
select一次,再进事务update) - 所有判断和修改必须包在同一个
TransactionTemplate或@Transactional方法里 - 如果业务允许,可临时降级为
READ_COMMITTED,但需确认不会引发幻读等副作用
死锁重试不是“再试一次刚才的操作”,而是“用新视角重新协商资源顺序”——这点最容易被忽略。










