Skip to content

OpenFeign内部原理与生产治理 ​

OpenFeign让远程HTTP调用看起来像调用Java接口,但它没有消除网络、序列化、连接池、超时和重复执行问题。真正掌握Feign,必须同时看清两条链:应用启动时怎样生成代理,以及一次业务调用怎样从代理走到真实IP:Port并返回。

本章以可复核的源码版本为样本:

版本线Java与BootSpring CloudOpenFeign集成Feign Core用途
存量线JDK 8、Boot 2.7.182021.0.83.1.811.10维护大量现存系统
现代线Java 17、Boot 3.2.42023.0.14.1.113.2.1本章源码对象与Demo基线

后续Boot 3.x和Cloud发布列车可能升级Feign版本、HTTP客户端与配置属性。实际项目必须查看依赖树,不能把本文样本版本假装成所有3.x版本。

一、学习目标 ​

学完后应能独立回答:

  1. @FeignClient为什么只有接口也能注入和执行。
  2. FeignClientsRegistrar、FeignClientFactoryBean、FeignClientFactory分别做什么。
  3. name、contextId、url和configuration怎样改变创建结果。
  4. 为什么注解不是每次请求都重新反射解析。
  5. Contract、MethodMetadata、RequestTemplate、Encoder和Decoder的边界。
  6. Feign怎样进入Spring Cloud LoadBalancer,又怎样绕过它。
  7. 真正建立TCP连接的是谁,连接池满与读取超时有什么区别。
  8. Feign Core重试、LoadBalancer重试和业务重试为什么是三件事。
  9. Fallback为什么不能把支付、扣库存失败伪装成成功。
  10. 怎样从异常、日志、指标和线程栈判断失败发生在哪一层。

二、先建立完整心智模型 ​

mermaid
flowchart TD
    A["启动时扫描Feign接口"] --> B["注册FactoryBean并创建代理"]
    B --> C["调用时代理定位MethodHandler"]
    C --> D["参数填入RequestTemplate"]
    D --> E["选择实例并重写真实地址"]
    E --> F["HTTP Client从连接池发送请求"]
    F --> G["ResponseHandler解码或抛出异常"]

不要把这条链缩成“Feign通过动态代理发HTTP”。这句话只说了入口,没有解释代理怎样创建、服务名怎样换成实例、哪一层重试、响应体由谁关闭以及为什么会卡在线程池或连接池。

三、源码对象职责地图 ​

对象所属层核心职责
FeignClientsRegistrarSpring Cloud OpenFeign扫描@FeignClient接口并注册BeanDefinition
FeignClientFactoryBeanSpring Cloud OpenFeign按客户端配置组装Builder并生产代理对象
FeignClientFactorySpring Cloud OpenFeign基于contextId维护命名子容器和客户端专属组件
TargeterSpring Cloud OpenFeign决定创建普通Feign代理还是带CircuitBreaker的代理
ReflectiveFeignFeign Core为接口方法建立MethodHandler映射并创建JDK代理
SpringMvcContractSpring Cloud OpenFeign按Spring MVC注解生成方法元数据
SynchronousMethodHandlerFeign Core单次同步方法调用、模板填充、重试循环和Client执行
FeignBlockingLoadBalancerClientOpenFeign与LoadBalancer桥接从URL主机名提取服务名、选实例并重建URL
ClientFeign Core扩展点调用真正的HTTP实现,例如HC5、OkHttp或其他客户端
ResponseHandlerFeign Core日志、ResponseInterceptor链和响应解码入口
InvocationContextFeign Core判断成功响应、404策略、Decoder或ErrorDecoder路径

这些名字不是面试背诵清单。排查堆栈时看到不同对象,代表请求处于不同阶段。

四、启动入口:@EnableFeignClients做了什么 ​

@EnableFeignClients导入FeignClientsRegistrar。Registrar实现ImportBeanDefinitionRegistrar,在普通Bean实例化之前参与定义注册。

mermaid
flowchart TD
    A["解析EnableFeignClients"] --> B["FeignClientsRegistrar执行"]
    B --> C["确定扫描包或显式clients"]
    C --> D["查找带FeignClient的接口"]
    D --> E["校验候选必须是接口"]
    E --> F["注册客户端配置和代理Bean定义"]

扫描有两种方式:

  1. 没有显式指定clients时,按基础包扫描@FeignClient。
  2. 指定clients时,直接注册给定接口,减少扫描范围,也更利于AOT分析。

