Skip to content

远程调用

Feign 是声明式 HTTP 客户端。它的目标是让远程 HTTP 调用像调用本地接口一样自然,开发者只需要定义接口和注解,Feign 负责生成代理对象、拼接请求、发送 HTTP、解析响应。

阅读入口:从一个接口方法追到网络包

按“接口契约 → 动态代理 → RequestTemplate → 服务发现与负载均衡 → HTTP Client → Decoder/异常”阅读。基础用法只解决如何声明接口,生产问题集中在内部原理与生产治理

核心原理:接口代理到 HTTP 响应的转换链

启动时 FeignClientsRegistrar 注册客户端工厂,调用时代理根据 MethodMetadata 创建 RequestTemplate,再交给负载均衡客户端选择实例和底层 HTTP Client 执行;响应最后经过 Decoder 或 ErrorDecoder 转成对象或异常。

mermaid
flowchart TD
    A["调用StockClient方法"] --> B["JDK动态代理/MethodHandler"]
    B --> C["Contract解析注解"]
    C --> D["Encoder构造RequestTemplate"]
    D --> E["Interceptor注入Trace/Token"]
    E --> F["Discovery + LoadBalancer"]
    F --> G["HTTP Client发送"]
    G --> H["Decoder或ErrorDecoder"]

本页负责零基础概念、常用配置和基础商业用法。需要继续追踪FeignClientsRegistrarFeignClientFactoryBean、命名子容器、ReflectiveFeignSynchronousMethodHandler、LoadBalancer桥接、连接池、三层重试和生产Runbook,请继续阅读:

为什么需要 Feign

不用 Feign 时,服务间调用通常要手写 URL、参数、请求头、序列化和反序列化逻辑。代码容易重复,也不利于统一处理异常、日志和超时。

使用 Feign 后,调用方只关注“我要调用哪个服务的哪个接口”。

mermaid
flowchart TD
    A[业务代码调用接口方法] --> B[Feign 代理对象]
    B --> C[根据注解构造 HTTP 请求]
    C --> D[通过服务名找到实例]
    D --> E[发送请求]
    E --> F[解析响应为 Java 对象]

基本使用思路

java
@FeignClient(name = "stock-service")
public interface StockClient {
    @GetMapping("/stock/{skuId}")
    StockDTO getStock(@PathVariable("skuId") Long skuId);
}

业务代码里直接注入 StockClient,调用 getStock 方法即可。

Feign 做了什么

能力说明
接口代理根据接口创建动态代理
请求构造根据 Spring MVC 注解生成 HTTP 请求
服务发现通过服务名找到服务实例
负载均衡配合 Ribbon 或 LoadBalancer 选择实例
编解码请求对象序列化,响应内容反序列化
异常处理可配置 ErrorDecoder、Fallback

Feign、Nacos、LoadBalancer和HTTP Client怎么分工

用一句话说清:

Feign负责把Java接口调用变成HTTP请求;Nacos负责告诉调用方某个服务有哪些实例;LoadBalancer负责从这些实例里选一个;真正把请求发出去的是底层HTTP Client。

例如:

java
@FeignClient(name = "stock-service")
public interface StockClient {
    @GetMapping("/stock/{skuId}")
    StockDTO getStock(@PathVariable("skuId") Long skuId);
}

业务代码调用:

java
StockDTO stock = stockClient.getStock(1001L);

它并不是在JVM里直接执行库存服务的方法,而是大致经历:

text
stockClient.getStock(1001L)
    -> Feign代理生成 GET http://stock-service/stock/1001
    -> LoadBalancer按 stock-service 找候选实例
    -> Nacos Discovery提供 stock-service 的实例快照
    -> LoadBalancer选中 10.10.2.17:8080
    -> 重建URL为 http://10.10.2.17:8080/stock/1001
    -> HTTP Client真正发送请求
mermaid
flowchart TD
    A["业务代码调用Feign接口"] --> B["Feign代理生成逻辑请求"]
    B --> C["URL主机名是服务名"]
    C --> D["LoadBalancer提取serviceId"]
    D --> E["DiscoveryClient读取实例列表"]
    E --> F["Nacos Client返回本地实例快照"]
    F --> G["LoadBalancer选择实例"]
    G --> H["替换为真实IP和端口"]
    H --> I["HTTP Client发送请求"]

