《Java 服务端通信工程》5.3 round_robin、pick_first 与健康检查

拿到地址列表只是开始,选哪一条、怎么判断它还能不能用才是负载均衡。本节对比 pick_first 与 round_robin 的子通道行为,用 HealthStatusManager 和 HealthGrpc 做出真实的 SERVING/NOT_SERVING 探活,并给出 linkd-v3 的探活周期与抖动控制。

本节目标:知道 pick_first 和 round_robin 各自维护什么状态、什么时候切换子通道,能用服务端健康服务和客户端探活把「不健康」变成解析结果里的「不出现」。
适用版本:JDK 25 + gRPC-Java 1.76.0(grpc-netty-shaded)+ grpc-services 1.76.0。

承接的问题

5.2 的 resolver 把 StaticRegistry 里的地址列表交给了 gRPC。接下来 gRPC 要回答两个问题:这么多地址,这次调用发给谁?发过去之前,怎么知道它还活着?

第一个问题由负载均衡策略决定。gRPC 默认是 pick_first:取列表第一个地址,连上就一直用它,直到它失败才换下一个。对两个实例的 libraryd,这意味着所有流量都压在第一个实例上,第二个只是热备。要让流量真正分开,需要 round_robin。

第二个问题更隐蔽。gRPC 的 subchannel 进入 READY 只代表 TCP 和 HTTP/2 握手成功,不代表业务能处理请求。libraryd 可能连不上 PostgreSQL,健康检查已经返回 NOT_SERVING,但 TCP 仍然可连;这时 round_robin 会把一半请求发给一个「连接正常但不能服务」的实例。健康检查要解决的就是这个错位。

这一节先对比两种 LB 策略,再讲服务端健康服务和客户端探活,最后讨论探活结果如何影响解析与重试。连接池和多路复用在第 6 章,本节只讨论「选谁」和「它还健康吗」。

本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8,gRPC-Java 1.76.0。文末的 health=SERVING、after-drain=NOT_SERVING 是本机实际运行结果。

核心机制一:pick_first 与 round_robin 的子通道模型

两种策略都建立在 subchannel 之上:resolver 返回的每个 EquivalentAddressGroup 对应一个 subchannel,subchannel 负责建连、维护 ConnectivityState、承载流。区别在于「什么时候创建、怎么选」:

维度pick_firstround_robin
子通道数量通常只连一个每个地址一个
选择方式取第一个可用的,粘住在 READY 的子通道间轮转
故障切换当前失败后换下一个跳过失败的,继续轮转
适用场景单实例、主备、需要粘性多实例均摊
默认是否,需要显式配置

pick_first 不是「只解析第一个地址」,它会按顺序尝试,直到找到一个能连上的;一旦连上,后续调用都走这条,直到它进入 TRANSIENT_FAILURE。这对「主备」很合适:主库地址在前,备库在后,主库挂了才切。对「两个对等实例」不合适,因为备实例平时不承载流量。

round_robin 会为每个地址建子通道,只有 READY 的参与轮转。一个实例连接失败进入 TRANSIENT_FAILURE,它会被跳过;恢复后重新加入。它不感知权重、zone 或业务健康,只按「地址数量」均分。需要加权时,得用自定义 LB 或在 resolver 层做地址复制,后者很粗糙,不推荐。

配置方式是 channel builder 上的 defaultLoadBalancingPolicy:

ManagedChannel channel = NettyChannelBuilder
        .forTarget("static-registry:///library", InsecureChannelCredentials.create())
        .defaultLoadBalancingPolicy("round_robin")
        .build();

策略名是字符串,写错不会在 build() 时失败,而是在第一次解析后报「Policy not found」。常见拼写错误是 round-robin(连字符)和 roundrobin;正确的是下划线 round_robin。pick_first 也是下划线。

round_robin 的前提是 resolver 真的返回多个地址。DNS 的 dns:///libraryd.internal:9093 在 Kubernetes 里通常解析到 ClusterIP,只有一个地址;要拿到 Pod 列表,需要 headless service,或者像 5.2 那样用注册表。用了 round_robin 但只解析到一个地址时,行为和 pick_first 差不多,不要误以为配置没生效。

核心机制二:服务端健康服务

