vlambda博客
学习文章列表

基于gRPC的注册发现与负载均衡的原理和实战

gRPC是一个现代的、高性能、开源的和语言无关的通用RPC框架,基于HTTP2协议设计,序列化使用PB(Protocol Buffer),PB是一种语言无关的高性能序列化框架,基于HTTP2+PB保证了的高性能。go-zero是一个开源的微服务框架,支持http和rpc协议,其中rpc底层依赖gRPC,本文会结合gRPC和go-zero源码从实战的角度和大家一起分析下服务注册与发现和负载均衡的实现原理。

基本原理

原理流程图如下:

Resolver模块

通过resolver.Register方法可以注册自定义的Resolver,Register方法定义如下,其中Builder为interface类型,因此自定义resolver需要实现该接口,Builder定义如下

// Register 注册自定义resolver
func Register(b Builder) {
m[b.Scheme()] = b
}

// Builder 定义resolver builder
type Builder interface {
Build(target Target, cc ClientConn, opts BuildOptions) (Resolver, error)
Scheme() string
}

Build方法的第一个参数target的类型为Target定义如下,创建ClientConn调用grpc.DialContext的第二个参数target经过解析后需要符合这个结构定义,target定义格式为:scheme://authority/endpoint_name

type Target struct {
Scheme string // 表示要使用的名称系统
Authority string // 表示一些特定于方案的引导信息
Endpoint string // 指出一个具体的名字
}

Build方法返回的Resolver也是一个接口类型。定义如下

type Resolver interface {
ResolveNow(ResolveNowOptions)
Close()
}

流程图下图

基于gRPC的注册发现与负载均衡的原理和实战

因此可以看出自定义Resolver需要实现如下步骤:

  • 定义target

  • 实现resolver.Builder

  • 实现resolver.Resolver

  • 调用resolver.Register注册自定义的Resolver,其中name为target中的scheme

  • 实现服务发现逻辑(etcd、consul、zookeeper)

go-zero中target的定义如下,默认的名字为discov

// BuildDiscovTarget 构建target
func BuildDiscovTarget(endpoints []string, key string) string {
return fmt.Sprintf("%s://%s/%s", resolver.DiscovScheme,
strings.Join(endpoints, resolver.EndpointSep), key)
}

// RegisterResolver 注册自定义的Resolver
func RegisterResolver() {
resolver.Register(&dirBuilder)
resolver.Register(&disBuilder)
}

Build方法的实现如下

func (d *discovBuilder) Build(target resolver.Target, cc resolver.ClientConn, opts resolver.BuildOptions) (
resolver.Resolver, error) {
hosts := strings.FieldsFunc(target.Authority, func(r rune) bool {
return r == EndpointSepChar
})
// 获取服务列表
sub, err := discov.NewSubscriber(hosts, target.Endpoint)
if err != nil {
return nil, err
}

update := func() {
var addrs []resolver.Address
for _, val := range subset(sub.Values(), subsetSize) {
addrs = append(addrs, resolver.Address{
Addr: val,
})
}
// 调用UpdateState方法更新
cc.UpdateState(resolver.State{
Addresses: addrs,
})
}

// 添加监听,当服务地址发生变化会触发更新
sub.AddListener(update)
// 更新服务列表
update()

return &nopResolver{cc: cc}, nil
}

那么注册进来的resolver在哪里用到的呢?当创建客户端的时候调用DialContext方法创建ClientConn的时候会进行如下操作

  • 拦截器处理

  • 各种配置项处理

  • 解析target

  • 获取resolver

  • 创建ccResolverWrapper

创建clientConn的时候会根据target解析出scheme,然后根据scheme去找已注册对应的resolver,如果没有找到则使用默认的resolver

基于gRPC的注册发现与负载均衡的原理和实战

ccResolverWrapper的流程如下图,在这里resolver会和balancer会进行关联,balancer的处理方式和resolver类似也是通过wrapper进行了一次封装

基于gRPC的注册发现与负载均衡的原理和实战

基于gRPC的注册发现与负载均衡的原理和实战

到此ClientConn创建过程基本结束,我们再一起梳理一下整个过程,首先获取resolver,其中ccResolverWrapper实现了resovler.ClientConn接口,通过Resolver的UpdateState方法触发获取Balancer,获取Balancer,其中ccBalancerWrapper实现了balancer.ClientConn接口,通过Balnacer的UpdateClientConnState方法触发创建连接(SubConn),最后创建HTTP2 Client

Balancer模块

balancer模块用来在客户端发起请求时进行负载均衡,如果没有注册自定义的balancer的话gRPC会采用默认的负载均衡算法,流程图如下

在go-zero中自定义的balancer主要实现了如下步骤:

  • 实现PickerBuilder,Build方法返回balancer.Picker

  • 实现balancer.Picker,Pick方法实现负载均衡算法逻辑

  • 调用balancer.Registet注册自定义Balancer

  • 使用baseBuilder注册,框架已提供了baseBuilder和baseBalancer实现了Builer和Balancer

Build方法的实现如下

func (b *p2cPickerBuilder) Build(readySCs map[resolver.Address]balancer.SubConn) balancer.Picker {
if len(readySCs) == 0 {
return base.NewErrPicker(balancer.ErrNoSubConnAvailable)
}

var conns []*subConn
for addr, conn := range readySCs {
conns = append(conns, &subConn{
addr: addr,
conn: conn,
success: initSuccess,
})
}

return &p2cPicker{
conns: conns,
r: rand.New(rand.NewSource(time.Now().UnixNano())),
stamp: syncx.NewAtomicDuration(),
}
}

go-zero中默认实现了p2c负载均衡算法,该算法的优势是能弹性的处理各个节点的请求,Pick的实现如下

func (p *p2cPicker) Pick(ctx context.Context, info balancer.PickInfo) (
conn balancer.SubConn, done func(balancer.DoneInfo), err error) {
p.lock.Lock()
defer p.lock.Unlock()

var chosen *subConn
switch len(p.conns) {
case 0:
return nil, nil, balancer.ErrNoSubConnAvailable // 没有可用链接
case 1:
chosen = p.choose(p.conns[0], nil) // 只有一个链接
case 2:
chosen = p.choose(p.conns[0], p.conns[1])
default: // 选择一个健康的节点
var node1, node2 *subConn
for i := 0; i < pickTimes; i++ {
a := p.r.Intn(len(p.conns))
b := p.r.Intn(len(p.conns) - 1)
if b >= a {
b++
}
node1 = p.conns[a]
node2 = p.conns[b]
if node1.healthy() && node2.healthy() {
break
}
}

chosen = p.choose(node1, node2)
}

atomic.AddInt64(&chosen.inflight, 1)
atomic.AddInt64(&chosen.requests, 1)
return chosen.conn, p.buildDoneFunc(chosen), nil
}

客户端发起调用的流程如下,会调用pick方法获取一个transport进行处理

总结

本文主要分析了gRPC的resolver模块和balancer模块,详细介绍了如何自定义resolver和balancer,以及通过分析go-zero中对resolver和balancer的实现了解了自定义resolver和balancer的过程,同时还分析可客户端创建的流程和调用的流程。希望本文能给大家带来一些帮助

项目地址

https://github.com/tal-tech/go-zero

如果觉得文章不错,欢迎 github 点个 star 🤝