跳到正文

远程调用与负载均衡 ​

一句话:Feign 把「接口方法调用」翻译成「HTTP 请求」,LoadBalancer 从注册中心给的实例列表里选一个;换来的是声明式调用,代价是超时、重试、连接池这些网络问题必须显式处理。

适用版本:Spring Boot 3.2.x / Spring Cloud 2023.0.x / Spring Cloud OpenFeign 4.x / Spring Cloud LoadBalancer 4.x。Ribbon 已在 Spring Cloud 2020.0 移除,本文不再按 Ribbon 讲。

为什么需要 ​

没有声明式客户端时,调用方要手写查实例、选实例、拼 URL、序列化、发 HTTP、处理超时/重试/连接复用/Header 透传。这些代码每个调用点重复一遍,出错方式还很隐蔽:超时没设、重试放大了非幂等写、连接池太小。Feign + LoadBalancer 把前四步封装掉,把最后一步暴露成配置,让问题至少是「可配置的」。

核心机制 ​

一次 Feign 调用的完整链路 ​

一次方法调用被拆成「代理拦截 → 生成请求模板 → 选实例 → 发 HTTP → 解码」五步,每一步都可替换:

关键点:Feign 只负责「把方法变成请求」;服务发现和选实例由 LoadBalancer 完成,真正的网络 IO 由底层 Client 完成。三者可以分别替换。生产要把默认 Client 换成 feign-hc5 或 OkHttp,否则没有连接复用。

LoadBalancer 怎么拿到实例 ​

实例列表不是每次都回源注册中心,中间隔了一层默认 35s 的缓存;这也是「注册成功」不等于「立刻可见」的原因。

默认策略是轮询;缓存 TTL 是 spring.cloud.loadbalancer.cache.ttl=35s。Nacos 推送会触发刷新,但缓存本身仍有 TTL,所以「注册成功」到「所有调用方都能看到」之间有窗口。

为什么是客户端负载均衡 ​

维度客户端 LB(LoadBalancer)服务端 LB(Nginx/HAProxy)
部署嵌入调用方,无独立进程独立部署,需高可用
实例列表从注册中心实时获取静态配置或额外服务发现
跳数调用方直连提供方多一跳
适用服务间调用入口流量

生产通常两层都有:外层 Nginx 做入口,内层 LoadBalancer 做服务间调用。两者不互斥。

超时、重试、连接池:三者要一起看 ​

Feign 的默认值(Spring Cloud OpenFeign 4.x):

  • connectTimeout=10s、readTimeout=60s(Feign Options 默认;Spring Cloud 未改这两个值)。
  • Retryer.NEVER_RETRY:Spring Cloud OpenFeign 默认不重试。
  • 底层 Client 默认是 HttpURLConnection,没有连接池;生产应引入 feign-hc5 或 OkHttp。

超时怎么定:

  • connectTimeout 1–5s:局域网握手远小于此;超过 5s 基本是对方挂了或网络分区。
  • readTimeout = 下游 P99 × 2(经验值):等于 P99 会把 1% 的正常慢请求误判为超时;×2 留出余量。
  • 总超时链:Gateway 超时 > Feign readTimeout × (重试次数 + 1) + connectTimeout。不满足时,上游先超时,重试还没结束,流量被放大。

重试的边界:

  • 只对幂等操作重试(查询、按幂等键写入)。
  • 非幂等写(扣款、创建订单)关闭重试,或在下游用幂等键去重。
  • retries 是「首次之外再试几次」:Feign 的 Retryer 和 Dubbo 的 retries 都是这个语义,别把它当成总次数。

Ribbon 迁移要改什么 ​

Ribbon 在 Spring Cloud 2020.0 已移除。升级时:

  • 删除 spring-cloud-starter-netflix-ribbon 及 ribbon.* 配置。
  • 负载均衡策略改用 spring.cloud.loadbalancer.* 或自定义 ReactorServiceInstanceLoadBalancer。
  • @LoadBalanced RestTemplate 仍然可用,底层换成 LoadBalancer。
  • 旧的自定义 IRule 要改写成 ReactorServiceInstanceLoadBalancer,不能直接复用。