这里最容易混淆的是Nacos的位置。Nacos像“服务通讯录”,不是“业务请求中转站”。真实业务请求不是:

text
订单服务 -> Nacos -> 库存服务

而是:

text
订单服务 -> 库存服务实例

Nacos只在控制面提供实例数据:

text
订单服务 -> Nacos:stock-service有哪些实例?
Nacos -> 订单服务:这些IP:Port可用
订单服务 -> 某个库存服务实例:发送真实HTTP请求

生产环境中,调用方通常使用Nacos Client本地缓存的实例列表,并通过订阅推送或定时刷新感知变化,不会每次Feign调用都同步请求一次Nacos Server。否则注册中心会被业务QPS打成瓶颈。

Feign 调用的底层流程

Feign 写起来像本地接口,但执行时一定会走网络。完整链路可以这样理解:

mermaid
flowchart TD
    A["业务代码调用 StockClient.getStock"] --> B["Feign 动态代理拦截方法"]
    B --> C["Contract 解析注解<br/>GetMapping / PathVariable"]
    C --> D["Encoder 编码请求参数"]
    D --> E["RequestInterceptor 添加请求头<br/>traceId / token"]
    E --> F["LoadBalancer 根据服务名选实例"]
    F --> G["HTTP Client 发送请求<br/>OkHttp / Apache HC / JDK"]
    G --> H["按成功、HTTP错误或网络失败分类"]
    H --> I["成功由Decoder解码<br/>错误由ErrorDecoder或异常表达"]
    I --> J["熔断启用时可进入fallback<br/>否则返回结果或向上抛错"]

图中把互斥结果纵向展示以适配窄屏;一次调用只会进入成功解码、HTTP错误或网络失败中的对应路径。是否进入fallback还取决于CircuitBreaker是否真正装配,完整分支见下方角色表和深层原理页

这里有几个角色:

角色作用
Contract解析接口上的注解,知道 URL、方法、参数怎么拼
Encoder把 Java 对象编码成请求体
Decoder把响应体解析成 Java 对象
RequestInterceptor统一加请求头,比如 token、traceId、租户 ID
Client真正发 HTTP 请求
ErrorDecoder把非 2xx 响应转换成业务异常或系统异常
LoadBalancer根据服务名选择具体实例

所以 Feign 的本质不是 RPC 魔法,而是“接口代理 + HTTP 请求模板 + 编解码 + 负载均衡 + 错误处理”。

Feign 在启动阶段做了什么

Feign 不是等你第一次调用接口时才临时分析注解。Spring Boot 启动时,OpenFeign 会扫描 @FeignClient 接口,并把这些接口注册成 Spring 容器里的代理 Bean。

mermaid
flowchart TD
    A["启动类启用 @EnableFeignClients"] --> B["扫描 @FeignClient 接口"]
    B --> C["为每个接口创建 FeignClientFactoryBean"]
    C --> D["解析服务名、路径、配置类"]
    D --> E["构建 Contract、Encoder、Decoder、Client"]
    E --> F["创建 JDK 动态代理对象"]
    F --> G["代理对象放入 Spring 容器"]

所以你注入的 StockClient 并不是自己写的实现类:

java
@Service
public class OrderService {
    private final StockClient stockClient;

    public OrderService(StockClient stockClient) {
        this.stockClient = stockClient;
    }
}

这里的 stockClient 本质是代理对象。调用 stockClient.getStock(1001L) 时,代理对象会把“方法调用”转换成“HTTP 请求”。

如果启动阶段没有正确创建代理,常见问题包括:

问题表现原因
没有启用 Feign 扫描注入 StockClient 失败缺少 @EnableFeignClients 或扫描包不对
@FeignClient 名称写错调用时报无实例服务名和注册中心不一致
接口注解不规范启动失败或请求路径错误@PathVariable 没写 name,参数绑定不清楚
配置类放错位置所有 Feign 都受影响局部配置被全局扫描

为什么 Feign 默认用 JDK 动态代理

Feign Client 通常是接口:

java
@FeignClient(name = "stock-service")
public interface StockClient {
    @GetMapping("/stock/{skuId}")
    StockDTO getStock(@PathVariable("skuId") Long skuId);
}

