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

基本原理

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

Resolver模块

源代码地址: https://github.com/grpc/grpc-go/blob/master/resolver/resolver.go

通过resolver.Register方法可以注册自定义的ResolverRegister方法定义如下,其中Builderinterface类型,因此自定义resolver需要实现该接口,Builder定义如下

  1. // Register 注册自定义resolver
  2. func Register(b Builder) {
  3. m[b.Scheme()] = b
  4. }
  5. // Builder 定义resolver builder
  6. type Builder interface {
  7. Build(target Target, cc ClientConn, opts BuildOptions) (Resolver, error)
  8. Scheme() string
  9. }

Build方法的第一个参数target的类型为Target定义如下,创建ClientConn调用grpc.DialContext的第二个参数target经过解析后需要符合这个结构定义,target定义格式为: scheme://authority/endpoint_name

  1. type Target struct {
  2. Scheme string // 表示要使用的名称系统
  3. Authority string // 表示一些特定于方案的引导信息
  4. Endpoint string // 指出一个具体的名字
  5. }

Build方法返回的Resolver也是一个接口类型。定义如下

  1. type Resolver interface {
  2. ResolveNow(ResolveNowOptions)
  3. Close()
  4. }

流程图下图
1627859009.jpg

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

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

go-zerotarget的定义如下,默认的名字为discov

源代码地址: https://github.com/tal-tech/go-zero/blob/master/zrpc/internal/resolver/resolver.go

  1. // BuildDiscovTarget 构建target
  2. func BuildDiscovTarget(endpoints []string, key string) string {
  3. return fmt.Sprintf("%s://%s/%s", resolver.DiscovScheme,
  4. strings.Join(endpoints, resolver.EndpointSep), key)
  5. }
  6. // RegisterResolver 注册自定义的Resolver
  7. func RegisterResolver() {
  8. resolver.Register(&dirBuilder) //directBuilder
  9. resolver.Register(&disBuilder) //discovBuilder
  10. }

Build方法的实现如下

源代码地址: https://github.com/tal-tech/go-zero/blob/master/zrpc/internal/resolver/discovbuilder.go

  1. func (d *discovBuilder) Build(target resolver.Target, cc resolver.ClientConn, opts resolver.BuildOptions) (
  2. resolver.Resolver, error) {
  3. hosts := strings.FieldsFunc(target.Authority, func(r rune) bool {
  4. return r == EndpointSepChar
  5. })
  6. // 获取服务列表
  7. sub, err := discov.NewSubscriber(hosts, target.Endpoint)
  8. if err != nil {
  9. return nil, err
  10. }
  11. update := func() {
  12. var addrs []resolver.Address
  13. for _, val := range subset(sub.Values(), subsetSize) {
  14. addrs = append(addrs, resolver.Address{
  15. Addr: val,
  16. })
  17. }
  18. // 调用UpdateState方法更新
  19. cc.UpdateState(resolver.State{
  20. Addresses: addrs,
  21. })
  22. }
  23. // 添加监听,当服务地址发生变化会触发更新
  24. sub.AddListener(update)
  25. // 更新服务列表
  26. update()
  27. return &nopResolver{cc: cc}, nil
  28. }

那么注册进来的resolver在哪里用到的呢?当创建客户端的时候调用DialContext方法创建ClientConn的时候回进行如下操作

  • 拦截器处理
  • 各种配置项处理
  • 解析target
  • 获取resolver
  • 创建ccResolverWrapper

创建clientConn的时候回根据target解析出scheme,然后根据scheme去找已注册对应的resolver,如果没有找到则使用默认的resolver
1627859093.jpg
ccResolverWrapper的流程如下图,在这里resolver会和balancer会进行关联,balancer的处理方式和resolver类似也是通过wrapper进行了一次封装
1627859177.jpg
紧着着会根据获取到的地址创建htt2的链接
1627859205(1).jpg
到此ClientConn创建过程基本结束,我们再一起梳理一下整个过程,首先获取resolver,其中ccResolverWrapper实现了resovler.ClientConn接口,通过ResolverUpdateState方法触发获取Balancer,获取Balancer,其中ccBalancerWrapper实现了balancer.ClientConn接口,通过BalnacerUpdateClientConnState方法触发创建连接(SubConn),最后创建HTTP2 Client

Balancer模块

balancer模块用来在客户端发起请求时进行负载均衡,如果没有注册自定义的balancer的话gRPC会采用默认的负载均衡算法,流程图如下
1627859233.jpg
go-zero中自定义的balancer主要实现了如下步骤:

  • 实现PickerBuilderBuild方法返回balancer.Picker
  • 实现balancer.PickerPick方法实现负载均衡算法逻辑
  • 调用balancer.Registet注册自定义Balancer
  • 使用baseBuilder注册,框架已提供了baseBuilderbaseBalancer实现了BuilerBalancer

Build方法的实现如下

  1. func (b *p2cPickerBuilder) Build(readySCs map[resolver.Address]balancer.SubConn) balancer.Picker {
  2. if len(readySCs) == 0 {
  3. return base.NewErrPicker(balancer.ErrNoSubConnAvailable)
  4. }
  5. var conns []*subConn
  6. for addr, conn := range readySCs {
  7. conns = append(conns, &subConn{
  8. addr: addr,
  9. conn: conn,
  10. success: initSuccess,
  11. })
  12. }
  13. return &p2cPicker{
  14. conns: conns,
  15. r: rand.New(rand.NewSource(time.Now().UnixNano())),
  16. stamp: syncx.NewAtomicDuration(),
  17. }
  18. }

go-zero中默认实现了p2c负载均衡算法,该算法的优势是能弹性的处理各个节点的请求,Pick的实现如下

  1. func (p *p2cPicker) Pick(ctx context.Context, info balancer.PickInfo) (
  2. conn balancer.SubConn, done func(balancer.DoneInfo), err error) {
  3. p.lock.Lock()
  4. defer p.lock.Unlock()
  5. var chosen *subConn
  6. switch len(p.conns) {
  7. case 0:
  8. return nil, nil, balancer.ErrNoSubConnAvailable // 没有可用链接
  9. case 1:
  10. chosen = p.choose(p.conns[0], nil) // 只有一个链接
  11. case 2:
  12. chosen = p.choose(p.conns[0], p.conns[1])
  13. default: // 选择一个健康的节点
  14. var node1, node2 *subConn
  15. for i := 0; i < pickTimes; i++ {
  16. a := p.r.Intn(len(p.conns))
  17. b := p.r.Intn(len(p.conns) - 1)
  18. if b >= a {
  19. b++
  20. }
  21. node1 = p.conns[a]
  22. node2 = p.conns[b]
  23. if node1.healthy() && node2.healthy() {
  24. break
  25. }
  26. }
  27. chosen = p.choose(node1, node2)
  28. }
  29. atomic.AddInt64(&chosen.inflight, 1)
  30. atomic.AddInt64(&chosen.requests, 1)
  31. return chosen.conn, p.buildDoneFunc(chosen), nil
  32. }

客户端发起调用的流程如下,会调用pick方法获取一个transport进行处理
1627859266(1).jpg

总结

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

项目地址

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

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

项目地址:
https://github.com/tal-tech/go-zero

原文链接

https://segmentfault.com/a/1190000038394021