Skip to content

Spring Cloud LoadBalancer内部原理与生产治理

负载均衡不是“把流量平均分给所有机器”这么简单。它实际由实例数据来源、缓存、过滤器链、选择算法、重试上下文、URL重建、连接池和实例生命周期共同决定。任何一层的数据不一致,都可能表现为无实例、旧实例、慢实例、负载不均或重试风暴。

本章以以下可复核版本为样本:

版本线Java与BootSpring CloudLoadBalancer说明
存量线JDK 8、Boot 2.7.182021.0.83.1.7已使用新LoadBalancer的存量系统
现代线Java 17、Boot 3.2.42023.0.14.1.2本章源码对象和Demo基线

Ribbon属于更早的Netflix客户端负载均衡体系。Boot 2.7并不等于必须使用Ribbon;Cloud 2021主线已经可以使用Spring Cloud LoadBalancer。选择组件时看Cloud发布列车和依赖树,不能只看JDK版本。

一、学习目标

学完后应能说明:

  1. 客户端负载均衡与Nginx、Gateway等服务端负载均衡的区别。
  2. LoadBalancerClientFactory为什么按服务名创建命名子容器。
  3. ServiceInstanceListSupplier怎样形成装饰器链。
  4. Discovery、缓存、健康、zone、hint、权重和算法分别在哪一层。
  5. 默认轮询为什么不保证全局流量绝对平均。
  6. 为什么Nacos有实例,LoadBalancer仍可能返回空。
  7. 为什么服务下线后仍存在旧实例调用窗口。
  8. LoadBalancer重试怎样避开上次实例,又为什么仍可能打回原实例。
  9. 怎样实现严格灰度,而不是“找不到灰度实例就回退全量”。
  10. 怎样用实例级指标、日志和线程证据定位负载不均与调用失败。

二、先分清四个角色

角色典型组件职责
注册发现Nacos、Eureka、Consul维护服务名与实例数据
候选供应ServiceInstanceListSupplier拉取、缓存、过滤或变换候选列表
选择算法ReactorServiceInstanceLoadBalancer从候选中选一个实例
网络发送HC5、OkHttp、WebClient等DNS、连接池、TCP/TLS和HTTP收发
mermaid
flowchart TD
    A["注册中心产生实例变化"] --> B["DiscoveryClient更新可见实例"]
    B --> C["Supplier链得到并过滤候选"]
    C --> D["LoadBalancer算法选择实例"]
    D --> E["重建真实URL"]
    E --> F["HTTP Client发送请求"]

注册中心不替调用方选最终实例,LoadBalancer也不建立TCP连接。把职责混在一起,会导致排查时只盯Nacos控制台或只调连接池参数。

三、客户端和服务端负载均衡

3.1 客户端负载均衡

每个调用方进程持有实例列表并自己选址:

text
order-service -> 本地LoadBalancer -> inventory-service某实例

优点是少一跳、可利用服务元数据做zone和版本路由;代价是每个调用方都有独立缓存和算法状态,短时间可能看到不同世界。

3.2 服务端负载均衡

调用方只访问统一入口,由Nginx、云负载均衡或Gateway转发:

text
client -> Gateway/Nginx -> 某个后端实例

它集中治理证书、外部流量和WAF,但多一跳且入口要高可用。商业系统通常同时使用:外部流量经服务端负载均衡,服务内部调用再使用客户端LoadBalancer。

四、源码对象职责地图

对象职责
LoadBalancerClientFactory按serviceId创建命名子上下文并取得专属组件
ServiceInstanceListSupplier提供Flux<List<ServiceInstance>>候选实例
DiscoveryClientServiceInstanceListSupplier从DiscoveryClient取得服务实例
CachingServiceInstanceListSupplier缓存实例列表,miss时调用delegate
HealthCheckServiceInstanceListSupplier主动探测并只发布健康结果
RoundRobinLoadBalancer本地原子游标轮询
RandomLoadBalancer从当前列表随机选一个
BlockingLoadBalancerClient阻塞式调用入口、选址、生命周期回调和执行
FeignBlockingLoadBalancerClientFeign请求与LoadBalancer的桥接Client
LoadBalancerLifecycle观察开始、选址后发送和完成状态
RetryAwareServiceInstanceListSupplier重试时优先排除上次实例