接口没有普通实现类,JDK 动态代理正适合“为接口生成代理对象”。代理对象的核心逻辑不是执行业务,而是进入 Feign 的 InvocationHandler

text
调用接口方法
  -> 进入代理对象
  -> 根据 Method 找到 MethodHandler
  -> 根据注解和参数创建 RequestTemplate
  -> 发起 HTTP 请求
  -> 解码响应

这也是为什么 Feign 方法不要写成本地工具方法思维。它看起来是 Java 方法,实际跨进程、跨网络、跨线程池、跨数据库。

@FeignClient 每个属性到底影响什么

java
@FeignClient(
        name = "stock-service",
        contextId = "stockClient",
        path = "/api",
        configuration = StockFeignConfig.class,
        fallbackFactory = StockClientFallbackFactory.class
)
public interface StockClient {
    @GetMapping("/stock/{skuId}")
    StockDTO getStock(@PathVariable("skuId") Long skuId);
}
属性含义写错会怎样
name / value目标服务名,用于服务发现和负载均衡注册中心找不到实例,报 No instances available
contextId当前 Feign Client 在 Spring 中的唯一上下文 ID多个同名 Client 时 Bean 冲突
path所有方法公共前缀路径重复或漏前缀导致 404
configuration当前 Client 的局部配置配错会影响编解码、超时、日志、拦截器
fallback固定降级实现拿不到原始异常原因
fallbackFactory能拿到异常原因的降级工厂写错容易把真正异常吞掉

生产项目里更推荐用 fallbackFactory,因为它能拿到失败原因。你可以区分是超时、404、500、熔断还是无实例。

RequestTemplate 是怎么拼出来的

Feign 解析注解后,会先生成一个请求模板。模板不是最终请求,因为方法参数还没填进去;真正调用时,Feign 会把参数填进模板。

java
@PostMapping("/orders/{orderId}/stock")
Result<Void> lockStock(@PathVariable("orderId") Long orderId,
                       @RequestParam("skuId") Long skuId,
                       @RequestBody LockStockCommand command);

调用:

java
stockClient.lockStock(9001L, 1001L, new LockStockCommand(2));

会被转换成:

text
POST /orders/9001/stock?skuId=1001
Content-Type: application/json

{"count":2}

流程如下:

mermaid
flowchart TD
    A["接口方法和注解"] --> B["Contract 解析 HTTP Method 和 Path"]
    B --> C["MethodMetadata 保存参数位置"]
    C --> D["调用时填充 PathVariable 和 RequestParam"]
    D --> E["Encoder 序列化 RequestBody"]
    E --> F["RequestInterceptor 添加 Header"]
    F --> G["生成最终 HTTP Request"]

常见错误和后果:

错误写法后果
@PathVariable 不指定名称某些编译参数缺失时无法绑定
GET 请求传复杂对象但下游不支持参数丢失或 400
POST 忘记 @RequestBody下游收不到 JSON body
Feign 接口路径和 Controller 路径不一致404
日期、枚举格式两边不一致反序列化失败或业务含义错误

Encoder 和 Decoder 原理

Encoder 负责把 Java 对象变成 HTTP 请求体,Decoder 负责把 HTTP 响应体变回 Java 对象。

mermaid
flowchart TD
    A["Java 请求对象"] --> B["Encoder"]
    B --> C["JSON / Form / Multipart 请求体"]
    C --> D["HTTP Client 发送"]
    D --> E["HTTP 响应体"]
    E --> F["Decoder"]
    F --> G["Java 响应对象"]

例如请求对象:

java
public class LockStockCommand {
    private Integer count;

    public LockStockCommand(Integer count) {
        this.count = count;
    }

    public Integer getCount() {
        return count;
    }
}

会被编码成:

json
{
  "count": 2
}

如果下游所有接口都返回 Result<T>,Feign 方法返回值也要和协议一致:

java
Result<StockDTO> getStock(Long skuId);

否则下游真实返回:

json
{
  "code": 0,
  "data": {
    "skuId": 1001,
    "count": 20
  }
}

而你却让 Feign 直接解码成 StockDTO,就会出现字段为空、反序列化失败或业务语义错乱。统一响应包装要在 SDK 层统一处理,不能让每个业务方各自猜。

