V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
kevinwan
V2EX  ›  推广

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

  •  2
     
  •   kevinwan · 2020-12-07 22:52:52 +08:00 · 2197 次点击
    这是一个创建于 1228 天前的主题,其中的信息可能已经有所发展或是发生改变。

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

    基本原理

    原理流程图如下:

    yuanli

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

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

    dialcontext

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

    ccresolverwrapper

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

    http2

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

    Balancer 模块

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

    balancer

    在 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 进行处理

    client_call

    总结

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

    项目地址

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

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

    9 条回复    2020-12-09 17:00:58 +08:00
    leeyuzhe
        1
    leeyuzhe  
       2020-12-08 09:02:12 +08:00
    编辑器字体是啥?挺好看的
    shawnsh
        2
    shawnsh  
       2020-12-08 09:26:40 +08:00
    grpc 不是基于 http 的?
    dbpe
        3
    dbpe  
       2020-12-08 09:53:21 +08:00
    @shawnsh http2
    securityCoding
        4
    securityCoding  
       2020-12-08 10:20:28 +08:00
    真不错
    ohoh
        5
    ohoh  
       2020-12-08 10:37:48 +08:00   ❤️ 1
    4k 的 star, issue 总共截止才有 61.
    kevinwan
        6
    kevinwan  
    OP
       2020-12-08 11:55:18 +08:00
    @ohoh 因为微信群里 2000 多人,大部分问题都在群里解决了
    KesonAn
        7
    KesonAn  
       2020-12-08 13:24:37 +08:00
    @ohoh 我在他们社区群里,他们回答很耐心,跟着 demo 体验了一下 go-zero,有些自己不太理解的地方,都很有耐心的解答了,这一点是非常值得肯定。据说已经有很多人用到生产了,听我同事说这里面有很多精辟的设计,慢慢去阅读一下他们源码学习一下。
    zhoushuguangking
        8
    zhoushuguangking  
       2020-12-08 13:28:30 +08:00
    基本是把 gRPC 的工作流程梳理清楚了
    yuyoung
        9
    yuyoung  
       2020-12-09 17:00:58 +08:00
    很赞
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   我们的愿景   ·   实用小工具   ·   5655 人在线   最高记录 6543   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 37ms · UTC 02:01 · PVG 10:01 · LAX 19:01 · JFK 22:01
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.