如果接口不在扫描范围,Spring不会凭注解自动发现它。此时常见错误是注入点报NoSuchBeanDefinitionException,而不是第一次调用才失败。

4.1 属性立即解析和延迟解析 ​

在OpenFeign 4.1.1样本中,默认立即解析@FeignClient属性;配置:

yaml
spring:
  cloud:
    openfeign:
      lazy-attributes-resolution: true

才切换为延迟属性解析。立即解析更早暴露占位符错误,并有利于AOT;延迟解析适合某些测试或属性稍后注入的场景,但也会把错误推迟到Bean真正创建时。

不能把“Feign Client懒加载”和“每次请求才解析注解”混为一谈。即使Bean延迟创建,代理创建完成后,方法元数据仍会被缓存为MethodHandler映射。

五、为什么注册的是FeignClientFactoryBean ​

接口本身不能直接实例化,因此Registrar注册一个FactoryBean。Spring注入接口时,拿到的是FactoryBean的getObject()结果,而不是FactoryBean本身。

mermaid
flowchart TD
    A["Feign接口BeanDefinition"] --> B["FeignClientFactoryBean"]
    B --> C["getObject调用getTarget"]
    C --> D["组装Feign.Builder"]
    D --> E["Targeter创建接口代理"]
    E --> F["代理作为接口Bean注入业务类"]

FactoryBean保存的关键属性包括:

属性影响
type要代理的接口类型
name默认服务标识,也是未配置URL时的逻辑主机名
contextId客户端命名子容器和专属配置键
url存在时按固定地址调用,通常绕过服务发现选址
path统一追加到请求路径前
fallback固定降级实现类型
fallbackFactory能获取原始失败原因的降级工厂
dismiss404是否把特定404交给正常解码路径

六、FeignClientFactory为什么要为客户端建命名子容器 ​

不同下游需要不同超时、日志、认证、编解码和错误处理。FeignClientFactory继承NamedContextFactory,按contextId提供客户端专属上下文。

例如两个接口都访问inventory-service,但一个面向内部库存,另一个面向审计查询:

java
@FeignClient(name = "inventory-service", contextId = "inventoryWriteClient")
interface InventoryWriteClient {}

@FeignClient(name = "inventory-service", contextId = "inventoryAuditClient")
interface InventoryAuditClient {}

它们可以共享服务名,却使用不同的客户端配置。若contextId重复且配置冲突,可能发生Bean定义冲突或错误复用。

mermaid
flowchart TD
    A["父ApplicationContext"] --> B["FeignClientFactory"]
    B --> C["按contextId创建客户端子上下文"]
    C --> D["inventoryWriteClient使用写接口配置"]
    D --> E["inventoryAuditClient使用查询接口配置"]
    E --> F["两个Client隔离超时、日志和编解码组件"]

图中子上下文是逻辑隔离。它可以继承父容器组件,也可通过FeignClientConfigurer控制是否继承。把某个客户端专属配置类放进主应用扫描范围,可能让它意外变成全局默认配置,这是常见污染来源。

七、Builder组装了哪些组件 ​

FeignClientFactoryBean.feign()至少取得:

  • Feign.Builder
  • FeignLoggerFactory生成的Logger
  • Encoder
  • Decoder
  • Contract

随后再应用:

  • Retryer
  • ErrorDecoder
  • Request.Options
  • 有序RequestInterceptor
  • ResponseInterceptor
  • QueryMapEncoder
  • Capability
  • 默认请求头和查询参数
  • 用户FeignBuilderCustomizer

OpenFeign 4.1.1样本默认的Spring适配包括:

组件默认思路
ContractSpringMvcContract解析Spring MVC注解
EncoderSpringEncoder复用Spring消息转换器
DecoderOptionalDecoder -> ResponseEntityDecoder -> SpringDecoder
RetryerRetryer.NEVER_RETRY
Builder普通Feign Builder或CircuitBreaker Builder
Observability满足条件时加入Micrometer Capability

配置类Bean和配置文件属性存在优先顺序,受default-to-properties等配置影响。生产不要同时在多个位置定义同一客户端超时并靠“猜最终谁覆盖谁”运行,应通过启动日志、断点或Actuator配置来源确认最终值。

八、配置url和不配置url有什么本质区别 ​

源码中的getTarget()存在两条路径。

8.1 没有固定URL ​

Feign把服务名构造成逻辑URL,例如:

text
http://inventory-service

然后把LoadBalancer Client装入Builder。运行时逻辑主机名会被替换成选中的实例地址。

8.2 配置了固定URL ​

如果注解或配置提供:

java
@FeignClient(name = "inventory-service", url = "https://inventory.example.com")

FactoryBean会使用固定Target;若当前Client外面包着FeignBlockingLoadBalancerClient,源码会取出其delegate,避免再次按服务名选实例。

现象应先检查什么
Nacos有实例但请求始终打固定域名是否配置了url
本地测试正常,生产没有负载均衡环境变量是否覆盖了客户端URL
配了服务名却报缺少LoadBalancer Client是否缺少spring-cloud-starter-loadbalancer

九、JDK动态代理怎样创建 ​

Feign Core的ReflectiveFeign.newInstance()会:

  1. 让Contract解析接口方法。
  2. 为每个方法创建MethodHandler。
  3. 建立Map<Method, MethodHandler>。
  4. 通过Proxy.newProxyInstance创建只实现目标接口的JDK代理。
  5. 绑定Java接口默认方法处理器。
mermaid
flowchart TD
    A["Contract解析接口方法"] --> B["生成MethodMetadata"]
    B --> C["为方法创建MethodHandler"]
    C --> D["建立Method到Handler映射"]
    D --> E["JDK Proxy创建接口代理"]
    E --> F["业务类注入代理引用"]

因此Feign天然适合接口代理,不需要CGLIB去继承一个实现类。equals、hashCode和toString由InvocationHandler特殊处理;普通接口方法才进入dispatch映射。

十、Contract和RequestTemplate不是一回事 ​

阶段对象工作
代理创建Contract解析@GetMapping、@RequestParam、@PathVariable等注解
代理创建MethodMetadata保存HTTP方法、路径、参数索引、返回类型等结构信息
调用开始RequestTemplate.Factory根据方法元数据和实参创建本次模板
调用过程RequestTemplate保存URL、查询参数、Header和Body
发送前RequestInterceptor修改本次模板,例如添加Token、Trace Header
java
@PostMapping("/api/inventories/{sku}/reservations")
ReservationResult reserve(
        @PathVariable("sku") String sku,
        @RequestHeader("Idempotency-Key") String idempotencyKey,
        @RequestBody ReserveCommand command);

这段接口在代理创建时确定路径模板和三个参数的位置;运行时才把具体sku、幂等键和命令对象写入本次请求。

错误注解可能在启动时就失败,也可能在第一次创建Client Bean时失败。例如@PathVariable名称与模板不一致,不应该等线上流量触发后再发现。

十一、一次同步方法调用的源码主链 ​

Feign Core 13.2.1样本中的SynchronousMethodHandler.invoke()主链是:

mermaid
flowchart TD
    A["代理InvocationHandler收到方法调用"] --> B["从dispatch取得MethodHandler"]
    B --> C["实参生成RequestTemplate"]
    C --> D["克隆本次调用Retryer"]
    D --> E["应用RequestInterceptor和Target"]
    E --> F["Client.execute发送请求"]
    F --> G["ResponseHandler处理响应"]

每次调用克隆Retryer,是为了避免多个并发请求共享重试计数和退避状态。只有执行抛出RetryableException时才会进入Feign Core重试循环;普通业务异常不会自动重试。

十二、Encoder怎样构造请求体 ​

Encoder主要处理请求体,不负责负载均衡和网络发送。SpringEncoder通常复用HttpMessageConverters:

mermaid
flowchart TD
    A["Java方法参数"] --> B["Contract确定参数位置"]
    B --> C["普通参数进入路径或查询串"]
    C --> D["RequestBody交给Encoder"]
    D --> E["消息转换器选择Content-Type"]
    E --> F["生成字节请求体"]

常见失败:

现象原因
415 Unsupported Media Typeconsumes与提供方支持类型不一致
日期格式变化Jackson配置或DTO字段类型不一致
文件上传失败multipart Encoder、参数注解或边界不正确
GET请求出现复杂Body接口设计和Contract处理不匹配,中间代理可能不支持
中文或大对象异常Charset、消息转换器或请求大小限制

不要把数据库Entity直接作为跨服务协议。Entity字段、懒加载代理和内部状态会随持久化实现变化,应使用稳定DTO和显式Schema。