五、为什么每个服务有一个命名子容器

LoadBalancerClientFactory继承NamedContextFactory。它以serviceId为名称,为不同下游提供独立的Supplier、算法、属性和生命周期组件。

mermaid
flowchart TD
    A["父ApplicationContext"] --> B["LoadBalancerClientFactory"]
    B --> C["按serviceId创建负载均衡子上下文"]
    C --> D["inventory-service使用库存策略"]
    D --> E["payment-service使用支付策略"]
    E --> F["两个服务隔离Supplier、算法和属性"]

这样库存服务可以使用同机房优先,支付服务可以使用严格版本路由。子上下文通常按需创建,因此第一次访问某个服务时可能出现初始化成本;高延迟敏感系统可评估eager load,但必须以当前版本配置为准。

配置类若被主应用组件扫描,可能在父容器全局生效。专属LoadBalancer配置应放在主扫描范围之外,或使用明确的嵌套配置边界。

六、启动和首次调用阶段

mermaid
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为例:

mermaid
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,再按调用顺序逐层包装。最后添加的装饰器位于最外层。

mermaid
flowchart TD
    A["Discovery取得原始实例"] --> B["Cache复用列表"]
    B --> C["Zone或版本过滤"]
    C --> D["Weight或Subset变换"]
    D --> E["算法从最终候选选择"]

示例:

java
ServiceInstanceListSupplier.builder()
        .withDiscoveryClient()
        .withCaching()
        .withZonePreference()
        .build(context);

构造结果概念上是:

text
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.ttl35秒写入后多久过期
spring.cloud.loadbalancer.cache.capacity256初始容量语义

若使用自定义Caffeine spec,它会覆盖其他LoadBalancer缓存设置,包括TTL。生产应显式确认最终spec,避免以为ttl: 5s生效但实际被Caffeine字符串覆盖。

mermaid
flowchart TD
    A["注册中心删除实例"] --> B["注册客户端感知变化"]
    B --> C["Discovery本地数据更新"]
    C --> D["LoadBalancer缓存仍可能持有旧列表"]
    D --> E["TTL到期或缓存刷新"]
    E --> F["后续选择不再包含旧实例"]

若注册客户端本身已经可靠缓存并推送,额外LB缓存会增加一层陈旧窗口。是否关闭不能一刀切,应测量Discovery调用成本、变化传播时间和故障时降级需求。

十一、RoundRobin源码原理

4.1.2样本使用AtomicInteger position保存当前进程、当前服务的轮询位置:

text
pos = position.incrementAndGet() & Integer.MAX_VALUE
index = pos % instances.size()

特殊情况:

  • 空列表返回EmptyResponse。
  • 只有一个实例时直接返回,不推进position。
  • 初始position使用随机种子,降低大量客户端同时从同一实例开始的概率。
mermaid
flowchart TD
    A["Supplier返回本次实例列表"] --> B["判断列表是否为空或单实例"]
    B --> C["AtomicInteger递增并清除符号位"]
    C --> D["对当前列表长度取模"]
    D --> E["返回索引对应实例"]

为什么轮询不保证全局绝对均匀

  1. 每个调用方进程有独立position,不共享全局计数。
  2. 每个调用方看到的实例列表顺序和版本可能不同。
  3. 请求耗时不同,请求数相同不代表并发和资源占用相同。
  4. 重试会产生额外选择。
  5. zone、hint、灰度、sticky等过滤后列表不同。
  6. 某些调用方流量本身就更大。

因此判断均衡要看实例级QPS、并发、P95/P99、CPU、线程池和错误率,不只看总请求数。

十二、常见算法和真实边界

算法或策略输入优点风险或边界
Round Robin当前候选列表简单稳定不感知实例实时负载
Random当前候选列表无共享游标小样本有自然波动
Weightedmetadata权重适合异构容量静态权重不能代表实时拥塞
Zone Preferencezone元数据减少跨机房流量本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。它生成逻辑加权列表,再由后续算法选择。