HTTP Client、连接池和线程为什么重要

Feign 自己不是 TCP 客户端,真正发请求的是底层 HTTP Client,例如 Apache HttpClient、OkHttp 或 JDK Client。

mermaid
flowchart TD
    A["Feign Request"] --> B["从连接池获取连接"]
    B --> C["有空闲连接则复用<br/>没有则建立新连接"]
    C --> D["应用连接池等待与Connect Timeout"]
    D --> E["发送请求并等待响应"]
    E --> F["应用Read Timeout"]
    F --> G["读取完成后归还或关闭连接"]

如果连接池配置不合理,会出现非常隐蔽的问题:

问题表现原因
连接池太小上游线程排队等连接并发超过连接池容量
读取超时太长线程长时间阻塞下游慢但上游不释放资源
连接未正确释放后续请求越来越慢响应体没读完或客户端配置错误
长连接遇到旧实例服务下线后仍打到旧连接连接池存量连接未关闭

生产建议:

yaml
spring:
  cloud:
    openfeign:
      httpclient:
        enabled: true
        max-connections: 200
        max-connections-per-route: 50
      client:
        config:
          default:
            connectTimeout: 1000
            readTimeout: 3000
            loggerLevel: basic

不同 Spring Cloud 版本配置项可能略有差异,项目里要以实际版本为准。原则是:不要只配 Feign 超时,也要关注 HTTP Client 连接池容量、每路由连接数、连接存活时间和指标监控。

为什么 Feign 不是本地调用

本地方法调用一般只受 CPU 和内存影响;Feign 调用会额外受这些因素影响:

  1. DNS 或注册中心实例列表。
  2. 负载均衡选择。
  3. 网络延迟。
  4. 下游线程池和连接池。
  5. HTTP 连接建立和复用。
  6. 序列化和反序列化。
  7. 超时、重试、熔断、降级。

如果把 Feign 当成本地调用,就很容易写出这种代码:

java
for (Long recordId : recordIds) {
    RecordDetail detail = recordClient.getDetail(recordId);
    handle(detail);
}

这会造成 N 次远程调用。商业系统里应优先改成批量接口:

java
List<RecordDetail> details = recordClient.getDetails(recordIds);
for (RecordDetail detail : details) {
    handle(detail);
}

医疗采集平台里,如果一批 1000 条数据逐条 Feign 调字典服务,延迟会被放大 1000 次,还可能拖垮字典服务。应设计批量查询、本地缓存或异步预热。

超时和重试

远程调用必须设置超时。没有超时的调用会长时间占用线程,最终拖垮上游服务。

mermaid
flowchart TD
    A["Feign发起请求"] --> B["按成功或超时分类"]
    B --> C["成功返回结果<br/>超时形成调用异常"]
    C --> D["熔断启用时可fallback<br/>未启用则异常向上抛出"]

成功与超时、执行fallback与向上抛错都是互斥结果;图中纵向合并用于窄屏阅读。Fallback是否可用取决于CircuitBreaker是否装配,不能只看注解属性。

重试要谨慎开启。查询接口可以适当重试,创建订单、扣库存、支付这类写操作必须考虑幂等,否则可能造成重复写入。

连接超时和读取超时

超时含义典型问题
连接超时建立连接最多等多久下游实例不可达、网络不通
读取超时请求发出后等响应最多多久下游处理慢、数据库慢

配置示例:

yaml
spring:
  cloud:
    openfeign:
      client:
        config:
          default:
            connectTimeout: 2000
            readTimeout: 5000
          dictionary-service:
            connectTimeout: 1000
            readTimeout: 3000

具体配置前缀会随 Spring Cloud 版本有差异,落地时以项目版本文档为准,但原则不变:所有远程调用都必须有明确超时。

为什么重试可能造成雪崩

下游已经很慢时,如果上游还不断重试,会把流量放大。

mermaid
flowchart TD
    A["下游变慢"] --> B["上游请求超时"]
    B --> C["自动重试"]
    C --> D["同一业务请求变成多次下游请求"]
    D --> E["下游压力更大"]
    E --> F["更多请求继续超时并形成雪崩"]

