开源推荐 基于 gRPC 的服务注册与发现和负载均衡的原理与实战

zhoushuguang · 2020年12月03日 · 635 次阅读
本帖已被设为精华帖!

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 的过程,同时还分析可客户端创建的流程和调用的流程。写作不易,如果觉得文章对你有帮助的话,有劳 star🤝

项目地址

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

框架地址

https://github.com/tal-tech/go-zero/tree/master/zrpc

文档地址

https://www.yuque.com/tal-tech/go-zero/rhakzy

微信交流群

更多原创文章干货分享,请关注公众号
  • 加微信实战群请加微信(注明:实战群):gocnio
astaxie 将本帖设为了精华贴 12月03日 09:58
mahuaibo GoCN 每日新闻 (2020-12-04) 中提及了此贴 12月04日 06:22
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册