必须确认:

  1. 注册中心权重是否映射成metadata的weight键。
  2. 当前Supplier配置是否真的选择了weighted
  3. 权重变更是否穿过Discovery和缓存传播。
  4. 低权重不等于立即零流量;要摘流应使用明确下线机制。

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,可配置初始延迟、检查间隔、实例重新获取间隔、端口和是否持续重复。

mermaid
flowchart TD
    A["Discovery提供实例列表"] --> B["按实例调用健康端点"]
    B --> C["超时或异常视为不健康"]
    C --> D["合并健康实例结果"]
    D --> E["回放最近列表给选择算法"]
    E --> F["按间隔重复检查"]

生产注意:

  • 注册中心已经做健康管理时,再对每个实例主动探测会增加流量。
  • 健康端点不能做昂贵全依赖深检查,否则依赖抖动会让所有实例同时被摘除。
  • 检查间隔也是超时上限的一部分,配置过短可能误判。
  • 健康列表暂时为空会导致无实例,必须监控而不是静默回退。
  • 管理端口与业务端口分离时要显式配置health-check port。

十四、LoadBalancerLifecycle能观测什么

阻塞客户端主链会调用:

  1. onStart(request):选择前。
  2. onStartRequest(request, response):已经选到实例、即将执行。
  3. onComplete(context):成功、失败或DISCARD。

Lifecycle适合记录:

  • serviceId和请求hint。
  • 候选数量与选中实例。
  • 选择耗时和最终状态。
  • retry previous instance。
  • 规范化URI和响应状态。

不要在Lifecycle做同步远程日志上报,否则观测组件本身会增加调用延迟或递归调用。

十五、LoadBalancer重试不是算法的一部分

现代属性对象包含:

属性样本默认值含义
retry.enabledtrue属性层允许重试
maxRetriesOnSameServiceInstance0同实例额外次数
maxRetriesOnNextServiceInstance1换实例额外次数
retryOnAllOperationsfalse是否对非GET也尝试

这些默认字段不等于所有调用入口一定启用了重试。阻塞Feign重试Client还依赖Spring Retry相关class、自动配置条件和属性;响应式重试链也有自己的条件。必须看最终Client类型和条件报告。

mermaid
flowchart TD
    A["第一次选择实例并调用"] --> B["判断异常或状态是否可重试"]
    B --> C["检查操作类型和重试预算"]
    C --> D["记录previous instance"]
    D --> E["重新取得候选并尽量换实例"]
    E --> F["预算耗尽后返回最终失败"]

写请求默认不应因网络错误自动换实例重试。第一个实例可能已经执行成功,第二次会重复写。必须先建立幂等键、状态查询和补偿。

十六、Ribbon与Spring Cloud LoadBalancer的原理对比

维度RibbonSpring Cloud LoadBalancer
所属生态Netflix OSSSpring Cloud Commons
状态老系统维护当前Spring Cloud主线
实例模型ServerServiceInstance
列表来源ServerListServerListFilterServiceInstanceListSupplier装饰链
算法IRuleReactorServiceInstanceLoadBalancer
探活IPing注册发现和HealthCheck Supplier
容器隔离Ribbon Client配置LoadBalancerClientFactory命名上下文
响应式集成不是设计主线Gateway、WebClient与阻塞客户端统一抽象

Ribbon常见主链是ILoadBalancer -> ServerList -> ServerListFilter -> IPing -> IRule。迁移不能把自定义IRule类名直接改掉;要拆分它究竟在做实例获取、过滤、健康还是选择,然后映射到Supplier或LoadBalancer扩展点。

