Spring @Retryable注解实现重试:退避策略、异常过滤与兜底方案

📅 发布时间:2026/10/15 16:49:15
Spring @Retryable注解实现重试:退避策略、异常过滤与兜底方案
作为后端开发我几乎每天都在和各种“偶发失败”打交道第三方接口超时、数据库偶发死锁、网络抖动返回个5xx。这种问题最磨人你重跑一遍往往就成功了可用户那次请求已经失败了。手写for循环重试我在不少项目里见过也自己写过但越写越觉得不对劲。后来真正吃透了spring Retryable注解实现重试这套方案才意识到声明式重试远不只是“加个注解”那么简单退避策略怎么配、哪些异常需要重试、重试全部失败后怎么兜底、和事务放在一起会不会冲突这些才是生产环境能不能放心用的关键。这篇文章我就围绕spring Retryable注解实现重试这件事把从依赖引入到参数配置、从实战案例到常见坑点一次讲清楚适合正在用Spring Boot写后端接口、又不想为了一点抖动就引入重中间件的同学参考。1. 业务代码里最常见的手写重试为什么总让人不放心1.1 那些看起来能用的 for 循环重试我接手过不少老项目里面最典型的重试写法长这样public OrderState queryOrder(String orderId) { int maxRetry 3; for (int i 0; i maxRetry; i) { try { return paymentClient.query(orderId); } catch (Exception ex) { if (i maxRetry - 1) { throw ex; } try { Thread.sleep(500); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); throw new RuntimeException(ie); } } } return null; }这段代码确实能让一部分偶发失败被掩盖过去但问题非常明显sleep时间是写死的没有退避概念重试循环和业务代码混在一起方法越写越长异常被吞掉又抛出来堆栈信息往往指向重试代码本身而不是真正的故障点最致命的是整个重试过程没有任何记录线上出了问题你只能看到最后一次异常前面几次为什么失败、隔了多久重试的全是黑盒。我在审查某个交易查询模块时就碰到过这种写法表面上超时率确实降了一点但每次排查都要靠猜时间成本翻了好几倍。后来我意识到手写重试的人不是偷懒而是“跳过设计直接写代码”——他默认只要重试就能解决问题却没想过重试本身也是一套需要设计的技术方案。1.2 手写循环缺失的三个关键设计维度要理解重试的设计重点你得先接受一个观念重试本质上是一种让步策略目的是给瞬时故障一个恢复窗口。既然是让步就要回答三个问题让多久、让几次、让什么异常让步。让多久即延迟策略。没有退避策略的话每次重试都在同一时间点发起如果下游刚好在重启窗口里你连续3次请求大概率都会撞在故障期上不仅浪费还会给下游增加压力。正确做法是按指数退避拉大间隔给下游留出恢复时间。让几次即重试上限。上限设多少不是拍脑袋的要看下游的平均恢复时间和业务对延迟的容忍度。后面我会专门聊配置思路。让什么异常即异常过滤。这是手写循环里最容易被忽略的。参数校验错误、业务状态冲突这类异常你重试100次也是同样的结果只会把接口响应时间人为拖长三倍还可能在日志里刷出一堆无意义的报错。这三个维度手写for循环基本只能靠程序员当时的临时感觉每个人写出来都不一样换个人维护更是灾难。而声明式重试的价值就是把这些策略集中、显式地描述出来。1.3 声明式重试带来的思维转变把重试策略从业务代码里剥离出来思维层面会发生一个变化业务方法只关心自己该做的事重试是方法之外的一层“加固”。你可以在一个注解里同时回答“让多久、让几次、让什么异常”而且这个配置是统一的、可审查的。不过我也得先把丑话说在前面注解不是万能的它基于Spring AOP天然有代理边界后面的坑基本都从这里来它也不是消息队列里的可靠投递重试次数耗尽之后仍然需要你的业务兜底。想清楚这两条再去动手配心里就有底了。2. Spring Retry 的激活方式依赖、开关与自动配置2.1 依赖坐标与版本匹配用Maven的话最少需要加两个依赖dependency groupIdorg.springframework.retry/groupId artifactIdspring-retry/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-aop/artifactId /dependencyGradle对应这样写implementation org.springframework.retry:spring-retry implementation org.springframework.boot:spring-boot-starter-aop有人可能不理解为什么非要aop这个starter。因为Spring Retry本身是基于Spring AOP动态代理实现的它通过一个方法拦截器把“重试逻辑”织入到被Retryable标注的方法外面。没有AOP能力拦截器就织不进去注解自然不生效。这是理解后面一切代理问题的基石。版本上Spring Boot 2.x和3.x项目都可以直接用Spring Retry 2.x具体到某个版本建议以Spring Boot的依赖管理为准不要手动写死版本号。常见的依赖冲突症状是启动报NoClassDefFoundError如果你同时引了多个版本的spring-retry先检查依赖树而不是去改业务代码。2.2 别忘了 EnableRetry 这个开关依赖加好了注解也标了结果发现一点反应都没有。我遇到过好多次这种问题最后原因都一样启动类上漏了EnableRetry。EnableRetry SpringBootApplication public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }EnableRetry的作用是向容器注册一个AdvisorSpring才会去扫描被Retryable标注的方法并生成代理。即使Spring Boot对spring-retry有自动装配我也建议你显式把这个开关打开。把它当成一种约定俗成的显式声明写在启动类或专门的配置类上后面接手项目的人一眼就能看到“这里用了重试”。顺便提一句如果你的场景不想用注解希望完全走编程式重试可以直接注入RetryTemplateService public class DemoService { private final RetryTemplate retryTemplate; public DemoService(RetryTemplate retryTemplate) { this.retryTemplate retryTemplate; } public OrderState query(String orderId) { return retryTemplate.execute(context - paymentClient.query(orderId)); } }Spring Boot检测到classpath里有spring-retry时通常会帮你装配一个默认的RetryTemplate。在下面的表格里我按实际使用经验给出注解和模板的选择建议。比较维度Retryable 注解RetryTemplate使用方式声明式策略写在方法上直观编程式策略在运行时组装灵活适用场景重试策略相对固定、方法边界清晰重试次数/延迟需要动态决定、或者需要很复杂的组合策略可读性高一眼看出哪些方法可重试一般重试逻辑散落在执行处推荐程度绝大多数业务场景推荐框架封装、中间件场景推荐你只需要记住注解最终也是通过RetryTemplate的底层机制来工作的只不过把配置前移到了注解里。别把两者对立起来。2.3 注解开关背后的AOP原理简介EnableRetry注册的Advisor会匹配所有带有Retryable的方法。Spring AOP对这类Bean生成代理对象如果你用的是JDK动态代理代理对象实现接口如果用的是CGLIB代理对象是目标类的子类。外部Bean调用你的方法时实际调用的是代理对象的方法拦截器链把重试逻辑包在外面。这里有个很容易混淆的点只有当调用方持有的是“代理对象”时重试才会生效。如果方法内部用this直接调用自身或者静态方法、private方法代理根本没机会介入。第5章我会用一个完整小节讲这个坑这里先埋个伏笔。3. Retryable 参数拆解maxAttempts、backoff 与异常过滤3.1 核心属性总览Retryable注解的属性不算多但每个都有讲究。我把最常用的整理成表格属性作用默认值备注maxAttempts最大尝试次数含首次3设为4表示首次3次重试retryFor触发重试的异常类型空默认对Exception重试建议显式指定noRetryFor排除哪些异常不重试空优先于retryForbackoff退避策略配置Backoff里面又有delay、multiplier、maxDelaylisteners指定重试监听器Bean名称空用于观测每次重试recover兜底方法名空通常直接用Recover注解替代exceptionExpressionSpEL表达式按异常信息过滤空适合第三方异常消息里带业务错误码的场景注意很多老教程里的include/exclude属性已经被弃用了新版本推荐使用retryFor/noRetryFor。代码里如果看到include/exclude说明是较老版本的写法功能上可能兼容但新代码建议直接换成新写法。这里还想强调一个容易踩的坑retryFor不配置时默认会对Exception及其子类重试。这个范围实在太大相当于告诉系统“除了Error以外什么错都值得再试一次”。实际项目中我建议一定要显式收敛到那些“值得再试”的异常上。3.2 固定延迟与指数退避怎么选backoff是重试策略的核心直接决定“让多久”。看一个完整示例Retryable( retryFor {TimeoutException.class, RemoteAccessException.class}, noRetryFor {IllegalArgumentException.class}, maxAttempts 4, backoff Backoff(delay 500, multiplier 2, maxDelay 8000) ) public OrderState syncOrderState(String orderId) { return paymentClient.queryOrder(orderId); }这个配置的含义是最多尝试4次首次失败后等500ms再试第2次第2次失败后等1000ms第3次失败后等2000ms……但单次间隔最长不超过8000ms。指数退避的计算逻辑是第N次重试前等待的时间等于delay乘以multiplier的N-1次方再和maxDelay取较小值。那什么时候用固定延迟什么时候用指数退避我的经验是如果是数据库死锁、分布式锁短暂冲突这类秒级恢复的故障固定延迟就够了如果是下游服务重启、网络分区恢复这类可能需要几十秒甚至更久的故障指数退避更合理。因为均匀重试很容易让你每次都撞在故障窗口上而指数退避会逐渐“远离”故障时刻给系统一个相对从容的恢复时间。不过指数退避也有代价等待时间可能很长调用方的线程会被占住。所以maxDelay必须设防止极端情况下重试一次要等几分钟。如果你担心的是高并发场景下的流量集中还可以在Backoff里加上random属性让延迟在一个范围内抖动避免大量请求同时发起重试。3.3 异常过滤的常见误区与 SpEL 表达式异常过滤这块最常见的两个误区第一个误区是retryFor配得太宽。有人直接写retryFor Exception.class结果参数错误、业务校验失败、状态冲突这些永久性异常全部被重试接口延迟被拖长好几倍日志里全是无意义的重复异常。正确姿势是只列出“可能因为瞬间抖动而恢复”的异常SocketTimeoutException、ConnectException、RemoteAccessException、部分DataAccessException等。第二个误区是只靠异常类型过滤没有按异常内容做更细的判断。比如第三方接口返回时可能把业务错误码放在异常消息里其中某个错误码代表“服务暂不可用”值得重试另一个错误码代表“订单状态非法”重试也没用。这种时候可以用exceptionExpressionRetryable( retryFor RuntimeException.class, exceptionExpression message ! null message.contains(TRY_LATER), maxAttempts 3, backoff Backoff(delay 1000) ) public OrderState syncOrderState(String orderId) { return paymentClient.queryOrder(orderId); }exceptionExpression是基于异常对象的message做SpEL判断不是对堆栈全文判断。你可以在本地单元测试里先验证表达式的计算结果再放到线上。这种精细化过滤往往能避免一大半无意义的重复请求。4. 实战案例第三方订单状态接口超时的重试设计4.1 场景切入与主配置假设你现在有一个订单状态同步服务核心逻辑是调用第三方支付平台的查询接口把最新订单状态同步回来。真实环境里这个接口偶尔会超时超时时间从几百毫秒到几秒不等但不是每次都会失败。这种场景就非常适合Retryable。我把重试的代码单独抽成一个方法放在专门调外部接口的Bean里Component public class PaymentClientWrapper { private final PaymentClient paymentClient; public PaymentClientWrapper(PaymentClient paymentClient) { this.paymentClient paymentClient; } Retryable( retryFor {SocketTimeoutException.class, RemoteAccessException.class}, noRetryFor {IllegalArgumentException.class}, maxAttempts 4, backoff Backoff(delay 500, multiplier 2, maxDelay 6000) ) public OrderState syncOrderState(String orderId) { return paymentClient.queryOrder(orderId); } }为什么这么配maxAttempts4意味着首次请求加3次重试。对于一个查询类接口4次尝试通常已经是合理上限再多的话单次请求的理论耗时可能要到十几秒业务上很难接受。delay500起步是因为支付平台的超时原因通常是网络瞬断500ms是一个不算长但也不至于太过密集的恢复窗口。multiplier2的指数退避让后续重试逐渐拉开距离。maxDelay6000防止间隔无限拉大。4.2 Recover 兜底方法重试耗尽后的最后防线重试次数耗尽后如果不做任何处理异常会原样抛给上层。更好的做法是提供一个Recover方法在重试全部失败后执行降级逻辑。Component public class PaymentClientWrapper { Retryable( retryFor {SocketTimeoutException.class}, maxAttempts 3, backoff Backoff(delay 500, multiplier 2) ) public OrderState syncOrderState(String orderId) { return paymentClient.queryOrder(orderId); } Recover public OrderState recoverSyncOrderState(SocketTimeoutException ex, String orderId) { log.error(订单状态同步重试耗尽orderId{}, orderId, ex); return OrderState.unknown(orderId); } }Recover有几个硬性规则写错了一个都可能不生效方法必须和Retryable在同一个类中较新版本支持外部类但默认行为仍是同类我建议保持同类最简单。第一个参数是重试方法可能抛出的异常类型如果重试方法声明了retryFor多个异常就要写多个Recover方法每个方法对应一个异常类型。返回类型必须和原方法一致。参数顺序、类型和原方法保持一致只是多了一个异常参数在最前面。我见过有人把Recover当成万能catch在方法里悄悄返回一个默认对象然后什么都不记。这样做的隐患是调用方以为数据是正常的实际上订单状态可能是未知的下游业务再用这个状态去做判断就会出大问题。我的建议是兜底方法里至少要做三件事——记录异常和重试次数、返回一个明确暴露“本次未成功”语义的结果、把这类失败落到一个可以人工补偿的存储里。4.3 监听器让每一次重试都变得可观测生产环境里重试不能闷头执行你得知道它重试了几次、每次失败原因是什么。Spring Retry提供了RetryListener接口我通常在项目里实现一个日志监听器Component(loggingRetryListener) public class LoggingRetryListener implements RetryListener { private static final Log log LogFactory.getLog(LoggingRetryListener.class); Override public T, E extends Throwable boolean open(RetryContext context, RetryCallbackT, E callback) { return true; } Override public T, E extends Throwable void onError(RetryContext context, RetryCallbackT, E callback, Throwable throwable) { String methodName context.getAttribute(methodName) null ? unknown : context.getAttribute(methodName).toString(); log.info(方法[{}]第{}次尝试失败异常信息{}, methodName, context.getRetryCount(), throwable.getMessage()); } Override public T, E extends Throwable void close(RetryContext context, RetryCallbackT, E callback, Throwable throwable) { if (throwable null) { log.info(方法重试后成功执行最终重试次数{}, context.getRetryCount()); } } }然后在注解上通过listeners属性指定这个监听器Retryable( retryFor SocketTimeoutException.class, maxAttempts 4, backoff Backoff(delay 500, multiplier 2, maxDelay 6000), listeners loggingRetryListener ) public OrderState syncOrderState(String orderId) { return paymentClient.queryOrder(orderId); }这里要注意listeners的值是Bean名称不是类型写错了会在运行时找不到Bean。如果想在监听器里拿到具体的业务参数可以通过上下文attributes传递或者干脆在日志里只记录方法名和异常信息这通常已经够用。如果你用了Micrometer这类监控指标库还可以在onError里直接累加一个计数器比如按方法名打tagMetrics.counter(retry.attempt, method, syncOrderState).increment();这样重试数据就进入到监控系统后面可以根据指标调参。4.4 别忘了幂等重试是重复执行不是补偿机制这是我最想强调的一点。重试意味着同一个请求可能会被发送多次如果你的方法不只是查询还涉及写操作、扣减库存、发送消息那么重复执行就可能造成重大事故。我曾经在一个优惠券发放模块里见过这种情况发放逻辑挂在同步接口上接口偶发超时后丢进重试结果同一张券被发了好几次。后来加了一幂等键也没用因为发的不是请求而是一整段写逻辑。所以在使用Retryable之前先确认这个方法的操作是幂等的。查询接口天然幂等这点没问题但写接口最好做到请求带唯一业务幂等键、数据库对关键业务字段建立唯一索引、执行前先做状态机判断。如果这些条件不满足我宁可不重试而是把失败信息扔进消息队列里的重试表由后台任务补偿。5. 自调用失效、事务裹挟与代理边界三个高频问题5.1 同一个类里 this 调用为什么不会重试这个坑我敢说十个人里至少六个人踩过。看这段代码Service public class OrderSyncServiceImpl { Retryable(retryFor SocketTimeoutException.class, maxAttempts 3) public OrderState syncOrderState(String orderId) { return paymentClient.queryOrder(orderId); } public void doSync(String orderId) { // 这里通过 this 调用Retryable 不会生效 this.syncOrderState(orderId); } }原因就是Spring AOP代理。你用Autowired注入OrderSyncServiceImpl时拿到的是代理对象但在方法内部用this调用syncOrderStatethis是原始对象本身不是代理。拦截器看到的是普通方法调用直接执行了原始逻辑重试自然不触发。解决办法有三种我按推荐程度排序把被重试方法挪到另一个Bean里在需要重试的类中注入这个Bean通过Bean调用代理方法。这是最清晰的方式。往当前类注入ApplicationContext用context.getBean(OrderSyncServiceImpl.class)获取代理对象再调用。用AopContext.currentProxy()但需要在启动类或配置里开启exposeProxy而且代码里到处是AopContext也不太优雅。真实项目里我几乎只用第一种方案。重试作为独立的横切关注点把它隔离在专门的对象里既解决了代理问题也方便以后把这个对象换掉或者加Mock。5.2 Transactional 和 Retryable 同框时的事务语义这两个注解都是通过AOP实现的一旦放在同一个方法上事情会变得非常微妙。我先明确表达观点不要轻易把Transactional和Retryable堆在同一个方法上。问题出在advisor的执行顺序。假如执行顺序是“事务拦截器在外、重试拦截器在内”第一次执行时事务已经开启方法内抛了异常事务被标记为rollback-only但是异常被重试拦截器拦截开始第二次调用。第二次调用时事务拦截器如果检测到上下文存在一个rollback-only标记的事务可能直接抛UnexpectedRollbackException重试后的结果仍然不干净。假如顺序反过来重试拦截器在外、事务拦截器在内每次重试等于新开一个事务但同一个方法内部连续执行多次写操作符合你的业务预期吗如果方法逻辑本身就是“扣库存、写订单、调外部接口”重试一次就多扣一次库存。最稳妥的设计原则是重试和事务各自管好自己的一亩三分地。把事务操作放在一个方法里把可重试的外部调用放在另一个方法里然后用外层方法把两者串起来。如果要重试的恰恰是数据库操作本身比如偶发死锁也建议把这个数据库操作单独拆成一个无事务上下文的Bean方法进行重试或者使用编程式事务让每个重试周期内的事务独立提交、独立回滚。5.3 内部吞异常与“假成功”问题还有一种让Retryable失效的情况不是代理问题而是方法内部把异常吞了Retryable(retryFor RemoteAccessException.class, maxAttempts 3) public OrderState syncOrderState(String orderId) { try { return paymentClient.queryOrder(orderId); } catch (RemoteAccessException ex) { log.error(调用失败, ex); return null; // 异常被吞掉了重试层根本感知不到 } }方法永远返回null或者默认值没有异常抛到拦截器层面注解自然不知道这次调用失败了。这种写法发生在重构时尤其隐蔽原本方法会抛异常后来有人为了“不让外层报错”顺手catch了一下重试就悄悄失灵了。排查这类问题时先看日志里有没有Spring Retry打出的“Retry: ... failed”之类的记录再看目标类名里有没有包含$$SpringCGLIB$$或jdk.proxy等代理标识。如果类名没有代理特征说明这个Bean压根没被代理。5.4 重试粒度太粗整个业务方法被重复执行最后一个高频问题是重试粒度。有人图省事把一整个业务方法加上Retryable里面可能包含写库、发消息、调外部接口结果外部接口超时后整个方法重试数据库状态被重复更新消息发了多遍。正确做法是让重试方法尽可能“小而专注”只包住那些确实会发生瞬时失败的代码段。业务编排方法应该保持无重试语义通过调用内层可重试方法来获得“重试能力”。换句话说重试粒度应该等于失败点的粒度而不是整个请求的粒度。如果你担心外部调用失败后内层方法重试成功但外层编排方法里前面已经写库的操作没有撤销其实这不是重试的问题而是整个调用链设计的问题。解决方案很简单把可重试的调用放在业务事务之外或者在重试之前先完成必要的写操作再让重试只作用于外部调用本身并保证外部调用成功之后的状态更新是幂等的。6. 重试与熔断、监控你的重试配置是否真的在给系统减负6.1 重试不是无限重试什么时候必须停手重试和熔断经常被放在一起讨论但它们的出发点是不同的。重试解决的是“下游瞬时抖动我再试一次可能就好了”熔断解决的是“下游已经明显故障我不能再拼命打了先短路保护自己和上游”。用生活类比的话电梯门卡住了你按一次关门键是正常操作连续按十次就是把门控系统往死里逼。对应到系统里如果重试请求持续涌入一个已经过载的依赖不仅救不回来反而可能把对方彻底拖垮。所以我对重试的边界有很明确的判断标准场景合适的处理方式偶发超时、瞬时网络抖动有限次数重试指数退避下游持续报错超过一定时间熔断直接降级下游恢复但流量溢出限流配合重试也要控制并发永久性业务错误不重试直接走异常流程或人工补偿Spring Retry本身不提供熔断能力。如果你需要在Reactive或者更加容器化的环境里做复杂的重试熔断组合可以考虑引入专门做容错的库比如Resilience4j但优先把Spring Retry的自带能力用透绝大多数场景就够了。6.2 用指标说话别拍脑袋调参数重试配置不是一次定死的我习惯给每个重试方法都接上指标用数据来判断参数是否合理。至少关注四个指标重试总次数展示重试发生的频繁程度。重试成功数重试之后调用成功的次数。重试耗尽数重试全部失败、最终走了兜底逻辑的次数。重试成功率重试成功数除以重试总次数这个比例最值得长期观察。如果重试成功率长期很高说明当前backoff配置比较有效如果重试成功率很低、重试耗尽数不断增长那么问题就不只是参数了——可能下游确实长期不可用这时候就算把maxAttempts调到10也没用反而加重下游负担。这时候应该考虑的是熔断或者告警介入而不是继续调高重试次数。在代码里监听器的onError和close回调是打点最合适的位置。用Micrometer示例Metrics.counter(retry.attempt, method, syncOrderState, exceptionType, throwable.getClass().getSimpleName() ).increment();在close回调里如果throwable不为空说明最终失败再累加一次retry exhausted计数。这样一套下来每个方法的重试画像就清楚了。6.3 几组我实际用下来的配置经验与调试技巧配置经验这部分我把自己踩过的坑整理一下。第一次上线一个重试方案时参数建议从保守值起步maxAttempts3delay500msmultiplier2maxDelay6000ms。千万别一上来就配5次、8次的重试次数因为一旦配错后果不只是延迟变高还可能造成下游流量放大。保守配置运行几天看监控数据再逐步调整。重试方法如果并发量很大要特别小心退避窗口内的线程堆积。比如delay2000、multiplier2高峰期有100个请求同时失败等待期间意味着有100个线程被占着不放。这种情况下可以配合线程池隔离或者考虑把重试操作放到异步线程里但异步又会引入线程池耗尽、上下文传递等问题。我的态度是先确认并发量再决定要不要上异步重试。最后分享一个调试技巧。想验证Retryable到底有没有生效最快的方法是临时把delay调成10ms、maxAttempts调成2然后在被重试方法里打印一条日志。如果日志按预期打印了两次说明代理生效、重试链路正常。配好之后再把参数调回去。这个方法我在测试环境用了很多次比在断点里一步步跟代理逻辑要快得多。也可以直接在监听器的onError里打日志生产环境开着这个日志就行信息量足够又不至于刷爆存储。说到底Retryable只是把重试这件事从“手写套路”变成了“声明式配置”但这不意味着你可以无脑使用。我在用它的过程中最大的收获是每次想给某个方法加重试时先问自己三件事——这个异常是可恢复的吗重试后有没有幂等保障重试全部失败之后用户看到的是什么这三个问题都回答清楚了再加注解。否则重试可能从一个解决偶发故障的好工具变成放大故障和掩盖问题的帮凶。跟Spring Retry打交道这些年我最深的体会就是配置参数看似简单背后每一步都是对业务稳定性边界的理解。