长事务里调 Feign:最容易忽略的坑 ​

@Transactional 方法里调 Feign,事务不会因为 HTTP 调用而提交;下游处理 500ms,数据库锁就多持有 500ms。高并发下锁等待暴涨,表现为「接口慢、数据库锁超时」,但慢的根因在远程调用。

做法:事务方法只做本地数据库操作;远程调用放到事务提交之后,或用本地消息表 + 补偿。需要「下单 + 通知」的原子性时,不要用一个大事务包住两个系统。

实战场景 ​

一、下游变慢 2 秒,上游整站线程耗尽 ​

现象:库存服务发布后 P99 从 200ms 涨到 2.1s。5 分钟内订单服务 http-nio-8080-exec-* 线程全部繁忙,连不依赖库存的健康检查接口都开始超时;库存恢复后订单没有自动恢复,重启才正常。

排查:调用方线程被谁占住,看栈和超时值就知道。

定位层取证据判定
线程卡在哪jstack 里 exec 线程栈是否停在 SocketInputStream.read / sun.net...HttpClient停在读 → 在等下游响应
实际 readTimeout日志或代码里 Feign Options;/actuator/configprops 查 spring.cloud.openfeign.client.config.*接近 60s(Feign 默认 readTimeout=60s)→ 一个慢请求占线程 60 秒
配置前缀是否还在写旧版 feign.client.config.*4.x 前缀是 spring.cloud.openfeign.client.config,写错等于没配
底层 Client依赖树里有没有 feign-hc5/OkHttp默认 HttpURLConnection 无连接池,每次新建连接
连接数ss -tn dst :8080 | wc -l 与 hc5 的 maxConnectionsTIME_WAIT 暴涨 → 无复用
恢复慢原因队列里积压请求在解阻塞后集中进入二次拥塞,需要重启才平息

一根线程等 60 秒,200 个线程只要 3.3 秒就能被全部占光。慢下游不是问题,上游没有把它隔离住才是。

对症做法:

yaml
spring:
  cloud:
    openfeign:
      httpclient:
        hc5:
          enabled: true                  # 换成 Apache HttpClient 5,拿到连接池
        max-connections: 400
        max-connections-per-route: 100
        connection-timeout: 300
      client:
        config:
          stock-service:
            connect-timeout: 300
            read-timeout: 600            # 下游 P99 200ms × 2 再加余量,不是 60s
          default:
            connect-timeout: 500
            read-timeout: 1500
java
// 线程隔离:库存调用走独立信号量/线程池,慢的时候只拒绝它自己
@SentinelResource(value = "stock.get", fallback = "stockFallback", blockHandler = "stockBlocked")
public Stock getStock(String sku) { return stockClient.get(sku); }

超时值的推导口径写在配置注释里:readTimeout = 下游 P99 × 2(经验值,需校准),且满足 Gateway 超时 > Feign readTimeout × (重试次数 + 1) + connectTimeout。等于 P99 会把 1% 的正常慢请求误判成超时,所以要留倍数。

复盘要点:默认值不适合生产这件事,要靠检查项而不是记忆。三条自动化门禁:依赖树里必须出现 feign-hc5 或 OkHttp;read-timeout 不允许是 60000;每个 @FeignClient 都要有显式 per-client 超时。再加上熔断的线程隔离,慢依赖就只能拒绝自己,不能带走整个进程。

二、开了重试之后出现重复扣款 ​

现象:周对账发现 7 笔重复扣款,金额需人工退回。客户端日志显示同一次用户操作触发了两次 POST /pay/deduct,两次间隔约 1.2 秒,traceId 相同。

排查:重试放大器一定能在链路里留下痕迹。