gRPC 定义了标准的健康检查协议 grpc.health.v1.Health,包含 Check(unary)和 Watch(server streaming)两个方法。服务端不用自己写 proto,grpc-services 提供了 HealthStatusManager,把它注册成服务即可。

package com.plume.linkd.v3.server;

import io.grpc.Server;
import io.grpc.health.v1.HealthCheckResponse;
import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder;
import io.grpc.protobuf.services.HealthStatusManager;

import java.io.IOException;

public final class HealthAwareServer {

    public static Server start(int port, LibraryServiceImpl service) throws IOException {
        HealthStatusManager health = new HealthStatusManager();
        health.setStatus("", HealthCheckResponse.ServingStatus.SERVING);
        health.setStatus("plume.linkd.v3.Library",
                HealthCheckResponse.ServingStatus.SERVING);

        return NettyServerBuilder.forPort(port)
                .addService(service)
                .addService(health.getHealthService())
                .build()
                .start();
    }
}

三个要点:

  • 空字符串代表所有服务。 health.setStatus("", SERVING) 是整体状态,具体服务可以单独覆盖。客户端查 plume.linkd.v3.Library 时,先看具体服务,再看整体。
  • 状态要在 start() 之前设好。 服务启动后到第一次 setStatus 之间有窗口,客户端可能查到默认的 UNKNOWN。启动前设为 SERVING,依赖自检通过后再真正对外宣告就绪。
  • enterTerminalState() 是停机专用。 它把所有服务永久标记为 NOT_SERVING,之后不能再改回;调用它之前要确保不再需要健康服务。6.3 的 drain 会用到。

健康服务本身也走 9093,不需要额外端口。它和业务方法共享同一条连接和同一套拦截器;如果拦截器强制要求 x-tenant-id,健康检查请求会被拒绝,所以拦截器要对健康方法放行,或者把健康服务挂到不经过业务拦截器的 server 上。linkd-v3 选择前者:拦截器里判断 method.getFullMethodName(),grpc.health.v1.Health/Check 和 Watch 直接放行。

本机用一个自包含探针验证:启动带健康服务的 server,先设 SERVING,用 HealthGrpc.newBlockingStub 查一次;再设 NOT_SERVING,再查一次。输出:

health=SERVING
after-drain=NOT_SERVING

这证明健康状态是运行时可变的,不是启动时写死的常量。客户端探活读到的是最新值,不需要重启服务。

核心机制三:客户端探活与注册表过滤

服务端有了健康服务,客户端还要主动去查。最直接的方式是为每个端点建一条探活 channel,用 300 ms 的 deadline 调 Check:

package com.plume.linkd.v3.core.discovery;

import io.grpc.ManagedChannel;
import io.grpc.StatusRuntimeException;
import io.grpc.health.v1.HealthCheckRequest;
import io.grpc.health.v1.HealthCheckResponse;
import io.grpc.health.v1.HealthGrpc;
import java.util.concurrent.TimeUnit;

public final class EndpointHealthProbe {

    public static final String LIBRARY_SERVICE = "plume.linkd.v3.Library";

    public static boolean isServing(ManagedChannel endpointChannel) {
        try {
            HealthCheckResponse response = HealthGrpc.newBlockingStub(endpointChannel)
                    .withDeadlineAfter(300, TimeUnit.MILLISECONDS)
                    .check(HealthCheckRequest.newBuilder()
                            .setService(LIBRARY_SERVICE)
                            .build());
            return response.getStatus() == HealthCheckResponse.ServingStatus.SERVING;
        } catch (StatusRuntimeException e) {
            return false;
        }
    }
}

HealthGrpc.newBlockingStub 是生成代码,方法名是 check,返回 HealthCheckResponse。getStatus() 是枚举,只有 SERVING 才放行;NOT_SERVING、UNKNOWN、SERVICE_UNKNOWN 都按不健康处理。探活失败可能是网络问题、服务端没注册健康服务、或者 deadline 太短,统一返回 false 是保守但可接受的选择;指标里要区分这三种原因,否则会把「探活配置错」当成「实例挂了」。

探活结果怎么进入解析?有两种做法:

做法实现优点缺点
解析时过滤resolver 只返回健康端点客户端简单注册表要维护健康状态
解析后过滤客户端拿到全量,自己剔除注册表简单每个客户端都要探活
交给 LB用自定义 LB 读健康属性集中实现复杂

