SpringNotes

第 18 章:服务调用与负载均衡

zjc 于 2026-01-18 发布

这是《Spring Boot 与 Spring Cloud 零基础实战指南》的独立章节版。本章从概念、实操和生产排查三个视角展开,代码块保留了原书可直接运行的版本。 服务调用负责把“逻辑服务名”变成一次具体请求。它包括客户端发现、负载均衡、超时、重试、熔断、隔离、重试预算和可观测性。调用链路设计不当,会放大故障。

18.1 调用方式

方式 契约 特点
RestTemplate / RestClient URL / HTTP 简单直接
WebClient HTTP / Reactive 非阻塞
OpenFeign 接口注解 声明式,常见
gRPC Protobuf 高性能、强契约
Dubbo 接口 RPC 生态完整

选择依据:

  1. 是否需要强契约;
  2. 是否跨语言;
  3. 是否浏览器可访问;
  4. 性能要求;
  5. 团队技术栈;
  6. 现有治理能力。

18.2 Spring Cloud LoadBalancer

依赖:

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>

RestClient 使用服务名:

@Configuration
public class HttpClientConfig {

    @Bean
    @LoadBalanced
    RestClient.Builder restClientBuilder() {
        return RestClient.builder();
    }
}

调用:

@Service
public class InventoryClient {
    private final RestClient restClient;

    public InventoryClient(RestClient.Builder builder) {
        this.restClient = builder.baseUrl("http://inventory-service").build();
    }

    public InventoryView get(Long skuId) {
        return restClient.get()
                .uri("/api/inventories/{id}", skuId)
                .retrieve()
                .body(InventoryView.class);
    }
}

http://inventory-service 会由 LoadBalancer 解析成具体实例。

18.3 OpenFeign

依赖:

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>

启用:

@EnableFeignClients
@SpringBootApplication
public class OrderApplication {
}

客户端:

@FeignClient(name = "inventory-service", path = "/api/inventories")
public interface InventoryFeignClient {

    @GetMapping("/{id}")
    InventoryView get(@PathVariable("id") Long id);

    @PutMapping("/{id}/reserve")
    void reserve(@PathVariable("id") Long id, @RequestBody ReserveRequest request);
}

Feign 将接口代理成 HTTP 调用,并集成注册中心、负载均衡和编解码器。

18.4 负载均衡策略

常见策略:

策略 特点
Round Robin 轮询,默认常见
Random 随机
Weighted 按权重
Zone Aware 同机房优先
Least Requests 少请求优先
Consistent Hash 相同 key 到相同实例

选择:

  1. 普通无状态服务:轮询;
  2. 机器配置差异大:权重;
  3. 多机房:同机房优先;
  4. 本地缓存:一致性哈希;
  5. 请求耗时差异大:最少请求。

负载均衡不能解决所有热点,业务 key 设计仍关键。

18.5 超时

必须为每层设置超时:

spring:
  cloud:
    openfeign:
      client:
        config:
          inventory-service:
            connect-timeout: 500
            read-timeout: 2000

超时层次:

网关超时
  > 服务间调用超时
    > 数据库 / 缓存超时

原则:

  1. 连接超时通常小于读超时;
  2. 下游超时不能超过上游允许时间;
  3. 不同接口不同超时;
  4. 超时后记录目标实例;
  5. 超时配置可观测。

没有超时等于把下游故障放大成线程池耗尽。

18.6 重试

可重试条件:

  1. 请求幂等;
  2. 错误类型可恢复;
  3. 下游仍有余量;
  4. 重试次数有限;
  5. 有退避;
  6. 有重试预算。

示例:

GET /products/{id} 可重试
POST /orders 不应盲目重试,除非有幂等键
PUT /configs/{id} 全量更新可按契约重试

退避:

100ms
200ms
400ms
jitter

重试风暴:

上游 100 请求
  -> 每个重试 3 次
  -> 下游瞬间 400 请求

控制方式:

  1. 每实例重试上限;
  2. 重试预算比例;
  3. 熔断优先;
  4. 只重试连接失败或明确可重试状态;
  5. 限制并发。

18.7 线程池隔离

调用不同下游使用独立线程池:

Order Service
  |-- pay-pool
  |-- inventory-pool
  |-- user-pool

好处:

  1. 一个下游慢不会耗尽全部线程;
  2. 故障范围可控;
  3. 可单独配置队列和超时;
  4. 便于监控。

缺点:

  1. 线程数增加;
  2. 配置复杂;
  3. 需要监控每个池;
  4. 可能掩盖容量问题。

关键指标:

active
queue size
rejected
task latency
downstream latency

18.8 熔断与降级

熔断器状态:

CLOSED
  -> 失败率超过阈值
OPEN
  -> 冷却时间后
HALF_OPEN
  -> 放少量请求探测
  -> 成功恢复 CLOSED / 失败回到 OPEN

降级方式:

  1. 返回缓存;
  2. 返回默认值;
  3. 功能关闭;
  4. 异步补偿;
  5. 提示用户稍后重试。

熔断不是让错误消失,而是防止故障扩散。核心交易链路的降级必须符合业务规则。

18.9 Hedging 与单飞

Hedging:同时向多个实例发请求,取最先成功结果。

优点:降低尾延迟。

代价:

  1. 下游流量倍增;
  2. 副作用请求需幂等;
  3. 浪费资源;
  4. 排查复杂。

适合:

  1. 只读请求;
  2. P999 极敏感;
  3. 下游余量充足;
  4. 有严格预算。

singleflight:同一 key 并发请求只放一个回源,其他等待结果。适合热点缓存重建。

18.10 可观测性

每次调用记录:

traceId
caller service
target service
target instance
method / path
status
latency
timeout
retry count
error type

指标:

client_request_seconds
client_request_total
client_timeout_total
client_retry_total
client_circuit_open
pool_active
pool_queue
pool_rejected

告警:

  1. 错误率突增;
  2. P99 超阈值;
  3. 超时增加;
  4. 熔断打开;
  5. 线程池拒绝;
  6. 某实例失败率异常。

本章小结

服务调用不只是发 HTTP 请求,而是服务发现、负载均衡、超时、重试、熔断、隔离和观测的组合。生产调用方必须明确幂等性、重试预算和降级语义;每层超时要逐级收敛;指标要能定位到目标实例,否则故障排查仍靠猜。

思考题

  1. 为什么每个下游调用必须有超时?
  2. 哪些请求可以重试?
  3. 线程池隔离有什么代价?
  4. Hedging 适合什么场景?
  5. 如何定位调用到了哪个坏实例?