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

2020-12-07 22:52:52 +08:00
 kevinwan

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 需要实现如下步骤:

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 的时候回进行如下操作

创建 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 主要实现了如下步骤:

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 🤝

2419 次点击
所在节点    推广
9 条回复
leeyuzhe
2020-12-08 09:02:12 +08:00
编辑器字体是啥?挺好看的
shawnsh
2020-12-08 09:26:40 +08:00
grpc 不是基于 http 的?
dbpe
2020-12-08 09:53:21 +08:00
@shawnsh http2
securityCoding
2020-12-08 10:20:28 +08:00
真不错
ohoh
2020-12-08 10:37:48 +08:00
4k 的 star, issue 总共截止才有 61.
kevinwan
2020-12-08 11:55:18 +08:00
@ohoh 因为微信群里 2000 多人,大部分问题都在群里解决了
KesonAn
2020-12-08 13:24:37 +08:00
@ohoh 我在他们社区群里,他们回答很耐心,跟着 demo 体验了一下 go-zero,有些自己不太理解的地方,都很有耐心的解答了,这一点是非常值得肯定。据说已经有很多人用到生产了,听我同事说这里面有很多精辟的设计,慢慢去阅读一下他们源码学习一下。
zhoushuguangking
2020-12-08 13:28:30 +08:00
基本是把 gRPC 的工作流程梳理清楚了
yuyoung
2020-12-09 17:00:58 +08:00
很赞

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/733117

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX