gRPC Go 负载均衡机制:从 pick_first 到 round_robin 再到自定义 Balancer

本文深入解析 gRPC Go 的客户端负载均衡机制,详细对比 pick_first 与 round_robin 的适用场景,并通过代码示例说明如何实现自定义 balancer,帮助你在多后端架构中正确选择并落地 gRPC 路由策略,避开常见生产环境坑点。

默认策略不是你以为的轮询

很多人第一次把 gRPC Go 客户端接到多个后端时,都会产生一个疑问:为什么所有请求都打到了同一个节点上?

gRPC Go 负载均衡机制:从 pick_first 到 round_robin 再到自定义 Balancer

原因很直接:gRPC Go 默认的负载均衡策略是 pick_first,不是 round_robin。它建立第一条可用连接后,后续请求都走这条连接。只有当这条连接断开,才会尝试下一个地址。这个行为和 HTTP 反向代理自动分流的直观印象差别很大,因此经常被当成“gRPC 不支持负载均衡”。

实际上,gRPC 在客户端做了负载均衡,并且预留了扩展机制。你既可以切换内置策略,也可以实现自定义 balancer。这篇文章就按从默认到定制的主线,把 gRPC Go 的负载均衡机制讲清楚。

先理解 resolver、subchannel 和 picker

gRPC Go 的调用链路可以简化成三块:resolver 负责把 target 解析成一组地址,balancer 负责维护这组地址对应的子通道,并在每个 RPC 开始时挑选一个合适的子通道,picker 则是 balancer 交给调用链路的“选择器”。

在 gRPC Go 里,一个后端地址对应一个 subConn。连接状态变化由 balancer 监听,某条连接从 Ready 变成 TransientFailure 时,balancer 可以更新整体状态,并替换 picker。调用方不直接看到连接对象,只看到 picker 返回的 SubConn 接口。

想理解负载均衡策略,关键不是看它怎么“平均”,而是看它如何处理这组子通道的状态。默认的 pick_first 策略,其实只维护一个子通道,其余地址只是备用。

pick_first:一条连接,其他都是备胎

pick_first 的语义非常简洁:

  • 从解析到的地址列表中按顺序尝试连接,只要有一条成功,就停止继续拨号。
  • 所有 RPC 都复用这条连接。
  • 当这条连接不可用时,重新尝试列表中的地址。

这种策略适合什么场景呢?最常见的是背后只有一个后端,或者地址列表中只有一个实例。有些私有 RPC 网关、带拓扑信息的服务发现,也会主动把首选地址放在最前面,利用 pick_first 的“按序优先”能力。

但如果你有多个副本,并且没有外部负载均衡器,pick_first 会让流量永远集中在某一个副本。生产环境里通常表现为:某个实例 CPU 跑到 80%,另外几个却只有 10%。问题不在实现,而在于你还没有打开轮询。

round_robin:从默认到内置轮询

切到 round_robin 只需要在拨号时带上服务配置:

conn, err := grpc.NewClient(
    "dns:///service.example.com:8080",
    grpc.WithDefaultServiceConfig(`{"loadBalancingPolicy":"round_robin"}`),
    grpc.WithTransportCredentials(insecure.NewCredentials()),
)

这段代码让客户端把解析到的所有 Ready 地址都视为可用的转发目标。每个 RPC 按顺序从列表里取下一个子通道。这里的“下一个”是全局计数,因此在不同 goroutine 并发发起 RPC 时,分配会尽量分散。

注意一点:round_robin 选择的是子通道,而不是请求连接。一条 gRPC 连接上可以承载大量并发 stream,所以即使两个后端只有一个连接,也能处理不少并发;但如果某条连接没有建立成功,它就不会进入轮询池。

为什么很多团队切到 round_robin 后依然觉得不够?因为轮询只解决“地址均匀”,不解决“压力均匀”。同一个服务里的请求,有的很轻,有的要跑很久;如果一个请求很大,它会一直占用某条连接。此时你看着 RPC 数量是均匀的,但实际资源消耗并不均匀。

内置策略的边界

官方内置策略除了 pick_first 和 round_robin,还有 grpclb 等,但大多数项目实际只会用到前两个。它们最大的共同问题是:不知道后端负载。

round_robin 不关心每个实例当前有多少 inflight 请求,也不知道 CPU、内存、延迟。它默认所有后端能力一样。只要连接是 Ready 的,就按固定顺序轮流选。

在这个前提下,你会发现几个常见误区:

  • 以为开启了 round_robin,就能解决慢节点问题。实际上慢节点仍然会分到相同比例的请求。
  • 以为连接数越多越好。gRPC 的并发模型不是“一个请求一个连接”,如果每个连接都大量空闲,增加连接数未必带来收益。
  • 以为服务端返回的负载均衡策略优先级更高。实际上如果客户端显式指定了 service config,它可能覆盖服务端的建议。

当服务规模变大,或者请求特征差异明显时,你可能需要更细致的策略——比如按权重、按最少请求数、按延迟,甚至按某个业务 key 做一致性哈希。这时就要写自定义 balancer 了。

自定义 balancer:把控制权交给你的代码

gRPC Go 的 balancer 接口并不复杂,但容易在细节上踩坑。先看核心的几个组成部分。

首先是 balancer.Builder:它负责在 Dial 时创建一个 balancer 实例,并返回一个名字,供 service config 引用:

