Note 21. 弹性外部调用:RestClient 与 Spring Retry 重试机制
发表于更新于
字数总计:2.1k阅读时长:8分钟阅读量: 广东
Note 21. 弹性外部调用:RestClient 与 Spring Retry 重试机制
摘要: 本章我们将通过 RestClient 掌握 Spring Boot 3.2+ 推荐的现代 HTTP 调用方式,取代过时的 RestTemplate。我们将深入探讨如何处理 HTTP 状态码,如何将响应体反序列化为对象。更重要的是,我们将引入 Spring Retry,为脆弱的网络调用穿上“防弹衣”,实现基于策略(指数退避)的自动重试与降级处理。
本章学习路径
- 工具革新:从
RestTemplate 迁移到流式 API 的 RestClient。 - 调用实战:配置超时时间,发起 GET/POST 请求,处理异常状态码。
- 弹性重试:引入
Spring Retry,区分“可重试”与“不可重试”异常。 - 兜底策略:使用
@Recover 实现最终失败后的降级逻辑。
21.1. 时代的接力棒:从 RestTemplate 到 RestClient
21.1.1. 为什么要换工具?
RestTemplate:经典的模板方法模式(Template Method),API 设计于 15 年前,此时已进入 维护模式(不再添加新功能)。它的代码显得比较臃肿,重载方法过多。WebClient:基于 Reactive 响应式流。虽然功能强大,但它引入了 Netty 和 Reactor 依赖。如果在传统的 Servlet(Tomcat)项目中使用,会显得“过重”且风格不搭。RestClient (Spring Boot 3.2+):最佳选择。它提供了类似 WebClient 的 流式 (Fluent) API,但底层依然基于同步的 Servlet 栈。它轻量、现代、易读。
21.1.2. 风格对比
场景:根据 ID 查询用户。
1 2 3 4 5 6 7 8 9
| RestTemplate template = new RestTemplate(); String url = "https://api.example.com/users/{id}";
try { UserDTO user = template.getForObject(url, UserDTO.class, 1); } catch (HttpClientErrorException e) { }
|
1 2 3 4 5 6 7 8 9 10
| RestClient client = RestClient.create();
UserDTO user = client.get() .uri("https://api.example.com/users/{id}", 1) .retrieve() .onStatus(HttpStatusCode::is4xxClientError, (req, resp) -> { throw new BusinessException("参数错误"); }) .body(UserDTO.class);
|
21.2. [实战] 集成 RestClient
21.2.1. 全局配置
虽然 RestClient.create() 可以直接用,但最佳实践是配置一个带 超时时间 和 Base URL 的全局 Bean。
文件路径: src/main/java/com/example/demo/config/HttpClientConfig.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| package com.example.demo.config;
import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.client.RestClient;
import java.time.Duration;
@Configuration public class HttpClientConfig {
@Bean public RestClient restClient(RestClient.Builder builder) { return builder .baseUrl("https://jsonplaceholder.typicode.com") .defaultHeader("User-Agent", "Spring-Boot-Demo/1.0") .build(); } }
|
21.2.2. 编写调用服务
我们来模拟调用一个外部 API(JSONPlaceholder)。
DTO 定义:
src/main/java/com/example/demo/dto/PostDTO.java
1 2 3 4 5 6 7
| @Data public class PostDTO { private Long id; private Long userId; private String title; private String body; }
|
Service 实现:
src/main/java/com/example/demo/service/RemoteService.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| @Service @Slf4j public class RemoteService {
@Autowired private RestClient restClient;
public PostDTO getPostById(Long id) { log.info("开始调用外部 API 查询文章: {}", id); return restClient.get() .uri("/posts/{id}", id) .retrieve() .body(PostDTO.class); } }
|
21.3. 弹性设计:Spring Retry 自动重试
网络调用是脆弱的。对方服务可能正在重启(503),或者网络抖动(Timeout)。这时候,重试 往往能解决问题。
但是,并不是所有错误都应该重试。
- 404 Not Found:重试 100 次也是 404,不应重试。
- 503 Service Unavailable:稍后重试可能成功,应该重试。
21.3.1. 引入依赖与开启
Spring Retry 是独立模块。
文件路径: pom.xml
1 2 3 4 5 6 7 8 9 10 11 12
| <dependencies> <dependency> <groupId>org.springframework.retry</groupId> <artifactId>spring-retry</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency> </dependencies>
|
开启注解:
在启动类或配置类加 @EnableRetry。
1 2 3
| @EnableRetry @SpringBootApplication public class DemoApplication { ... }
|
21.3.2. 改造 Service:精准重试
我们使用 @Retryable 注解来赋予方法重试能力。
文件路径: src/main/java/com/example/demo/service/RemoteService.java (修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51
| import org.springframework.retry.annotation.Backoff; import org.springframework.retry.annotation.Recover; import org.springframework.retry.annotation.Retryable; import org.springframework.web.client.HttpServerErrorException; import java.io.IOException;
@Service @Slf4j public class RemoteService {
@Autowired private RestClient restClient;
@Retryable( retryFor = {IOException.class, HttpServerErrorException.class}, noRetryFor = {HttpClientErrorException.class}, // 4xx 不重试 maxAttempts = 3, backoff = @Backoff(delay = 1000, multiplier = 2) ) public PostDTO getPostById(Long id) { log.info("尝试调用外部 API..."); return restClient.get() .uri("/posts/{id}", id) .retrieve() .toEntity(PostDTO.class) .getBody(); }
@Recover public PostDTO recover(Exception e, Long id) { log.error("重试多次失败,执行兜底逻辑。ID: {}, 异常: {}", id, e.getMessage()); PostDTO fallback = new PostDTO(); fallback.setId(id); fallback.setTitle("服务暂时不可用 (降级数据)"); return fallback; } }
|
21.4. 验证测试:模拟故障
为了验证重试机制,我们需要一个“不稳定”的 API。我们可以利用 httpbin.org 或者手动抛出异常来模拟。
21.4.1. 编写测试 Case
我们在测试类中 Mock 一下 RestClient 的行为(或者简单点,直接让 Service 抛异常)。
这里为了演示方便,我们暂时修改 Service 代码,手动抛出异常:
1 2 3 4 5
| public PostDTO getPostById(Long id) { log.info("调用 API..."); throw new HttpServerErrorException(HttpStatus.SERVICE_UNAVAILABLE); }
|
21.4.2. 观察日志
启动测试,调用该方法。
控制台输出:
1 2 3 4
| [INFO] 调用 API... (第1次,时间 00:00:01) [INFO] 调用 API... (第2次,时间 00:00:02 - 等待了1秒) [INFO] 调用 API... (第3次,时间 00:00:04 - 等待了2秒) [ERROR] 重试多次失败,执行兜底逻辑...
|
我们可以看到,指数退避 (Exponential Backoff) 生效了,重试次数也符合预期,最后成功走到了兜底逻辑,没有让异常直接崩坏前端页面。
21.5. 本章总结与弹性调用速查
摘要回顾
本章我们完成了外部调用的现代化升级。我们抛弃了陈旧的 RestTemplate,拥抱了语义清晰的 RestClient。更重要的是,我们通过 Spring Retry 为系统增加了韧性,学会了区分“瞬时故障”和“永久故障”,并配置了合理的重试策略和降级方案。
遇到以下 3 种外部调用场景时,请直接参考代码模版:
1. 场景一:简单的 GET 请求
需求:调用天气 API,带参数。
代码:
1 2 3 4
| WeatherDto data = restClient.get() .uri("/weather?city={city}", "Beijing") .retrieve() .body(WeatherDto.class);
|
2. 场景二:POST 提交 JSON 并处理错误
需求:提交订单,如果 400 抛业务异常,其他抛系统异常。
代码:
1 2 3 4 5 6 7 8 9
| restClient.post() .uri("/orders") .contentType(MediaType.APPLICATION_JSON) .body(orderDto) .retrieve() .onStatus(HttpStatusCode::is4xxClientError, (req, res) -> { throw new BusinessException("参数错误"); }) .toBodilessEntity();
|
3. 场景三:高可用调用 (重试)
需求:调用短信网关,网络波动时自动重试。
代码:
1 2 3 4 5
| @Retryable(retryFor = IOException.class, maxAttempts = 3) public void sendSms() { ... }
@Recover public void saveToDb(IOException e) { ... }
|
4. 核心避坑指南
@Recover 方法不执行
- 现象:重试耗尽后,直接抛出了异常,没进兜底方法。
- 原因:
@Recover 方法的 返回值类型 必须和原方法一致(或是其子类)。如果原方法返回 PostDTO,兜底方法返回 void,Spring 找不到对应的方法。 - 对策:检查返回值签名。
重试陷入死循环
- 现象:一直重试,直到 StackOverflow 或超时。
- 原因:把
BusinessException 或 400 BadRequest 加入了重试列表。这些是逻辑错误,重试一万次也是错的。 - 对策:只重试 网络异常 (IOException) 和 服务端临时异常 (503/504)。
AOP 自调用失效 (老生常谈)
- 现象:在
Service 内部 this.getPostById(),重试不生效。 - 原因:
@Retryable 也是基于 AOP 代理。 - 对策:从 Controller 调用,或注入
self。