Spring重试机制实战:@Retryable与@Recover注解详解及踩坑指南

📅 发布时间:2026/10/10 16:14:20
Spring重试机制实战:@Retryable与@Recover注解详解及踩坑指南
最近在排查线上接口偶发超时的问题日志里那个第三方支付回调重试了三次才成功当时第一反应就是幸亏当时在代码里留了重试逻辑。但后来看代码才发现不少同事处理重试还是老一套for循环里sleep几秒再手动判断是否成功代码丑不说还很容易写出bug。其实Spring框架早就给了现成的方案——Retryable和Recover这兄弟俩一个负责重试一个负责重试失败后的降级兜底。今天想把实际项目里的落地经验和踩过的坑完整整理出来内容围绕Spring框架中两个核心注解展开适合正准备重构重试逻辑的同学也适合想弄清楚注解背后到底怎么工作的人。1. 为什么最终选了注解化重试而不是业务代码里手动写循环先说场景。当时我们做的是一个对外接口集成服务下游是几个银行和第三方平台的开放API这类接口有个共同特点不稳定。59x开头的网络抖动、连接池满了、超时超到一半网关直接断连什么妖魔鬼怪都能碰上。业务需求很明确接口调用失败后要自动重试重试要有间隔不能风暴式压过去连续重试超过一定次数后要走降级逻辑记录报文人工介入或者落库补偿。最传统的实现是手动循环int maxRetry 3; for (int i 0; i maxRetry; i) { try { Result r thirdPartyClient.submit(orderVO); if (r.isSuccess()) break; } catch (Exception e) { log.warn(第{}次调用失败, i 1, e); Thread.sleep(1000 * (i 1)); } }当时我没觉得这代码有多大的问题直到有一次做代码走查一个老哥问了一句如果第三次也失败了你直接吞掉异常走到了日志那行那业务流程要怎么感知这次失败后续的补偿任务怎么触发线程被sleep占着你有没有考虑过线程池场景下这是在白白浪费资源这几个问题直接把我问住了。确实手动循环重试至少有这几个隐患异常被吞重试逻辑和业务逻辑混在一起失败后很容易被误判为成功导致数据不一致。sleep带来的资源灾难在Java线程池里sleep不会释放线程例如corePoolSize只有10如果10个请求同时进行重试sleep线程池直接占满其他正常请求全部排队。重试条件和间隔难维护今天要做3次明天改成5次后天要区分哪些异常才重试写出来的代码全是if-else。不可观测没有回调机制想统计重试次数、失败原因、走的哪个重试分支都得自己在日志里一层一层拼。而Spring框架提供的Retryable就是奔着解决这些问题来的——声明式重试把重试策略从业务代码里剥离出来用注解直接定义什么异常要重试最多重试几次每次间隔多久最终失败后交给哪个方法降级。它和Recover的配合方式本质上是在做重试补偿这个完整的闭环而不是简单地把代码包在循环里。2. 看懂Spring的重试机制代理、异常捕获与重试上下文很多人在第一次尝试Retryable的时候会发现一个奇怪的现象明明配置了但就是不重试或者重试了之后Recover方法进不去。要弄明白这些得先知道Spring Retry这层机制是怎么搭起来的。Spring框架中Retryable并不是什么黑魔法它背后是基于AOP动态代理实现的。项目启动时Spring扫描到标注了Retryable的方法就会通过AnnotationAwareAspectJAutoProxyCreator为这个Bean生成一个代理对象。外部Bean调用你这个方法实际调的是代理对象代理在进入目标方法之前会先走拦截器链这个拦截器链里注册了一个重量级角色——RetryOperationsInterceptor。真正干重试活的是RetryTemplate它内部维护了一个RetryPolicy重试策略和一个BackOffPolicy退避策略。每次方法抛出异常拦截器会问RetryPolicy这个异常还能再试吗RetryPolicy根据你的配置比如maxAttempts、include/exclude来做判断。如果还能再试就调用BackOffPolicy看看要等多久再执行下一轮如果不能试了则把这个异常继续往外抛或者转发给Recover定义的兜底方法。这里有个很多新手容易忽视的关键点整个重试过程里目标方法其实被执行了多次但代理只向外抛出了最后一次的异常或者Recover方法处理后的结果。意味着你写在业务代码里的catch块只能捕获最后一次异常而不是每一次的。有一次同事跟我说我在方法里catch了异常想记日志然后继续抛让注解去重试但是为什么只打印了一次其实很正常——方法内部的catch处理的是每次执行各自的异常如果catch后你没有把异常抛出去而是选择吞掉Retryable根本感知不到失败它只认从目标方法里抛出来的异常。如果你在方法内部catch了还返回一个null那这次执行在AOP眼里就是成功重试逻辑直接被跳过了。这是一个需要时刻记在脑子里的边界要触发重试方法内部不许吞异常异常一定要向外抛。再补充一个容易被忽略的配置——EnableRetry。Spring Boot的自动配置里通常已经默认开启了AOP和重试支持但要确认你当前的项目是不是真的启用了。在纯Spring项目里忘加EnableRetry简直是家常便饭我有一次排查了半天最后发现注解根本没生效就是这个原因。如果你不确定启动日志里搜RetryConfiguration相关输出或者直接看Bean工厂里有没有对应的advisor。3. 基础落地一个请求-响应式接口的注解重试完整配置理论讲清楚了下面给出一套可以直接抄作业的代码。我们用一个典型的支付订单状态查询接口举例——这类接口经常遇到下游瞬时抖动查一次没返回隔几百毫秒再查往往就成功了。先加依赖。Maven项目里Spring Boot 2.x/3.x通用dependency groupIdorg.springframework.retry/groupId artifactIdspring-retry/artifactId /dependency注意如果你用的Spring Boot 2.4以上且项目里引入了spring-boot-starter-aop这个依赖不需要指定版本版本由Boot的BOM统一管理。纯粹使用Spring Framework的兜底方案版本会老可能不兼容建议要么走Boot版本管理要么单独指定一个较新的spring-retry版本比如2.0.x系列。然后是启用Retry。如果项目里还没有开启过AOP相关功能在任意一个Configuration类上补充Configuration EnableRetry public class RetryConfiguration { }EnableRetry有一个proxyTargetClass属性默认false表示优先使用JDK动态代理。如果你的目标Bean没有实现接口要强制走CGLIB配置成true即可。要是不配在某些老版本里会出现Bean不是接口类型无法代理之类的报错。Spring Boot现在普遍默认CGLIB但纯Spring项目还是显式配置更稳妥。接着定义service层的重试方法。为什么强调service而不是controller因为Retryable基于Spring代理生效而Spring MVC的Controller虽然也是Spring管理的Bean但如果你在Controller里直接用this调用同一个Bean的另一个方法代理不生效重试逻辑会静默消失。所以正确姿势是把重试方法写在独立的Service组件中让外部调用者通过注入的代理对象访问。Service public class PaymentOrderQueryService { private static final Logger log LoggerFactory.getLogger(PaymentOrderQueryService.class); Retryable( retryFor {IOException.class, TimeoutException.class}, noRetryFor {IllegalArgumentException.class}, maxAttempts 4, backoff Backoff(delay 300, multiplier 2, maxDelay 5000) ) public PaymentResult queryOrderFromChannel(String orderId) throws IOException { log.info(开始查询渠道订单orderId{}, orderId); PaymentResult result channelClient.queryOrder(orderId); if (result null || !result.isSuccess()) { // 渠道返回了业务失败也要抛出异常让上层感知并触发可能的恢复逻辑 throw new IOException(渠道返回失败响应内容 result); } return result; } Recover public PaymentResult recoverQueryOrder(IOException e, String orderId) { log.error(订单查询重试后仍然失败orderId{}异常信息, orderId, e); // 返回一个降级结果或者写入待补偿表由补偿任务继续处理 PaymentResult fallback new PaymentResult(); fallback.setOrderId(orderId); fallback.setSuccess(false); fallback.setRetryFlag(true); return fallback; } }这里的关键配置拆解一下retryFor与noRetryForSpring Retry从2.0版本开始推荐使用这两个属性替代老版本的include和exclude。语义更清晰——retryFor告诉框架只要抛出这些异常就触发重试noRetryFor告诉框架这类异常直接放弃重试。有人问过如果只写maxAttempts不写retryFor会怎样默认情况是捕获Throwable任何异常都会重试这很容易把业务性参数错误如参数校验失败、请求不存在也重试好几轮造成下游不必要的压力。所以一定要显式声明哪些异常值得重试。maxAttempts4注意这个4不是额外重试4次而是总执行次数4也就是第一次执行算1真正额外重试3次。很多人在看日志时会误解我配置了4为什么执行了5次答案一定是你的业务catch又内部调了一次。backoff配置delay300表示第一次失败后等300毫秒再重试multiplier2表示每次间隔按倍数增长也就是第二次等300ms第三次等600ms第四次等1200msmaxDelay5000是上限防止指数增长到不可控的天文数字。这样设计是合理的——重试前几次要密集因为是瞬时抖动后面要把节奏放缓因为连续失败说明问题可能不是简单的抖动。再看Recover方法。有几个硬性规则漏一个运行时就报错Recover方法的第一个参数必须是异常类型并和Retryable上的异常声明匹配。Recover方法的返回值必须和Retryable标注的方法一致否则Spring无法知道怎么把兜底结果返回给调用方。Recover方法可以额外传后续参数但参数列表要和原方法的参数列表从第二个参数开始完全对应。对应不上时Spring会抛出CircuitBreakerException或ArgumentMismatchException之类的异常而不是默默忽略。上面这个demo代码里Recover方法第一个参数是IOException e第二个参数是String orderId正好和业务方法queryOrderFromChannel(String orderId)的参数顺序对齐。如果你的业务方法有多个参数比如queryOrder(orderId, merchantId)那么recover方法就得写成recoverQueryOrder(IOException e, String orderId, String merchantId)。还有个细节参数顺序多对多时Spring是按类型匹配的如果两个参数类型一样比如都是String顺序一定不能反否则匹配到的是错位参数。4. 应对复杂场景手动注入RecoveryCallback和RetryTemplate注解式重试解决了一大半问题但实际业务里总有一些情况是纯注解搞不定的。比如重试过程中需要传递上下文对象比如traceId、登录用户信息失败后要把这些上下文从重试栈里取出来。重试失败后不止要降级返回还要触发告警、落库、发MQ一气呵成。不同方法的重试策略差异大不想在注解里重复一堆一模一样的配置。第一个建议是如果重试参数复杂、且失败后的动作不止一个return那么用RetryTemplateRetryCallback会更灵活。Spring Retry提供了编程式API和注解共用同一套核心组件。举个例子Configuration public class RetryTemplateConfig { Bean public RetryTemplate retryTemplate() { RetryTemplate template new RetryTemplate(); // 简单线性重试策略最多4次 SimpleRetryPolicy retryPolicy new SimpleRetryPolicy(4, Collections.singletonMap(IOException.class, true)); template.setRetryPolicy(retryPolicy); // 固定间隔200ms不做指数倍增 FixedBackOffPolicy backOffPolicy new FixedBackOffPolicy(); backOffPolicy.setBackOffPeriod(200); template.setBackOffPolicy(backOffPolicy); return template; } }使用时就变成String orderId A123456; try { PaymentResult result retryTemplate.execute( context - { log.info(第{}次调用渠道, context.getRetryCount() 1); return channelClient.queryOrder(orderId); }, context - { log.error(重试耗尽最后一次异常, context.getLastThrowable()); // 拿到最后一次异常可以在这里做更完整的告警和补偿 PaymentResult fallback buildFallback(orderId); fallback.setReason(context.getLastThrowable().getMessage()); return fallback; } ); return result; } catch (Exception e) { // RetryTemplate在recover没有匹配时才会抛到这里else情况下recover结果已经涵盖 log.error(重试执行过程出现非预期异常, e); throw new BizException(查询失败请稍后重试); }第二种答案是如果你的重试失败后需要把方法返回值当作恢复逻辑的重要依据比如拿到了下游返回的错误码就不太适合用异常驱动这套方案。有个落地方案是在方法内部返回一个包装结果然后外部通过ExecutionEvaluator来判断结果是否值得重试Spring Retry 2.0之后提供了Retryable的label、listeners等扩展点可以通过RetryListener监听每个阶段的事件甚至可以在注解上指定listener Bean来统计重试次数和原因。这个比较进阶后续有机会再单独写一篇。我这里想强调一个经验注解和Template不是互斥的。实际项目里我经常混用——默认的幂等查询、同步调用用Retryable因为简单直观异步任务、需要依赖上一个重试上下文结果的场景用RetryTemplate因为控制力更强。两者底层都是同一个RetryTemplate在跑所以核心配置指数退避、异常归类、最大次数完全一致切换成本并不高。5. 高频踩坑排查为什么我配了注解却完全没生效这里把实际遇到的和网上高频出现的问题集中梳理一下很多坑都是同一个根因。5.1 坑一类内部方法自调用代理压根没进来这是最常见、最隐蔽的一个坑。我一同事写的代码长这样Service public class OrderService { public void process(String orderId) { // 业务处理... this.queryChannel(orderId); // 自调用 } Retryable(...) public PaymentResult queryChannel(String orderId) { ... } }结果他调试时发现queryChannel报错后完全不重试。原理上一节说过了外部调用orderService.process()时拿到的是Spring容器代理对象但process()内部使用this调方法this指向的是目标对象本身根本没经过代理重试注解被直接忽略。解决办法有几种使用方法内部注入自身的代理对象比如Autowired或Lazy Autowired自己。将queryChannel抽到另一个Bean中比如独立成ChannelQueryService在OrderService注入它完成调用这也是最自然、最可测试的方式。5.2 坑二方法或类被final修饰CGLIB代理失效Spring Boot默认使用CGLIB来生成代理。CGLIB通过生成目标类的子类来覆盖方法但final类无法继承、final方法无法覆盖。所以标注了Retryable的方法一定不能是final的类最好也不要加final。这个坑在Spring 4.3之前的老版本尤其明显那时候CGLIB代理final类直接抛异常新版本会转向JDK动态代理但前提是你的类实现了接口。总之别把重试方法设计成final。5.3 坑三同一个类里有多个Recover方法重载解析过了头假设你有两个重试方法Retryable(retryFor IOException.class) public void handleA() {} Retryable(retryFor TimeoutException.class) public void handleB() {}那你可能需要两个Recover方法一个接收IOException一个接收TimeoutException。Spring会根据异常类型去匹配。问题来了如果两个Recover方法的参数列表过于相似比如都是(Exception e)那Spring可能会分不清该调哪个因为IOException也是Exception的子类。这时候严格显式写出异常类型参数的Recover方法就是必须的方案是让每个Recover方法的第一个参数精确到子类不要把两个兜底方法都写成Exception。5.4 坑四异常被拦截器吞掉导致恢复方法进不去Recover设计上是最后一次重试失败后才执行的但有时候你会发现它根本没执行异常直接抛给了上层。排查方向检查Retryable里的异常声明和Recover里的异常参数是否一致。例如Retryable(retryFor {IOException.class}) public void call() throws IOException {} Recover public void fallback(Exception e) {}这种写法Recover参数是ExceptionSpring匹配时会认为它能处理所有Exception但如果只有IOException声明会重试其他异常根本不走重试机制而fallback(Exception e)理论上可以接住所有。问题在于Recover的匹配规则很聪明——它会找能处理目标异常的最具体类型。IOException和Exception都匹配IOException但更具体的是IOException如果你的Recover方法却是ExceptionSpring有时会认为匹配不到精确的Recover处理器直接抛错。解决方案Recover的参数类型和Retryable声明的异常类型保持完全一致不要偷懒写成一个通用的Exception。5.5 坑五Recover返回值类型不一致导致空指针或类型转换错误这个最容易出现在习惯了弱类型的同学身上。如果你的重试方法定义返回的是PaymentResult而Recover方法返回的是StringSpring在运行时尝试把Recover返回结果包装成原方法的返回类型时直接类型转换失败或方法调用报错。记得写Recover方法前先看一眼原方法的返回值类型严格保持一致。如果业务上确实需要返回不同类型的降级结果最合理的方式是定义一个统一响应对象比如Result/Response重试方法和Recover方法都返回它。5.6 坑六异步线程池与事务注解叠加如果Retryable标注的方法同时被Transactional标注且你的事务边界是REQUIRED那么重试期间每次异常回滚重试后的新开启事务其实是在原事务的failed state基础上操作可能导致意想不到的矛盾。一个常见的方案是拆开先做分布式事务外的同步重试再在重试成功后的方法边界上开事务或者把Retryable标在无事务的调大门方法上内部调用真正的事务方法。顺序上切忌Retryable在外、Transactional在内直接叠写。5.7 坑七不区分业务异常和系统异常导致无效重试上个月的一次线上事故让我印象很深。当时某个下游接口对同一笔支付回调返回了固定的业务错误订单状态不允许付款因为重试配置里retryFor写的是Exception.class结果系统硬是重试了4次每次都是同一个错误白白浪费了资源还因为重试间隔叠加放大了耗时。这也是我写上面demo时为什么特意加noRetryFor {IllegalArgumentException.class}的原因只有瞬时性、偶发性异常才值得重试业务拦截类异常参数错误、状态冲突、幂等冲突直接抛出去给人处理别做无谓的挣扎。6. 重试策略怎么选从固定间隔到指数退避再到抖动优化有了代码经验还得聊策略。Backoff是Retryable的节奏器它支持三种形式我逐个说下适用场景以及一个容易被忽略的抖动randomization参数。固定间隔Fixed每次失败后固定等一样长的时间比如Backoff(delay 1000)。适合下游服务抖动周期相对固定或者调用频率不高的场景。优点是节奏稳定缺点是一旦下游忙不过来固定频率的重试和不重试差别不大反而会形成稳定的压力波。指数退避Exponential MultiplierBackoff(delay 500, multiplier 2, maxDelay 10000)。这是最常用的策略。比如第一次等0.5秒第二次等1秒第三次等2秒第四次等4秒逐渐拉开节奏既能在短时抖动时快速抢回成功又能在连续失败时避免把下游打崩。maxDelay必须设置否则指数增长到后面一次等几分钟甚至更久完全不可控。指数退避随机抖动RandomizationBackoff(delay 500, multiplier 2, maxDelay 10000, random true)。这个设置我强烈建议在并发量大的场景下开启。想象一下100个请求在相同时间点触发重试如果大家都遵循同一套指数退避时间它们会在几乎同一时刻继续请求形成重试风暴retry storm这比原始那波流量更可怕。开启randomtrue后Spring在delay和maxDelay范围内以指数趋势为基础做随机偏移大家的下一轮请求时间就会散开。它的本质是把同步的尖峰流量变成异步的平摊流量。还有一个概念很容易混淆delay和maxDelay不是重试间隔从0到max递增而是第一次间隔delay之后每次在上一次间隔基础上乘以multiplier直到上限maxDelay。如果你希望前几次快速失败、后面放慢也完全可以用Backoff(delay 100, maxDelay 3000, multiplier 3)下面是不同策略的对比表方便你按业务特征快速选择策略参数示例适用场景注意点固定间隔Backoff(delay1000)下游偶发抖动、调用频率很低重试节奏恒定压力波稳定线性/指数乘以倍数Backoff(delay300, multiplier2)绝大多数通用场景必须设置maxDelay防止无限膨胀指数随机Backoff(delay300, multiplier2, randomtrue)高并发、多个客户端同时重试随机区间基于delay和maxDelay无间隔Backoff(delay0)内部服务、历史上几乎瞬时恢复尽量少用容易加重下游压力从实际运维的角度讲别一上来就追求花哨策略。先用固定间隔跑通业务再根据线上监控的响应时间曲线判断要不要换指数退避。重试不是为了追求一定能成功而是要在成功率和下游压力之间找平衡点。7. 重试之外的兜底Recover如何和MQ、告警、补偿任务配合最后这块算是进阶了说说Recover方法的真正价值。很多人理解成Recover就是返回降级结果给前端。但在企业级集成场景里Recover是重试链路最后的收尸人它承担了异常出口的职责。你的调用方不需要感知你重试了多久它只需要知道最终结果是什么要不要做数据补偿。在我负责的订单同步模块里Recover的完整逻辑是这么写的Recover public SyncResult recoverSyncOrder(IOException e, SyncOrderRequest req) { // 1. 记录重试终态日志带上下文 log.error([sync-order] 同步订单最终失败reqId{}, req.getReqId(), e); // 2. 告警区分阈值告警和普通失败 alertService.sendAlertIfNecessary(req.getReqId(), e); // 3. 写入本地待补偿表由定时任务每小时扫描重推一次 compensator.save(req); // 4. 返回降级结果上层可以决定是否对用户展示失败 return SyncResult.failure(req.getReqId(), 同步超时请稍后查询); }这种设计有几个好处。第一上层调用方不需要知道内部发生过几次重试只需要根据返回值决定后续动作。第二所有终态失败都归一化到一条路径上排查问题时看日志、看补偿表、看告警记录就全链路可溯。第三将重试和恢复彻底解耦哪怕以后重试从4次改成8次Recover方法的逻辑完全不用动。还有个小技巧如果你希望Recover方法里拿到重试最终异常以外的信息比如总耗时、重试次数可以通过RetrySynchronizationManager.getContext()拿到当前线程绑定的重试上下文Recover public PaymentResult recover(IOException e, String orderId) { RetryContext context RetrySynchronizationManager.getContext(); if (context ! null) { log.info(orderId{} 累计重试{}次, orderId, context.getRetryCount()); } ... }但注意RetrySynchronizationManager.getContext()不是什么时候都有值它绑定的是当前重试请求的线程生命周期。如果你在Recover里把逻辑丢到线程池里异步执行那子线程里拿不到这个上下文必须在进入异步之前把需要的数据提取出来传过去。这个特性在打印日志、慢请求分析时很有用我每次排查重试耗时都是以这个上下文为主的。还有一个容易被日常忽略的点Retryable标注的方法如果是在事务内调用重试期间事务传播行为要谨慎。一个事务方法里调用一个Retryable方法重试失败后外层的Transactional不会因为你Recover返回了兜底结果就认为事务成功。要把重试理解成方法级别的一次尝试和事务边界是两个维度。我的经验是重试尽量放在事务外面或者让重试方法和事务方法分开层次避免事务管理器在重试过程中产生部分提交的错觉。8. 从注解重试到全局重试生态监控、压测与演进现在回到开头那个线上问题。为那套接口接好Retryable之后我们还顺手做了一套重试监控每个标注了Retryable的方法都加了RetryListener监听每次重试的开始、完成、异常统计重试次数分布和耗时。有了这些指标整个重试机制不再是黑盒——事后来看这套体系的好处你能发现哪些下游是稳定的一两次抖动就恢复哪些是持续几分钟不可用。前者重试2-3次就够后者应该走熔断降级而不是无限重试。压测也是一个值得说的话题。很多人在代码里写了重试就以为金钟罩护体了但压测时只顾着铺正常流量没考虑重试带来的放大效应。假设你的接口QPS是1000下游恢复前所有请求都失败重试3次后真实打到下游的QPS就是4000加上指数退避的密集前几次瞬时会打到接近原始流量的3-4倍。建议在压测脚本里模拟下游500错误的比例比如10%的请求返回网络异常验证一下重试策略下系统是否稳定。关于演进方向从注解重试还可以延伸到几个更强的机制一是结合CircuitBreaker熔断器做连续性保护——连续20次失败进入熔断状态直接快速失败不再重试这能有效防止下游长时间不可用时重试请求堆积成雪崩有专门的断路器框架可以做不展开讲二是重试结果结合消息队列做异步补偿把Recover里的落库待补偿变成发MQ独立补偿执行器吞吐量更高、解耦更彻底三是把重试参数配置化丢到配置中心可以通过配置动态调整不同下游的重试次数和退避策略不需要改代码发版。聊回到个人感受。最初用Spring框架里的Retryable和Recover时我也是抱着不就是个重试的语法糖嘛的心态完全没料到它背后牵扯到AOP、代理、异常归类、退避策略这些深水区。踩完article之后才发现重试是一门权衡的艺术——重试太激进下游被压垮重试太保守用户明显感知到故障不重试又挡不住偶发抖动。好的重试设计是让用户感知不到故障存在同时让下游系统始终在自己能承受的边界内运行。这是Spring这两个注解带给项目最大的价值也是任何一个做集成系统的人都值得认真思考的问题。