SpringCloud 核心组件解析:服务调用与负载均衡
技术栈 :Spring Boot 3.2.0 + Spring Cloud 2023.0.0 + OpenFeign + LoadBalancer
已不维护 :Netflix Ribbon → 替代用 Spring Cloud LoadBalancer
已不维护:Netflix Feign → 替代用 Spring Cloud OpenFeign
2.1 是什么 --- 服务调用的两种范式
2.1.1 生活化类比:点外卖
你想吃一碗牛肉面,有两种方式:
| 方式 | 做法 | 类比 |
|---|---|---|
| 自己打电话 | 拨号 → 问"老板今天开门吗?" → 报地址 → 付钱 | RestTemplate:手动拼 URL,管理连接 |
| 打开美团 | 搜索"牛肉面" → 点一下"下单" → 美团帮你叫骑手 | OpenFeign:接口+注解,框架自动发 HTTP |
美团好在哪?
- 你不用记每个店的电话(不用记 IP)
- 美团自动选最近/最快的店(负载均衡)
- 这家店关了自动换另一家(故障转移)
- 超时了自动取消退款(熔断降级)
2.1.2 技术定义
服务调用(Service Invocation)是微服务间的核心通信方式。
主流方案有两种:
- RestTemplate + @LoadBalanced:手动编排 HTTP 调用(灵活,代码多)
- OpenFeign:声明式接口代理(简洁,功能强,✅ 推荐)
2.2 为什么 --- 从裸 HTTP 到声明式调用的演进
2.2.1 问题驱动:先看看最早的写法有多糟
java
// ❌ 远古写法:Java 原生 URLConnection
public String callPayService(Integer id) throws Exception {
URL url = new URL("http://192.168.1.10:8001/pay/get/" + id);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setConnectTimeout(2000);
conn.setReadTimeout(2000);
BufferedReader reader = new BufferedReader(
new InputStreamReader(conn.getInputStream()));
StringBuilder sb = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
sb.append(line);
}
reader.close();
conn.disconnect();
return sb.toString();
}
// 问题:代码臃肿、硬编码 IP、无负载均衡、连接管理复杂
2.2.2 Ribbon 时代的解决方案(了解即可)
Netflix Ribbon 是早期 Spring Cloud 的客户侧负载均衡器。它在 RestTemplate 上套了一层拦截器:
Consumer
│
│ restTemplate.getForObject("http://cloud-payment-service/pay/get/1")
│
▼
┌──────────────────┐
│ Ribbon 拦截器 │ ← 拦截所有 RestTemplate 请求
│ ① 解析服务名 │
│ ② 从 Eureka 拉取 │
│ ③ 轮询选一个实例 │
│ ④ 替换为真实 URL │
└──────────────────┘
│
▼
Provider: http://192.168.1.10:8001/pay/get/1
java
// Ribbon 时代典型写法(已废弃)
@Bean
@LoadBalanced // ← Ribbon 提供的注解
public RestTemplate restTemplate() {
return new RestTemplate();
}
⚠️ Ribbon 于 2018 年进入维护模式 。Spring Cloud 2020.0 起用 Spring Cloud LoadBalancer 替代,API 完全兼容(
@LoadBalanced保持不变)。
2.3 RestTemplate + LoadBalancer 方式
2.3.1 工作原理
Consumer (OrderController)
│
│ restTemplate.getForObject(
│ "http://cloud-payment-service/pay/get/1", ...)
│
▼
┌──────────────────────────────────────┐
│ LoadBalancerInterceptor │
│ (由 @LoadBalanced 触发) │
│ │
│ ① intercept() 拦截请求 │
│ ② 解析 URI: "cloud-payment-service" │
│ ③ 调用 LoadBalancerClient.choose() │
│ ④ 轮询策略: 8001 → 8002 → 8001 ... │
│ ⑤ 替换 URI: │
│ "http://192.168.1.10:8001/pay/get/1"│
│ ⑥ 执行真实 HTTP 请求 │
└──────────────────────────────────────┘
2.3.2 完整代码
步骤 ①:RestTemplate Bean 配置
java
// cloud-consumer-order80/src/main/java/com/atguigu/cloud/config/RestTemplateConfig.java
@Configuration
@LoadBalancerClient(
value = "cloud-payment-service", // 对哪个服务做负载均衡
configuration = RestTemplateConfig.class // 配置类
)
public class RestTemplateConfig {
@Bean
@LoadBalanced // ← 核心:赋予 RestTemplate 负载均衡能力
public RestTemplate restTemplate() {
return new RestTemplate();
}
}
步骤 ②:自定义负载均衡策略(可选)
java
// 将默认轮询策略切换为随机策略
@Bean
ReactorLoadBalancer<ServiceInstance> randomLoadBalancer(
Environment environment,
LoadBalancerClientFactory loadBalancerClientFactory) {
String name = environment.getProperty(
LoadBalancerClientFactory.PROPERTY_NAME);
return new RandomLoadBalancer(
loadBalancerClientFactory.getLazyProvider(
name, ServiceInstanceListSupplier.class),
name
);
}
步骤 ③:Consumer 端调用
java
// cloud-consumer-order80/.../controller/OrderController.java
@RestController
public class OrderController {
// 关键:URL 中写服务名,不是 IP
static final String PAY_URL = "http://cloud-payment-service";
@Resource
private RestTemplate restTemplate;
// POST --- 使用 postForObject
@GetMapping("/consumer/pay/add")
public ResultData addOrder(PayDTO payDTO) {
return restTemplate.postForObject(
PAY_URL + "/pay/add", payDTO, ResultData.class);
}
// GET --- 使用 getForObject(带占位符参数)
@GetMapping("/consumer/pay/get/{id}")
public ResultData getPayInfo(@PathVariable("id") Integer id) {
return restTemplate.getForObject(
PAY_URL + "/pay/get/" + id, ResultData.class, id);
}
// GET 全部
@GetMapping("/consumer/pay/getAll")
public ResultData getAllPayInfo() {
return restTemplate.getForObject(
PAY_URL + "/pay/getAll", ResultData.class);
}
// DELETE --- 使用 exchange 方法(更底层,可控制 HttpMethod)
@DeleteMapping("consumer/pay/del/{id}")
public ResultData deletePayInfo(@PathVariable("id") Integer id) {
return restTemplate.exchange(
PAY_URL + "/pay/del/" + id,
HttpMethod.DELETE,
null,
ResultData.class
).getBody();
}
// 验证负载均衡:交替返回 8001 和 8002
@GetMapping(value = "/consumer/pay/get/info")
public String getPayInfo() {
return restTemplate.getForObject(
PAY_URL + "/pay/getPort", String.class);
}
}
RestTemplate 常用方法速查:
| 方法 | HTTP 方法 | 返回值 |
|---|---|---|
getForObject(url, class) |
GET | 直接返回对象 |
getForEntity(url, class) |
GET | 返回 ResponseEntity(含状态码+头) |
postForObject(url, body, class) |
POST | 直接返回对象 |
postForEntity(url, body, class) |
POST | 返回 ResponseEntity |
put(url, body) |
PUT | void |
delete(url) |
DELETE | void |
exchange(url, method, entity, class) |
任意 | 通用方法,返回 ResponseEntity |
2.4 OpenFeign --- 声明式调用(✅ 推荐)
2.4.1 是什么
OpenFeign 是 Spring Cloud 对 Netflix Feign 的重写版本,通过 接口 + 注解 声明远程服务契约,编译时生成 JDK 动态代理。
java
// 你只需写一个接口
@FeignClient(value = "cloud-payment-service")
public interface PayFeignApi {
@GetMapping("/pay/get/{id}")
ResultData getPayInfo(@PathVariable("id") Integer id);
}
// 调用时像本地方法一样
@Resource
private PayFeignApi payFeignApi; // ← Spring 自动注入代理对象
public ResultData someMethod() {
return payFeignApi.getPayInfo(1); // ← 实际发出 HTTP GET 请求
}
对比 RestTemplate:
java
// RestTemplate 方式 ------ 10 行
ResultData result = restTemplate.getForObject(
"http://cloud-payment-service/pay/get/" + id,
ResultData.class, id);
// OpenFeign 方式 ------ 1 行
ResultData result = payFeignApi.getPayInfo(id);
2.4.2 为什么用 OpenFeign
| 优势 | RestTemplate | OpenFeign |
|---|---|---|
| 代码简洁度 | ⭐⭐ | ⭐⭐⭐⭐⭐ |
| 可读性 | URL 字符串拼接 | 像本地方法调用 |
| 类型安全 | 运行时才知道返回类型 | 编译期检查参数和返回值 |
| 超时配置 | 需手动设置 HTTP 连接池 | 配置文件集中管理 |
| 日志 | 需手动拦截 | 内置 Feign Logger |
| 编码器/解码器 | 手动处理 | 自动 Spring 集成 |
| 熔断集成 | 需手动 try-catch 包装 | 一行配置启用 Resilience4J/Sentinel |
| 链路追踪 | 手动传递 Header | 自动传播 TraceId |
2.4.3 怎么做 --- 完整步骤
步骤 ①:公共模块抽取 Feign 接口
🔑 最佳实践 :将 Feign 接口定义在独立的公共模块(如
cloud-api-commons)中,Provider 和 Consumer 共同依赖,避免接口重复定义。
公共模块 pom.xml:
xml
<!-- cloud-api-commons/pom.xml -->
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
Feign 接口定义:
java
// cloud-api-commons/.../apis/PayFeignApi.java
@FeignClient(value = "cloud-gateway") // ← 指向网关(也可以直连 cloud-payment-service)
public interface PayFeignApi {
@PostMapping("/pay/add")
ResultData addPay(@RequestBody PayDTO payDTO);
@GetMapping("/pay/get/{id}")
ResultData getPayInfo(@PathVariable("id") Integer id);
@GetMapping("/pay/getPort")
String getInfo();
// 熔断测试
@GetMapping(value = "/pay/circuit/{id}")
String myCircuit(@PathVariable("id") Integer id);
// 舱壁测试
@GetMapping(value = "/pay/bulkhead/{id}")
String myBulkhead(@PathVariable("id") Integer id);
// 限流测试
@GetMapping(value = "/pay/ratelimit/{id}")
String myRatelimit(@PathVariable("id") Integer id);
// 链路追踪
@GetMapping("pay/micrometer/{id}")
String myMicrometer(@PathVariable("id") Integer id);
// 通过网关路由
@GetMapping("pay/gateway/get/{id}")
ResultData<PayDTO> getPayInfoByGateway(@PathVariable("id") Integer id);
@GetMapping("pay/gateway/get/info")
ResultData<String> getInfoByGateway();
}
统一响应体 DTO:
java
// cloud-api-commons/.../resp/ResultData.java
@Data
@Accessors(chain = true)
public class ResultData<T> {
private String code;
private String message;
private T data;
private long timestamp;
public ResultData() {
this.timestamp = System.currentTimeMillis();
}
public static <T> ResultData<T> success(T data) {
ResultData<T> resultData = new ResultData<>();
resultData.setCode(ReturnCodeEnum.RC200.getCode());
resultData.setMessage(ReturnCodeEnum.RC200.getMessage());
resultData.setData(data);
return resultData;
}
public static <T> ResultData<T> fail(String code, String message) {
ResultData<T> resultData = new ResultData<>();
resultData.setCode(code);
resultData.setMessage(message);
resultData.setData(null);
return resultData;
}
}
// cloud-api-commons/.../resp/ReturnCodeEnum.java
@Getter
public enum ReturnCodeEnum {
RC999("999", "操作失败"),
RC200("200", "success"),
RC201("201", "服务开启降级保护,请稍后再试!"),
RC202("202", "热点参数限流,请稍后再试!"),
RC500("500", "系统异常,请稍后重试"),
// ...更多状态码
INVALID_TOKEN("2001", "访问令牌不合法"),
ACCESS_DENIED("2003", "没有权限访问该资源");
// 提供 getReturnCodeEnumV2 静态查找方法
}
步骤 ②:Consumer 引入依赖
xml
<!-- Consumer 端 -->
<dependency>
<groupId>com.atguigu.cloud</groupId>
<artifactId>cloud-api-commons</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
步骤 ③:Consumer 启动类
java
// cloud-consumer-feign-order80/.../MainOpenFeigen80.java
@SpringBootApplication
@EnableDiscoveryClient
@EnableFeignClients // ← 启用 Feign 客户端扫描(自动扫描 @FeignClient 接口)
public class MainOpenFeigen80 {
public static void main(String[] args) {
SpringApplication.run(MainOpenFeigen80.class);
}
}
步骤 ④:Feign 配置(application.yml)
yaml
# cloud-consumer-feign-order80/src/main/resources/application.yml
server:
port: 80
spring:
application:
name: cloud-consumer-order-openfeign
cloud:
consul:
host: localhost
port: 8500
discovery:
service-name: ${spring.application.name}
openfeign:
client:
config:
default:
connectTimeout: 2000 # 连接超时 2 秒
readTimeout: 2000 # 读取超时 2 秒
httpclient:
enabled: true # 启用 Apache HttpClient
okhttp:
enabled: false # 不使用 OkHttp
compression: # 请求/响应 Gzip 压缩
request:
enabled: true
min-request-size: 2048 # 最小触发压缩的大小
mime-types: text/xml,application/xml,application/json
response:
enabled: true
circuitbreaker: # 整合 Resilience4J 断路器
enabled: true
# 开启 Feign 详细日志(开发调试用)
logging:
level:
com.atguigu.cloud.apis.PayFeignApi: debug
步骤 ⑤:Feign Java 配置类
java
// cloud-consumer-feign-order80/.../config/FeignConfig.java
@Configuration
public class FeignConfig {
// 重试策略
@Bean
public Retryer myRetryer() {
return Retryer.NEVER_RETRY; // 默认不重试
// return new Retryer.Default(100, 1000, 3); // 自定义:间隔100ms,最大1000ms,最多3次
}
// 日志级别
// NONE(默认无日志)/ BASIC(仅记录方法和URL)/ HEADERS(记录Basic+请求头)/ FULL(全部)
@Bean
Logger.Level myLoggerLevel() {
return Logger.Level.FULL;
}
}
步骤 ⑥:Consumer 调用
java
// cloud-consumer-feign-order80/.../controller/OrderController.java
@RestController
public class OrderController {
@Resource
private PayFeignApi payFeignApi; // 直接注入 Feign 接口
@PostMapping("/feign/pay/add")
public ResultData addPay(@RequestBody PayDTO payDTO) {
System.out.println("第一步:模拟本地addOrder新增订单成功(省略sql操作),"
+ "第二步:再开启addPay支付微服务远程调用");
return payFeignApi.addPay(payDTO); // 像调用本地方法一样
}
@GetMapping("/feign/pay/get/{id}")
public ResultData getPayInfo(@PathVariable("id") Integer id) {
System.out.println("-------支付微服务远程调用-------");
ResultData payInfo = null;
try {
System.out.println("调用开始-------" + DateUtil.now());
payInfo = payFeignApi.getPayInfo(id);
} catch (Exception e) {
e.printStackTrace();
System.out.println("调用结束-------" + DateUtil.now());
return ResultData.fail(ReturnCodeEnum.RC500.getCode(), e.getMessage());
}
return payInfo;
}
@GetMapping("/feign/pay/get/info")
public String getInfo() {
return payFeignApi.getInfo();
}
}
2.5 负载均衡深入
2.5.1 负载均衡算法对比
| 算法 | 策略 | 适用场景 | 实现类 |
|---|---|---|---|
| 轮询 | 依次分配 | 各节点性能相当 | RoundRobinLoadBalancer(默认) |
| 随机 | 随机选取 | 简单场景 | RandomLoadBalancer |
| 加权 | 权重高的处理更多 | 异构集群 | 需自行实现 |
| 最小连接数 | 选最少连接的 | 长连接场景 | 需自行实现 |
| 一致性哈希 | 相同参数→相同节点 | 有状态服务 | 需自行实现 |
| 区域感知 | 就近访问 | 多机房 | ZonePreference |
2.5.2 Spring Cloud LoadBalancer 源码要点
java
// RoundRobinLoadBalancer 核心逻辑(简化)
public class RoundRobinLoadBalancer implements ReactorLoadBalancer<ServiceInstance> {
private final AtomicInteger position = new AtomicInteger(0);
@Override
public Mono<Response<ServiceInstance>> choose(Request request) {
ServiceInstanceListSupplier supplier = ...;
return supplier.get()
.next()
.map(instances -> {
// 取模轮询
int pos = Math.abs(position.incrementAndGet());
ServiceInstance instance = instances.get(pos % instances.size());
return new DefaultResponse(instance);
});
}
}
2.6 RestTemplate vs OpenFeign 对比
| 维度 | RestTemplate + @LoadBalanced | OpenFeign |
|---|---|---|
| 代码风格 | 过程式,手动拼 URL | 声明式,接口+注解 |
| 可读性 | ⭐⭐ URL 字符串 | ⭐⭐⭐⭐⭐ 方法调用 |
| 行数 | ~10 行/次调用 | ~1 行/次调用 |
| 类型安全 | 运行时 ClassCastException | 编译期检查 |
| 超时配置 | 需配置 RestTemplate | 配置文件集中管理 |
| 日志 | 手动添加拦截器 | 内置 Logger.Level |
| 重试 | 手动实现 | Feign Retryer |
| 压缩 | 手动 Gzip | 配置即用 |
| 熔断集成 | try-catch 包裹 | 配置 circuitbreaker.enabled=true |
| 链路追踪 | 手动传递 TraceId | 自动传播 |
2.7 面试题
Q1:@LoadBalanced 的工作原理是什么?
答:
@LoadBalanced注解标记 RestTemplate Bean- Spring 自动为标注了
@LoadBalanced的 RestTemplate 添加LoadBalancerInterceptor拦截器 - 每次 RestTemplate 发请求时,拦截器拦截
http://服务名/...格式的 URI - 解析出服务名 → 调用
LoadBalancerClient.choose()→ 从注册中心获取实例列表 → 负载均衡选一个 - 替换 URI 为真实的
http://IP:Port/...→ 发起实际 HTTP 请求
Q2:Feign 和 OpenFeign 有什么区别?
答:
- Feign:Netflix 开源的声明式 HTTP 客户端,已停止维护
- OpenFeign :Spring Cloud 团队接管后重命名的社区版本,整合了 Spring MVC 注解(
@RequestMapping、@PathVariable等),支持 Spring Cloud 生态(熔断、链路追踪、配置中心) - 简单说:OpenFeign = Feign + Spring Cloud 深度集成
Q3:OpenFeign 的底层是如何实现的?
答:
@EnableFeignClients扫描标注了@FeignClient的接口- 通过
FeignClientFactoryBean为每个接口创建 JDK 动态代理 - 代理对象拦截接口方法调用 → 读取方法上的
@RequestMapping/@GetMapping/@PostMapping注解 - 构造 HTTP 请求(URL + 方法 + Header + Body)
- 通过 LoadBalancer 解析服务名 → 发送请求 → 解析响应
2.8 踩坑指南
| 坑 | 现象 | 原因 | 解决 |
|---|---|---|---|
| 🔴 Feign 接口 404 | feign.FeignException$NotFound |
Feign 扫描不到接口包 | @EnableFeignClients(basePackages = "com.atguigu.cloud.apis") |
| 🔴 超时不触发降级 | 超时后直接报错 | readTimeout 只在连接层生效,熔断需要配置 circuitbreaker.enabled=true |
加上 circuitbreaker.enabled: true |
| 🔴 POST 请求参数丢失 | 参数传了但 Provider 收到 null | Feign 默认 GET 请求忽略 Body | 确认方法上标注了 @PostMapping 且参数是 @RequestBody |
| 🔴 Feign 日志不输出 | 配置了 Logger.Level.FULL 但没日志 | 还需要配置 logging.level | yml 中加 logging.level.com.xxx.PayFeignApi: debug |
| 🔴 @PathVariable missing | 编译错误 | Feign 要求 @PathVariable 必须显式指定 value |
写法:@PathVariable("id"),不能省略括号 |
2.9 章节总结
| 要点 | 说明 |
|---|---|
| Ribbon → LoadBalancer | Ribbon 已停更,LoadBalancer 无缝替代,@LoadBalanced 语法不变 |
| Feign → OpenFeign | OpenFeign 整合 Spring MVC 注解,声明式调用首选 |
| RestTemplate 方式 | URL 用服务名 http://服务名/路径,配合 @LoadBalanced |
| OpenFeign 方式 | 定义接口 → @FeignClient → 注入即用,一行代码完成调用 |
| 公共模块 | 将 Feign 接口抽取到 cloud-api-commons,双方依赖 → 接口统一 |
| 负载均衡 | 默认轮询,可通过 @Bean 替换为随机/加权等策略 |