定位层取证据判定
是否重复发送trace 里同 traceId 的下游 span 数量与间隔>1 次 → 有重试
谁重试的Feign Retryer / Gateway retry-filter / LoadBalancer MaxAutoRetries / 客户端 SDK多层都开时会叠乘
语义是总次数还是额外次数配置项含义核对retries 是“首次之外再试几次”,不是总次数
操作是否幂等下游有没有幂等键与去重表无唯一键即可能重复生效
触发条件重试发生在 connectTimeout 还是 readTimeout读超时重试最危险:请求可能已经执行成功

对症做法:

yaml
spring:
  cloud:
    openfeign:
      client:
        config:
          pay-service:
            retryer: feign.Retryer$NeverRetry      # 写接口一律不自动重试(默认值就是安全的)
          query-service:
            retryer: com.example.BoundedBackoffRetryer   # 只读接口才允许,且带退避
java
// 幂等键由调用方生成,服务端唯一索引兜底,重试才真正安全
public class DeductReq { @NotBlank String requestId; }        // 客户端生成并复用
// 服务端
INSERT ... ON CONFLICT (request_id) DO NOTHING;               // 唯一键去重
// 命中冲突时返回首次结果,而不是报错,保证重试语义等价于“至少一次 + 幂等”

Retryer.NEVER_RETRY 是 Spring Cloud OpenFeign 的默认值。要开重试,必须同时具备幂等键、最大尝试次数和退避(含抖动),三者缺一就别开。

复盘要点:任何一层的重试都要进同一张链路表,否则 Gateway 重试 × Feign 重试会叠乘。对资金类接口,判据是“同一 requestId 在下游只生效一次”,用唯一索引和一条重复请求测试固化;把“读超时后不重试非幂等写”写进代码模板。

三、新实例一上线就被打挂:轮询 + 35s 缓存的组合 ​

现象:服务扩容 4 个实例,新 Pod 启动完成后 10 秒内 RT 飙到 3 秒,其中两个新实例连续 Full GC 后被健康检查摘除;老实例反而正常。

排查:客户端负载均衡默认轮询,不感知实例温度;缓存让流量在某个时刻集中切换。

定位层取证据判定
流量分布按实例统计 QPS(网关或 metrics 里按 instance 维度)新实例上线瞬间被分到 1/N 流量 → 无预热
缓存生效时刻spring.cloud.loadbalancer.cache.ttl(默认 35s)与 Nacos 推送日志集中刷新点即集中打流点
实例是否真热JIT 编译、连接池初始化、本地缓存加载完成时间进程起来 ≠ 能承接生产流量
依赖侧压力新实例到 DB/Redis 的连接建立曲线冷连接集中建立 → 数据库瞬时压力
健康判定就绪探针是否只检查端口端口通就 ready,等于把预热交给运气

对症做法:

yaml
# 1. 就绪探针要表达“预热完成”,而不是端口通
readinessProbe:
  httpGet: { path: /actuator/health/readiness, port: 8080 }
  initialDelaySeconds: 0
  periodSeconds: 3
management:
  endpoint: { health: { group: { readiness: { include: ping,db,redis,warmup } } } }
java
// 2. 预热在 ready 之前完成:字典加载、连接池握手、JIT 触发
@EventListener(ApplicationReadyEvent.class)
void warmup() throws Exception {
    dataSource.getConnection();                       // 建连
    httpClient.warmUpDownstream();                     // TLS 会话
    for (int i = 0; i < 2000; i++) processor.handle(sampleRequest(i));   // 触发编译
    warmupFlag.set(true);                              // readiness 这时才 UP
}

// 3. 灰度期用权重让新实例先少接流量:Nacos 元数据 + 自定义策略
spring:
  cloud:
    loadbalancer:
      strategy: weighted               // 按 metadata.weight 选实例

Nacos 权重 + 分批放量是当前最省事的做法:新实例 weight=0.2 起步,观察 RT 与 GC 后再拉平。这与 流量切换平台 里的拉入逻辑是同一条纪律。