type Builder interface {
    Build(cc balancer.ClientConn, opts balancer.BuildOptions) balancer.Balancer
    Name() string
}

然后是 balancer.Balancer,它负责接收 resolver 推送的地址更新,并为每个地址创建或复用子通道:

type Balancer interface {
    UpdateClientConnState(state balancer.ClientConnState) error
    ResolverError(err error)
    UpdateSubConnState(sc balancer.SubConn, state balancer.SubConnState)
    Close()
}

真正的 RPC 路由发生在 Picker 上。当 balancer 的状态发生变化时,调用 cc.UpdateState 把新的 Picker 交给 gRPC 调用链路。每次 RPC 发起时,Picker 的 Pick 方法会被调用:

type Picker interface {
    Pick(info balancer.PickInfo) (balancer.PickResult, error)
}

一个典型的权重轮询 balancer,大致是这样一个流程:在 UpdateClientConnState 里根据地址属性解析权重,创建子通道;在每个子通道状态变化时,重新计算可用的连接集合;然后构建一个持有权重列表和计数器的 picker。Pick 时按权重递增偏移量,返回对应的 subConn。

我建议把核心状态机控制在 balancer 内,picker 尽量做成无锁的只读快照。因为 Pick 方法发生在每个 RPC 的调用路径上,锁竞争会直接放大成延迟。用 atomic.Value 保存当前 picker,或者用只读 map + atomic 指针,是常见做法。

还有一个容易忽略的地方:UpdateSubConnState 中必须处理 state.ConnectivityState 的完整生命周期,尤其是 TransientFailure。如果你只在地址更新时构建 picker,但某个后端突然断连,那么连接状态变化后,旧的 picker 仍然可能把请求发给一个坏掉的 subConn,导致连续错误。

什么时候值得自定义 balancer

自定义 balancer 不是万金油。它带来灵活性,也要你承担状态管理和维护成本。我建议先看是否命中下面这些条件:

  • 多个后端的容量不齐,需要按固定权重分配。
  • 需要按照某个请求属性做哈希路由,例如用户 ID、租户 ID。
  • 需要根据实时指标动态调整分发,例如最小 inflight 请求数、滑动窗口延迟。
  • 内置的轮询策略让你无法感知“某个连接正在大量超时”。

如果只是想让请求分散到多个副本,先切 round_robin,再配合服务端指标观察一段时间,往往就够了。自定义 balancer 更适合流量模型复杂、需要精确控制的团队。

三种策略放在一起看

策略 选择逻辑 适合场景 复杂度 主要限制
pick_first 按序选一条可用连接 单后端、有拓扑偏好 多副本时流量倾斜
round_robin 在所有 ready 连接间轮询 多副本容量相近 无法感知负载与延迟
自定义 balancer 自定义 picker,可加权、哈希、按负载 容量不均、需要精确路由 中高 需要维护状态机

这张表省去了一些边缘细节,但对于选型已经足够。选哪个不取决于“哪个更高级”,而取决于你的流量特征和运维能力。

落地时容易踩的坑

最后聊几个实际项目里常见的坑。

第一个坑:只配置了负载均衡策略,但服务端没有返回任何地址。DNS resolver 解析不到地址时,balancer 无法创建 subConn,策略不会生效。你需要先确认 resolver 拿到了正确的 endpoint 列表,而不是一个虚拟域名。

第二个坑:在 Kubernetes 环境里直接使用普通 Service。普通 Service 的 ClusterIP 背后有 kube-proxy,客户端解析到的是 ClusterIP,gRPC 的 resolver 只会得到一个地址,round_robin 实际上退化成单连接。这时候应该用 headless Service,或者通过 DNS 解析到多个 Pod IP。

第三个坑:自定义 balancer 里的 subConn 不会自动重连。gRPC 的重连机制由 subConn 自己管理,但 balancer 必须对状态变化做出响应。有些实现只处理 Ready 状态,对 Idle、Connecting、TransientFailure 都置若罔闻,结果就是 RPC 在某个连接上长时间失败。

第四个坑:在自定义 balancer 里做太多事情。比如在 Pick 路径上记录日志、统计指标,或者执行阻塞网络调用。Picker 是热路径,应该只做纯内存操作。指标采集应该通过 stats handler 或者异步上报。

从轮询到自定义的演进路径

如果你想在自己的服务里稳妥地引入 gRPC Go 负载均衡机制,我建议按这个顺序走:

  1. 先把默认策略从 pick_first 切换成 round_robin,确保多副本流量分散。
  2. 配套一个简单的指标看板,观察每个后端的 QPS、错误率、inflight 请求数。
  3. 如果发现轮询下仍然存在明显倾斜,分析是连接状态波动还是请求特征差异。
  4. 明确你的分发目标,确定是权重、最少请求数,还是哈希路由,再动手写自定义 balancer。
  5. 给 balancer 加好日志和状态可视化,一旦出问题可以快速定位。

这个过程没有跳过的必要。很多团队一步到位写了一个复杂的加权策略,最后发现真正的问题只是 DNS 解析没有拿到多个地址。从默认策略到自定义 balancer,理解每层的变化原因,比实现一个看起来很聪明的算法更重要。

原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/998/

(0)
上一篇 15小时前
下一篇 1小时前

相关推荐