linkd-v3 用第一种:探活器把结果写回 StaticRegistry 的健康集合,5.2 的 resolver 在 resolve 时过滤。这样 NameResolver、网关、管理端看到的是同一份健康视图,不会出现「resolver 认为健康、网关认为不健康」的分裂。

探活不能太频繁,也不能一次失败就剔除。建议:

  • 周期 5 秒,deadline 300 ms。 和单跳调用预算一致,避免探活本身成为慢请求。
  • 连续 2 次失败才标记不健康。 单次超时可能是 GC 或网络抖动。
  • 恢复要连续 2 次成功。 避免实例在「刚好能回一个 Check」时被瞬间打满。
  • 剔除后保留冷却时间。 至少 10 秒不再探测,避免把刚重启的实例反复打挂。

这些参数不是越保守越好。周期太长,故障实例会多接几秒流量;太短,探活流量和注册表写入会成为负担。按实例数量和可接受的故障收敛时间算:20 个实例、5 秒周期,探活 QPS 是 4,可以接受;200 个实例就要考虑分层探活或服务端主动上报。

核心机制四:READY、业务健康与重试的交互

三个「健康」概念要分开:

概念来源含义失败表现
传输就绪subchannel READYTCP/HTTP2 可写连接被拒、握手失败
业务健康Health/Check应用能处理请求NOT_SERVING
调用成功业务响应这次请求成功业务错误码

round_robin 只看第一个。一个实例可能 READY 但 NOT_SERVING,也可能 NOT_SERVING 但某些只读接口仍然可用。把业务健康塞进 LB 的 READY 判断里,会让 gRPC 的 subchannel 状态失去标准含义;正确做法是让 resolver 不返回不健康的地址,LB 继续只负责传输层。

重试和健康检查的关系也要理清。客户端收到 UNAVAILABLE 时,gRPC 可能按 service config 重试;但重试到同一个不健康实例没有意义。健康检查把不健康实例从地址列表里拿掉,重试才会落到其他实例。反过来,如果所有实例都不健康,resolver 返回空列表,channel 进入 TRANSIENT_FAILURE,重试也只会得到 UNAVAILABLE;这时应该快速失败,而不是 waitForReady() 无限等。

waitForReady() 会让调用在 channel 未就绪时排队,直到 deadline。对 800 ms 总预算的用户请求,排队只会把延迟吃满,最后还是 DEADLINE_EXCEEDED;它适合后台批处理或启动预热,不适合同步 API。linkd-v3 的同步调用不启用 waitForReady,由 deadline 和重试预算兜底。

在 linkd-v3 里落地:两个实例、一个策略、一套探活

linkd-v3 的部署里有两个 libraryd 实例,9093 分别监听在 10.0.1.11 和 10.0.1.12。注册表里注册两条记录,客户端用 round_robin:

StaticRegistry registry = new StaticRegistry();
registry.register("library", "10.0.1.11", 9093);
registry.register("library", "10.0.1.12", 9093);

ManagedChannel channel = NettyChannelBuilder
        .forTarget("static-registry:///library", InsecureChannelCredentials.create())
        .defaultLoadBalancingPolicy("round_robin")
        .maxInboundMessageSize(4 * 1024 * 1024)
        .build();

探活器每 5 秒遍历注册表里的端点,对每个端点复用一条探活 channel(不要每次新建)。探活 channel 也走 round_robin 没有意义,它只有一个目标,用默认的 pick_first 即可。探活结果写回注册表的健康集合,resolver 下一次 refresh 时过滤。

观察分流是否生效,不能只看 channel.getState(true):它返回的是 channel 的整体状态,不告诉你两个子通道各接了多少流。要看每个实例的请求计数(11.1 的指标)、tracing 里的 net.peer.address,或者 9093 服务端的访问日志。压测时如果发现某个实例 QPS 为 0,先确认 resolver 是否真的返回了两个地址,再看那个子通道是不是一直停在 TRANSIENT_FAILURE。

public final class HealthLoop implements AutoCloseable {

    private final StaticRegistry registry;
    private final Map<StaticRegistry.Endpoint, ManagedChannel> channels;
    private final ScheduledExecutorService scheduler;

