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

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

基本原理

原理流程图如下:

从图中可以看出go-zero实现了gRPC的resolver和balancer接口,然后通过gprc.Register方法注册到gRPC中,resolver模块提供了服务注册的功能,balancer模块提供了负载均衡的功能。当client发起服务调用的时候会根据resolver注册进来的服务列表,使用注册进来的balancer选择一个服务发起请求,如果没有进行注册gRPC会使用默认的resolver和balancer。服务地址的变更会同步到etcd中,go-zero监听etcd的变化通过resolver更新服务列表

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()
}

流程图下图

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

  • 定义target
  • 实现resolver.Builder
  • 实现resolver.Resolver
  • 调用resolver.Register注册自定义的Resolver,其中name为target中的scheme
  • 实现服务发现逻辑(etcd、consul、zookeeper)
  • 通过resolver.ClientConn实现服务地址的更新

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

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

紧着着会根据获取到的地址创建htt2的链接

到此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 :handshake: