1. 重试机制到底解决了什么问题1.1 远程调用失败的常态与痛点做后端开发的朋友应该都遇到过这种场景调用第三方接口超时、数据库连接池暂时被占满、外部服务临时抖动返回500。这些状况在分布式系统里不是“会不会出现”的问题而是“多久出现一次”的问题。我刚工作那会儿写过一段非常蠢的代码——在catch块里套Thread.sleep()强行等几秒钟再重试结果生产环境某次下游服务长时间不可用线程被活活拖死最后整条调用链路发生连锁故障那可真是血泪教训。其实重试这事儿本身不复杂核心点就三块什么条件下重试、最多重试几次、两次重试之间隔多久。但自己手写这些逻辑非常容易踩坑。比如你只重试网络异常结果业务异常也被吞掉重试了又比如重试间隔用固定值每次抖动时一堆请求同时重试直接打爆下游。即便你把逻辑写对了代码里到处是for循环套catch那观感也够呛更别提事后要调整重试参数还得翻N处代码。Spring框架提供的Retryable和Recover注解恰好就是解决这块问题的标准方案。它们由Spring Retry模块提供核心思路是把重试逻辑从业务代码里彻底剥离开你用注解声明“这个方法需要重试”框架在运行时自动帮你做重试调度重试次数耗尽后还可以用Recover方法走降级兜底逻辑。这样业务代码里只剩干净的原始流程重试策略统一收敛在注解里改参数只需要动一个地方。1.2 Retryable 与 Recover 的关系这里得理清一个容易混淆的概念Retryable负责的是“重试”这个动作本身而Recover负责的是“重试依然失败之后”的兜底逻辑。两者配合起来才是一个完整的健壮性方案。用一句大白话概括就是Retryable负责“再试几次”Recover负责“实在不行了怎么办”。打个比方你把一个包裹交给快递公司方法调用如果快递员第一趟没送到执行异常快递公司会再派几次重试每次间隔可能有讲究退避策略。如果几次都送不到快递公司会给你一个最终答复Recover方法——比如“退回发件人”或者“抱歉我们赔款”。这个最终答复和正常签收方法正常返回值是两码事它是“失败后的补偿结果”。如果你只关注Retryable不关心Recover那当重试次数用尽后异常会直接抛给调用方体验类似于快递公司几次送不到就把责任推给发件人。如果加了Recover等于你提前约定好了“送不到时该怎么处理”整个流程更闭环。这两个注解从Spring Batch 2.2.0开始进入Spring家族后来独立成Spring Retry库再后来被Spring Boot自动装配体系集成。到现在Spring Boot 3.x、Spring Framework 6.x时代它们依然是做方法级重试的标准姿势。我觉得每个Java后端都值得把这套机制吃透它属于那种“学过之后代码质量立刻上一个台阶”的知识点。2. 环境准备与核心依赖2.1 引入 Spring Retry 依赖要用Retryable第一件事就是引入依赖。如果你用的是Spring Boot特别注意Spring Boot本身不自动带Spring Retry——别以为项目里有spring-boot-starter-web就能直接用Retryable那会直接编译报错。我见过不少朋友在这问题上卡壳把Retryable当成Spring框架自带能力了其实它不在spring-context里。Maven项目加这段dependency groupIdorg.springframework.retry/groupId artifactIdspring-retry/artifactId version2.0.7/version /dependency顺便把AOP依赖也加上因为Spring Retry的默认实现是基于Spring AOP的缺少AOP支持会运行不起来dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-aop/artifactId /dependency如果你是Gradle项目对应加这两条依赖就行。版本方面Spring Boot 2.7.x对应Spring Retry 1.3.xSpring Boot 3.x对应Spring Retry 2.0.x。如果你不想关心版本号直接用Spring Boot的BOM管理也可以但要注意Spring Boot 2.x的BOM里没有Spring Retry的版本管理这块反而得自己指定。2.2 开启重试能力EnableRetry依赖加好只是第一步还得在配置类或启动类上显式开启重试功能加上EnableRetrySpringBootApplication EnableRetry public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }这个注解干的事儿本质上是向容器里注册RetryInterceptor相关的Bean让带有Retryable的方法能真正被代理拦截。如果你忘记加这个注解方法上的Retryable会完全失效异常照常抛出重试一次都不会发生。而且Spring Boot启动时不会报错提醒你属于“静默失效”的坑。EnableRetry有几个可选配置项比如proxyTargetClass用来指定是否使用CGLIB代理。如果你的Retryable用在接口实现类上默认的JDK动态代理就够了如果用在普通类内部方法上可能得开启CGLIB代理。不过Spring Boot默认已经配置了CGLIB代理这个参数大部分场景不用动。顺带提醒EnableRetry的作用范围是当前配置类所在的包及其子包。比如启动类在com.example.demo那Retryable放在com.example.demo.service下必然生效但你要是把Bean放在启动类包之外的路径就得额外留意。2.3 Spring Boot 3.x 的特殊注意事项如果你用的是Spring Boot 3.x也就是Jakarta EE时代javax改成jakartaSpring Retry 2.0.x在功能上没什么变化但AOP这一层的依赖有讲究。Spring Boot 3.x默认使用Spring Framework 6.x而Spring Framework 6.x的AOP模块对Pointcut的匹配规则也有微调敏感一些的切点表达式需要重新验证。我自己的项目升级到Spring Boot 3.2时Retryable没有做任何改动就能正常工作但如果你在用自定义的RetryListener或MethodInvocation相关代码升级时要多留个心眼。另外Spring Boot 3.x支持虚拟线程这在Java 21里比较香如果你把Retryable用在虚拟线程执行的任务里要注意重试期间的Thread.sleep退避在虚拟线程环境下不会像传统平台线程那样占用昂贵资源这反而是个好消息。3. Retryable 注解的完整玩法3.1 基本用法注解一个方法先写个最简单的示例。假设我们有个OrderService里面有个方法要调用远程库存服务如果网络抖动或返回临时错误我们就重试Service public class OrderService { private static final Logger log LoggerFactory.getLogger(OrderService.class); Retryable(maxAttempts 3, backoff Backoff(delay 1000)) public int deductStock(Long skuId, int count) { log.info(尝试扣减库存, skuId{}, count{}, skuId, count); // 模拟远程接口偶发失败 if (System.currentTimeMillis() % 3 0) { throw new RuntimeException(库存服务暂时不可用); } return count; } }先别急着研究参数看整体结构Retryable挂在方法上方法内部就是纯业务代码没有任何重试循环。重试3次、每次间隔1秒——这些全部由注解声明。调用方感知不到重试过程如果3次都失败异常会继续抛出如果没有Recover。这里有个很重要的潜意识转变加了Retryable之后方法被调用的次数可能不止一次。所以方法内部必须设计成可重复执行的不能有不可逆的副作用。比如扣库存这种操作如果接口本身不支持幂等你重试3次就等于扣了3次库存那灾难了。关于幂等这个话题我在后面经验篇里详细展开。3.2 核心参数详解maxAttempts与backoffRetryable注解里的参数我逐个说清楚使用要点这些是面试常考也是日常最容易配错的点。maxAttempts最大重试次数包含首次执行。默认值是3。也就是说配成3实际执行模式是“首次 2次重试”。这个细节坑过不少人——经理问你“重试几次”你说“配置的3次”实际是“总共执行3次”区别要讲清楚。在我个人经验里对内网服务重试2~3次足够对外部未知服务通常也不要超过5次。重试次数太多会拖垮响应时间还会放大部分业务影响。backoff退避策略。最简单的用法是配delay单位毫秒表示每次重试前固定等待多久。刚才示例里Backoff(delay 1000)表示首次失败后等待1秒再重试第二次失败后又等1秒。如果要让重试间隔“越来越长”配multiplier。比如Retryable(maxAttempts 4, backoff Backoff(delay 500, multiplier 2))这表示第一次重试前等0.5秒第二次等1秒第三次等2秒每次乘2。这种指数级退避非常适合下游服务压力大的场景能有效避免“雪崩效应”——想象一下某个接口挂了你的服务还在1秒内连续打3次下游直接被打死。include / exclude指定哪些异常类型需要重试或排除哪些异常不重试。这个非常关键因为默认情况下Retryable只对Exception及其子类中的非运行时异常重试不对准确说是Throwable的子类都会匹配其实这有个常见的坑默认会重试所有异常。要精确控制异常类型必须显式配置includeRetryable( maxAttempts 3, include {TimeoutException.class, RemoteAccessException.class}, exclude {IllegalArgumentException.class}, backoff Backoff(delay 800) ) public void callRemoteService() { // 业务逻辑 }用include限定重试范围用exclude把某些异常排除出去。原则是只对“临时性、可恢复”的异常重试对“永久性、业务性”的异常直接放弃。例如参数不合法IllegalArgumentException、业务校验失败自定义BizException就绝对不能重试——重试一万次结果都一样白白浪费时间。3.3 方法返回值与重试的关系Retryable有个容易被忽略的点它只在方法抛出异常时才触发重试。如果方法正常返回哪怕返回的结果是个错误标志位比如返回false、返回null框架也认为“方法成功执行了”不会重试。所以设计上要注意想让重试框架生效你必须在失败时抛出异常而不是返回错误码。很多老代码习惯用返回值比如Result对象里放successfalse标识失败这种风格和Retryable天然不匹配。有两种改法一是失败时直接抛异常二是用RetryTemplate配合RetryCallback做更精细的“结果判断”。如果你只是想让重试逻辑简单统一我建议把“结果型失败”统一转成异常。3.4 自定义重试监听器与统计能力框架自带了一个很实用的扩展点RetryListener。通过监听器你可以在每次重试时记录日志、上报指标、甚至动态修改退避策略。我之前在一个支付回调项目里就用监听器把每次重试的时间点、异常信息都打到了日志和监控系统里排查线上问题时省了大量力气。实现很简单——写一个类实现RetryListener接口Spring Retry 2.0里是RetryListener里面三个方法open、onError、close然后注册为Spring BeanComponent public class CustomRetryListener implements RetryListener { Override public T, E extends Throwable boolean open(RetryContext context, RetryCallbackT, E callback) { // 重试开始前执行返回false则不执行本次重试 return true; } Override public T, E extends Throwable void onError(RetryContext context, RetryCallbackT, E callback, Throwable throwable) { System.out.println(第 context.getRetryCount() 次重试失败异常 throwable.getMessage()); } Override public T, E extends Throwable void close(RetryContext context, RetryCallbackT, E callback, Throwable throwable) { // 所有重试结束无论成功或失败都会执行 } }Spring Boot下只要这个类在扫描包内Spring Retry的拦截器会自动收集它。更妙的是open方法返回false可以“取消本次重试”等于可以动态控制是否继续重试。比如你做了个开关上半句判断当前时间在维护窗口内直接关掉重试这非常灵活。3.5 编程式重试RetryTemplate注解方案适合绝大多数场景但有些场景注解不够灵活——比如重试参数要根据运行时上下文动态变化或者重试过程里要嵌套另一个重试模板。这时可以用RetryTemplate这是Spring Retry提供的编程式APIConfiguration public class RetryConfig { Bean public RetryTemplate retryTemplate() { RetryTemplate template new RetryTemplate(); // 设置重试策略最多3次 SimpleRetryPolicy retryPolicy new SimpleRetryPolicy(3); template.setRetryPolicy(retryPolicy); // 设置退避策略:固定间隔500ms FixedBackOffPolicy backOffPolicy new FixedBackOffPolicy(); backOffPolicy.setBackOffPeriod(500); template.setBackOffPolicy(backOffPolicy); return template; } }使用时Service public class PayService { private final RetryTemplate retryTemplate; public PayService(RetryTemplate retryTemplate) { this.retryTemplate retryTemplate; } public String notifyPayResult() { return retryTemplate.execute( arg - { // 重试的业务逻辑 return doNotify(); }, arg - { // 重试耗尽后的兜底逻辑 return FAILED; } ); } }execute方法第一个参数是RetryCallback业务逻辑第二个参数是RecoveryCallback失败兜底。我个人觉得注解方案已经足够覆盖90%以上场景但RetryTemplate在“动态退避参数”和“非Spring管理的类里做重试”时非常有价值两者都值得掌握。4. Recover 注解与降级兜底逻辑4.1 Recover 怎么和 Retryable 配合Recover是重试次数耗尽后的“最后防线”。它必须和Retryable在同一个类中使用或者遵循代理的可见性规则后面细说方法签名上有一系列硬性约束我先把正确示例摆出来Service public class StockService { Retryable(maxAttempts 3, backoff Backoff(delay 500)) public String queryStock(String skuId) { // 模拟远程调用 if (skuId null) { throw new IllegalArgumentException(skuId不能为空); } throw new RemoteAccessException(库存服务暂时不可用); } Recover public String recover(RemoteAccessException e, String skuId) { // 兜底逻辑返回兜底库存数据或者一个降级结果 return default-stock:0; } }调用queryStock(123)时框架先尝试3次每次都抛RemoteAccessException3次之后自动进入recover(RemoteAccessException, String)方法。注意看这个方法的参数顺序第一个参数是异常类型后面紧跟被Retryable方法的每一个入参。如果不遵守这个签名规则Spring会启动报错或者运行时找不到兜底方法具体报错信息比较抽象我遇过几次都是靠查源码才明白的。再强调一点Recover方法的第一个参数限定了它“捞”哪种异常。上例中RemoteAccessException进这个方法如果抛的是IllegalArgumentException就不会走这个recover而是直接抛出给调用方。4.2 多种异常对应多个 Recover 方法实际项目里一个方法可能抛出多种异常我们要分别处理。比如调用库存服务可能抛超时异常和业务异常超时我们可以给个兜底库存业务异常则必须上抛告警。那就可以写多个RecoverRetryable( maxAttempts 3, include {TimeoutException.class, BusinessException.class} ) public StockInfo queryStockInfo(String skuId) { // 业务逻辑 } Recover public StockInfo recoverTimeout(TimeoutException e, String skuId) { log.warn(查询库存超时兜底返回0skuId{}, skuId); return StockInfo.zero(); } Recover public StockInfo recoverBusiness(BusinessException e, String skuId) { log.error(库存业务异常直接抛出skuId{}, skuId); throw e; // 业务异常不建议吞掉继续上抛 }Spring会根据实际抛出的异常类型匹配参数最接近的Recover方法。如果某个异常没有匹配到任何Recover它就不再兜底原异常原样抛出。就我的经验来说业务异常最好别放在Recover里吞掉因为业务异常代表的是“结果不对”重试和降级都不能让结果变对乱吞只会在后续流程里引发更大的麻烦。网络类、系统类异常才适合兜底。4.3 在 Recover 里做告警与补偿Recover方法不只是一个“返回值”的地方更是一个天然的告警与补偿点。我通常会在里面做三件事。第一记录失败上下文。把重试数、最终异常、方法参数都记录到日志或监控系统。比如Recover public OrderResult recover(RemoteAccessException e, Long orderId) { log.error(订单{}处理最终失败累计重试3次, orderId, e); metricCollector.incrementRetryExhausted(order.process); return OrderResult.failed(orderId, 下游服务暂时不可用); }第二发送告警通知。如果重试也失败说明这个异常不是偶发运维人员应该第一时间介入。在Recover里发个钉钉/邮件告警比让上游调用方慢慢发现可靠多了。第三做数据补偿。比如把失败请求写入本地消息表等下游恢复后通过定时任务重放。这种“本地消息表最终一致”的模式在分布式系统里很常见而Recover正好是做这件事的入口。5. 退避策略的原理与参数计算5.1 固定间隔退避FixedBackOffPolicy固定间隔退避是最简单的策略每次重试前固定等待同样时长。适合的场景是下游偶发抖动1秒内基本能恢复。参数就是delay毫秒。举例Retryable(backoff Backoff(delay 1000))这个配置隐含默认maxAttempts3。执行时间轴大概是这样t0ms第1次执行抛异常t1000ms第2次执行抛异常t2000ms第3次执行成功返回如果3次全失败t2000ms时抛异常或进入Recover。也就是说最差情况下方法阻塞了约2秒。这个阻塞时长得算进你的接口响应时间预算不然上游超时配置太紧你自己的重试还没跑完就被调用方掐断了。5.2 指数退避multiplier 的作用指数退避的核心思想每次失败后等待时间是上一次的固定倍数既给了下游恢复的时间又不至于在故障期疯狂打请求。配置方式Retryable( maxAttempts 5, backoff Backoff(delay 500, multiplier 2, maxDelay 5000) )实际等待时间序列500ms、1000ms、2000ms、4000ms。然后用maxDelay限制最大等待间隔到5秒如果multiplier算出来的等待时间超过maxDelay就以maxDelay为准避免等待过长。为什么要设maxDelay想想看如果重试次数配到8次delay500multiplier2最后一次等待就是500 * 2^7 64000ms整整64秒——你的调用方早就超时了。加上maxDelay之后把等待时间封顶在合理范围比如5秒整个重试过程的总时长才可控。5.3 随机退避避免惊群效应固定间隔和纯指数存在一个隐患多个调用方都在同一时间失败下一次重试也都在同一时间点发起形成了“重试波峰”。我见过一个真实案例下游Redis集群短暂抖动上游30个线程同步失败每个线程重试间隔都是固定1秒于是1秒后30个请求同时打到下游直接把下游打挂了。这就是重试的惊群效应。解决思路很简单——在等待时间上引入随机性Retryable( maxAttempts 4, backoff Backoff(delay 500, multiplier 1.5, random true) )random true会在原本的退避时间上增加一个随机偏移范围在0到当前等待时间之间分散请求流量。实测下来这个开关非常适合“异步任务批量重试”和“定时任务”场景。5.4 无间隔重试什么时候不用退避某些场景里退避反而不好比如本地缓存预热、内存操作、快速幂等校验失败原因极可能是瞬时锁竞争0间隔重试能快速解决。那可以Retryable(maxAttempts 3, backoff Backoff(delay 0))或者干脆不配置backoff默认delay0。但我的态度是对外部任何I/O操作无论缓存还是数据库都建议至少配一个极短的退避间隔比如100ms既不影响体验又给系统一个喘息窗口。6. 真实场景实战远程调用重试降级6.1 业务场景设定我拿一个实际做过的“订单状态同步”场景来完整演示。假设有个电商订单系统需要把订单状态同步到仓储系统。仓储系统偶尔会网络抖动或返回“正在处理稍后重试”。这套流程放在一个Service里大概这样Service public class OrderSyncService { private static final Logger log LoggerFactory.getLogger(OrderSyncService.class); Retryable( maxAttempts 3, include {TimeoutException.class, RemoteAccessException.class}, exclude {OrderSyncException.class}, backoff Backoff(delay 500, multiplier 2, maxDelay 2000) ) public void syncOrderStatus(String orderId, String status) { // 调用仓储系统接口 WarehouseClient client WarehouseClientFactory.getClient(); try { client.syncStatus(orderId, status); } catch (WarehouseTimeoutException e) { // 时间超过2秒算超时这种值得重试 throw new TimeoutException(仓储同步超时, e); } catch (WarehouseBizException e) { // 仓储系统返回业务错误例如订单号不存在 throw new OrderSyncException(仓储业务拒绝, e); } } Recover public void recoverSync(TimeoutException e, String orderId, String status) { // 超时兜底记录待补偿表 log.error(订单{}状态{}同步超时写入补偿表, orderId, status); pendingCompensationService.add(orderId, status); } Recover public void recoverSync(RemoteAccessException e, String orderId, String status) { log.error(订单{}状态{}同步网络异常写入补偿表, orderId, status); pendingCompensationService.add(orderId, status); } }这个设计里超时异常和网络异常都被降级为“写入补偿表”等于把失败压力转移到了异步环节。而OrderSyncException没有被任何Recover匹配重试耗尽后直接上抛由上游或MQ消费端处理。这里还体现了一个原则重试策略要区分“可重试异常”和“不可重试异常”这是整个配置的灵魂。6.2 方法参数如何传给 Recover很多新手会问Recover方法怎么拿到被调用方法的参数答案就是——按顺序声明在异常参数后面。比如被调用方法有(String orderId, String status)两个参数Recover就是(TimeoutException e, String orderId, String status)。Spring Retry在调用Recover时会从RetryContext里取出原始参数列表注入给兜底方法。注意参数顺序和类型必须完全匹配少了不对多了也不对。而且Recover方法里的参数名无所谓Spring按类型和数量匹配不看Param注解。6.3 幂等性设计加了重试之后方法会被执行多次。如果方法里有非幂等操作重试就是事故放大器。我举几个典型的“非幂等问题”以及解决思路。问题1扣减库存接口。如果每次调用都真实减库存重试3次就减了3次。解决办法是让接口支持幂等键比如传一个全局唯一的requestId服务端保存这个ID的处理结果重复请求直接返回原结果。问题2发送短信验证码。重试会重复发送短信用户会收到一堆验证码。解决办法同样是幂等键或者把“发送中”状态先落库重试时判断状态。问题3更新订单状态。从“待支付”改成“已支付”如果第一次已经改成功但响应超时了客户端认为失败重试时发现状态已经是“已支付”应该直接返回成功而不是再次修改。总之**使用Retryable之前先问自己一句这个方法重复执行安全吗**如果不安全要么改造方法本身做到幂等要么不要对它使用重试。7. 常见问题与排查技巧实录7.1 为什么 Retryable 不生效这是社区里被问烂的问题排查思路按优先级排列确认依赖已引入。就说spring-retry和spring-boot-starter-aop是否在pom.xml或build.gradle里。确认EnableRetry已开启。没有它所有注解形同虚设。确认方法是不是被同类内部调用。同一个类里的方法A调用方法BRetryable在B上A的调用不会触发重试。这是Spring AOP代理的经典局限。不信你写个Service内部this.selfCall()试试重试完全不生效。确认方法是否为public。Spring AOP默认只能拦截public方法。确认异常类型是否被匹配。如果你include里只写了TimeoutException实际抛的是RemoteAccessException那重试也不会触发。针对“内部自调用”这个坑我的解法有三种把B方法拆到独立的Service里由Spring代理去调用或者注入SelfAutowired这种自引用代理或者在同一个类里注入ApplicationContext拿代理对象。最干净的是第一种独立Service职责清晰也方便单元测试。7.2 事务与重试的顺序问题如果方法同时被Transactional和Retryable标注重试和事务的执行顺序可能会让你头大。Spring默认的事务拦截器优先级高于重试拦截器所以执行顺序大致是先开启事务再进入重试拦截器每次重试执行方法体时都在同一个事务里。如果第一次重试时事务写入了数据还未提交第二次重试又写了一次最后提交时可能产生重复数据。我的经验是重试和事务尽量不要叠加在同一个方法上。要么把重试放在事务方法的外层比如通过Controller或Service外层再封一层重试方法要么重试的方法内部只做“读取调用返回”将真正的状态变更操作放到重试成功之后单独执行。拿6.1的例子来说syncOrderStatus最好不标Transactional因为每次失败回滚后再重试才是干净状态。7.3 Recover 方法匹配不到的尴尬有时候你明明写了Recover但重试耗尽后异常照样抛出去说明兜底方法没被识别。常见原因异常类型不匹配Retryable里include的是TimeoutExceptionRecover第一参数写的却是Exception。Spring Retry的匹配并不是“子类兼容”而是优先精确匹配匹配不到就放弃。参数不对Recover方法参数列表和原方法不一致。不在同一个类Recover与Retryable必须在同一个类中。方法不是publicRecover方法也得是public否则AOP无法调用。这类问题排查有个笨但有效的方法在Recover方法第一行打日志如果日志没出来就是没被调用如果出来了但结果不对再看异常类型匹配。7.4 重试导致线程池阻塞这是一个容易在压测中暴露的问题。假如你的接口QPS很高每个请求都有最多3次重试每次退避1秒那在线程池大小有限的情况下大量线程会卡在“退避等待”里新的请求排队RT飙升。曾有同事把某个同步调用接口设置了5次重试、退避2秒压测500QPS时线程池直接打满。解决方向有这么几个调低重试次数和退避时间重试是“缓解偶发故障”的手段不是“解决持续故障”的银弹。对于异步处理场景用Async配合Retryable让重试不占用Web线程。考虑熔断机制比如Resilience4j与重试配合连续失败N次后直接熔断不再重试给下游恢复时间。7.5 重试是否要配合熔断一起使用这是一个经常被忽略的点。重试和熔断是两个维度的保护重试解决“偶发瞬时故障”熔断解决“持续故障时保护上游不被打爆”。如果下游已经挂了5分钟你的重试就是在给它“雪上加霜”因为每一次重试对下游都是一次请求。我通常的建议是在Spring Cloud场景下Retryable用于方法级短时容错熔断交给Resilience4j或Sentinel这样的组件做更宏观的保护。两者可以共存但要规划好优先级。重试放内层先重试几次熔断放外层连续失败达到阈值就短路直接进入降级不再触发重试。这样既保证偶发故障的恢复能力又防止持续故障打垮系统。8. 演进与扩展从注解到更完整的可靠性方案8.1 重试与异步任务、消息队列的结合在实际项目里最容易和重试“纠缠”在一块儿的是异步任务和MQ消费。比如一个MQ消费者从队列里取消息然后调用外部接口。接口偶发失败时你会面临两个选择让消息重试重新入队还是让方法重试Retryable。如果两者都用可能出现一种尴尬局面MQ那边也重试方法这边也重试次数相乘消息被重复消费非常多次。我建议明确分工MQ的重试负责“消息级别”的最终成功Retryable负责“单次投递内”的瞬时故障恢复。比如设置MQ重投次数为3Retryable的重试次数为2那总尝试次数最多为6次3次投递每次投递内2次重试需要在设计文档里写清楚。8.2 与Spring Cloud OpenFeign融合微服务场景下Retryable不仅能用在Service方法上还能用在Feign客户端接口上但对Feign接口加Retryable要特别小心。因为Feign本身在配合Ribbon或LoadBalancer时已经有自己的重试机制旧版Ribbon的MaxAutoRetries等参数。两层层叠会导致重试次数膨胀。我现在的习惯是Feign层面关闭重试或交给LoadBalancer统一控制业务层通过Retryable控制重试这样才能确保重试策略全局可见、可控。相比之下Spring Cloud LoadBalancer Spring Retry的配置方式比较麻烦我见过有团队用RetryTemplate包装Feign调用效果也不错。8.3 使用场景的边界与“反模式”任何方案都有使用边界Retryable也不例外。我总结几个典型的“反模式”写代码时可以对照自查对写入型接口无脑重试只要不满足幂等这种重试就是在制造脏数据。重试次数过多超过5次的重试通常意味着你的下游稳定性有大问题治标不治本。不区分异常类型把业务异常、参数异常纳入重试范围浪费资源且掩盖真正的错误。重试不配监控没有日志、没有指标出了故障都不知道重试发生过几次。全局无统一配置每个方法的maxAttempts、backoff各写各的后期维护成本极高。建议在团队内规定默认值比如重试3次、间隔500ms起步、指数退避封顶2秒特殊场景单独覆盖。8.4 结合Spring AOP原理做更深的定制Retryable的底层是Spring AOP理解这一点对排错很有帮助。框架用AnnotationAwareRetryOperationsInterceptor作为切点增强拦截到方法调用后把整个调用包装进RetryTemplate的execute中。这个过程中RetryContext是每次重试之间的“状态载体”它保存了重试次数、最后一次异常等信息。如果你要做自定义扩展可以在RetryContext里放业务属性也可以用RetrySynchronizationManager在重试过程中传递上下文。不过日常开发大部分时候用不到这么底层的东西。我的建议是先把注解和参数用熟再在遇到“用注解解决不了的问题”时去研究源码。Spring Retry的源码量不算大核心类就RetryTemplate、RetryListener、RetryPolicy、BackOffPolicy几个花两个晚上就能大致摸透。到那时候你对重试机制的理解会从“会用注解”进化到“能设计重试策略”。9. 写在最后的实战体会我个人的体会是Retryable和Recover属于那种“学习成本极低、收益极高”的框架特性。你不一定要把Spring全家桶都搞透但这两个注解值得放在“必备清单”里。它们能让代码里的重试逻辑从“到处散落”变成“集中声明”让故障恢复能力成为方法设计的一部分也让降级方案有了一个天然的挂载点。最后再分享一个小技巧如果团队里对重试的配置一致性有要求可以自定义一个“组合注解”——把Retryable的默认参数比如重试3次、退避500ms乘2封顶2秒封装成一个自定义注解比如叫ServiceRetryable。这样团队里所有人使用统一的默认配置个别场景需要调整时再在原属性上覆盖。我当时在团队里推了这个做法之后重试配置的混乱程度下降了一个量级查日志也舒服多了。重试是软件容错的第一道防线但它不是万能的。真正可靠的系统需要的是一套组合拳超时控制、重试、熔断、限流、降级、补偿以及贯穿始终的可观测性。Retryable和Recover能帮你打好其中“重试降级”这一段剩下的拳法还得在实际项目里慢慢练。