因此重试要满足:

  1. 只对幂等接口开启。
  2. 设置最大次数。
  3. 使用退避策略。
  4. 配合熔断和限流。
  5. 写操作必须有业务唯一键或幂等表兜底。

重试放大效应怎么算

假设入口 QPS 是 1000,订单服务调用库存服务,Feign 配了最多重试 2 次,也就是一次业务请求最多打 3 次库存服务。

text
下游最大请求量 = 原始请求量 * (1 + 重试次数)
             = 1000 * (1 + 2)
             = 3000 QPS

如果订单服务还会调用优惠券服务、积分服务、用户服务,每个服务都重试,链路上的放大效应会更明显。下游越慢,上游越重试,越容易把“局部慢”放大成“系统雪崩”。

mermaid
flowchart TD
    A["入口请求增加"] --> B["订单服务调用库存"]
    B --> C["库存超时"]
    C --> D["订单服务重试"]
    D --> E["库存请求量放大"]
    E --> F["库存更慢"]
    F --> C

生产上可以按接口类型决定是否允许自动重试:

接口类型是否建议自动重试原因
查询字典、查询配置可以少量重试天然幂等,失败影响较小
查询订单状态可以少量重试查询不会改变状态
创建订单不建议盲目重试可能重复创建
扣库存只能在幂等保护下重试可能重复扣减
支付扣款极其谨慎超时后可能已经扣款成功
发送短信谨慎可能重复通知用户

Fallback 降级

Feign 可以结合 Hystrix 实现 fallback。下游不可用时,返回默认值或友好错误,避免异常继续扩散。

降级不是“假装成功”。例如库存服务不可用时,不能默认扣库存成功,而应该返回“系统繁忙”或进入补偿流程。

FallbackFactory 比 Fallback 更适合排查

简单 fallback:

java
@Component
public class StockClientFallback implements StockClient {
    @Override
    public Result<StockDTO> getStock(Long skuId) {
        return Result.fail("库存服务不可用");
    }
}

这种写法的问题是:业务知道降级了,但不知道为什么降级。是无实例、连接超时、读取超时、下游 500,还是熔断打开?

更推荐:

java
@Component
public class StockClientFallbackFactory implements FallbackFactory<StockClient> {
    @Override
    public StockClient create(Throwable cause) {
        return skuId -> {
            log.warn("调用库存服务失败, skuId={}, cause={}", skuId, cause.toString());
            return Result.fail("库存服务暂不可用,请稍后重试");
        };
    }
}

降级要遵守两个原则:

  1. 不能把失败伪装成成功。
  2. 不能吞掉关键异常信息。

例如扣库存失败时,不能返回“扣库存成功”。正确做法通常是返回失败、记录异常、触发补偿或让订单进入待确认状态。

商业场景:订单创建调用库存服务

下面是一个更接近真实项目的 Feign 设计。目标不是“能调通”,而是让调用具备超时、幂等、错误语义和排查能力。

1. 定义幂等命令

java
public class LockStockCommand {
    private String requestNo;
    private Long orderId;
    private Long skuId;
    private Integer count;

    public String getRequestNo() {
        return requestNo;
    }

    public Long getOrderId() {
        return orderId;
    }

    public Long getSkuId() {
        return skuId;
    }

    public Integer getCount() {
        return count;
    }
}

requestNo 是幂等号。即使上游因为网络抖动重试,下游也可以根据 requestNo 判断这是不是同一次业务请求。

2. 定义 Feign Client

java
@FeignClient(
        name = "stock-service",
        path = "/api/stocks",
        fallbackFactory = StockClientFallbackFactory.class
)
public interface StockClient {

    @PostMapping("/lock")
    Result<Void> lock(@RequestBody LockStockCommand command);

    @GetMapping("/lock-records/{requestNo}")
    Result<LockRecordDTO> queryLockRecord(@PathVariable("requestNo") String requestNo);
}

为什么要有 queryLockRecord?因为远程写操作超时时,调用方无法确定下游到底有没有执行成功。此时应该查询状态,而不是直接重试或直接判失败。

3. 调用方处理超时不确定性