十三、RequestInterceptor的执行边界 ​

SynchronousMethodHandler.targetRequest()先按列表顺序执行RequestInterceptor,再由Target把模板变为Request。

适合放入Interceptor的内容:

  • Trace和Correlation ID。
  • 经过白名单筛选的租户标识。
  • OAuth2访问令牌。
  • 统一User-Agent或客户端版本。
  • 受控的接口签名字段。

不适合:

  • 在Interceptor里做慢数据库查询。
  • 不加判断地透传所有入站Header。
  • 把用户原始Authorization转发给任意下游。
  • 每次请求创建新的HTTP Client或线程池。
  • 吞掉签名、令牌获取失败并继续匿名请求。

Interceptor是请求模板层扩展点,不保证线程模型永远绑定原始Servlet线程。异步执行时ThreadLocal和MDC需要显式上下文传播。

十四、Feign怎样进入LoadBalancer ​

没有固定URL时,OpenFeign使用FeignBlockingLoadBalancerClient包装真正的HTTP Client。

mermaid
flowchart TD
    A["Feign Request URL含服务名"] --> B["提取host作为serviceId"]
    B --> C["构造LoadBalancer请求上下文"]
    C --> D["LoadBalancer选择ServiceInstance"]
    D --> E["用实例host和port重建URL"]
    E --> F["请求Transformer追加实例信息"]
    F --> G["delegate HTTP Client发送"]

例如:

text
原始逻辑URL:http://inventory-service/api/stocks/SKU-1
选中实例:10.10.2.17:8080
重建URL:http://10.10.2.17:8080/api/stocks/SKU-1

Nacos或Eureka不转发这个HTTP请求。注册中心只影响候选实例数据;最终选址由调用方进程中的LoadBalancer完成。

当没有实例时,4.1.1样本的FeignBlockingLoadBalancerClient会构造503响应,之后Feign响应处理链通常经ErrorDecoder转成异常。看到503时仍要分辨它来自真实下游,还是客户端本地“无实例”合成的响应。

LoadBalancer内部细节见LoadBalancer内部原理与生产治理。

十五、真正发送请求的是HTTP Client ​

Feign负责声明式代理和协议编排,TCP连接由底层Client实现。现代OpenFeign会根据依赖和配置装配HC5、OkHttp、JDK HTTP/2 Client或自定义Client;必须以当前依赖树和条件报告为准。

mermaid
flowchart TD
    A["Feign完成Request"] --> B["HTTP Client申请目标连接"]
    B --> C["复用空闲连接或建立新连接"]
    C --> D["DNS、TCP和TLS阶段"]
    D --> E["发送Header和Body"]
    E --> F["等待响应并读取Body"]
    F --> G["释放或归还连接"]

15.1 连接池不是线程池 ​

资源控制什么耗尽表现
Web工作线程同时处理多少上游请求请求排队、线程数持续满
HTTP总连接数客户端所有目标的连接上限多个下游互相争抢连接
每路由连接数单个目标主机可并发连接某服务调用卡在租借连接
数据库连接池同时执行多少数据库会话下游即使有线程也拿不到连接

连接池过小会出现“Feign调用慢但下游根本没收到请求”。连接池过大又可能压垮下游、耗尽文件描述符或制造大量TLS握手。

15.2 至少区分四类等待 ​

超时等待对象常见证据
连接池租借超时等待空闲连接线程栈停在连接池lease/acquire
Connect Timeout建立TCP连接ConnectTimeoutException或连接超时
TLS/握手阶段建立安全会话SSL握手异常、证书或协议问题
Read/Response Timeout已发送后等待或读取响应读取超时,但下游可能已执行成功

Feign的Request.Options主要表达连接和读取等选项;连接池租借、写入、整体调用Deadline等能力取决于底层HTTP Client和外层治理组件。不要看到readTimeout=2s就认为整个调用严格最多2秒。

十六、响应怎样走到Java返回值 ​

Feign Core 13.2.1样本中:

mermaid
flowchart TD
    A["HTTP Client返回Response"] --> B["记录日志并按需重新缓冲"]
    B --> C["执行ResponseInterceptor链"]
    C --> D["InvocationContext判断状态与返回类型"]
    D --> E["成功响应交给Decoder"]
    E --> F["失败响应交给ErrorDecoder"]
    F --> G["关闭响应体或交由调用方管理"]

