你有没有在开发 Spring 应用时,为了提高系统容错能力,在数据库操作上加了 @Retryable 注解?是不是以为加上去就万事大吉,只要网络抖一抖、数据库卡一下,Spring 就能自动重试帮你搞定一切?别天真了!如果你把 @Retryable 和 @Transactional 一股脑套在同一个方法上,而没搞清楚它们的执行顺序和事务边界,那你的重试不但可能无效,甚至可能让你整个事务滚回起点——连第一次成功写入的数据都给你抹掉!
这不是危言耸听,而是无数 Java 工程师踩过的经典陷阱。今天我们就来彻底拆解这个“看似简单实则危险”的组合,告诉你为什么 Spring 官方文档都不建议你直接用注解方式混用,并且手把手教你两种真正安全的重试策略:一种是通过调整 AOP 切面顺序“勉强可用”的声明式方案,另一种则是完全可控、清晰可靠的编程式方案。
无论你是刚入行的 Junior Dev,还是负责核心系统稳定性的 Senior 架构师,这篇文章都值得你逐字细读,因为一个不小心,你线上服务的“自动重试”可能就是“自动丢数据”的隐形炸弹。
为什么重试必须搭配新事务?旧事务是“滚雪球式灾难”的根源
想象一下这个场景:你的用户点击“发布文章”按钮,后端调用 publishArticle 方法,开始执行数据库操作。
第一步,查出草稿;第二步,更新状态为已发布;第三步,生成唯一 slug 并保存。看起来顺风顺水,对吧?但如果在第三步保存时,数据库因为瞬时锁竞争抛出一个 OptimisticLockException(乐观锁异常),整个方法就失败了。
这时候你心想:“没关系,加个重试不就好了?”于是你给方法加上 @Retryable(maxAttempts = 5),心想最多试五次,总有一次能成功。但问题来了——如果这个方法同时被 @Transactional 修饰,那么默认情况下,整个方法(包括所有重试)都跑在同一个数据库事务里!
这意味着:第一次尝试即使更新了状态、生成了 slug,只要最终失败,整个事务就会被标记为 rollback-only。等第二次重试进来时,虽然代码重新执行,但数据库连接还是那个连接,事务还是那个事务——而这个事务已经被“判了死刑”。
更糟的是,某些数据库驱动在事务被标记回滚后,即使你重试,也无法再提交任何操作。结果就是:五次重试全白跑,最后一次直接抛异常,用户看到“发布失败”,而你连日志都看不出哪里错了,因为表面上每次重试都“执行了代码”,实际上数据库根本没写进去。
所以,真正的重试机制必须保证:每一次尝试都运行在一个全新的、独立的数据库事务中。只有这样,上一次失败才不会污染下一次尝试,每一次都是“干净开局”。这,才是可靠重试的黄金法则。
声明式方案:用 @Retryable + @Transactional 的“危险舞蹈”
Spring Retry 提供了一个看似优雅的解决方案:只需在方法上加 @Retryable,再配合 @EnableRetry 开启重试功能。于是很多人直接这么写:
@Component |
看起来完美,对吧?但问题就出在 Spring AOP 的代理机制上。Spring 的事务管理(@Transactional)和重试机制(@Retryable)都是通过 AOP 代理实现的。
而默认情况下,@Transactional 的切面优先级高于 @Retryable。
这意味着:外部调用进来时,先被事务代理包裹,再进入重试代理。
结果就是——重试逻辑被包在事务内部!
换句话说,整个重试循环都在同一个事务上下文中执行。
这完全违背了“每次重试都应有新事务”的原则。那怎么解决?答案是:手动调整切面顺序。
通过在 @EnableRetry 注解中指定 order = Ordered.LOWEST_PRECEDENCE,我们可以强制让重试切面的优先级低于事务切面。这样,调用顺序就变成了:先重试代理,再事务代理。于是每次重试都会触发一个新的事务代理调用,从而开启一个全新的数据库事务。具体写法如下:
@EnableRetry(order = Ordered.LOWEST_PRECEDENCE) |
这样配置后,@Retryable 会在外层,每次重试都会重新进入被 @Transactional 修饰的方法,从而开启新事务。但请注意:这种方案高度依赖对 Spring AOP 代理机制的理解,稍有不慎(比如忘了配 order,或者用了自调用 this.method()),就会失效。而且,一旦项目中存在多个自定义切面,顺序冲突的风险会指数级上升。所以,虽然声明式写法简洁,但它本质上是在“走钢丝”——能用,但不推荐在关键业务路径上使用。
编程式方案:用 TransactionTemplate + RetryTemplate 掌控一切
如果你追求的是清晰、可控、可测试的重试逻辑,那么强烈建议你放弃注解的“魔法”,转而使用 Spring 提供的模板类:TransactionTemplate 和 RetryTemplate。这两个工具类让你用代码显式地定义事务边界和重试策略,没有任何隐式代理、没有顺序陷阱、没有“看似工作实则崩坏”的惊喜。首先,你需要注入 TransactionTemplate:
@Autowired |
然后,构建一个 RetryTemplate。Spring Boot 2.2+ 提供了 RetryTemplateBuilder,用起来非常流畅:
private final RetryTemplate retryTemplate = new RetryTemplateBuilder() |
接下来,把你的业务逻辑包在 retryTemplate.execute() 里面,而事务逻辑再包在 transactionTemplate.execute() 里面。注意嵌套顺序:RetryTemplate 在外,TransactionTemplate 在内。这样,每次重试都会触发一次全新的事务执行:
public Article publishArticle_v2(Long draftId) { |
这段代码的执行逻辑一目了然:最多重试 5 次,每次间隔 100 毫秒;每次重试都开启一个全新事务;事务内执行完整的查找-更新-保存流程。即使某次事务失败(比如数据库锁超时),也不会影响下一次重试,因为每次都是独立连接、独立事务。更重要的是,这种写法完全绕过了 AOP 代理的复杂性,自调用、循环依赖、切面顺序等问题统统不存在。你可以轻松在单元测试中 mock RetryTemplate 和 TransactionTemplate,验证重试逻辑是否按预期工作。对于金融、电商、内容平台等对数据一致性要求极高的场景,这种编程式方案才是真正的“生产级可靠”。
总结:别再让“自动重试”变成“自动丢数据”
重试机制是构建高可用系统的基石,但必须与事务管理协同设计。@Retryable 与 @Transactional 的简单叠加,看似省事,实则暗藏数据丢失风险。正确的做法是确保每次重试都在全新事务中执行。声明式方案需谨慎调整 AOP 顺序,编程式方案则更透明可靠。生产环境建议优先采用 TransactionTemplate + RetryTemplate 的组合,用代码明确表达意图,远离代理陷阱。