java
public void createOrder(CreateOrderCommand command) {
    String requestNo = "LOCK-" + command.getOrderId();
    LockStockCommand lockCommand = buildLockCommand(command, requestNo);

    try {
        Result<Void> result = stockClient.lock(lockCommand);
        if (!result.isSuccess()) {
            throw new BusinessException("库存锁定失败: " + result.getMessage());
        }
    } catch (RetryableException ex) {
        Result<LockRecordDTO> record = stockClient.queryLockRecord(requestNo);
        if (record.isSuccess() && record.getData().isLocked()) {
            return;
        }
        throw new BusinessException("库存状态未知,请稍后重试");
    }
}

这段代码体现了远程写操作的核心原则:

原则为什么
写操作带幂等号防止重试造成重复扣减
超时后查状态因为超时不代表下游失败
不把降级当成功防止订单和库存状态不一致
失败要可补偿可以进入异常表或人工处理

Feign 线上排查路径

遇到“Feign 调用失败”,不要一上来就改超时。按下面路径定位:

mermaid
flowchart TD
    A["Feign 调用失败"] --> B["保存 traceId、methodKey和第一处cause"]
    B --> C["判断代理、编码、选址或网络阶段"]
    C --> D["核对最终实例和HTTP状态"]
    D --> E["核对连接池、下游Trace和资源指标"]
    E --> F["核对重试、熔断与fallback"]
    F --> G["按根因恢复并用真实请求验证"]

具体异常与检查项不是并行乱查,而是按证据分层。No instances、Connect Timeout、Read Timeout、404、500和熔断的第一检查点见紧随其后的表格;更完整的命令与恢复边界见Feign生产Runbook

排查证据建议:

证据说明
traceId串起调用方、网关、下游日志
methodKey明确哪个 Feign 方法失败
serviceId 和 instanceId确认请求打到哪台实例
connect/read timeout 配置判断是否等待过长或过短
下游 P95/P99看是不是长尾请求
下游线程池和连接池判断是否资源耗尽
HTTP Client 连接池指标判断是否等连接
熔断器状态判断是否被快速失败

统一请求头和错误处理 Demo

透传 traceId

java
import feign.RequestInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class FeignTraceConfig {

    @Bean
    public RequestInterceptor traceInterceptor() {
        return template -> {
            String traceId = TraceContext.getTraceId();
            if (traceId != null) {
                template.header("X-Trace-Id", traceId);
            }
        };
    }
}

这样跨服务排查时,Gateway、采集服务、字典服务、数据库日志能用同一个 traceId 串起来。

统一解析错误响应

java
import feign.Response;
import feign.codec.ErrorDecoder;

public class BizErrorDecoder implements ErrorDecoder {
    @Override
    public Exception decode(String methodKey, Response response) {
        if (response.status() == 404) {
            return new DownstreamNotFoundException("下游资源不存在: " + methodKey);
        }
        if (response.status() >= 500) {
            return new DownstreamUnavailableException("下游服务异常: " + methodKey);
        }
        return new RuntimeException("Feign 调用失败, status=" + response.status());
    }
}

不要让下游 HTML 错误页、Nginx 默认错误页直接冒到业务层。业务层应收到可识别的异常或统一错误码。

商业落地清单

检查项为什么
是否设置超时防止线程无限等待
是否批量接口防止循环远程调用
写接口是否幂等防止重试导致重复写
是否透传 traceId方便跨服务排查
是否统一 ErrorDecoder避免错误语义混乱
是否有熔断降级防止下游故障扩散
是否限制并发防止调用方线程池被耗尽
是否记录耗时发现慢下游和长尾请求

开发建议

  1. 每个下游服务单独定义 Client 接口,避免一个接口塞太多方法。
  2. 给所有远程调用设置连接超时和读取超时。
  3. 写操作慎用自动重试,必须保证幂等。
  4. 统一处理错误码和异常,不要让下游 HTML 错误页直接返回给业务。
  5. 调用链路中打印 traceId,方便排查跨服务问题。

Feign 通常会配合 EurekaNacos 获取服务地址,配合 Ribbon/LoadBalancer 做负载均衡,配合 HystrixSentinel 做熔断降级。想把这些组件串成一次完整请求,可以继续看 服务调用链路

面试标准回答已独立整理到OpenFeign独立面试题,完整源码推导和线上取证统一放在OpenFeign内部原理与生产治理,避免知识点与背诵答案混在一起。