主题
远程调用与负载均衡
一句话: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(FeignOptions默认;Spring Cloud 未改这两个值)。Retryer.NEVER_RETRY:Spring Cloud OpenFeign 默认不重试。- 底层
Client默认是HttpURLConnection,没有连接池;生产应引入feign-hc5或 OkHttp。
超时怎么定:
connectTimeout1–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 的 maxConnections | TIME_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: 1500java
// 线程隔离:库存调用走独立信号量/线程池,慢的时候只拒绝它自己
@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 调用期间数据库锁一直持有,慢下游会把本地事务拖成长事务。