在分布式系统里,RPC调用的稳定性直接决定业务可用性。Golang凭借轻量协程和原生net/rpc以及grpc生态,常被用来写高性能微服务。但当后端节点多于一个时,如果不做负载均衡,客户端总会盯着一个地址发请求,其余节点闲置,故障节点还会让调用全部失败。实现高可用RPC负载均衡,核心思路是在客户端或代理层把请求合理分到多个实例,并动态剔除不健康节点。

一、RPC负载均衡的基本模式
负载均衡通常分为服务端侧和客户端侧两类。服务端侧依靠Nginx、Envoy等反向代理,对Golang服务透明;客户端侧则由Golang代码自己决定调哪个节点,灵活度更高,也能减少中间链路。本文聚焦客户端侧,因为在云原生环境中,服务注册中心随时变更实例,客户端感知更快。
常见的分发算法包括轮询、加权轮询、随机、一致性哈希。轮询实现简单,但节点性能不均时会积压;随机在节点数多时分布尚可,但无法处理权重;一致性哈希适合有状态服务,能让同一用户固定在某节点,减少缓存失效。Golang标准库未内置这些,需要结合grpc-go或自研transport。
二、基于grpc-go的Balancer接口实践
grpc-go从1.30后提供了稳定的balancer包,开发者实现Resolver和Balancer即可接管节点选择。Resolver负责从etcd或Consul拉取地址,Balancer里的Picker根据策略返回具体连接。下面代码展示一个加权轮询的简化实现。
package main
import (
"context"
"fmt"
"sync"
"google.golang.org/grpc/balancer"
"google.golang.org/grpc/balancer/base"
"google.golang.org/grpc/resolver"
)
// 加权节点信息
type node struct {
addr resolver.Address
weight int
current int
}
// 加权轮询Picker
type weightedPicker struct {
mu sync.Mutex
nodes []node
}
func (p *weightedPicker) Pick(info balancer.PickInfo) (balancer.PickResult, error) {
p.mu.Lock()
defer p.mu.Unlock()
// 简单平滑加权轮询
var best *node
total := 0
for i := range p.nodes {
p.nodes[i].current += p.nodes[i].weight
total += p.nodes[i].weight
if best == nil || p.nodes[i].current > best.current {
best = &p.nodes[i]
}
}
if best != nil {
best.current -= total
return balancer.PickResult{SubConn: nil}, nil
}
return balancer.PickResult{}, fmt.Errorf("no node")
}
func main() {
// 实际中通过balancer.Register注册并配置grpc.Dial
_ = base.NewBalancerBuilder
_ = context.Background
}
上面代码省略了SubConn管理细节,但表达了加权轮询的核心:每个节点累加权重,选当前值最大的并减去总权重,长期看调用比接近权重比。相比普通轮询,它能让8核机器比4核机器多扛一倍流量。
在真实项目中,Picker还要结合健康检查。如果某SubConn连续失败,Resolver应将其地址暂时移出列表,或者Balancer标记其权重为0。grpc-go的connectivity状态回调可用来自动摘流,不需要业务代码判断。
三、自研HTTP RPC的负载均衡客户端
有些团队用Golang的net/rpc或自封装HTTP JSON RPC,没有grpc框架。此时可以写一个通用的Client结构,内部维护节点池和锁,每次调用前选节点。下面示例用随机加重试实现高可用。
package main
import (
"fmt"
"math/rand"
"net/rpc"
"sync"
"time"
)
type RPCClient struct {
mu sync.RWMutex
conns []*rpc.Client
healthy []bool
}
func (c *RPCClient) Call(method string, arg interface{}, reply interface{}) error {
c.mu.RLock()
defer c.mu.RUnlock()
for i := 0; i < 3; i++ {
idx := rand.Intn(len(c.conns))
if !c.healthy[idx] {
continue
}
// 设置超时防止挂死
done := make(chan error, 1)
go func() {
done <- c.conns[idx].Call(method, arg, reply)
}()
select {
case err := <-done:
if err == nil {
return nil
}
c.mu.RUnlock()
c.mu.Lock()
c.healthy[idx] = false
c.mu.Unlock()
c.mu.RLock()
case <-time.After(200 * time.Millisecond):
// 标记不健康并换节点
c.mu.RUnlock()
c.mu.Lock()
c.healthy[idx] = false
c.mu.Unlock()
c.mu.RLock()
}
}
return fmt.Errorf("all nodes failed")
}
这段代码在调用失败时标记节点不健康并重试其他节点,从而在某台机器宕机时业务不中断。随机算法在这里足够用,因为节点少且超时已隔离慢节点。要注意的是,健康标记应有定时探测任务恢复,否则节点恢复后永远不被选中。
对比grpc方案,自研客户端更轻,但要自己处理连接池、超时与熔断。如果团队规模小、接口不多,这种写法上线快;若服务网格复杂,直接用grpc-balancer更稳妥。
四、常见误区与高可用要点
不少人在Golang里只做了连接级负载均衡,以为Dial时传多个地址就万事大吉。实际上如果某节点处理业务慢,协程全阻塞在它那里,其他节点很闲,这就叫队列倾斜。解决方法是每次调用都重新选点,并加上单次超时与熔断。
另一个误区是忽略注册中心的一致性。假如Resolver缓存了旧地址,Balancer还会往死节点发请求。应给Resolver设较短TTL,并在节点下线时由平台发送删除事件,而不是等心跳超时。只有把服务发现、选点策略、健康探测三者串起来,Golang RPC才算真正高可用。
五、总结
用Golang实现RPC负载均衡并不复杂,关键是选对策略并补齐健康闭环。grpc-go适合标准微服务,自研Client适合轻量场景。无论哪种,都要在代码中落实超时、重试与节点剔除,才能让系统在节点波动时平稳运行。
GolangRPCload_balancing修改时间:2026-07-31 12:54:36