复盘要点:判据是“实例从 ready 到承接满流量的时间”,不是“启动耗时”。给每个服务定义预热预算(例如 ready 后 60 秒内只允许 20% 流量,经验值,需校准),并在发布流水线里强制分批;扩容不等于立刻满负荷,这一点要靠权重和探针表达出来。

最小上手 ​

依赖(Spring Boot 3.2 + Cloud 2023.0):

xml
<dependency>
  <groupId>org.springframework.cloud</groupId>
  <artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.cloud</groupId>
  <artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
<dependency>
  <groupId>com.alibaba.cloud</groupId>
  <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
java
@SpringBootApplication
@EnableFeignClients
public class OrderApplication { }

@FeignClient(name = "stock-service")
public interface StockClient {
    @GetMapping("/stock/{sku}")
    Stock get(@PathVariable("sku") String sku);
}
yaml
spring:
  cloud:
    openfeign:
      client:
        config:
          default:
            connect-timeout: 2000
            read-timeout: 3000
            logger-level: basic

启动两个服务后,调用订单接口;Feign 日志出现 GET http://stock-service/stock/SKU-1 且返回 200,说明代理、发现、负载均衡、解码都通了。注意日志里的 URL 仍是服务名,真正选中的 ip:port 在 LoadBalancer 日志或追踪里。

生产动作 ​

  • 换掉默认 Client:引入 io.github.openfeign:feign-hc5,设置最大连接数、每路由连接数、连接存活时间;HttpURLConnection 没有池,扛不住高并发。
  • 超时按下游分别配:spring.cloud.openfeign.client.config.<clientName>.read-timeout,不要一个 default 打天下。
  • 重试只给幂等接口:默认 NEVER_RETRY 是安全的;要开重试就同时加幂等键和最大尝试次数。
  • Header 透传:实现 RequestInterceptor,把 traceId、灰度标识、认证信息传下去;注意不要透传 Content-Length、Host 这类由客户端计算的 Header。
  • GET 传对象:用 @SpringQueryMap,不要用 @RequestBody;后者在 GET 上行为依赖实现。
  • 文件上传:用 MultipartSupportConfig 或 feign-form,并确认编码器已注册。
  • 排障:超时不生效先查配置前缀(4.x 是 spring.cloud.openfeign.client.config,不是旧版 feign.client.config);404 查路径与 @FeignClient(path=...);连接池耗尽查 feign-hc5 的 maxConnections 与下游 P99。

边界 ​

  • 高吞吐内部 RPC:Feign 是 HTTP/JSON,序列化和连接开销高于二进制 RPC;吞吐敏感用 gRPC/Dubbo。
  • 大文件/流式:不要用 Feign 传大文件;走对象存储或流式接口。
  • 跨语言:Feign 的注解模型是 Java 侧约定;跨语言用 OpenAPI/gRPC。
  • 服务网格:如果治理能力已经下沉到 Sidecar,客户端 LB 和重试策略要避免与网格重复。
  • 非幂等写:不要为了「高可用」盲目开重试;重试是放大器,不是修复手段。

讲清楚 ​

Feign 的本质是「接口 + 注解 → HTTP 请求」:动态代理拦截方法调用,解析注解生成请求模板,LoadBalancer 从注册中心缓存里选一个实例,底层 Client 发请求,Decoder 反序列化。它把重复代码收掉了,但没有消除网络问题:超时要按下游 P99 设,重试只能给幂等接口,连接池必须换掉默认的 HttpURLConnection。Ribbon 在 2020.0 已移除,新代码用 Spring Cloud LoadBalancer,默认轮询、实例缓存 35s。最容易忽略的是在 @Transactional 里调 Feign:HTTP 调用期间数据库锁一直持有,慢下游会把本地事务拖成长事务。

把一门技术讲透到既懂也会用