    public HealthLoop(StaticRegistry registry,
                      Map<StaticRegistry.Endpoint, ManagedChannel> channels,
                      ScheduledExecutorService scheduler) {
        this.registry = registry;
        this.channels = channels;
        this.scheduler = scheduler;
    }

    public void start() {
        scheduler.scheduleWithFixedDelay(this::probeAll, 0, 5, TimeUnit.SECONDS);
    }

    private void probeAll() {
        for (Map.Entry<StaticRegistry.Endpoint, ManagedChannel> entry : channels.entrySet()) {
            boolean serving = EndpointHealthProbe.isServing(entry.getValue());
            registry.markHealth(entry.getKey(), serving);
        }
    }

    @Override
    public void close() {
        scheduler.shutdownNow();
    }
}

registry.markHealth 只改健康位,不删除端点;解析时由 5.1 的 resolve 过滤,管理端的 snapshot 仍然保留全量。管理端 9095 可以看到 SERVING/NOT_SERVING 和最近一次探测时间,排障时能区分「没注册」和「注册了但不健康」。

如果所有端点都不健康,resolve 返回空列表,resolver 走 5.2 的 onError(UNAVAILABLE),channel 进入 TRANSIENT_FAILURE。这时网关应该返回 503 并带上 Retry-After,而不是把请求排队到 800 ms 超时。

常见错误与边界

错误一:用了 round_robin 但 resolver 只有一个地址。 DNS 解析到 ClusterIP 时只有一个地址,轮转退化成单实例。先确认 resolve 返回的数量,再看策略是否生效。

错误二:把 READY 当成业务健康。 TCP 连上不代表数据库可用。libraryd 的 NOT_SERVING 必须通过健康检查或解析过滤体现,不能指望 subchannel 状态自动反映。

错误三:健康检查压垮服务端。 每个客户端每 5 秒对每个实例探活,实例多、客户端多时,探活 QPS 会超过业务 QPS。用服务端主动上报、分层探活或共享探活结果,不要每个客户端独立全量探。

错误四:一次失败就剔除。 网络抖动、GC、短暂的连接队列满都会让一次 Check 超时;一次失败就剔除会造成流量震荡。连续失败计数加冷却时间是最低要求。

错误五:探活 channel 不复用。 每次探活新建 channel,会把 TCP 握手和 HTTP/2 设置的开销算进探活延迟,也会让 fd 数量随实例数增长。每个端点维护一条长生命周期 channel,关闭时统一收。

边界:健康检查不等于熔断。 健康检查判断「实例整体能不能服务」,熔断判断「这类调用在当前窗口的失败率」。一个实例可能健康,但某个方法因为下游故障持续失败;这时需要第 7 章的熔断,而不是把整个实例摘掉。

边界:round_robin 不保证严格均分。 它按请求轮转,但每个请求的耗时不同,长请求会让某个子通道上的在途流更多。要看实际分布,用服务端的请求计数或 tracing,不要假设 50/50。

小结

  • pick_first 取第一个可用地址并粘住,适合单实例和主备;round_robin 为每个地址建子通道并轮转,适合多实例均摊。
  • 默认策略是 pick_first,用 defaultLoadBalancingPolicy("round_robin") 显式切换;策略名是下划线。
  • 服务端用 HealthStatusManager 注册 grpc.health.v1.Health,启动前设 SERVING,停机时设 NOT_SERVING 并 enterTerminalState()。
  • 客户端用 HealthGrpc.newBlockingStub 加 300 ms deadline 探活;连续失败才剔除,恢复要连续成功。
  • READY 是传输层状态,业务健康要靠健康检查;两者不能混为一谈。
  • 健康检查把不健康地址从解析结果里拿掉,重试才有意义;所有实例都不健康时应快速失败,不要 waitForReady() 无限等。
  • 本机探针输出 health=SERVING 和 after-drain=NOT_SERVING,证明健康状态可以在运行时切换。

下一节从「发给谁」转到「一条连接能承载多少」:HTTP/2 的流、MAX_CONCURRENT_STREAMS、流控窗口和连接数的估算。

阅读导航:上一节 5.2 自定义 NameResolver | 下一节 6.1 HTTP/2 多路复用与连接数

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收