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

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

基本原理

原理流程图如下:

基于 gRPC 的注册发现与负载均衡的原理和实战_第1张图片

从图中可以看出 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()
}

流程图下图

基于 gRPC 的注册发现与负载均衡的原理和实战_第2张图片

因此可以看出自定义 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

基于 gRPC 的注册发现与负载均衡的原理和实战_第3张图片

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

基于 gRPC 的注册发现与负载均衡的原理和实战_第4张图片

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

基于 gRPC 的注册发现与负载均衡的原理和实战_第5张图片

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

Balancer 模块

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

基于 gRPC 的注册发现与负载均衡的原理和实战_第6张图片

在 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 的注册发现与负载均衡的原理和实战_第7张图片

总结

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

项目地址

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

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

你可能感兴趣的:(grpc,发现,负载均衡)