准确边界:

  • 2xx通常进入Decoder。
  • 开启dismiss404且返回类型允许时,404可进入正常解码。
  • 返回void时可能直接关闭Body。
  • 返回原始Response时,调用方可能承担Body生命周期,不能忘记关闭。
  • 非成功状态通常进入ErrorDecoder。
  • 解码完成后默认关闭响应体,避免连接无法归还池中。

Logger.Level.FULL可能读取并重新缓冲请求或响应体。大响应会增加内存和日志IO,敏感字段还可能泄漏;生产默认不应全量记录支付报文、Token、身份证或医疗数据。

十七、HTTP错误、业务错误和解码错误要分开 ​

类型示例应怎样处理
网络错误连接拒绝、读取超时判断是否已送达、是否允许重试
HTTP错误400、404、409、500、503ErrorDecoder映射为有语义异常
业务失败HTTP 200但code != 0统一响应Decoder或业务网关校验
解码错误JSON字段或类型不匹配保存脱敏响应样本和契约版本

如果下游统一返回:

json
{"code":"OUT_OF_STOCK","message":"库存不足","data":null}

却始终使用HTTP 200,Feign的ErrorDecoder不会自动执行。团队必须明确“业务错误用HTTP状态还是响应码表达”,并建立统一契约,不能两套机制混用后让调用方猜测。

十八、三层重试必须分别计算 ​

18.1 Feign Core重试 ​

SynchronousMethodHandler捕获RetryableException,调用本次克隆的Retryer决定继续还是抛出。但Spring Cloud OpenFeign 4.1.1默认提供:

java
Retryer.NEVER_RETRY

所以不能笼统回答“Feign默认会自动重试”。Feign Core自身的默认行为与Spring Cloud集成后的默认Bean不是同一语境。

18.2 LoadBalancer重试 ​

LoadBalancer可通过Spring Retry等机制重新选择实例。启用条件依赖classpath和配置;它由RetryableFeignBlockingLoadBalancerClient等对象执行,与Feign Core Retryer不是同一循环。

18.3 业务重试 ​

业务层可能通过任务、MQ、补偿平台或调用方循环再次请求。这一层时间跨度更长,必须依赖幂等键、状态机和审计记录。

mermaid
flowchart TD
    A["一次业务操作"] --> B["业务层尝试次数"]
    B --> C["每次可能进入熔断或Feign代理"]
    C --> D["Feign Core可能重试"]
    D --> E["每次HTTP又可能由LoadBalancer重试"]
    E --> F["计算最坏请求放大倍数"]

若业务重试2次表示最多3次尝试,Feign最多2次,LoadBalancer最多2次,最坏不是3+2+2,而可能接近3×2×2=12次下游请求。生产必须只保留一个明确的近端重试层,并设置总预算。

十九、读取超时为什么是“不确定结果” ​

时间线可能是:

mermaid
flowchart TD
    A["调用方发送扣库存请求"] --> B["下游事务成功提交"]
    B --> C["响应返回途中网络抖动"]
    C --> D["调用方读取超时"]
    D --> E["调用方无法仅凭超时判断是否成功"]
    E --> F["按幂等键查询状态或执行补偿"]

此时直接重试没有幂等键,可能重复扣库存;直接返回失败,又可能让订单和库存状态分裂。正确方案是:

  1. 调用前生成稳定幂等键。
  2. 下游把幂等键与业务结果原子保存。
  3. 超时后先查询状态。
  4. 查询仍不确定时进入可审计补偿,而不是伪装成功。

二十、CircuitBreaker和Fallback接在哪里 ​

启用Spring Cloud CircuitBreaker并存在对应Factory时,FeignCircuitBreakerTargeter使用CircuitBreaker Builder创建代理调用链。它包住方法调用,并在失败或熔断时执行fallback。

能力作用不能替代什么
Timeout结束一次过慢等待不能证明下游未执行
Retry恢复短暂失败不能替代幂等和容量控制
CircuitBreaker持续失败时快速失败不能修复下游
Fallback提供受控退化结果不能把核心写失败说成成功

fallbackFactory能得到原始cause,更适合区分无实例、超时、HTTP 500、熔断拒绝和解码失败。Fallback仍必须输出可观测证据,不能吞异常只返回空对象。

安全的降级示例:商品详情拿不到推荐,可返回“无推荐”;危险示例:库存扣减失败却返回“扣减成功”。

二十一、观测一次Feign调用 ​

