本节目标:知道
pick_first和round_robin各自维护什么状态、什么时候切换子通道,能用服务端健康服务和客户端探活把「不健康」变成解析结果里的「不出现」。
适用版本:JDK 25 + gRPC-Java 1.76.0(grpc-netty-shaded)+grpc-services1.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_first | round_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 READY | TCP/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 多路复用与连接数
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。