SpringCloud 核心组件解析:服务调用和负载均衡

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 的工作原理是什么?

  1. @LoadBalanced 注解标记 RestTemplate Bean
  2. Spring 自动为标注了 @LoadBalanced 的 RestTemplate 添加 LoadBalancerInterceptor 拦截器
  3. 每次 RestTemplate 发请求时,拦截器拦截 http://服务名/... 格式的 URI
  4. 解析出服务名 → 调用 LoadBalancerClient.choose() → 从注册中心获取实例列表 → 负载均衡选一个
  5. 替换 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 的底层是如何实现的?

  1. @EnableFeignClients 扫描标注了 @FeignClient 的接口
  2. 通过 FeignClientFactoryBean 为每个接口创建 JDK 动态代理
  3. 代理对象拦截接口方法调用 → 读取方法上的 @RequestMapping/@GetMapping/@PostMapping 注解
  4. 构造 HTTP 请求(URL + 方法 + Header + Body)
  5. 通过 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 替换为随机/加权等策略
相关推荐
插件开发1 小时前
英伟达cuda程序通用性关键 geforce 20xx代到最新版 在20xx上编译的c++程序可以通用吗?
java·c++·人工智能
JackSparrow4141 小时前
彻底理解Java NIO(三)Java实现 I/O多路复用+Reactor模式及开源框架代码解读
java·c语言·开发语言·后端·nio·reactor模式
程序员黑豆1 小时前
AI全栈开发 - Java:数据类型
java·前端
曹牧1 小时前
Java:Xml中的大、小于
java·开发语言
zavoryn1 小时前
Jackson 序列化踩坑:LocalDateTime、Long 精度丢失和 boolean isXxx 字段
java·开发语言·后端
江华森1 小时前
Tomcat 10 实战部署指南:从零到生产级 Web 容器
java·前端·tomcat
曹牧1 小时前
Java:XML转义
xml·java·开发语言
swordbob1 小时前
【RabbitMQ】消息丢失的 6 大场景及解决方案
后端·rabbitmq
leo_yu_yty1 小时前
Go语言分布式计算(并发Debug)
开发语言·笔记·后端·golang