默认策略不是你以为的轮询
很多人第一次把 gRPC Go 客户端接到多个后端时,都会产生一个疑问:为什么所有请求都打到了同一个节点上?

原因很直接: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 负载均衡机制,我建议按这个顺序走:
- 先把默认策略从 pick_first 切换成 round_robin,确保多副本流量分散。
- 配套一个简单的指标看板,观察每个后端的 QPS、错误率、inflight 请求数。
- 如果发现轮询下仍然存在明显倾斜,分析是连接状态波动还是请求特征差异。
- 明确你的分发目标,确定是权重、最少请求数,还是哈希路由,再动手写自定义 balancer。
- 给 balancer 加好日志和状态可视化,一旦出问题可以快速定位。
这个过程没有跳过的必要。很多团队一步到位写了一个复杂的加权策略,最后发现真正的问题只是 DNS 解析没有拿到多个地址。从默认策略到自定义 balancer,理解每层的变化原因,比实现一个看起来很聪明的算法更重要。
原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/998/