现代OpenFeign在条件满足时可加入Micrometer Observation Capability。建议至少采集:

  • 客户端名、方法和规范化URI模板。
  • 最终选中实例的脱敏标识或zone。
  • 连接池等待、连接、TLS、首字节和总耗时。
  • 状态码、异常类型、重试次数、熔断结果。
  • 请求与响应字节数。
  • Trace ID和业务幂等键的安全摘要。

禁止把订单号、患者ID、原始URL查询串作为Metrics Tag。高基数字段应进入脱敏日志或受控Trace属性。

mermaid
flowchart TD
    A["入口Trace上下文"] --> B["Feign Observation创建客户端Span"]
    B --> C["请求Header注入传播信息"]
    C --> D["记录选中实例和HTTP阶段"]
    D --> E["下游提取上下文并创建服务端Span"]
    E --> F["状态码、异常和耗时回填"]

二十二、安全边界 ​

  1. Header透传必须采用白名单,防止伪造租户、管理员或内部路由字段。
  2. OAuth2 Token由受信客户端管理器获取,不要把用户Token无条件转给所有服务。
  3. mTLS校验证书链和主机名,不能用disableSslValidation解决生产证书问题。
  4. Feign FULL日志只用于受控短时排查,并做字段脱敏。
  5. URL动态配置必须限制协议和目标网段,防止SSRF。
  6. ErrorDecoder不要把下游原始响应全部返回给外部用户。

二十三、Java 17与Boot 3可运行商业Demo ​

该Demo不依赖Nacos,使用SimpleDiscoveryClient配置一个静态实例,便于先验证Feign和LoadBalancer主链。生产再替换为Nacos Discovery。

23.1 Maven依赖 ​

xml
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.2.4</version>
</parent>

<properties>
    <java.version>17</java.version>
    <spring-cloud.version>2023.0.1</spring-cloud.version>