十七、负载不均的十二类原因

  1. 每个调用方有独立轮询位置。
  2. 调用方看到的实例列表不同或顺序不同。
  3. LB缓存TTL造成变化传播差异。
  4. zone、hint、灰度或租户过滤导致候选不同。
  5. sticky或same-instance偏好形成热点。
  6. 实例规格不同却使用等权轮询。
  7. 某些请求耗时远长于其他请求。
  8. 重试额外命中某些实例。
  9. 长连接、HTTP/2复用和每路由连接池限制改变并发分布。
  10. 部分实例刚启动还未预热。
  11. 某实例慢SQL、Full GC或线程池拥塞但健康检查仍通过。
  12. 指标聚合维度错误,只看Pod累计请求而没按时间窗口和入口拆分。

十八、为什么默认轮询避不开慢实例

注册中心健康通常只表达“实例还活着”,轮询只读取实例列表和游标,不知道实时延迟。某实例发生Full GC、线程池排队或数据库慢查询时,仍可能被持续选中。

治理手段:

  • 实例级P95/P99、并发和错误率监控。
  • 慢调用熔断和隔离。
  • Readiness或注册元数据摘流。
  • 经过验证的动态权重。
  • 自定义P2C/EWMA算法时处理指标延迟、冷启动和异常值。

不要让选择算法直接同步查询监控系统。算法必须在本地使用低成本、时效明确的数据,否则监控故障会变成调用故障。

十九、优雅下线为什么必须等待传播

mermaid
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 应用绑定专属配置

java
@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链

java
public class InventoryLoadBalancerConfiguration {
    @Bean
    ServiceInstanceListSupplier inventorySupplier(
            ConfigurableApplicationContext context) {
        ServiceInstanceListSupplier base = ServiceInstanceListSupplier.builder()
                .withDiscoveryClient()
                .withCaching()
                .build(context);
        return new StrictVersionServiceInstanceListSupplier(base);
    }
}

20.3 严格过滤实现

java
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编译,证明两个调用方拥有独立游标,且列表顺序不同会产生不同结果:

java
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));
        }
    }
}

编译运行:

bash
javac -encoding UTF-8 -source 8 -target 8 LocalRoundRobinDemo.java
java LocalRoundRobinDemo

这个Demo只说明本地游标原理,不声称完全复刻4.1.2所有回调和响应式对象。

二十二、生产配置示例与解释

yaml
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减少连接扇出并保留冗余
mermaid
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 无实例

mermaid
flowchart TD
    A["出现No instances或本地503"] --> B["确认serviceId和调用方环境"]
    B --> C["导出Discovery原始实例列表"]
    C --> D["逐层记录缓存和过滤后数量"]
    D --> E["确认最终LoadBalancer Bean与算法"]
    E --> F["修复后用真实调用验证"]

检查顺序:

  1. Feign原始URL的host究竟是什么。
  2. 调用方namespace、group、serviceId和注册中心地址。
  3. DiscoveryClient当前返回多少实例,而不是控制台多少。
  4. LB缓存中是什么版本的列表。
  5. zone、hint、version、health、subset每层剩多少。
  6. 选址是否返回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前要评估实例规模和生命周期,否则频繁扩缩容会制造大量时间序列。

二十七、常见反模式

  1. 只看注册中心控制台,不看调用方实际候选。
  2. 把RoundRobin理解成全局精确平均。
  3. 把Hint默认回退当成严格灰度。
  4. 注册客户端已有缓存,还叠加长TTL却没有传播SLO。
  5. 每个调用方主动高频探测所有提供方实例。
  6. 写操作启用跨实例透明重试但没有幂等键。
  7. 慢实例问题只通过加权掩盖,不修GC、线程池或SQL。
  8. 自定义配置类被全局扫描,所有服务使用同一策略。
  9. 权重配置变化后只看控制台,不确认调用方是否收敛。
  10. 优雅下线只注销注册信息后立即退出进程。

二十八、源码阅读路线

以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

二十九、关联知识点

本章小结

Spring Cloud LoadBalancer先通过命名子容器取得服务专属组件,再由Supplier装饰链生成候选列表,最后由本地算法选择ServiceInstance并重建URL。缓存、过滤、重试和HTTP连接池都能改变最终表现。真正的生产治理不是更换一个算法名字,而是定义候选数据SLO、严格与偏好路由边界、重试预算、下线传播时间和实例级可观测证据。