Spring Cloud LoadBalancer内部原理与生产治理
负载均衡不是“把流量平均分给所有机器”这么简单。它实际由实例数据来源、缓存、过滤器链、选择算法、重试上下文、URL重建、连接池和实例生命周期共同决定。任何一层的数据不一致,都可能表现为无实例、旧实例、慢实例、负载不均或重试风暴。
本章以以下可复核版本为样本:
| 版本线 | Java与Boot | Spring Cloud | LoadBalancer | 说明 |
|---|---|---|---|---|
| 存量线 | JDK 8、Boot 2.7.18 | 2021.0.8 | 3.1.7 | 已使用新LoadBalancer的存量系统 |
| 现代线 | Java 17、Boot 3.2.4 | 2023.0.1 | 4.1.2 | 本章源码对象和Demo基线 |
Ribbon属于更早的Netflix客户端负载均衡体系。Boot 2.7并不等于必须使用Ribbon;Cloud 2021主线已经可以使用Spring Cloud LoadBalancer。选择组件时看Cloud发布列车和依赖树,不能只看JDK版本。
一、学习目标
学完后应能说明:
- 客户端负载均衡与Nginx、Gateway等服务端负载均衡的区别。
LoadBalancerClientFactory为什么按服务名创建命名子容器。ServiceInstanceListSupplier怎样形成装饰器链。- Discovery、缓存、健康、zone、hint、权重和算法分别在哪一层。
- 默认轮询为什么不保证全局流量绝对平均。
- 为什么Nacos有实例,LoadBalancer仍可能返回空。
- 为什么服务下线后仍存在旧实例调用窗口。
- LoadBalancer重试怎样避开上次实例,又为什么仍可能打回原实例。
- 怎样实现严格灰度,而不是“找不到灰度实例就回退全量”。
- 怎样用实例级指标、日志和线程证据定位负载不均与调用失败。
二、先分清四个角色
| 角色 | 典型组件 | 职责 |
|---|---|---|
| 注册发现 | Nacos、Eureka、Consul | 维护服务名与实例数据 |
| 候选供应 | ServiceInstanceListSupplier | 拉取、缓存、过滤或变换候选列表 |
| 选择算法 | ReactorServiceInstanceLoadBalancer | 从候选中选一个实例 |
| 网络发送 | HC5、OkHttp、WebClient等 | DNS、连接池、TCP/TLS和HTTP收发 |
flowchart TD
A["注册中心产生实例变化"] --> B["DiscoveryClient更新可见实例"]
B --> C["Supplier链得到并过滤候选"]
C --> D["LoadBalancer算法选择实例"]
D --> E["重建真实URL"]
E --> F["HTTP Client发送请求"]注册中心不替调用方选最终实例,LoadBalancer也不建立TCP连接。把职责混在一起,会导致排查时只盯Nacos控制台或只调连接池参数。
三、客户端和服务端负载均衡
3.1 客户端负载均衡
每个调用方进程持有实例列表并自己选址:
order-service -> 本地LoadBalancer -> inventory-service某实例优点是少一跳、可利用服务元数据做zone和版本路由;代价是每个调用方都有独立缓存和算法状态,短时间可能看到不同世界。
3.2 服务端负载均衡
调用方只访问统一入口,由Nginx、云负载均衡或Gateway转发:
client -> Gateway/Nginx -> 某个后端实例它集中治理证书、外部流量和WAF,但多一跳且入口要高可用。商业系统通常同时使用:外部流量经服务端负载均衡,服务内部调用再使用客户端LoadBalancer。
四、源码对象职责地图
| 对象 | 职责 |
|---|---|
LoadBalancerClientFactory | 按serviceId创建命名子上下文并取得专属组件 |
ServiceInstanceListSupplier | 提供Flux<List<ServiceInstance>>候选实例 |
DiscoveryClientServiceInstanceListSupplier | 从DiscoveryClient取得服务实例 |
CachingServiceInstanceListSupplier | 缓存实例列表,miss时调用delegate |
HealthCheckServiceInstanceListSupplier | 主动探测并只发布健康结果 |
RoundRobinLoadBalancer | 本地原子游标轮询 |
RandomLoadBalancer | 从当前列表随机选一个 |
BlockingLoadBalancerClient | 阻塞式调用入口、选址、生命周期回调和执行 |
FeignBlockingLoadBalancerClient | Feign请求与LoadBalancer的桥接Client |
LoadBalancerLifecycle | 观察开始、选址后发送和完成状态 |
RetryAwareServiceInstanceListSupplier | 重试时优先排除上次实例 |
五、为什么每个服务有一个命名子容器
LoadBalancerClientFactory继承NamedContextFactory。它以serviceId为名称,为不同下游提供独立的Supplier、算法、属性和生命周期组件。
flowchart TD
A["父ApplicationContext"] --> B["LoadBalancerClientFactory"]
B --> C["按serviceId创建负载均衡子上下文"]
C --> D["inventory-service使用库存策略"]
D --> E["payment-service使用支付策略"]
E --> F["两个服务隔离Supplier、算法和属性"]这样库存服务可以使用同机房优先,支付服务可以使用严格版本路由。子上下文通常按需创建,因此第一次访问某个服务时可能出现初始化成本;高延迟敏感系统可评估eager load,但必须以当前版本配置为准。
配置类若被主应用组件扫描,可能在父容器全局生效。专属LoadBalancer配置应放在主扫描范围之外,或使用明确的嵌套配置边界。
六、启动和首次调用阶段
flowchart TD
A["LoadBalancer自动配置生效"] --> B["创建LoadBalancerClientFactory"]
B --> C["注册默认客户端配置规范"]
C --> D["首次按serviceId请求组件"]
D --> E["创建该服务命名子上下文"]
E --> F["装配Supplier与RoundRobin"]
F --> G["后续调用复用组件状态"]现代样本默认算法Bean是RoundRobinLoadBalancer。默认Supplier配置通常是DiscoveryClient加LoadBalancer缓存;启用zone、健康、权重、subset等配置时,会选择不同Supplier链。
七、一次Feign选址的完整源码主链
以OpenFeign 4.1.1和LoadBalancer 4.1.2为例:
flowchart TD
A["Feign请求host是服务名"] --> B["FeignBlockingLoadBalancerClient.execute"]
B --> C["构造RequestDataContext与hint"]
C --> D["BlockingLoadBalancerClient.choose"]
D --> E["Factory取得该服务LoadBalancer"]
E --> F["Supplier链输出候选列表"]
F --> G["算法返回ServiceInstance"]
G --> H["用实例重建URI并交给HTTP Client"]BlockingLoadBalancerClient.choose()内部取得ReactiveLoadBalancer后,会把其Publisher阻塞为同步结果。这不代表算法本身是网络客户端;响应式抽象只是统一了WebClient、Gateway和阻塞客户端的选择模型。
如果没有选中实例:
BlockingLoadBalancerClient.execute()路径会抛No instances available。- Feign桥接样本可生成本地503 Response,再由Feign ErrorDecoder转异常。
- Gateway和WebClient会以各自适配层表达失败。
所以同样的“无实例”在不同调用入口中异常形态可能不同。
八、Supplier是装饰器链,不是一个列表变量
ServiceInstanceListSupplierBuilder先创建base supplier,再按调用顺序逐层包装。最后添加的装饰器位于最外层。
flowchart TD
A["Discovery取得原始实例"] --> B["Cache复用列表"]
B --> C["Zone或版本过滤"]
C --> D["Weight或Subset变换"]
D --> E["算法从最终候选选择"]示例:
ServiceInstanceListSupplier.builder()
.withDiscoveryClient()
.withCaching()
.withZonePreference()
.build(context);构造结果概念上是:
ZonePreference(Caching(Discovery))请求先进入最外层zone过滤,zone层向缓存层取列表,缓存miss再访问Discovery。顺序会影响缓存的是原始列表还是过滤结果,因此自定义链必须画出来评审,不能只看Builder方法都有调用。
callGetWithRequestOnDelegates控制部分装饰器是否继续把Request上下文传给delegate。现代样本默认true;缓存和健康Supplier有特殊位置要求,应靠近网络实例获取层,避免把某个请求的Header过滤结果错误缓存给所有请求。
九、Discovery Supplier怎样取得实例
DiscoveryClientServiceInstanceListSupplier支持阻塞和响应式DiscoveryClient:
- 阻塞Discovery调用放到
boundedElastic执行,避免直接阻塞响应式线程。 - 现代样本服务发现调用默认有独立timeout字段,超时或异常时转换为空列表并记录日志。
- Supplier得到的实例是否已来自注册客户端本地缓存,取决于Nacos、Eureka等Discovery实现。
这意味着“LoadBalancer每次选实例都实时请求Nacos Server”通常是错误描述。Nacos客户端往往先维护自己的本地实例数据,LoadBalancer看到的是DiscoveryClient暴露结果,外面还可能再包一层LoadBalancer缓存。
十、LoadBalancer缓存原理与旧实例窗口
4.1.2样本的CachingServiceInstanceListSupplier先查LoadBalancerCacheManager:命中则返回列表,miss才从delegate取一次并写缓存。
样本默认缓存属性:
| 属性 | 默认值 | 含义 |
|---|---|---|
spring.cloud.loadbalancer.cache.enabled | 通常启用 | 是否装配LB缓存 |
spring.cloud.loadbalancer.cache.ttl | 35秒 | 写入后多久过期 |
spring.cloud.loadbalancer.cache.capacity | 256 | 初始容量语义 |
若使用自定义Caffeine spec,它会覆盖其他LoadBalancer缓存设置,包括TTL。生产应显式确认最终spec,避免以为ttl: 5s生效但实际被Caffeine字符串覆盖。
flowchart TD
A["注册中心删除实例"] --> B["注册客户端感知变化"]
B --> C["Discovery本地数据更新"]
C --> D["LoadBalancer缓存仍可能持有旧列表"]
D --> E["TTL到期或缓存刷新"]
E --> F["后续选择不再包含旧实例"]若注册客户端本身已经可靠缓存并推送,额外LB缓存会增加一层陈旧窗口。是否关闭不能一刀切,应测量Discovery调用成本、变化传播时间和故障时降级需求。
十一、RoundRobin源码原理
4.1.2样本使用AtomicInteger position保存当前进程、当前服务的轮询位置:
pos = position.incrementAndGet() & Integer.MAX_VALUE
index = pos % instances.size()特殊情况:
- 空列表返回EmptyResponse。
- 只有一个实例时直接返回,不推进position。
- 初始position使用随机种子,降低大量客户端同时从同一实例开始的概率。
flowchart TD
A["Supplier返回本次实例列表"] --> B["判断列表是否为空或单实例"]
B --> C["AtomicInteger递增并清除符号位"]
C --> D["对当前列表长度取模"]
D --> E["返回索引对应实例"]为什么轮询不保证全局绝对均匀
- 每个调用方进程有独立position,不共享全局计数。
- 每个调用方看到的实例列表顺序和版本可能不同。
- 请求耗时不同,请求数相同不代表并发和资源占用相同。
- 重试会产生额外选择。
- zone、hint、灰度、sticky等过滤后列表不同。
- 某些调用方流量本身就更大。
因此判断均衡要看实例级QPS、并发、P95/P99、CPU、线程池和错误率,不只看总请求数。
十二、常见算法和真实边界
| 算法或策略 | 输入 | 优点 | 风险或边界 |
|---|---|---|---|
| Round Robin | 当前候选列表 | 简单稳定 | 不感知实例实时负载 |
| Random | 当前候选列表 | 无共享游标 | 小样本有自然波动 |
| Weighted | metadata权重 | 适合异构容量 | 静态权重不能代表实时拥塞 |
| Zone Preference | zone元数据 | 减少跨机房流量 | 本zone为空时默认回退全量 |
| Hint | 请求hint与实例metadata | 支持版本或标签路由 | 默认找不到hint会回退全量 |
| Same Instance Preference | 上次已选实例 | 提高局部亲和 | 容易形成热点,不是强一致会话 |
| Sticky Session | 请求Cookie中的实例ID | 会话亲和 | 实例失效时回退,状态仍不应只放本机 |
| Subset | 调用方ID和实例列表 | 限制大规模连接扇出 | 4.1.0后样本能力,需精确版本 |
| P2C/EWMA | 实时或历史负载 | 更能避开慢实例 | Spring Cloud默认不直接等于已启用,需要自定义和可靠指标 |
12.1 Weighted不是注册中心权重自动万能生效
现代样本的WeightedServiceInstanceListSupplier读取实例metadata中的weight,要求正整数;缺失、解析异常或非正数会回退默认权重1。它生成逻辑加权列表,再由后续算法选择。
必须确认:
- 注册中心权重是否映射成metadata的
weight键。 - 当前Supplier配置是否真的选择了
weighted。 - 权重变更是否穿过Discovery和缓存传播。
- 低权重不等于立即零流量;要摘流应使用明确下线机制。
12.2 Zone和Hint默认是偏好,不是严格隔离
源码行为很重要:
- Zone过滤找不到同zone实例时,返回原始全部实例。
- Hint过滤找不到匹配实例时,也返回原始全部实例。
这对高可用友好,但对“v2请求绝不能进入v1”或“敏感租户绝不能跨区”并不安全。严格灰度必须自定义Supplier:匹配为空时明确失败或走受控回退策略,而不是默认全量。
12.3 RetryAware怎样避开上次实例
当Request上下文是RetryableRequestContext且包含previous instance时,RetryAwareServiceInstanceListSupplier从列表移除上次实例。若移除后仍有实例,就选其他实例;若列表只剩上次实例,它会记录警告并返回原列表。
所以“LoadBalancer重试一定换机器”也是错误说法。只有候选中存在其他实例并且重试链正确携带上次实例时,才会换实例。
12.4 Subset解决什么
当调用方和提供方都有成百上千实例时,让每个调用方连接所有提供方会产生连接、健康检查和缓存扇出。Subset使用调用方实例ID对提供方列表做确定性分桶,使同一调用方相对稳定地只访问一个子集。
它降低连接扇出,不代表全局流量天然均匀。实例列表变化可能重新分桶,subset大小过小会降低故障冗余,过大又失去控制扇出的意义。
十三、客户端健康检查原理
HealthCheckServiceInstanceListSupplier对delegate实例调用健康端点,并通过replay(1).refCount(1)保存最近健康结果。现代样本默认路径为/actuator/health,可配置初始延迟、检查间隔、实例重新获取间隔、端口和是否持续重复。
flowchart TD
A["Discovery提供实例列表"] --> B["按实例调用健康端点"]
B --> C["超时或异常视为不健康"]
C --> D["合并健康实例结果"]
D --> E["回放最近列表给选择算法"]
E --> F["按间隔重复检查"]生产注意:
- 注册中心已经做健康管理时,再对每个实例主动探测会增加流量。
- 健康端点不能做昂贵全依赖深检查,否则依赖抖动会让所有实例同时被摘除。
- 检查间隔也是超时上限的一部分,配置过短可能误判。
- 健康列表暂时为空会导致无实例,必须监控而不是静默回退。
- 管理端口与业务端口分离时要显式配置health-check port。
十四、LoadBalancerLifecycle能观测什么
阻塞客户端主链会调用:
onStart(request):选择前。onStartRequest(request, response):已经选到实例、即将执行。onComplete(context):成功、失败或DISCARD。
Lifecycle适合记录:
- serviceId和请求hint。
- 候选数量与选中实例。
- 选择耗时和最终状态。
- retry previous instance。
- 规范化URI和响应状态。
不要在Lifecycle做同步远程日志上报,否则观测组件本身会增加调用延迟或递归调用。
十五、LoadBalancer重试不是算法的一部分
现代属性对象包含:
| 属性 | 样本默认值 | 含义 |
|---|---|---|
retry.enabled | true | 属性层允许重试 |
maxRetriesOnSameServiceInstance | 0 | 同实例额外次数 |
maxRetriesOnNextServiceInstance | 1 | 换实例额外次数 |
retryOnAllOperations | false | 是否对非GET也尝试 |
这些默认字段不等于所有调用入口一定启用了重试。阻塞Feign重试Client还依赖Spring Retry相关class、自动配置条件和属性;响应式重试链也有自己的条件。必须看最终Client类型和条件报告。
flowchart TD
A["第一次选择实例并调用"] --> B["判断异常或状态是否可重试"]
B --> C["检查操作类型和重试预算"]
C --> D["记录previous instance"]
D --> E["重新取得候选并尽量换实例"]
E --> F["预算耗尽后返回最终失败"]写请求默认不应因网络错误自动换实例重试。第一个实例可能已经执行成功,第二次会重复写。必须先建立幂等键、状态查询和补偿。
十六、Ribbon与Spring Cloud LoadBalancer的原理对比
| 维度 | Ribbon | Spring Cloud LoadBalancer |
|---|---|---|
| 所属生态 | Netflix OSS | Spring Cloud Commons |
| 状态 | 老系统维护 | 当前Spring Cloud主线 |
| 实例模型 | Server | ServiceInstance |
| 列表来源 | ServerList、ServerListFilter | ServiceInstanceListSupplier装饰链 |
| 算法 | IRule | ReactorServiceInstanceLoadBalancer |
| 探活 | IPing | 注册发现和HealthCheck Supplier |
| 容器隔离 | Ribbon Client配置 | LoadBalancerClientFactory命名上下文 |
| 响应式集成 | 不是设计主线 | Gateway、WebClient与阻塞客户端统一抽象 |
Ribbon常见主链是ILoadBalancer -> ServerList -> ServerListFilter -> IPing -> IRule。迁移不能把自定义IRule类名直接改掉;要拆分它究竟在做实例获取、过滤、健康还是选择,然后映射到Supplier或LoadBalancer扩展点。
十七、负载不均的十二类原因
- 每个调用方有独立轮询位置。
- 调用方看到的实例列表不同或顺序不同。
- LB缓存TTL造成变化传播差异。
- zone、hint、灰度或租户过滤导致候选不同。
- sticky或same-instance偏好形成热点。
- 实例规格不同却使用等权轮询。
- 某些请求耗时远长于其他请求。
- 重试额外命中某些实例。
- 长连接、HTTP/2复用和每路由连接池限制改变并发分布。
- 部分实例刚启动还未预热。
- 某实例慢SQL、Full GC或线程池拥塞但健康检查仍通过。
- 指标聚合维度错误,只看Pod累计请求而没按时间窗口和入口拆分。
十八、为什么默认轮询避不开慢实例
注册中心健康通常只表达“实例还活着”,轮询只读取实例列表和游标,不知道实时延迟。某实例发生Full GC、线程池排队或数据库慢查询时,仍可能被持续选中。
治理手段:
- 实例级P95/P99、并发和错误率监控。
- 慢调用熔断和隔离。
- Readiness或注册元数据摘流。
- 经过验证的动态权重。
- 自定义P2C/EWMA算法时处理指标延迟、冷启动和异常值。
不要让选择算法直接同步查询监控系统。算法必须在本地使用低成本、时效明确的数据,否则监控故障会变成调用故障。
十九、优雅下线为什么必须等待传播
flowchart TD
A["实例Readiness变为不可接流量"] --> B["注册中心摘除或标记下线"]
B --> C["调用方Discovery数据更新"]
C --> D["LoadBalancer缓存旧列表过期"]
D --> E["存量请求与连接排空"]
E --> F["停止业务线程、消费者和进程"]等待时间至少考虑:
- 注册中心检测或注销传播。
- Discovery客户端订阅更新。
- LoadBalancer缓存TTL。
- 网关和其他调用方缓存。
- 最长正常请求和连接排空。
- Kubernetes Endpoint更新与负载均衡传播。
强杀进程会让仍选中旧实例的调用失败。仅调用注销后立即退出,也无法覆盖客户端缓存窗口。
二十、Java 17与Boot 3严格版本路由Demo
内置Hint Supplier在没有匹配实例时会回退全部实例。下面实现“请求指定v2时,找不到v2就返回空列表”,适用于不能错误回退旧协议的严格灰度。
20.1 应用绑定专属配置
@SpringBootApplication
@LoadBalancerClient(
name = "inventory-service",
configuration = InventoryLoadBalancerConfiguration.class)
public class OrderApplication {
public static void main(String[] args) {
SpringApplication.run(OrderApplication.class, args);
}
}InventoryLoadBalancerConfiguration应放在主应用组件扫描范围之外,避免成为所有服务的全局配置。
20.2 构造Supplier链
public class InventoryLoadBalancerConfiguration {
@Bean
ServiceInstanceListSupplier inventorySupplier(
ConfigurableApplicationContext context) {
ServiceInstanceListSupplier base = ServiceInstanceListSupplier.builder()
.withDiscoveryClient()
.withCaching()
.build(context);
return new StrictVersionServiceInstanceListSupplier(base);
}
}20.3 严格过滤实现
public final class StrictVersionServiceInstanceListSupplier
extends DelegatingServiceInstanceListSupplier {
public StrictVersionServiceInstanceListSupplier(
ServiceInstanceListSupplier delegate) {
super(delegate);
}
@Override
public Flux<List<ServiceInstance>> get() {
return delegate.get();
}
@Override
public Flux<List<ServiceInstance>> get(Request request) {
return delegate.get(request)
.map(instances -> filter(instances, requestedVersion(request)));
}
private String requestedVersion(Request request) {
Object context = request.getContext();
if (context instanceof RequestDataContext requestDataContext
&& requestDataContext.getClientRequest() != null) {
return requestDataContext.getClientRequest()
.getHeaders().getFirst("X-Service-Version");
}
return null;
}
private List<ServiceInstance> filter(
List<ServiceInstance> instances, String version) {
if (version == null || version.isBlank()) {
return instances;
}
return instances.stream()
.filter(instance -> version.equals(
instance.getMetadata().get("version")))
.toList();
}
}Java 17的Stream.toList()返回不可修改列表,适合本例只读候选。JDK 8分支必须改成collect(Collectors.toList())。
严格过滤为空会让调用失败,这是有意的安全策略。若业务允许回退,应在需求中明确“回退稳定版、失败还是转人工”,并记录指标,不能静默回退。
二十一、JDK 8本地轮询原理Demo
下面代码无需Spring依赖,可直接用JDK 8编译,证明两个调用方拥有独立游标,且列表顺序不同会产生不同结果:
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
public class LocalRoundRobinDemo {
static final class LocalRoundRobin {
private final AtomicInteger position = new AtomicInteger(-1);
String choose(List<String> instances) {
if (instances.isEmpty()) {
throw new IllegalStateException("no instance");
}
int pos = position.incrementAndGet() & Integer.MAX_VALUE;
return instances.get(pos % instances.size());
}
}
public static void main(String[] args) {
LocalRoundRobin clientA = new LocalRoundRobin();
LocalRoundRobin clientB = new LocalRoundRobin();
List<String> listA = Arrays.asList("node-1", "node-2", "node-3");
List<String> listB = Arrays.asList("node-3", "node-1", "node-2");
for (int i = 0; i < 4; i++) {
System.out.println("A=" + clientA.choose(listA)
+ ", B=" + clientB.choose(listB));
}
}
}编译运行:
javac -encoding UTF-8 -source 8 -target 8 LocalRoundRobinDemo.java
java LocalRoundRobinDemo这个Demo只说明本地游标原理,不声称完全复刻4.1.2所有回调和响应式对象。
二十二、生产配置示例与解释
spring:
cloud:
loadbalancer:
cache:
enabled: true
ttl: 5s
capacity: 256
retry:
enabled: false
clients:
inventory-service:
health-check:
path:
default: /actuator/health/readiness
interval: 10s这不是通用最优值:
- TTL要小于可接受的旧实例窗口,同时避免频繁访问昂贵Discovery实现。
- 主动健康检查是否启用,要看注册中心健康机制和实例规模。
- 写服务默认关闭透明重试,读服务也要按总Deadline和容量决定。
- per-client属性覆盖全局默认,排查必须看最终绑定值。
二十三、商业场景:同城优先的订单平台
订单服务部署在上海和北京,库存服务也有两地实例。目标不是“永不跨城”,而是正常同城、同城全不可用时按业务决定跨城或失败。
| 场景 | 建议策略 |
|---|---|
| 普通库存查询 | 同城优先,空时可回退异地 |
| 强地域合规数据 | 严格zone,空时失败,不跨区 |
| 灰度v2协议 | 严格version,空时失败或显式回稳定协议 |
| 异构实例 | 静态权重作为容量初值,再用指标校准 |
| 大规模实例 | subset减少连接扇出并保留冗余 |
flowchart TD
A["请求携带zone和版本意图"] --> B["Discovery提供全部可见实例"]
B --> C["先执行合规或版本严格过滤"]
C --> D["再执行同城偏好和权重"]
D --> E["算法选择最终实例"]
E --> F["记录候选数、选中zone和回退原因"]严格条件必须在偏好条件之前明确。若先执行默认Hint并回退全量,再执行轮询,可能让v2请求进入v1实例。
二十四、失败窗口表
| 窗口 | 现象 | 证据 | 恢复 |
|---|---|---|---|
| Discovery不可用 | Supplier超时后给空列表 | Discovery错误日志与候选数0 | 恢复注册客户端,评估缓存降级 |
| 注册已删除、缓存未过期 | 仍选到旧实例 | 实例更新时间、LB缓存TTL | 等待/失效缓存并完善摘流等待 |
| 过滤后为空 | 控制台有实例但调用无实例 | 原始列表与每层过滤数量 | 修metadata、请求hint或回退策略 |
| 本地轮询偏斜 | 少量窗口请求不均 | 每调用方position无法全局共享 | 看更长窗口和实例资源指标 |
| 慢实例仍健康 | 某实例P99高且持续接流量 | Trace、GC、线程池、SQL | 摘流、熔断、修实例根因 |
| 重试放大 | 原始QPS低但下游尝试数高 | retry attempt和previous instance | 降低预算、限流、熔断 |
| 健康探测风暴 | 每个调用方探测所有实例 | health-check QPS和连接数 | 降频、集中健康、使用subset |
| 子上下文配置污染 | 多个服务使用错误算法 | Bean来源与配置类扫描路径 | 隔离配置并重启验证 |
二十五、生产排查Runbook
25.1 无实例
flowchart TD
A["出现No instances或本地503"] --> B["确认serviceId和调用方环境"]
B --> C["导出Discovery原始实例列表"]
C --> D["逐层记录缓存和过滤后数量"]
D --> E["确认最终LoadBalancer Bean与算法"]
E --> F["修复后用真实调用验证"]检查顺序:
- Feign原始URL的host究竟是什么。
- 调用方namespace、group、serviceId和注册中心地址。
- DiscoveryClient当前返回多少实例,而不是控制台多少。
- LB缓存中是什么版本的列表。
- zone、hint、version、health、subset每层剩多少。
- 选址是否返回EmptyResponse。
25.2 选到旧实例
保存四个时间:提供者停止时间、注册中心移除时间、调用方Discovery更新时间、LB缓存失效时间。再核对失败请求的最终目标IP。没有时间线,只说“缓存问题”无法确定是哪一层缓存。
25.3 负载不均
按调用方和提供方双向拆指标:
- 每个调用方对各实例的选择次数。
- 每个实例实际接收QPS、并发、P95/P99和错误率。
- 候选列表版本、顺序和长度。
- zone、version、hint、sticky命中情况。
- 连接池每路由上限和等待时间。
- 重试次数与首次/重试实例。
25.4 慢实例
如果选择次数均匀但一台实例并发持续高,说明请求耗时或实例容量不同,不是轮询游标错误。继续查该实例CPU、GC、线程池、连接池、慢SQL和下游依赖。
25.5 恢复动作
| 根因 | 合理动作 | 不应做 |
|---|---|---|
| 单实例故障 | Readiness摘流并保留现场 | 给所有调用增加重试 |
| Metadata错误 | 修正注册元数据并验证过滤 | 临时关闭全部隔离规则 |
| LB缓存过长 | 调整TTL并测Discovery压力 | 每次请求直接打注册Server |
| 权重不合适 | 按容量和指标渐进调整 | 瞬间把权重从1改到100 |
| 下游整体过载 | 限流、熔断、扩容、降级 | 只换负载均衡算法 |
二十六、应监控哪些指标
serviceId维度候选实例数量。- 原始、缓存后、过滤后列表长度。
- 选中实例、zone、version与算法。
- 选址耗时和空结果次数。
- 每实例请求数、并发、错误率、P95/P99。
- retry attempt、same/next instance次数。
- LB缓存命中、miss、TTL和列表年龄。
- 健康检查成功率、耗时和探测QPS。
- 下线后旧实例最后一次被选中的时间。
实例ID适合受控日志或Trace属性;直接作为Metrics高基数Tag前要评估实例规模和生命周期,否则频繁扩缩容会制造大量时间序列。
二十七、常见反模式
- 只看注册中心控制台,不看调用方实际候选。
- 把RoundRobin理解成全局精确平均。
- 把Hint默认回退当成严格灰度。
- 注册客户端已有缓存,还叠加长TTL却没有传播SLO。
- 每个调用方主动高频探测所有提供方实例。
- 写操作启用跨实例透明重试但没有幂等键。
- 慢实例问题只通过加权掩盖,不修GC、线程池或SQL。
- 自定义配置类被全局扫描,所有服务使用同一策略。
- 权重配置变化后只看控制台,不确认调用方是否收敛。
- 优雅下线只注销注册信息后立即退出进程。
二十八、源码阅读路线
以LoadBalancer 4.1.2为例:
| 问题 | 源码入口 |
|---|---|
| 每服务子容器 | LoadBalancerClientFactory |
| 默认算法 | LoadBalancerClientConfiguration.reactorServiceInstanceLoadBalancer() |
| 默认Supplier链 | LoadBalancerClientConfiguration的Reactive/Blocking配置 |
| Builder包装顺序 | ServiceInstanceListSupplierBuilder.build() |
| Discovery获取 | DiscoveryClientServiceInstanceListSupplier |
| 缓存读写 | CachingServiceInstanceListSupplier |
| 默认缓存属性 | LoadBalancerCacheProperties |
| 轮询游标 | RoundRobinLoadBalancer.getInstanceResponse() |
| 权重处理 | WeightedServiceInstanceListSupplier |
| zone回退 | ZonePreferenceServiceInstanceListSupplier |
| hint回退 | HintBasedServiceInstanceListSupplier |
| 重试避开上次实例 | RetryAwareServiceInstanceListSupplier |
| 子集算法 | SubsetServiceInstanceListSupplier |
| 主动健康 | HealthCheckServiceInstanceListSupplier |
| 阻塞选址与生命周期 | BlockingLoadBalancerClient |
| Feign桥接 | FeignBlockingLoadBalancerClient |
二十九、关联知识点
- LoadBalancer与Ribbon零基础页面
- LoadBalancer独立面试题
- OpenFeign内部原理
- 完整服务调用链
- Nacos内部原理
- 分布式负载均衡、P2C与EWMA
- 优雅停机与Boot 3生产治理
本章小结
Spring Cloud LoadBalancer先通过命名子容器取得服务专属组件,再由Supplier装饰链生成候选列表,最后由本地算法选择ServiceInstance并重建URL。缓存、过滤、重试和HTTP连接池都能改变最终表现。真正的生产治理不是更换一个算法名字,而是定义候选数据SLO、严格与偏好路由边界、重试预算、下线传播时间和实例级可观测证据。