</properties>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <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>
</dependencies>

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-dependencies</artifactId>
            <version>${spring-cloud.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

23.2 库存提供者 ​

java
@SpringBootApplication
public class InventoryApplication {
    public static void main(String[] args) {
        SpringApplication.run(InventoryApplication.class, args);
    }
}
java
public record ReserveCommand(String orderNo, int quantity) {}
public record ReservationResult(String orderNo, String sku, String status) {}
java
@RestController
@RequestMapping("/api/inventories")
public class InventoryController {
    private final ConcurrentMap<String, ReservationResult> results = new ConcurrentHashMap<>();

    @PostMapping("/{sku}/reservations")
    public ReservationResult reserve(
            @PathVariable String sku,
            @RequestHeader("Idempotency-Key") String idempotencyKey,
            @RequestBody ReserveCommand command) {
        return results.computeIfAbsent(idempotencyKey,
                key -> new ReservationResult(command.orderNo(), sku, "RESERVED"));
    }
}

提供者配置:

yaml
server:
  port: 8081
spring:
  application:
    name: inventory-service

Demo用ConcurrentMap只是展示同一个幂等键返回同一结果。商业系统必须用数据库唯一约束和本地事务原子保存幂等记录与扣减结果,单机Map无法跨实例共享,也不能防进程重启丢失。

23.3 订单调用方 ​

java
@SpringBootApplication
@EnableFeignClients
public class OrderApplication {
    public static void main(String[] args) {
        SpringApplication.run(OrderApplication.class, args);
    }
}
java
@FeignClient(
        name = "inventory-service",
        contextId = "inventoryClient",
        configuration = InventoryFeignConfiguration.class)
public interface InventoryClient {
    @PostMapping("/api/inventories/{sku}/reservations")
    ReservationResult reserve(
            @PathVariable("sku") String sku,
            @RequestHeader("Idempotency-Key") String idempotencyKey,
            @RequestBody ReserveCommand command);
}
java
public class InventoryFeignConfiguration {
    @Bean
    Retryer inventoryRetryer() {
        return Retryer.NEVER_RETRY;
    }

    @Bean
    Logger.Level inventoryLoggerLevel() {
        return Logger.Level.BASIC;
    }

    @Bean
    ErrorDecoder inventoryErrorDecoder() {
        ErrorDecoder delegate = new ErrorDecoder.Default();
        return (methodKey, response) -> {
            if (response.status() == 409) {
                return new IllegalStateException("库存状态冲突,method=" + methodKey);
            }
            return delegate.decode(methodKey, response);
        };
    }
}
java
@Service
public class InventoryGateway {
    private final InventoryClient inventoryClient;

    public InventoryGateway(InventoryClient inventoryClient) {
        this.inventoryClient = inventoryClient;
    }

    public ReservationResult reserve(String orderNo, String sku, int quantity) {
        String idempotencyKey = "reserve:" + orderNo + ":" + sku;
        return inventoryClient.reserve(
                sku,
                idempotencyKey,
                new ReserveCommand(orderNo, quantity));
    }
}

调用方配置:

yaml
server:
  port: 8080
spring:
  application:
    name: order-service
  cloud:
    discovery:
      client:
        simple:
          instances:
            inventory-service:
              - uri: http://127.0.0.1:8081
    openfeign:
      client:
        config:
          inventoryClient:
            connectTimeout: 800
            readTimeout: 1500
            loggerLevel: basic
    loadbalancer:
      retry:
        enabled: false

运行顺序:

  1. 启动库存服务8081。
  2. 启动订单服务8080。
  3. 调用订单侧测试接口或在测试中调用InventoryGateway.reserve()。
  4. 连续使用同一订单号和SKU,验证幂等结果一致。
  5. 停掉库存服务,分别观察连接拒绝与读取超时证据。

二十四、JDK 8与Boot 2.7存量Demo边界 ​

存量项目使用以下兼容矩阵:

xml
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.7.18</version>
</parent>
<properties>
    <java.version>8</java.version>
    <spring-cloud.version>2021.0.8</spring-cloud.version>
</properties>

Cloud 2021.0.8样本解析到OpenFeign 3.1.8、Feign Core 11.10和LoadBalancer 3.1.7。DTO必须使用普通Java类:

java
public class ReserveCommand {
    private String orderNo;
    private int quantity;

    public ReserveCommand() {}

    public ReserveCommand(String orderNo, int quantity) {
        this.orderNo = orderNo;
        this.quantity = quantity;
    }

    public String getOrderNo() { return orderNo; }
    public void setOrderNo(String orderNo) { this.orderNo = orderNo; }
    public int getQuantity() { return quantity; }
    public void setQuantity(int quantity) { this.quantity = quantity; }
}

不能把Java 17的Record、文本块、模式匹配或List.of复制进JDK 8分支。Boot 2与Boot 3服务可以通过稳定JSON契约互相调用,版本迁移不要求所有服务同一晚升级。

二十五、商业订单场景怎样设计 ​

订单创建需要调用价格、库存、优惠和风控服务。错误做法是串行调用四次且每层各重试三次。推荐设计:

调用失败语义建议
查询商品价格可短暂重试,不能使用过期价格静默下单短超时、有限重试、版本校验
预占库存超时结果不确定幂等键、状态查询、补偿释放
查询优惠可按业务允许降级为无优惠,但必须提示只读降级、记录原因
风控决策通常不可默认通过失败关闭或转人工审核
mermaid
flowchart TD
    A["订单请求生成全局截止时间"] --> B["价格与可并行查询并发执行"]
    B --> C["预占库存携带幂等键"]
    C --> D["保存订单与调用结果状态"]
    D --> E["超时则查询下游幂等结果"]
    E --> F["仍不确定则进入补偿和告警"]

二十六、失败窗口表 ​

阶段失败窗口调用方知道什么恢复方式
代理创建前接口未扫描、配置冲突应用通常启动失败修扫描和客户端配置
模板构造注解、参数或编码失败请求尚未发送修契约并补契约测试
选址候选为空、过滤为空请求通常未到下游查注册、缓存、过滤和配置
连接建立IP不可达、拒绝、TLS失败通常未执行,但需结合网络证据摘除坏实例、修网络证书
发送中连接中断可能部分送达写请求依赖幂等和状态查询
下游执行下游慢、事务锁等待上游可能仍在等待查下游Trace、线程和数据库
响应返回下游成功但调用方超时结果不确定按幂等键查状态
解码JSON不兼容下游可能已成功保存响应样本、修契约
Fallback熔断或异常触发进入退化路径不伪装核心写成功,保留原因

二十七、Feign生产排查Runbook ​

第一步:先保存一次失败的完整证据 ​

至少保存:

  • 发生时间、Trace ID、调用方实例、Feign客户端名和方法。
  • 原始异常第一处Caused by,不要只复制最外层包装异常。
  • HTTP状态、最终目标实例、请求次数和总耗时。
  • 当时的超时、重试、熔断和连接池配置版本。
  • 下游是否收到同一Trace或幂等键。

第二步:按阶段分类 ​

mermaid
flowchart TD
    A["Feign调用失败"] --> B["确认代理是否成功创建"]
    B --> C["确认模板和编码是否成功"]
    C --> D["确认是否选到实例"]
    D --> E["确认是否取得连接并发出请求"]
    E --> F["确认下游是否执行"]
    F --> G["确认响应状态与解码结果"]

具体证据:

现象第一检查点
注入Feign接口失败扫描范围、BeanDefinition、contextId冲突
No instances available或本地503服务名、命名空间、候选列表、过滤链
ConnectException选中IP、端口、Pod Endpoint、防火墙
Read Timeout下游Trace、线程池、数据库和是否已提交
调用耗时远大于Read Timeout多层重试、连接池等待、熔断队列
反序列化异常状态码、Content-Type、脱敏响应体、DTO版本
Fallback不触发CircuitBreaker是否启用、Targeter和Factory是否存在
流量始终打固定地址url属性或环境变量覆盖

第三步:线程栈判断卡点 ​

  • 停在连接池lease/acquire:不是下游业务慢,而是客户端连接不足或泄漏。
  • 停在socket read:请求已发出,继续查下游和网络。
  • 停在DNS或connect:查解析、网络、端口和实例地址。
  • 大量线程在Fallback逻辑:降级本身可能阻塞或访问同一故障依赖。

第四步:恢复动作必须对应根因 ​

根因合理恢复危险动作
单个坏实例摘流并查实例给所有请求增加重试
连接池过小按容量验证后调整并释放泄漏无限扩大连接数
下游整体过载限流、熔断、降级和扩容缩短超时同时高频重试
契约不兼容回滚提供方或兼容Decoder吞掉解码异常返回空对象
配置URL错误回滚配置并验证目标重启所有服务但不修配置

二十八、常见反模式 ​

  1. Feign接口放在公共Jar后扫描整个公司根包,引入不需要的客户端和配置。
  2. 每个服务各自定义不同错误响应,调用方ErrorDecoder无法统一。
  3. 所有请求使用FULL日志,既泄密又放大IO与内存。
  4. 写接口遇到超时立即无幂等重试。
  5. Feign、LoadBalancer、Gateway和业务任务同时重试。
  6. Fallback返回空列表或成功对象,隐藏真实故障。
  7. 只设置Read Timeout,不监控连接池租借等待。
  8. 直接把Entity作为Feign请求和响应模型。
  9. 配置固定url后仍以为请求经过注册中心负载均衡。
  10. 只看调用方异常,不核对下游是否已经执行成功。

二十九、源码阅读路线 ​

以OpenFeign 4.1.1和Feign Core 13.2.1为例:

问题源码入口
接口怎样扫描FeignClientsRegistrar.registerFeignClients()
Bean怎样注册FeignClientsRegistrar.registerFeignClient()
Builder怎样组装FeignClientFactoryBean.feign()与configureFeign()
固定URL与负载均衡怎样分流FeignClientFactoryBean.getTarget()
代理怎样创建ReflectiveFeign.newInstance()
方法怎样派发FeignInvocationHandler.invoke()
单次同步调用SynchronousMethodHandler.invoke()
Interceptor何时执行SynchronousMethodHandler.targetRequest()
响应怎样分类ResponseHandler.handleResponse()与InvocationContext.proceed()
Feign怎样选实例FeignBlockingLoadBalancerClient.execute()
LoadBalancer重试怎样接入RetryableFeignBlockingLoadBalancerClient.execute()
Spring默认RetryerFeignClientsConfiguration.feignRetryer()

阅读源码时必须先确认版本。类名相同也可能在后续版本改变实现,面试回答应说明“以某版本主链为例”,避免把实现细节说成永恒规范。

三十、关联知识点 ​

本章小结 ​

Feign代理只负责把接口方法编排成远程请求。启动时由Registrar和FactoryBean建立代理与客户端组件,运行时由MethodHandler构造模板、LoadBalancer选择实例、HTTP Client管理连接、ResponseHandler解码结果。超时、重试、熔断和Fallback各自解决不同问题;只有把代理、选址、连接、下游执行和响应解码逐层取证,才能真正定位线上远程调用故障。