Spring Cloud服务调用链路
Spring Cloud 的服务调用链路,本质上不是“像本地方法一样直接跳到另一个服务里执行代码”,而是把一套分布式调用能力组合起来:
服务注册发现 -> 服务名解析 -> 客户端负载均衡 -> HTTP 请求发送 -> 服务端处理 -> 响应解码 -> 超时、重试、熔断、限流、降级兜底。
如果只会写 @FeignClient,但不理解这条链路,一旦出现“调用超时、服务明明在线却找不到、某个实例压力特别大、下游慢导致上游崩、traceId 断了”等问题,就很难定位。
本页负责把组件串成端到端链路。每个内部对象的深度原理分别沉淀到:
- OpenFeign内部原理与生产治理:以OpenFeign 4.1.1和Feign Core 13.2.1为现代源码样本,同时标明JDK 8存量边界。
- LoadBalancer内部原理与生产治理:以LoadBalancer 4.1.2为现代源码样本,讲清Supplier、缓存、算法和重试。
- Nacos内部原理与生产治理:讲清注册、订阅、本地实例快照和变更传播。
JDK 8、Boot 2.7与Java 17、Boot 3的总体调用思想一致,但具体类版本、自动配置条件和HTTP Client会变化。排查和面试必须先声明精确版本,不能把某个4.x实现倒灌成所有旧版本的固定行为。
先用一句话建立模型
在 Spring Cloud 里,调用方通常不写死下游 IP,而是写服务名:
@FeignClient("user-service")
public interface UserClient {
@GetMapping("/users/{id}")
UserDTO getUser(@PathVariable("id") Long id);
}业务代码看起来像本地调用:
UserDTO user = userClient.getUser(1001L);但底层真实发生的是:
userClient.getUser(1001L)
-> Feign 动态代理拦截方法调用
-> 根据注解拼出 HTTP 请求 GET /users/1001
-> 根据服务名 user-service 找可用实例
-> LoadBalancer 选择一个实例
-> 发 HTTP 请求到真实 IP:Port
-> 用户服务 Controller 处理
-> 返回 JSON
-> Feign 反序列化成 UserDTO到底是谁负责发请求
很多人会把这句话说成“Feign从Nacos拿地址,然后负载均衡后发请求”。这句话方向接近,但职责不够准确。
更精确的模型是:
Feign负责把Java接口方法转换成HTTP请求;Nacos负责提供服务实例数据;Spring Cloud LoadBalancer负责从实例列表里选择一个实例;真正建立连接并发送HTTP报文的是底层HTTP Client。
也就是说,Feign不应该被理解成“直接去Nacos查地址并亲自发TCP包”的组件。Feign看到的是逻辑服务名,例如user-service;服务名怎样变成192.168.1.11:8081,由Spring Cloud的服务发现抽象和LoadBalancer完成;最后由Apache HttpClient、OkHttp、JDK Client或Reactor Netty这类HTTP客户端真正发出网络请求。
flowchart TD
A["业务代码调用Feign接口"] --> B["Feign动态代理接管方法调用"]
B --> C["Feign按注解构造逻辑HTTP请求"]
C --> D["请求地址仍是服务名<br/>http://user-service/users/1001"]
D --> E["LoadBalancer处理服务名"]
E --> F["通过DiscoveryClient读取实例列表"]
F --> G["Nacos Client提供本地实例快照"]
G --> H["LoadBalancer选择一个ServiceInstance"]
H --> I["重建为真实地址<br/>http://192.168.1.11:8081/users/1001"]
I --> J["底层HTTP Client发送请求"]
J --> K["目标服务Controller处理"]这张图要注意两个“不”:
| 容易误解的说法 | 正确理解 |
|---|---|
| Feign直接调用Nacos拿地址 | Feign通过Spring Cloud LoadBalancer和DiscoveryClient间接使用Nacos实例数据 |
| Nacos参与转发每一次业务请求 | Nacos是注册发现控制面,不是业务流量代理;业务请求由调用方直连被选中的服务实例 |
可以把几类组件理解成不同角色:
| 组件 | 负责什么 | 不负责什么 |
|---|---|---|
| OpenFeign | 生成接口代理、解析注解、构造请求、编解码响应 | 不维护注册表,不做注册中心存储,不直接保证下游成功 |
| Nacos | 保存服务名和实例列表,处理注册、心跳、订阅、推送、本地缓存更新 | 不转发订单、库存、用户这类业务HTTP请求 |
| DiscoveryClient / NacosDiscoveryClient | 把注册中心能力适配成Spring Cloud统一服务发现接口 | 不决定本次一定选哪台实例 |
| Spring Cloud LoadBalancer | 根据服务名和候选实例列表选择一个ServiceInstance | 不建立TCP连接,不解析业务JSON |
| HTTP Client | 建立或复用连接,发送HTTP请求,读取HTTP响应 | 不理解@FeignClient接口语义,不保存注册中心数据 |
因此面试里被问“Spring Cloud中是谁负责发请求”时,应该回答:
真正发请求的是底层HTTP Client。Feign负责把接口调用组装成HTTP请求,
LoadBalancer负责选实例,Nacos负责注册发现和提供实例列表。
Nacos不在业务请求转发链路上,调用方拿到实例后会直连目标服务。再补一个生产细节:调用方通常不会每次请求都实时访问Nacos Server。Nacos Client会维护本地实例缓存,服务变化通过订阅推送或刷新机制更新;LoadBalancer调用时更多是读取当前实例快照。这样可以避免注册中心成为每一次业务调用的同步瓶颈。
怎么保证每次请求尽量最新可用
先给结论:不能保证每次请求都是全局最新且绝对可用,只能保证每次请求在当前调用方视角下尽量可用。
原因是一次调用跨了很多异步环节:Provider状态变化、注册中心服务表更新、Nacos Client本地缓存刷新、LoadBalancer候选过滤、HTTP连接池复用、请求在途执行。任何一个环节都存在毫秒到秒级窗口。并且“可用”也不是永久属性,实例被LoadBalancer选中后一瞬间仍可能重启、Full GC、网络断开或数据库连接池打满。
正确做法不是让Feign每次都同步查Nacos,而是建立下面这条闭环:
flowchart TD
A["Feign生成逻辑请求"] --> B["读取当前实例快照"]
B --> C["LoadBalancer过滤候选实例"]
C --> D["选择健康、版本、权重匹配实例"]
D --> E["HTTP Client发请求"]
E --> F{"调用结果"}
F --> G["成功返回"]
F --> H["连接失败或超时"]
H --> I["按错误语义判断"]
I --> J["读请求有限换实例重试"]
I --> K["写请求查幂等事实再补偿"]这条链路里各组件的真实职责如下:
| 环节 | 组件 | 做什么 | 不能解决什么 |
|---|---|---|---|
| 实例事实传播 | Nacos、Eureka、Consul | 把Provider上线、下线、健康变化传播给Consumer | 不能消灭传播延迟 |
| 本地快照维护 | Nacos Client、DiscoveryClient | 在后台刷新服务列表,让请求读本地快照 | 不能保证每个瞬间都是全局最新 |
| 请求级选址 | Spring Cloud LoadBalancer | 过滤不健康、权重0、版本不匹配实例,再选一个 | 不能保证选中后实例永远可用 |
| 网络发送 | Apache HttpClient、OkHttp、JDK Client、Reactor Netty | 建连接、复用连接、发送HTTP请求、读响应 | 连接池可能短暂复用旧连接 |
| 失败治理 | Sentinel、Resilience4j、Retry、Fallback | 限流、熔断、隔离、重试、降级 | 配错会雪崩,写请求不能盲目重试 |
| 业务兜底 | 幂等表、状态机、补偿任务 | 写请求超时后查询事实并补偿 | 不能用“重试一次”代替事实确认 |
为什么不每次请求都实时查Nacos?
| 原因 | 说明 |
|---|---|
| 注册中心会被业务QPS打爆 | 注册中心是控制面,不应该成为每次业务调用的同步数据库 |
| 链路RT变长 | 每次调用多一次远程查询,P95/P99都会变差 |
| 可用性反而下降 | 注册中心抖动会拖垮原本可直连成功的业务请求 |
| 仍然不能绝对保证 | 查完到发请求之间实例仍可能故障 |
所以面试回答要这样说:
不能保证每次请求都是全局最新可用。Spring Cloud通常是Nacos Client后台维护本地实例快照,
Feign发起调用时由LoadBalancer读取当前快照并过滤候选实例,最后由HTTP Client发请求。
工程上通过Readiness摘流、订阅推送、LB缓存TTL、健康过滤、连接池回收、短超时、
有限重试、熔断隔离、幂等号和事实查询来保证“当前视角下尽量可用”。
读请求可以在Deadline内换实例重试,写请求超时必须先查业务事实,不能盲目重放。更完整的服务发现原理见:微服务服务发现。
整体调用链路图
flowchart TD
A["用户请求"] --> B["Spring Cloud Gateway"]
B --> C["匹配 Route 和 Predicate"]
C --> D["执行网关 Filter<br/>鉴权、限流、日志、Trace"]
D --> E["lb://order-service"]
E --> F["LoadBalancer 选择订单服务实例"]
F --> G["订单服务 Controller"]
G --> H["业务代码调用 UserClient"]
H --> I["Feign 动态代理"]
I --> J["构造 HTTP 请求模板"]
J --> K["查询 user-service 实例列表"]
K --> L["LoadBalancer 选择用户服务实例"]
L --> M["HTTP Client 发请求"]
M --> N["用户服务 Controller"]
N --> O["返回 JSON"]
O --> P["Feign Decoder 反序列化"]
P --> Q["订单服务继续执行业务"]这张图里包含两类调用:
- 外部请求进入微服务:通常经过 Gateway。
- 微服务内部互相调用:通常使用 OpenFeign、RestTemplate、WebClient 等客户端。
Gateway、Feign、LoadBalancer、注册中心不是互相替代关系,而是各司其职。
一次商业请求的完整调用流程
下面用“用户提交订单,订单服务需要查询用户、锁库存、发消息”来串起完整链路。实际项目可能没有全部组件,但只要使用 Spring Cloud,核心问题都绕不开:入口怎么进来、服务名怎么变成 IP、请求由谁发出、限流熔断在哪里生效、失败后怎样返回或补偿。
先看最完整的一条同步调用主线:
flowchart TD
A["客户端请求<br/>POST /orders"] --> B["Gateway接收请求"]
B --> C["生成或读取TraceId"]
C --> D["路由匹配<br/>Route和Predicate"]
D --> E["网关前置Filter<br/>鉴权、灰度、限流"]
E --> F["识别lb://order-service"]
F --> G["Gateway侧LoadBalancer选订单实例"]
G --> H["Gateway HTTP Client转发到订单服务"]
H --> I["订单服务Web容器接收请求"]
I --> J["服务端过滤器链<br/>认证、日志、Trace"]
J --> K["Controller参数绑定和校验"]
K --> L["OrderService执行业务"]
L --> M["调用UserClient或StockClient"]
M --> N["Feign动态代理"]
N --> O["组装RequestTemplate和Header"]
O --> P["客户端限流或熔断判断"]
P --> Q["Feign侧LoadBalancer选下游实例"]
Q --> R["底层HTTP Client发送请求"]
R --> S["下游服务处理并返回"]
S --> T["Decoder或ErrorDecoder处理响应"]
T --> U["记录指标、Trace和熔断结果"]
U --> V["订单服务继续提交事务或返回失败"]这张图表达的是常见主线,不表示所有项目中每个组件的装配顺序都绝对相同。比如 Sentinel、Resilience4j、Spring Cloud CircuitBreaker、LoadBalancer Retry、Feign Core Retry、Gateway Filter 的具体先后,要以项目依赖、自动配置条件、调用栈和链路追踪为准。但职责边界不变:
| 阶段 | 典型组件 | 核心职责 | 出问题的典型现象 |
|---|---|---|---|
| 入口接入 | Nginx、Ingress、Gateway | 接收外部请求、TLS、路由、鉴权、入口限流 | 404、401、403、429、网关 503/504 |
| 服务发现 | Nacos、Eureka、Consul | 维护服务名到实例列表的映射 | 控制台有实例但调用方无实例 |
| 实例选择 | Spring Cloud LoadBalancer、Ribbon | 从候选实例里选择一个 | 负载不均、选到旧实例、灰度误路由 |
| 客户端调用 | OpenFeign、RestTemplate、WebClient | 组装请求、发起远程调用、解码响应 | 参数丢失、序列化失败、连接超时、读取超时 |
| HTTP 传输 | Apache HttpClient、OkHttp、JDK Client、Reactor Netty | 连接池、TCP/TLS、发送和读取报文 | 等连接、端口不通、TLS失败、旧连接命中下线实例 |
| 服务端处理 | Tomcat、Spring MVC、Service、DB | 业务执行、事务、数据库访问 | 慢 SQL、锁等待、线程池满、业务 500 |
| 容错治理 | Sentinel、Resilience4j、Hystrix | 限流、熔断、隔离、降级、统计 | 被限流、熔断打开、fallback误返回成功 |
| 可观测性 | Micrometer、OpenTelemetry、SkyWalking、日志 MDC | Trace、指标、日志关联 | traceId 丢失、只看到局部错误 |
1. Gateway阶段:请求还没进业务服务
外部请求先到 Gateway 时,Gateway 会先做路由匹配和过滤器处理。它不应该执行业务事务,但适合处理入口横切能力。
flowchart TD
A["请求进入Gateway"] --> B["匹配Route"]
B --> C["执行前置Filter"]
C --> D["鉴权和用户上下文"]
C --> E["入口限流"]
C --> F["灰度和路径重写"]
D --> G["通过lb://服务名准备转发"]
E --> G
F --> G这里的限流通常保护入口。例如同一个 IP、用户、租户、医院编码、接口路径在短时间内请求过多,可以直接返回 429 或统一错误码。入口限流发生时,请求可能根本没有到订单服务,所以排查时不能只看订单服务日志。
Gateway 中的 lb://order-service 会触发 Gateway 侧的 LoadBalancer。它从注册中心或本地缓存拿订单服务实例,然后转发到某个真实实例。注意这里已经发生过一次负载均衡:选的是订单服务实例。
2. 订单服务入口阶段:服务端也可能限流和熔断
请求进入订单服务后,会经过 Web 容器、过滤器链、拦截器、Controller 参数绑定、参数校验,再进入 Service。
flowchart TD
A["HTTP请求到达订单服务"] --> B["Tomcat工作线程"]
B --> C["Filter和Interceptor"]
C --> D["认证上下文和Trace上下文"]
D --> E["Controller参数绑定"]
E --> F["参数校验"]
F --> G["OrderService业务方法"]如果订单服务自己也接入 Sentinel,那么可以在入口资源、Controller 方法或业务方法上做限流、热点参数限流、线程数控制、慢调用熔断。这里保护的是订单服务自身,不是下游库存服务。
常见区别:
| 位置 | 保护对象 | 例子 |
|---|---|---|
| Gateway 限流 | 整个入口或路由 | 防止某租户刷 /orders |
| 订单服务入口限流 | 订单服务线程和核心接口 | 防止订单创建接口被打满 |
| Feign 调用限流 | 对某个下游依赖的调用量 | 防止订单服务把库存服务打爆 |
| 数据库前限流 | 数据库连接和慢 SQL 资源 | 防止写库并发超过承载 |
3. Feign阶段:本地方法调用变成远程HTTP
业务代码调用:
stockClient.lockStock(command);看起来像调用本地对象,其实进入 Feign 代理。Feign 会做:
flowchart TD
A["调用stockClient.lockStock"] --> B["JDK动态代理拦截"]
B --> C["根据Method找到MethodHandler"]
C --> D["Contract元数据确定HTTP方法和路径"]
D --> E["填充Path、Query、Header和Body"]
E --> F["Encoder序列化请求体"]
F --> G["RequestInterceptor添加Trace和认证头"]
G --> H["生成Feign Request"]Feign 在这里还没有完成“真实发请求”。此时请求地址可能仍然是逻辑地址:
http://stock-service/api/stocks/lock如果配置了固定 url,通常会直接走固定地址,不再按服务名走注册中心负载均衡;如果只配置 name,才会进入服务发现和 LoadBalancer。
4. 熔断器和限流器阶段:可能请求还没发出去
Feign 调用常会被 Sentinel、Resilience4j 或 Spring Cloud CircuitBreaker 包一层。它们可能在请求发出前就做判断。
flowchart TD
A["Feign准备调用下游"] --> B["限流规则判断"]
B --> C["熔断器状态判断"]
C --> D["允许通过"]
D --> E["进入LoadBalancer和HTTP Client"]
B --> F["限流拒绝"]
C --> G["熔断打开快速失败"]
F --> H["返回降级或抛Block异常"]
G --> H要把几个概念分清:
| 能力 | 解决什么 | 请求是否一定发到下游 |
|---|---|---|
| 限流 | 流量太大,先挡住一部分 | 不一定,限流命中时不会发 |
| 熔断 | 下游持续失败或慢,不再继续打 | 熔断打开时不会发 |
| 降级 | 失败后给兜底结果 | 可能没发,也可能发了失败后降级 |
| 超时 | 已经等待太久,释放调用方资源 | 通常请求可能已经发出 |
| 重试 | 偶发失败后再尝试 | 可能产生多次真实请求 |
这也是线上排查的关键:如果下游没有日志,不一定是网络丢了,也可能是限流、熔断、无实例、连接池等连接在本地就失败了。
5. LoadBalancer阶段:服务名变成真实实例
LoadBalancer 的输入通常是服务名和请求上下文,输出是一个 ServiceInstance。
flowchart TD
A["serviceId=stock-service"] --> B["ServiceInstanceListSupplier"]
B --> C["读取DiscoveryClient实例列表"]
C --> D["使用本地缓存或注册中心快照"]
D --> E["过滤不可用、灰度、版本、区域"]
E --> F["轮询、随机、权重或自定义算法"]
F --> G["返回ServiceInstance"]例如候选实例是:
stock-service:
10.10.2.17:8080 version=v1 zone=shanghai weight=100
10.10.2.18:8080 version=v1 zone=shanghai weight=100
10.20.1.09:8080 version=v2 zone=beijing weight=10如果调用方带了灰度标记 version=v2,过滤后可能只剩 10.20.1.09:8080。如果过滤条件写错,就会出现“注册中心明明有实例,但 Feign 报无实例”。所以排查时不能只看 Nacos 控制台,要看调用方最终候选列表和过滤结果。
选中实例后,请求 URL 会从:
http://stock-service/api/stocks/lock变成:
http://10.10.2.17:8080/api/stocks/lock6. HTTP Client阶段:真正发送网络请求
真正发请求的是底层 HTTP Client。它要处理连接池、DNS、TCP、TLS、写请求、读响应。
flowchart TD
A["拿到真实URL"] --> B["从连接池申请连接"]
B --> C["复用空闲连接或新建连接"]
C --> D["建立TCP和TLS"]
D --> E["写Header和Body"]
E --> F["等待下游响应"]
F --> G["读取响应Body"]
G --> H["归还或关闭连接"]这里至少有几类等待:
| 等待位置 | 说明 | 常见证据 |
|---|---|---|
| 连接池等待 | 本地没有空闲连接 | 线程栈停在 acquire/lease |
| Connect Timeout | TCP 连接建不起来 | connect timeout、端口不通 |
| TLS 握手 | 证书或协议协商失败 | SSLHandshakeException |
| Read Timeout | 请求发出后迟迟没响应 | socket read timeout |
| 响应解码 | 响应体太大或格式不符 | decode exception、JSON错误 |
读取超时最容易被误解。Read Timeout 不等于下游没有执行。下游可能已经完成扣库存或支付,只是响应返回晚了。因此写接口超时后要按幂等号查询状态,而不是直接重试。
7. 下游服务端阶段:对方也有自己的线程池和数据库
库存服务收到请求后,并不是“网络到了就马上成功”。它还要经过自己的 Web 容器、过滤器、Controller、Service、事务、数据库。
flowchart TD
A["库存服务收到HTTP请求"] --> B["Web容器线程"]
B --> C["服务端限流或认证"]
C --> D["Controller参数绑定"]
D --> E["Service开启事务"]
E --> F["校验幂等号"]
F --> G["扣减库存或返回已有结果"]
G --> H["提交事务"]
H --> I["返回统一响应"]所以 Feign 慢可能是调用方慢、网络慢,也可能是下游自己慢。常见下游原因:
| 下游原因 | 表现 |
|---|---|
| Tomcat线程池满 | 请求排队,P99升高 |
| 数据库连接池满 | 业务线程阻塞等连接 |
| 慢 SQL 或锁等待 | 下游处理时间长 |
| Full GC | 整个实例短时间无响应 |
| 第三方接口慢 | 下游继续级联等待 |
8. 响应返回阶段:Decoder、ErrorDecoder、指标和熔断统计
下游返回后,Feign 会根据状态码进入不同处理:
flowchart TD
A["HTTP Client返回Response"] --> B["Feign记录日志和响应"]
B --> C["按状态码分类"]
C --> D["2xx进入Decoder"]
C --> E["非2xx进入ErrorDecoder"]
D --> F["反序列化为Java返回值"]
E --> G["映射为异常"]
F --> H["记录成功和耗时指标"]
G --> I["记录失败、慢调用或异常指标"]
H --> J["返回业务代码"]
I --> K["可能触发重试、熔断或fallback"]熔断器通常根据一段时间窗口内的失败率、慢调用比例、异常数或并发数更新状态。也就是说,熔断不是“某一次失败就永远断开”,而是统计窗口和状态机共同作用。
典型状态:
| 状态 | 含义 | 行为 |
|---|---|---|
| Closed | 正常关闭 | 请求正常通过,并持续统计 |
| Open | 熔断打开 | 请求快速失败或降级,不打下游 |
| Half-Open | 半开探测 | 放少量请求试探恢复 |
9. 重试阶段:一次逻辑请求可能变成多次物理请求
重试可能发生在多个层次:
| 重试位置 | 例子 | 风险 |
|---|---|---|
| Gateway 重试 | 网关转发失败重试 | 放大入口流量 |
| LoadBalancer 重试 | 换实例再试 | 写操作可能重复执行 |
| Feign Core 重试 | Retryer 重试 RetryableException | 与LB重试叠加 |
| Resilience4j Retry | 装饰器重试 | 与熔断统计顺序有关 |
| 业务代码循环重试 | catch后再次调用 | 最容易失控 |
如果每层都重试 2 次,含义不是总共 2 次,而可能相乘:
物理请求上限 = Gateway 3次 * Feign 3次 * 业务 3次 = 27次写接口必须先满足幂等,再谈重试。订单、库存、支付这类场景应携带 requestNo、orderNo、deductNo 这类业务唯一键,并在下游用唯一索引和事务保证重复请求返回同一结果。
10. 降级阶段:兜底不能破坏业务正确性
Fallback 常见返回方式:
| 业务场景 | 合理降级 | 危险降级 |
|---|---|---|
| 查询头像 | 默认头像 | 返回其他用户头像 |
| 查询推荐 | 热门列表 | 返回过期敏感数据 |
| 查询字典 | 本地缓存旧值并标记版本 | 悄悄返回空导致业务误判 |
| 锁库存 | 返回失败或待确认 | 假装锁定成功 |
| 支付 | 返回处理中并异步查单 | 假装支付成功 |
降级的原则是:保护系统可以,欺骗业务不行。特别是写操作,降级一般只能返回失败、待确认、排队补偿或人工处理,不能为了页面“看起来成功”制造数据不一致。
11. 成功路径和失败路径分别怎么收口
成功路径:
flowchart TD
A["下游2xx响应"] --> B["Decoder解码"]
B --> C["业务校验返回码"]
C --> D["订单服务继续事务"]
D --> E["写本地数据库或发送事务消息"]
E --> F["返回客户端成功"]失败路径:
flowchart TD
A["远程调用失败"] --> B["判断失败类型"]
B --> C["未发出<br/>限流、熔断、无实例"]
B --> D["已发出但失败<br/>连接、读取、5xx"]
C --> E["可直接失败或降级"]
D --> F["写操作按幂等号查状态"]
F --> G["确认成功则继续"]
F --> H["确认失败或未知则补偿"]注意“请求是否已经发出”非常重要:
| 失败类型 | 请求是否可能到达下游 | 处理重点 |
|---|---|---|
| 限流拒绝 | 没到 | 返回限流提示,调整规则或扩容入口 |
| 熔断打开 | 没到 | 看熔断原因和恢复窗口 |
| 无实例 | 没到 | 查注册中心、命名空间、本地缓存 |
| 连接超时 | 通常没建立连接 | 查网络、端口、实例状态 |
| 读取超时 | 很可能已经到达 | 写操作要查最终状态 |
| 下游 500 | 已到达 | 查下游日志和业务异常 |
12. 一句话背后的完整面试答案
如果面试官问“Spring Cloud 一次服务调用完整流程是什么”,可以这样答:
外部请求通常先进入 Gateway,Gateway 根据 Route 和 Predicate 匹配路由,
执行鉴权、限流、日志、Trace 等 Filter;如果目标是 lb:// 服务名,
Gateway 会通过 LoadBalancer 从注册中心或本地实例缓存中选择一个服务实例,
再由 Netty/HTTP Client 转发到目标服务。
服务内部调用通常使用 OpenFeign。启动时 OpenFeign 扫描 @FeignClient 接口并创建代理;
运行时业务代码调用接口方法,Feign 代理通过 Contract、Encoder、RequestInterceptor
把方法调用转换成 HTTP 请求。没有固定 url 时,请求中的服务名会交给
Spring Cloud LoadBalancer,LoadBalancer 通过 DiscoveryClient 读取 Nacos/Eureka 等
注册中心的实例快照,经过健康、灰度、区域、权重等过滤和轮询/随机等算法选出
ServiceInstance,再把服务名替换为真实 IP:Port。真正发请求的是底层 HTTP Client。
调用过程中还会叠加超时、重试、限流、熔断和降级。限流解决流量过大,
熔断解决下游持续慢或异常,超时防止线程无限等待,重试只适合幂等和瞬时故障,
降级提供兜底但不能把失败伪装成成功。所有调用要透传 TraceId,记录每阶段耗时,
线上排查要区分请求是否发出、选中了哪个实例、是否等连接、是否超时、
下游是否执行以及熔断限流是否提前拦截。启动阶段发生了什么
一次服务调用不是从“发请求那一刻”才开始的。服务启动时已经做了很多准备。
1. 服务提供者注册自己
比如用户服务启动 3 个实例:
user-service
192.168.1.10:8081
192.168.1.11:8081
192.168.1.12:8081每个实例会向注册中心注册自己的信息:
| 信息 | 例子 | 作用 |
|---|---|---|
| 服务名 | user-service | 调用方通过服务名查实例 |
| IP | 192.168.1.10 | 真实请求地址 |
| 端口 | 8081 | 真实请求端口 |
| 健康状态 | UP | 判断实例是否可用 |
| 元数据 | version=v1、zone=shanghai | 灰度、同机房优先、权重路由 |
注册中心负责保存“服务名和实例列表”的映射关系。
flowchart TD
A["user-service 三个实例启动"] --> B["每个实例分别发起注册"]
B --> C["注册中心按服务名维护实例集合"]
C --> D["保存IP、端口、状态和元数据"]2. 服务消费者拉取或订阅实例列表
订单服务要调用用户服务,就需要知道 user-service 有哪些实例。
调用方通常通过 DiscoveryClient 或 Spring Cloud 封装的服务发现组件获取实例列表。不同注册中心实现不同:
- Eureka:客户端定期拉取注册表,本地会有缓存。
- Nacos:支持服务订阅和推送,也会有本地缓存。
- Consul:也可以通过服务发现 API 获取实例。
重点不是背实现细节,而是理解:
调用方不是每次都直接连数据库查服务地址,而是通过服务发现组件拿到实例列表,并在本地参与选择。
2.1 本地服务列表怎样保证尽量最新
调用方本地实例列表无法保证任意瞬间绝对最新。服务注册发现是一条异步控制面链路:提供者状态变化后,注册中心先更新服务表,再通过推送或客户端拉取让调用方刷新本地快照,最后 LoadBalancer 才在新快照上选址。即使快照已更新,HTTP 连接池也可能短时间复用旧连接。
flowchart TD
A["提供者上线、下线或变更metadata"] --> B["注册中心更新服务表"]
B --> C["推送或等待消费者拉取"]
C --> D["消费者替换本地实例快照"]
D --> E["LoadBalancer按新快照选实例"]
E --> F["HTTP连接池逐步回收旧连接"]所以“最新”在工程上不是强一致承诺,而是通过这些机制共同逼近:
| 机制 | 解决什么 | 边界 |
|---|---|---|
| Nacos订阅推送或Eureka定期拉取 | 让调用方最终收到实例变化 | 网络抖动和客户端处理有延迟 |
| 本地缓存TTL或定期刷新 | 避免缓存永远不变 | TTL太短会增加注册中心压力 |
| Readiness先摘流 | 下线前先让实例不再接新请求 | 调用方刷新前仍可能有旧快照 |
| 优雅停机 | 等存量请求结束再退出 | 配置太短会中断请求 |
| 健康过滤和权重0 | LoadBalancer过滤不可用实例 | 健康检查不等于业务一定健康 |
| 连接池最大存活时间 | 避免旧连接长期复用旧地址 | 过短会增加建连和TLS成本 |
| 短超时和有限重试 | 旧实例不可达时快速恢复 | 写请求重试必须先有幂等 |
如果真的调到了旧服务,排查时按顺序确认:调用方 Trace 里的目标 ip:port 和版本、注册中心当时服务表、调用方本地快照、LoadBalancer 过滤结果、HTTP Client 是否复用旧连接、Gateway/Feign 是否丢灰度标签、K8s Readiness 和 preStop 是否足够。只读请求通常依赖兼容协议容忍短窗口;写请求不能简单再次打到新服务,应按幂等号查询事实后补偿。
调用方也不是每次请求都更新本地实例快照。每次业务请求通常只是读取当前快照并负载均衡;真正的快照刷新由后台服务发现客户端负责,例如 Nacos 订阅推送、Eureka 定期拉取、Consul Blocking Query、Kubernetes EndpointSlice Watch 或 Mesh xDS 推送。
flowchart TD
A["业务请求"] --> B["读取当前实例快照"]
B --> C["负载均衡选实例"]
C --> D["HTTP或RPC调用"]
E["后台发现线程"] --> F["收到推送或定期拉取"]
F --> G["构造新实例快照"]
G --> H["替换本地快照"]
H --> B如果每次请求都实时更新,注册中心会被业务 QPS 打爆,调用延迟也会变高。正确理解是:业务调用链路负责“读快照并发请求”,服务发现刷新链路负责“异步更新快照”。这也是为什么旧实例窗口无法彻底消除,只能通过推送、短 TTL、优雅下线、连接回收、超时重试和幂等补偿缩短和兜底。
更完整的跨组件对比、JDK 8 本地快照 Demo、Nacos/Eureka/Consul/Kubernetes/Service Mesh 差异和优雅上下线流程见:微服务服务发现:注册、订阅、本地缓存、健康检查与优雅上下线。
3. Feign 创建代理对象
Spring Boot 启动时,@EnableFeignClients 会触发 Feign Client 扫描。Spring Cloud OpenFeign 会为 @FeignClient 接口创建代理对象。
@EnableFeignClients
@SpringBootApplication
public class OrderApplication {
public static void main(String[] args) {
SpringApplication.run(OrderApplication.class, args);
}
}接口没有实现类:
@FeignClient("user-service")
public interface UserClient {
@GetMapping("/users/{id}")
UserDTO getUser(@PathVariable("id") Long id);
}但 Spring 容器里会有一个代理对象。你注入的不是普通实现类,而是 Feign 创建的代理。
运行阶段的完整流程
以订单服务调用用户服务为例:
@RestController
@RequestMapping("/orders")
public class OrderController {
private final UserClient userClient;
public OrderController(UserClient userClient) {
this.userClient = userClient;
}
@GetMapping("/{orderId}")
public OrderVO getOrder(@PathVariable Long orderId) {
Long userId = 1001L;
UserDTO user = userClient.getUser(userId);
return new OrderVO(orderId, user);
}
}调用链路如下:
flowchart TD
A["OrderController 调用 userClient.getUser"] --> B["Feign 代理拦截方法"]
B --> C["Contract 解析注解<br/>GET /users/{id}"]
C --> D["Encoder 处理请求参数"]
D --> E["RequestInterceptor 添加请求头"]
E --> F["生成 RequestTemplate"]
F --> G["根据 user-service 找实例列表"]
G --> H["LoadBalancer 选择实例"]
H --> I["HTTP Client 发送请求"]
I --> J["UserController 处理请求"]
J --> K["返回 JSON"]
K --> L["Decoder 转成 UserDTO"]第一步:Feign 代理拦截方法
Feign 让远程调用看起来像本地方法,核心靠动态代理。
动态代理的作用是:当你调用接口方法时,不是真的执行某个 Java 实现类,而是进入代理逻辑。
代理逻辑会做这些事:
- 找到当前方法对应的 HTTP Method。
- 找到请求路径。
- 找到参数如何放入 path、query、header、body。
- 构造请求对象。
- 调用底层 HTTP Client。
- 把响应转成 Java 对象。
这就是为什么 Feign 是“声明式 HTTP 客户端”,不是“真正的本地调用”。
第二步:Contract 解析注解
Feign 需要知道接口方法和 HTTP 请求之间的映射关系。
@GetMapping("/users/{id}")
UserDTO getUser(@PathVariable("id") Long id);会被解析成:
HTTP Method: GET
Path: /users/1001
ServiceId: user-service
Return Type: UserDTOSpring Cloud OpenFeign 支持 Spring MVC 注解,所以你可以使用 @GetMapping、@PostMapping、@PathVariable、@RequestParam、@RequestBody。
第三步:Encoder 编码请求
如果是 GET 请求,参数可能放在 path 或 query 中。
如果是 POST 请求:
@PostMapping("/users")
UserDTO createUser(@RequestBody CreateUserCommand command);Encoder 会把 Java 对象序列化成 JSON 请求体。
{
"name": "Tom",
"age": 18
}如果编码配置不一致,就会出现下游收不到参数、日期格式不对、枚举解析失败等问题。
第四步:RequestInterceptor 处理请求头
微服务调用通常要透传一些上下文:
X-Trace-Id:链路追踪 ID。Authorization:用户 token。X-Tenant-Id:租户 ID。X-Request-Source:来源系统。
示例:
@Configuration
public class FeignHeaderConfig {
@Bean
public RequestInterceptor traceInterceptor() {
return template -> {
String traceId = TraceContext.getTraceId();
if (traceId != null) {
template.header("X-Trace-Id", traceId);
}
};
}
}如果请求头不透传,常见后果是:
- 下游拿不到登录用户。
- 多租户数据隔离失效。
- 日志无法通过 traceId 串起来。
- 排查问题只能按时间猜。
第五步:服务发现获取实例列表
Feign 知道要调用 user-service,但还不知道真实 IP。
这一步会通过服务发现组件拿到实例:
user-service
-> 192.168.1.10:8081
-> 192.168.1.11:8081
-> 192.168.1.12:8081服务发现组件只回答“有哪些实例”,不负责“本次选哪个实例”。
核心原理:注册中心到底做什么
注册中心解决的是服务地址动态变化问题。
没有注册中心时,调用方可能写死地址:
restTemplate.getForObject("http://192.168.1.10:8081/users/1001", UserDTO.class);这样会有几个问题:
| 问题 | 后果 |
|---|---|
| 服务扩容 | 新实例没人调用 |
| 服务下线 | 调用方还打到旧 IP |
| 容器重启 | IP 变化后调用失败 |
| 多环境部署 | 地址配置到处改 |
| 灰度发布 | 很难按版本、标签、机房路由 |
有注册中心后,调用方只依赖服务名:
user-service服务名到实例列表的映射由注册中心维护:
flowchart TD
A["服务提供者启动"] --> B["注册服务名、IP、端口、元数据"]
B --> C["注册中心保存实例"]
C --> D["消费者订阅或读取本地实例数据"]
D --> E["业务调用按服务名取得候选列表"]
E --> F["本地负载均衡选择实例"]前三步属于提供者注册,后三步属于消费者发现和调用;纵向连接表示实例数据依赖,不表示由同一线程连续执行。
注册中心通常还会处理:
- 心跳续约:实例定期证明自己还活着。
- 健康检查:判断实例是否可用。
- 服务剔除:长时间无心跳的实例下线。
- 元数据管理:版本、集群、权重、区域等信息。
核心原理:客户端负载均衡
拿到实例列表后,需要决定本次请求发给谁。
user-service 实例列表:
192.168.1.10:8081
192.168.1.11:8081
192.168.1.12:8081LoadBalancer 会选择其中一个:
本次选择: 192.168.1.11:8081flowchart TD
A["调用方"] --> B["实例列表"]
B --> C["Spring Cloud LoadBalancer"]
C --> D["先执行健康、区域和版本过滤"]
D --> E["按轮询、随机或自定义算法选择"]
E --> F["返回一个ServiceInstance"]轮询、随机和自定义算法是互斥选择,不会在一次请求中依次执行;各算法和过滤器的输入、回退边界见LoadBalancer内部原理。
客户端负载均衡和服务端负载均衡区别
客户端负载均衡:
flowchart TD
A["订单服务"] --> B["注册中心拿实例列表"]
B --> C["订单服务本地选择实例"]
C --> D["用户服务实例"]服务端负载均衡:
flowchart TD
A["订单服务"] --> B["Nginx / SLB / Kubernetes Service"]
B --> C["服务端负载均衡维护后端实例池"]
C --> D["为本次请求选择一个用户服务实例"]
D --> E["转发并返回响应"]Spring Cloud 内部调用常见客户端负载均衡;公网入口、Kubernetes 集群入口、传统反向代理常见服务端负载均衡。
为什么微服务内部常用客户端负载均衡
优点:
- 调用链路短,不需要所有内部请求经过统一转发层。
- 调用方可以结合服务元数据做灰度、同机房优先。
- 每个调用方本地选择实例,不容易形成单一转发瓶颈。
缺点:
- 调用方会缓存实例列表,可能短时间拿到旧实例。
- 每个语言、每个框架都要有客户端治理能力。
- 负载策略分散在调用方,治理复杂度更高。
所以生产环境经常是两者一起用:
外部流量 -> Nginx / Ingress / Gateway
内部调用 -> OpenFeign + LoadBalancerSpring Cloud LoadBalancer 和 Ribbon
Spring Cloud 负载均衡有两代常见方案:
| 方案 | 状态 | 说明 |
|---|---|---|
| Ribbon | 老项目常见 | Netflix 体系的客户端负载均衡,已不推荐新项目继续选型 |
| Spring Cloud LoadBalancer | 新项目推荐 | Spring Cloud 官方客户端负载均衡抽象和实现 |
新项目里更推荐理解 Spring Cloud LoadBalancer。老项目里遇到 Ribbon,也要知道它承担的是同一类职责:从服务实例列表中选一个实例。
常见算法:
| 算法 | 说明 | 适合场景 |
|---|---|---|
| 轮询 | 每次选择下一个实例 | 实例规格接近,默认通用 |
| 随机 | 随机选择一个实例 | 实例接近,简单均衡 |
| 权重 | 按权重分配请求 | 机器规格不同、灰度流量 |
| 同机房优先 | 优先选择同区域实例 | 降低跨机房延迟 |
| 版本优先 | 按元数据选择版本 | 灰度发布、金丝雀发布 |
核心原理:HTTP Client 真正发请求
负载均衡选出实例后,才会真正发 HTTP 请求。
GET http://192.168.1.11:8081/users/1001底层 HTTP Client 可能是:
- JDK 默认 HTTP 能力。
- Apache HttpClient。
- OkHttp。
- Reactor Netty。
不同客户端的连接池、超时、线程模型不同,但核心都要处理:
- 建立 TCP 连接。
- 复用连接。
- 写请求头和请求体。
- 等待响应。
- 读取响应体。
- 处理超时和异常。
这说明 Feign 调用不是普通方法调用,它会受到网络、连接池、序列化、下游线程池、数据库等因素影响。
核心原理:服务端如何处理请求
下游服务收到请求后,和普通 Spring MVC 请求一样:
flowchart TD
A["HTTP 请求进入用户服务"] --> B["Tomcat / Undertow / Netty"]
B --> C["Spring MVC DispatcherServlet"]
C --> D["HandlerMapping 找 Controller"]
D --> E["参数绑定和反序列化"]
E --> F["Controller 调用 Service"]
F --> G["返回对象"]
G --> H["序列化 JSON 响应"]所以服务调用链路横跨两个进程:
- 调用方进程:Feign、LoadBalancer、HTTP Client。
- 提供方进程:Web 容器、Spring MVC、业务代码。
只要其中任何一段慢,整体调用就慢。
核心原理:响应解码和异常处理
下游返回后,Feign 会根据状态码和响应体处理。
flowchart TD
A["收到HTTP响应"] --> B["按成功状态或错误状态分类"]
B --> C["成功由Decoder解码<br/>错误由ErrorDecoder映射"]
C --> D["最终返回Java对象或抛出异常"]成功解码和错误映射是互斥分支,纵向合并用于窄屏阅读。404是否按成功路径处理还取决于dismiss404等配置,完整边界见Feign响应链。
例如下游返回:
{
"id": 1001,
"name": "Tom"
}Feign Decoder 会转成:
public class UserDTO {
private Long id;
private String name;
public UserDTO() {
}
public UserDTO(Long id, String name) {
this.id = id;
this.name = name;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}如果下游返回 500,不应该把 HTML 错误页直接交给业务。应该通过 ErrorDecoder 转成统一异常。
public class DownstreamErrorDecoder 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("远程调用失败, status=" + response.status());
}
}超时、重试、熔断、限流在链路中的位置
真实项目里,服务调用链路通常还会加治理能力。
flowchart TD
A["业务代码"] --> B["Feign 代理"]
B --> C["限流判断"]
C --> D["熔断器判断"]
D --> E["LoadBalancer 选实例"]
E --> F["HTTP Client 发请求"]
F --> G["按成功、超时或异常记录结果"]
G --> H["成功返回<br/>失败进入重试、熔断或降级判断"]
H --> I["按治理结果返回兜底或向上抛错"]一次请求不会同时执行成功和失败路径。治理组件的真实先后顺序取决于代理和过滤器装配,排查时应通过调用栈、Observation和条件报告确认,而不是只背固定图序。
超时
超时解决的是“不能无限等下游”。
如果没有超时,下游慢时,上游线程会一直卡住。请求量一上来,上游线程池被占满,最终上游也不可用。
常见配置:
spring:
cloud:
openfeign:
client:
config:
default:
connectTimeout: 2000
readTimeout: 5000两个超时要分清:
| 超时 | 含义 | 常见原因 |
|---|---|---|
| 连接超时 | 建立连接等多久 | 实例不可达、网络不通、防火墙问题 |
| 读取超时 | 请求发出后等响应多久 | 下游处理慢、数据库慢、线程池满 |
重试
重试解决的是“偶发失败”。
但重试不是越多越好。下游已经慢了,上游大量重试会放大流量。
flowchart TD
A["下游响应变慢"] --> B["上游超时"]
B --> C["上游自动重试"]
C --> D["同一请求变成多次请求"]
D --> E["下游压力更大"]
E --> F["更多请求超时并形成重试风暴"]适合重试:
- 查询接口。
- 幂等写接口。
- 网络抖动导致的短暂失败。
不适合盲目重试:
- 创建订单。
- 扣库存。
- 支付扣款。
- 发优惠券。
写操作如果要重试,必须有业务唯一键、幂等表或状态机保护。
熔断
熔断解决的是“下游已经明显不可用,不要继续打爆它”。
flowchart TD
A["正常调用持续统计失败和慢调用"] --> B["超过阈值后打开熔断器"]
B --> C["后续请求快速失败或降级"]
C --> D["等待恢复窗口后进入半开"]
D --> E["使用少量请求探测下游"]
E --> F["探测成功关闭熔断<br/>失败则重新打开"]Hystrix、Sentinel、Resilience4j 都是在解决这类问题,只是实现和生态不同。
限流
限流解决的是“请求太多,系统来不及处理”。
限流可以发生在:
- Gateway 入口。
- 服务接口。
- Feign 调用前。
- 下游核心资源前,例如数据库、第三方接口。
限流不是错误,而是保护系统的一种主动策略。宁愿拒绝一部分请求,也不要让全部请求一起超时。
降级
降级解决的是“失败时给一个可接受的兜底结果”。
降级不能乱用。比如:
| 场景 | 可以怎么降级 | 不能怎么降级 |
|---|---|---|
| 查询用户头像 | 返回默认头像 | 返回别人的头像 |
| 查询推荐列表 | 返回默认热门列表 | 返回错误用户数据 |
| 库存扣减 | 返回系统繁忙 | 假装扣减成功 |
| 支付调用 | 返回支付处理中 | 假装支付成功 |
降级要遵守业务正确性,不能为了“接口不报错”牺牲数据一致性。
Gateway 调用链路
外部请求通常先进入 Gateway。
spring:
cloud:
gateway:
routes:
- id: order-route
uri: lb://order-service
predicates:
- Path=/orders/**lb://order-service 表示网关不写死订单服务地址,而是通过 LoadBalancer 选择订单服务实例。
Gateway 的处理流程:
flowchart TD
A["外部请求 /orders/1001"] --> B["Gateway 接收请求"]
B --> C["RoutePredicateHandlerMapping 匹配路由"]
C --> D["执行 GlobalFilter"]
D --> E["执行 GatewayFilter"]
E --> F["识别 lb://order-service"]
F --> G["LoadBalancer 选择实例"]
G --> H["NettyRoutingFilter 转发请求"]
H --> I["订单服务处理"]Gateway 适合做横切能力:
- 鉴权。
- 限流。
- 路由。
- 跨域。
- 日志。
- traceId 生成。
- 灰度路由。
不要把复杂业务逻辑写进 Gateway。网关是入口和治理层,不应该变成大业务服务。
RestTemplate、WebClient、OpenFeign 的区别
Spring Cloud 服务调用不只有 Feign。
| 调用方式 | 特点 | 适合场景 |
|---|---|---|
| RestTemplate | 命令式写法,老项目常见 | 简单 HTTP 调用、历史项目 |
| WebClient | 响应式、非阻塞 | 高并发、流式、WebFlux 项目 |
| OpenFeign | 声明式接口,最常见 | 微服务内部 HTTP 调用 |
RestTemplate 示例:
@Bean
@LoadBalanced
public RestTemplate restTemplate() {
return new RestTemplate();
}String result = restTemplate.getForObject(
"http://user-service/users/1001",
String.class
);@LoadBalanced 的作用是让 RestTemplate 具备服务名解析和负载均衡能力。
WebClient 示例:
@Bean
@LoadBalanced
public WebClient.Builder webClientBuilder() {
return WebClient.builder();
}Mono<UserDTO> userMono = webClientBuilder.build()
.get()
.uri("http://user-service/users/{id}", 1001L)
.retrieve()
.bodyToMono(UserDTO.class);链路追踪为什么重要
一次用户请求可能经过很多服务:
Gateway -> order-service -> user-service -> coupon-service -> payment-service如果没有 traceId,你只能看到每个服务各自的日志,很难知道哪些日志属于同一次请求。
推荐做法:
flowchart TD
A["Gateway 生成 traceId"] --> B["写入请求头 X-Trace-Id"]
B --> C["order-service 日志打印 traceId"]
C --> D["Feign 透传 traceId"]
D --> E["user-service 日志打印同一个 traceId"]相关技术:
- Micrometer Tracing。
- Zipkin。
- SkyWalking。
- OpenTelemetry。
- 日志 MDC。
链路追踪不是锦上添花。微服务数量变多后,没有 traceId,排查跨服务问题会非常痛苦。
常见故障和排查思路
1. 服务找不到实例
表现:
No instances available for user-service排查:
user-service是否启动。- 服务名是否拼错。
- 是否注册到同一个注册中心命名空间。
- 注册中心是否能看到实例。
- 消费者是否拉到最新实例列表。
2. 明明有实例却还调用失败
原因可能是:
- 注册中心实例状态还没刷新。
- 实例已经下线,但消费者本地缓存还没更新。
- 健康检查不准确。
- 网络不通。
- 端口暴露错误。
这就是为什么必须配置超时和熔断,不能完全依赖注册中心剔除。
3. 下游慢导致上游也慢
链路:
flowchart TD
A["用户服务 DB 慢"] --> B["用户服务响应慢"]
B --> C["订单服务 Feign 等待"]
C --> D["订单服务线程被占用"]
D --> E["订单服务请求堆积"]
E --> F["订单服务也不可用"]处理:
- 设置读取超时。
- 下游慢调用熔断。
- 上游降级。
- 优化下游数据库。
- 避免循环 Feign 调用。
- 能批量就批量。
4. 重试导致流量放大
假设一次请求超时后重试 2 次,原本 100 QPS 会变成最多 300 QPS。下游越慢,上游越重试,越容易雪崩。
处理:
- 写接口不要盲目重试。
- 查询接口重试次数要少。
- 使用退避策略。
- 配合熔断。
- 所有写操作做幂等。
5. 循环远程调用导致性能差
错误写法:
for (Long userId : userIds) {
UserDTO user = userClient.getUser(userId);
result.add(user);
}如果 userIds 有 1000 个,就会发 1000 次远程调用。
推荐改成批量:
List<UserDTO> users = userClient.getUsers(userIds);远程调用的成本远高于本地方法,必须减少调用次数。
服务调用故障排查决策树
生产里“服务调用失败”不能只看 Feign 报错。一次调用横跨 Gateway、注册中心、本地实例缓存、负载均衡、HTTP Client、下游 Web 容器、下游业务线程池、数据库和网络。要先定位失败发生在哪一层。
flowchart TD
A["服务调用失败或超时"] --> B["保存入口、状态码、traceId和第一处cause"]
B --> C["检查Gateway是否匹配和完成转发"]
C --> D["检查调用方是否取得候选并选中实例"]
D --> E["检查连接池、网络和请求是否发出"]
E --> F["检查提供方是否收到并完成处理"]
F --> G["检查响应状态、解码和治理组件"]
G --> H["按根因恢复并回归真实链路"]这张图表示取证顺序,不把所有异常压进一张左右决策树。Gateway 404/503/504、Feign无实例、连接超时、读取超时和4xx/5xx的具体证据见紧随其后的表格。
看状态码先判断层次
| 现象 | 更可能的问题层 | 优先检查 |
|---|---|---|
| Gateway 404 | 路由未匹配 | Path、Predicate、StripPrefix、路由顺序 |
| Gateway 503 | 没有可用实例或转发失败 | 注册中心、服务名、实例健康、LoadBalancer |
| Gateway 504 | 下游响应超时 | Gateway 超时、下游慢、网络 |
Feign No instances available | 服务发现层 | 服务名、命名空间、注册中心缓存 |
| Feign connect timeout | 网络连接层 | IP、端口、防火墙、Pod/容器网络 |
| Feign read timeout | 下游处理层 | 下游线程池、DB、锁、GC、第三方接口 |
| Feign 401/403 | 鉴权上下文 | Token、Header 透传、权限服务 |
| Feign 500 | 下游业务异常 | 下游日志、traceId、ErrorDecoder |
查调用方还是查提供方
很多人排查远程调用时只盯调用方日志。正确方式是先用 traceId 把调用方和提供方日志串起来。
| 证据 | 说明 |
|---|---|
| 调用方发出请求,但提供方没有日志 | 请求没到下游,查负载均衡、网络、网关 |
| 提供方收到请求,但响应慢 | 查提供方业务、线程池、DB、锁、GC |
| 提供方很快返回,调用方仍慢 | 查网络、响应体过大、客户端连接池、反序列化 |
| 调用方没有发请求 | 查熔断、限流、LoadBalancer、实例列表 |
flowchart TD
A["拿到 traceId"] --> B["查调用方日志"]
B --> C["确认是否生成目标实例和发送记录"]
C --> D["没有发送则查限流、熔断、无实例和参数"]
D --> E["有发送则按traceId查提供方"]
E --> F["未收到查网络与转发<br/>已收到查耗时与异常"]“没有发送”和“已经发送”是互斥分支,纵向合并用于适配窄屏。实际判断以调用方发送事件、最终目标IP和提供方入口日志三份证据为准。
单实例压力特别大怎么判断
如果所有服务都在线,但某一台实例 CPU、线程池、连接数明显高于其他实例,要判断是负载均衡不均、实例能力不同、灰度元数据、长连接粘滞,还是某类请求天然集中。
| 原因 | 现象 | 处理 |
|---|---|---|
| 实例规格不同 | 小规格实例更容易满 | 权重负载或统一规格 |
| 负载算法不合适 | 请求数均匀但耗时不均 | 最少连接、权重、按耗时治理 |
| 灰度规则偏流 | 某版本实例流量集中 | 检查版本、Header、元数据路由 |
| 长连接或连接池复用 | 流量粘在少数实例 | 调整连接池和负载策略 |
| 热租户/热用户 | 某些请求天然重 | 按租户限流、隔离、缓存 |
| 实例自身慢 | 它被选中后响应慢 | 下线实例、查 GC/DB/线程 |
负载均衡不是“请求数绝对平均”。真正要看的是每个实例的 QPS、P95/P99、错误率、CPU、线程池和下游依赖。如果实例请求数一样,但某台耗时明显高,问题可能在实例本身,而不是负载均衡算法。
重试和熔断怎么一起看
远程调用失败时,重试、熔断、限流、降级经常同时出现。排查顺序建议:
- 先看是否设置了合理超时。
- 再看是否开启重试,以及重试次数是否放大流量。
- 再看熔断是否打开,是否导致请求还没发出就被降级。
- 再看限流规则是否误伤正常请求。
- 最后看 fallback 是否掩盖真实错误。
flowchart TD
A["远程调用失败"] --> B["检查超时"]
B --> C["检查重试次数"]
C --> D["计算总尝试数和下游放大倍数"]
D --> E["检查熔断状态与触发原因"]
E --> F["检查限流和fallback是否掩盖错误"]
F --> G["收敛重试预算并修复下游根因"]写接口尤其要谨慎重试。支付、扣库存、创建订单这类操作必须依赖业务唯一号、幂等表或状态机,不能靠 HTTP 客户端“再试一次”赌运气。
最小完整 Demo
用户服务接口
@RestController
@RequestMapping("/users")
public class UserController {
@GetMapping("/{id}")
public UserDTO getUser(@PathVariable Long id) {
return new UserDTO(id, "user-" + id);
}
}public class UserDTO {
private Long id;
private String name;
public UserDTO() {
}
public UserDTO(Long id, String name) {
this.id = id;
this.name = name;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}订单服务 Feign Client
@FeignClient(
name = "user-service",
fallback = UserClientFallback.class
)
public interface UserClient {
@GetMapping("/users/{id}")
UserDTO getUser(@PathVariable("id") Long id);
}降级实现
@Component
public class UserClientFallback implements UserClient {
@Override
public UserDTO getUser(Long id) {
return new UserDTO(id, "unknown");
}
}注意:这个 fallback 适合非核心展示场景。如果是支付、扣库存、权限校验,不应该随便返回默认成功。
订单服务调用
@RestController
@RequestMapping("/orders")
public class OrderController {
private final UserClient userClient;
public OrderController(UserClient userClient) {
this.userClient = userClient;
}
@GetMapping("/{id}")
public OrderVO getOrder(@PathVariable Long id) {
UserDTO user = userClient.getUser(1001L);
return new OrderVO(id, user.getId(), user.getName());
}
}public class OrderVO {
private Long orderId;
private Long userId;
private String userName;
public OrderVO() {
}
public OrderVO(Long orderId, Long userId, String userName) {
this.orderId = orderId;
this.userId = userId;
this.userName = userName;
}
public Long getOrderId() {
return orderId;
}
public void setOrderId(Long orderId) {
this.orderId = orderId;
}
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public String getUserName() {
return userName;
}
public void setUserName(String userName) {
this.userName = userName;
}
}配置示例
spring:
application:
name: order-service
cloud:
nacos:
discovery:
server-addr: 127.0.0.1:8848
openfeign:
client:
config:
default:
connectTimeout: 2000
readTimeout: 5000这个 Demo 对应的真实链路:
flowchart TD
A["GET /orders/1"] --> B["OrderController"]
B --> C["UserClient Feign 代理"]
C --> D["GET /users/1001"]
D --> E["Nacos 获取 user-service 实例"]
E --> F["LoadBalancer 选择实例"]
F --> G["UserController"]
G --> H["返回 UserDTO JSON"]
H --> I["Feign 转成 UserDTO"]
I --> J["OrderController 组装 OrderVO"]独立面试入口
调用链相关标准回答不再混在知识正文中:
学习跳转
- 技术演进与选型:理解经典技术和当前技术如何对应。
- Nacos:理解服务注册、发现、配置中心和命名空间。
- Eureka:理解经典注册中心、续约、剔除和服务发现。
- OpenFeign内部原理:理解Registrar、FactoryBean、MethodHandler、编解码、连接池和重试边界。
- LoadBalancer内部原理:理解Supplier链、缓存、过滤、算法、健康检查和优雅下线。
- Gateway:理解外部请求如何路由到微服务。
- Sentinel:理解限流、熔断、降级和慢调用保护。
- Hystrix:理解经典熔断器思想和故障隔离。
- 链路追踪与可观测性:理解 traceId、span、日志和调用链如何关联。
小结
Spring Cloud 服务调用链路可以拆成三条线:
- 地址线:注册中心维护服务名到实例列表的映射。
- 调用线:Feign 或其他 HTTP 客户端把接口调用转换成网络请求。
- 治理线:LoadBalancer、超时、重试、熔断、限流、降级、链路追踪保证调用可控。
真正掌握这条链路后,再看 Feign、Gateway、Nacos、LoadBalancer、Sentinel 就不会觉得它们是零散组件,而是一套围绕“服务如何稳定互相调用”的完整机制。
