OpenFeign内部原理与生产治理
OpenFeign让远程HTTP调用看起来像调用Java接口,但它没有消除网络、序列化、连接池、超时和重复执行问题。真正掌握Feign,必须同时看清两条链:应用启动时怎样生成代理,以及一次业务调用怎样从代理走到真实IP:Port并返回。
本章以可复核的源码版本为样本:
| 版本线 | Java与Boot | Spring Cloud | OpenFeign集成 | Feign Core | 用途 |
|---|---|---|---|---|---|
| 存量线 | JDK 8、Boot 2.7.18 | 2021.0.8 | 3.1.8 | 11.10 | 维护大量现存系统 |
| 现代线 | Java 17、Boot 3.2.4 | 2023.0.1 | 4.1.1 | 13.2.1 | 本章源码对象与Demo基线 |
后续Boot 3.x和Cloud发布列车可能升级Feign版本、HTTP客户端与配置属性。实际项目必须查看依赖树,不能把本文样本版本假装成所有3.x版本。
一、学习目标
学完后应能独立回答:
@FeignClient为什么只有接口也能注入和执行。FeignClientsRegistrar、FeignClientFactoryBean、FeignClientFactory分别做什么。name、contextId、url和configuration怎样改变创建结果。- 为什么注解不是每次请求都重新反射解析。
Contract、MethodMetadata、RequestTemplate、Encoder和Decoder的边界。- Feign怎样进入Spring Cloud LoadBalancer,又怎样绕过它。
- 真正建立TCP连接的是谁,连接池满与读取超时有什么区别。
- Feign Core重试、LoadBalancer重试和业务重试为什么是三件事。
- Fallback为什么不能把支付、扣库存失败伪装成成功。
- 怎样从异常、日志、指标和线程栈判断失败发生在哪一层。
二、先建立完整心智模型
flowchart TD
A["启动时扫描Feign接口"] --> B["注册FactoryBean并创建代理"]
B --> C["调用时代理定位MethodHandler"]
C --> D["参数填入RequestTemplate"]
D --> E["选择实例并重写真实地址"]
E --> F["HTTP Client从连接池发送请求"]
F --> G["ResponseHandler解码或抛出异常"]不要把这条链缩成“Feign通过动态代理发HTTP”。这句话只说了入口,没有解释代理怎样创建、服务名怎样换成实例、哪一层重试、响应体由谁关闭以及为什么会卡在线程池或连接池。
三、源码对象职责地图
| 对象 | 所属层 | 核心职责 |
|---|---|---|
FeignClientsRegistrar | Spring Cloud OpenFeign | 扫描@FeignClient接口并注册BeanDefinition |
FeignClientFactoryBean | Spring Cloud OpenFeign | 按客户端配置组装Builder并生产代理对象 |
FeignClientFactory | Spring Cloud OpenFeign | 基于contextId维护命名子容器和客户端专属组件 |
Targeter | Spring Cloud OpenFeign | 决定创建普通Feign代理还是带CircuitBreaker的代理 |
ReflectiveFeign | Feign Core | 为接口方法建立MethodHandler映射并创建JDK代理 |
SpringMvcContract | Spring Cloud OpenFeign | 按Spring MVC注解生成方法元数据 |
SynchronousMethodHandler | Feign Core | 单次同步方法调用、模板填充、重试循环和Client执行 |
FeignBlockingLoadBalancerClient | OpenFeign与LoadBalancer桥接 | 从URL主机名提取服务名、选实例并重建URL |
Client | Feign Core扩展点 | 调用真正的HTTP实现,例如HC5、OkHttp或其他客户端 |
ResponseHandler | Feign Core | 日志、ResponseInterceptor链和响应解码入口 |
InvocationContext | Feign Core | 判断成功响应、404策略、Decoder或ErrorDecoder路径 |
这些名字不是面试背诵清单。排查堆栈时看到不同对象,代表请求处于不同阶段。
四、启动入口:@EnableFeignClients做了什么
@EnableFeignClients导入FeignClientsRegistrar。Registrar实现ImportBeanDefinitionRegistrar,在普通Bean实例化之前参与定义注册。
flowchart TD
A["解析EnableFeignClients"] --> B["FeignClientsRegistrar执行"]
B --> C["确定扫描包或显式clients"]
C --> D["查找带FeignClient的接口"]
D --> E["校验候选必须是接口"]
E --> F["注册客户端配置和代理Bean定义"]扫描有两种方式:
- 没有显式指定
clients时,按基础包扫描@FeignClient。 - 指定
clients时,直接注册给定接口,减少扫描范围,也更利于AOT分析。
如果接口不在扫描范围,Spring不会凭注解自动发现它。此时常见错误是注入点报NoSuchBeanDefinitionException,而不是第一次调用才失败。
4.1 属性立即解析和延迟解析
在OpenFeign 4.1.1样本中,默认立即解析@FeignClient属性;配置:
spring:
cloud:
openfeign:
lazy-attributes-resolution: true才切换为延迟属性解析。立即解析更早暴露占位符错误,并有利于AOT;延迟解析适合某些测试或属性稍后注入的场景,但也会把错误推迟到Bean真正创建时。
不能把“Feign Client懒加载”和“每次请求才解析注解”混为一谈。即使Bean延迟创建,代理创建完成后,方法元数据仍会被缓存为MethodHandler映射。
五、为什么注册的是FeignClientFactoryBean
接口本身不能直接实例化,因此Registrar注册一个FactoryBean。Spring注入接口时,拿到的是FactoryBean的getObject()结果,而不是FactoryBean本身。
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,但一个面向内部库存,另一个面向审计查询:
@FeignClient(name = "inventory-service", contextId = "inventoryWriteClient")
interface InventoryWriteClient {}
@FeignClient(name = "inventory-service", contextId = "inventoryAuditClient")
interface InventoryAuditClient {}它们可以共享服务名,却使用不同的客户端配置。若contextId重复且配置冲突,可能发生Bean定义冲突或错误复用。
flowchart TD
A["父ApplicationContext"] --> B["FeignClientFactory"]
B --> C["按contextId创建客户端子上下文"]
C --> D["inventoryWriteClient使用写接口配置"]
D --> E["inventoryAuditClient使用查询接口配置"]
E --> F["两个Client隔离超时、日志和编解码组件"]图中子上下文是逻辑隔离。它可以继承父容器组件,也可通过FeignClientConfigurer控制是否继承。把某个客户端专属配置类放进主应用扫描范围,可能让它意外变成全局默认配置,这是常见污染来源。
七、Builder组装了哪些组件
FeignClientFactoryBean.feign()至少取得:
Feign.BuilderFeignLoggerFactory生成的LoggerEncoderDecoderContract
随后再应用:
RetryerErrorDecoderRequest.Options- 有序
RequestInterceptor ResponseInterceptorQueryMapEncoderCapability- 默认请求头和查询参数
- 用户
FeignBuilderCustomizer
OpenFeign 4.1.1样本默认的Spring适配包括:
| 组件 | 默认思路 |
|---|---|
| Contract | SpringMvcContract解析Spring MVC注解 |
| Encoder | SpringEncoder复用Spring消息转换器 |
| Decoder | OptionalDecoder -> ResponseEntityDecoder -> SpringDecoder |
| Retryer | Retryer.NEVER_RETRY |
| Builder | 普通Feign Builder或CircuitBreaker Builder |
| Observability | 满足条件时加入Micrometer Capability |
配置类Bean和配置文件属性存在优先顺序,受default-to-properties等配置影响。生产不要同时在多个位置定义同一客户端超时并靠“猜最终谁覆盖谁”运行,应通过启动日志、断点或Actuator配置来源确认最终值。
八、配置url和不配置url有什么本质区别
源码中的getTarget()存在两条路径。
8.1 没有固定URL
Feign把服务名构造成逻辑URL,例如:
http://inventory-service然后把LoadBalancer Client装入Builder。运行时逻辑主机名会被替换成选中的实例地址。
8.2 配置了固定URL
如果注解或配置提供:
@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()会:
- 让Contract解析接口方法。
- 为每个方法创建MethodHandler。
- 建立
Map<Method, MethodHandler>。 - 通过
Proxy.newProxyInstance创建只实现目标接口的JDK代理。 - 绑定Java接口默认方法处理器。
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 |
@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()主链是:
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:
flowchart TD
A["Java方法参数"] --> B["Contract确定参数位置"]
B --> C["普通参数进入路径或查询串"]
C --> D["RequestBody交给Encoder"]
D --> E["消息转换器选择Content-Type"]
E --> F["生成字节请求体"]常见失败:
| 现象 | 原因 |
|---|---|
| 415 Unsupported Media Type | consumes与提供方支持类型不一致 |
| 日期格式变化 | 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。
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发送"]例如:
原始逻辑URL:http://inventory-service/api/stocks/SKU-1
选中实例:10.10.2.17:8080
重建URL:http://10.10.2.17:8080/api/stocks/SKU-1Nacos或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;必须以当前依赖树和条件报告为准。
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样本中:
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、503 | ErrorDecoder映射为有语义异常 |
| 业务失败 | HTTP 200但code != 0 | 统一响应Decoder或业务网关校验 |
| 解码错误 | 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默认提供:
Retryer.NEVER_RETRY所以不能笼统回答“Feign默认会自动重试”。Feign Core自身的默认行为与Spring Cloud集成后的默认Bean不是同一语境。
18.2 LoadBalancer重试
LoadBalancer可通过Spring Retry等机制重新选择实例。启用条件依赖classpath和配置;它由RetryableFeignBlockingLoadBalancerClient等对象执行,与Feign Core Retryer不是同一循环。
18.3 业务重试
业务层可能通过任务、MQ、补偿平台或调用方循环再次请求。这一层时间跨度更长,必须依赖幂等键、状态机和审计记录。
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次下游请求。生产必须只保留一个明确的近端重试层,并设置总预算。
十九、读取超时为什么是“不确定结果”
时间线可能是:
flowchart TD
A["调用方发送扣库存请求"] --> B["下游事务成功提交"]
B --> C["响应返回途中网络抖动"]
C --> D["调用方读取超时"]
D --> E["调用方无法仅凭超时判断是否成功"]
E --> F["按幂等键查询状态或执行补偿"]此时直接重试没有幂等键,可能重复扣库存;直接返回失败,又可能让订单和库存状态分裂。正确方案是:
- 调用前生成稳定幂等键。
- 下游把幂等键与业务结果原子保存。
- 超时后先查询状态。
- 查询仍不确定时进入可审计补偿,而不是伪装成功。
二十、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属性。
flowchart TD
A["入口Trace上下文"] --> B["Feign Observation创建客户端Span"]
B --> C["请求Header注入传播信息"]
C --> D["记录选中实例和HTTP阶段"]
D --> E["下游提取上下文并创建服务端Span"]
E --> F["状态码、异常和耗时回填"]二十二、安全边界
- Header透传必须采用白名单,防止伪造租户、管理员或内部路由字段。
- OAuth2 Token由受信客户端管理器获取,不要把用户Token无条件转给所有服务。
- mTLS校验证书链和主机名,不能用
disableSslValidation解决生产证书问题。 - Feign FULL日志只用于受控短时排查,并做字段脱敏。
- URL动态配置必须限制协议和目标网段,防止SSRF。
- ErrorDecoder不要把下游原始响应全部返回给外部用户。
二十三、Java 17与Boot 3可运行商业Demo
该Demo不依赖Nacos,使用SimpleDiscoveryClient配置一个静态实例,便于先验证Feign和LoadBalancer主链。生产再替换为Nacos Discovery。
23.1 Maven依赖
<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 库存提供者
@SpringBootApplication
public class InventoryApplication {
public static void main(String[] args) {
SpringApplication.run(InventoryApplication.class, args);
}
}public record ReserveCommand(String orderNo, int quantity) {}
public record ReservationResult(String orderNo, String sku, String status) {}@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"));
}
}提供者配置:
server:
port: 8081
spring:
application:
name: inventory-serviceDemo用ConcurrentMap只是展示同一个幂等键返回同一结果。商业系统必须用数据库唯一约束和本地事务原子保存幂等记录与扣减结果,单机Map无法跨实例共享,也不能防进程重启丢失。
23.3 订单调用方
@SpringBootApplication
@EnableFeignClients
public class OrderApplication {
public static void main(String[] args) {
SpringApplication.run(OrderApplication.class, args);
}
}@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);
}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);
};
}
}@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));
}
}调用方配置:
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运行顺序:
- 启动库存服务8081。
- 启动订单服务8080。
- 调用订单侧测试接口或在测试中调用
InventoryGateway.reserve()。 - 连续使用同一订单号和SKU,验证幂等结果一致。
- 停掉库存服务,分别观察连接拒绝与读取超时证据。
二十四、JDK 8与Boot 2.7存量Demo边界
存量项目使用以下兼容矩阵:
<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类:
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契约互相调用,版本迁移不要求所有服务同一晚升级。
二十五、商业订单场景怎样设计
订单创建需要调用价格、库存、优惠和风控服务。错误做法是串行调用四次且每层各重试三次。推荐设计:
| 调用 | 失败语义 | 建议 |
|---|---|---|
| 查询商品价格 | 可短暂重试,不能使用过期价格静默下单 | 短超时、有限重试、版本校验 |
| 预占库存 | 超时结果不确定 | 幂等键、状态查询、补偿释放 |
| 查询优惠 | 可按业务允许降级为无优惠,但必须提示 | 只读降级、记录原因 |
| 风控决策 | 通常不可默认通过 | 失败关闭或转人工审核 |
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或幂等键。
第二步:按阶段分类
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错误 | 回滚配置并验证目标 | 重启所有服务但不修配置 |
二十八、常见反模式
- Feign接口放在公共Jar后扫描整个公司根包,引入不需要的客户端和配置。
- 每个服务各自定义不同错误响应,调用方ErrorDecoder无法统一。
- 所有请求使用FULL日志,既泄密又放大IO与内存。
- 写接口遇到超时立即无幂等重试。
- Feign、LoadBalancer、Gateway和业务任务同时重试。
- Fallback返回空列表或成功对象,隐藏真实故障。
- 只设置Read Timeout,不监控连接池租借等待。
- 直接把Entity作为Feign请求和响应模型。
- 配置固定
url后仍以为请求经过注册中心负载均衡。 - 只看调用方异常,不核对下游是否已经执行成功。
二十九、源码阅读路线
以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默认Retryer | FeignClientsConfiguration.feignRetryer() |
阅读源码时必须先确认版本。类名相同也可能在后续版本改变实现,面试回答应说明“以某版本主链为例”,避免把实现细节说成永恒规范。
三十、关联知识点
- OpenFeign零基础与常用配置
- OpenFeign独立面试题
- LoadBalancer内部原理与生产治理
- Spring Cloud完整服务调用链
- Nacos内部原理
- Sentinel内部原理
- Hystrix与Resilience4j迁移
- 请求、消息与任务幂等
本章小结
Feign代理只负责把接口方法编排成远程请求。启动时由Registrar和FactoryBean建立代理与客户端组件,运行时由MethodHandler构造模板、LoadBalancer选择实例、HTTP Client管理连接、ResponseHandler解码结果。超时、重试、熔断和Fallback各自解决不同问题;只有把代理、选址、连接、下游执行和响应解码逐层取证,才能真正定位线上远程调用故障。
