go-zero-zrpc

127 阅读3分钟

zrpc 是什么

zrpc是go-zero的rpc部分,简单易用可直接用于生产的企业级rpc框架 zRPC底层依赖gRPC,内置了服务注册、负载均衡、拦截器等模块,其中还包括自适应降载,自适应熔断,限流等微服务治理方案

zrpc 组成

image.png

zRPC主要有以下几个模块组成:

  • discov: 服务发现模块,默认为etcd实现服务发现功能
  • resolver: 服务注册模块,实现了gRPC的resolver.Builder接口并注册到gRPC
  • interceptor: 拦截器,对请求和响应进行拦截处理
  • balancer: 负载均衡模块,实现了p2c负载均衡算法,并注册到gRPC
  • client: zRPC客户端,负责发起请求
  • server: zRPC服务端,负责处理请求

Interceptor模块

image.png

gRPC提供了拦截器功能,主要是对请求前后进行额外处理的拦截操作,其中拦截器包含客户端拦截器和服务端拦截器

客户端拦截器:

type UnaryClientInterceptor func(ctx context.Context, method string, req, reply interface{}, cc *ClientConn, invoker UnaryInvoker, opts ...CallOption) error

method为方法名,req,reply分别为请求和响应参数,cc为客户端连接对象,invoker参数是真正执行rpc方法的handler其实在拦截器中被调用执行

服务端拦截器:

type UnaryServerInterceptor func(ctx context.Context, req interface{}, info *UnaryServerInfo, handler UnaryHandler) (resp interface{}, err error)

req为请求参数,info中包含了请求方法属性,handler为对server端方法的包装,也是在拦截器中被调用执行

zRPC中内置了丰富的拦截器,其中包括自适应降载、自适应熔断、权限验证、prometheus指标收集等等

自适应熔断 breaker

当客户端向服务端发起请求,客户端会记录服务端返回的错误,当错误达到一定的比例,客户端会自行的进行熔断处理,丢弃掉一定比例的请求以保护下游依赖,且可以自动恢复。zRPC中自适应熔断遵循《Google SRE》中过载保护策略,算法如下

requests: 总请求数量

accepts: 正常请求数量

K: 倍值 (Google SRE推荐值为2)

可以通过修改K的值来修改熔断发生的激进程度,降低K的值会使得自适应熔断算法更加激进,增加K的值则自适应熔断算法变得不再那么激进

func BreakerInterceptor(ctx context.Context, method string, req, reply interface{},
    cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
  // target + 方法名
    breakerName := path.Join(cc.Target(), method)
    return breaker.DoWithAcceptable(breakerName, func() error {
    // 真正执行调用
        return invoker(ctx, method, req, reply, cc, opts...)
    }, codes.Acceptable)
}

accept方法实现了Google SRE过载保护算法,判断否进行熔断, 源码

func (b *googleBreaker) accept() error {

// accepts为正常请求数,total为总请求数

accepts, total := b.history()

weightedAccepts := b.k * float64(accepts)

// 算法实现
// protection 默认为5,k 默认1.5
dropRatio := math.Max(0, (float64(total-protection)-weightedAccepts)/float64(total+1))

if dropRatio <= 0 {

return nil

}

// 是否超过比例

if b.proba.TrueOnProba(dropRatio) {

return ErrServiceUnavailable

}

return nil

}

doReq方法首先判断是否熔断,满足条件直接返回error(circuit breaker is open),不满足条件则对请求数进行累加

func (b *googleBreaker) doReq(req func() error, fallback func(err error) error, acceptable Acceptable) error {
   if err := b.accept(); err != nil {
      if fallback != nil {
         return fallback(err)
      } else {
         return err
      }
   }

   defer func() {
      if e := recover(); e != nil {
         b.markFailure()
         panic(e)
      }
   }()
    
   // 此处执行RPC请求
   err := req()
   // 正常请求total和accepts都会加1
   if acceptable(err) {
      b.markSuccess()
   } else {
     // 请求失败只有total会加1
      b.markFailure()
   }

   return err
}
prometheus指标收集

服务监控是了解服务当前运行状态以及变化趋势的重要手段,监控依赖于服务指标的收集,通过prometheus进行监控指标的收集是业界主流方案,zRPC中也采用了prometheus来进行指标的收集

这个拦截器主要是对服务的监控指标进行收集,这里主要是对RPC方法的耗时和调用错误进行收集,这里主要使用了Prometheus的Histogram和Counter数据类型

func UnaryPrometheusInterceptor() grpc.UnaryServerInterceptor {
    return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (
        interface{}, error) {
    // 执行前记录一个时间
        startTime := timex.Now()
        resp, err := handler(ctx, req)
    // 执行后通过Since算出执行该调用的耗时
        metricServerReqDur.Observe(int64(timex.Since(startTime)/time.Millisecond), info.FullMethod)
    // 方法对应的错误码
        metricServerReqCodeTotal.Inc(info.FullMethod, strconv.Itoa(int(status.Code(err))))
        return resp, err
    }
}

resolver模块

zRPC服务注册架构图

image.png

zRPC中自定义了resolver模块,用来实现服务的注册功能。zRPC底层依赖gRPC,在gRPC中要想自定义resolver需要实现resolver.Builder接口

type Builder interface {
    Build(target Target, cc ClientConn, opts BuildOptions) (Resolver, error)
    Scheme() string
}

Build方法返回Resolver,Resolver定义如下:

type Resolver interface {
    ResolveNow(ResolveNowOptions)
    Close()
}

zRPC中定义了两种resolver,direct和discov

func RegisterResolver() {
    resolver.Register(&dirBuilder)
    resolver.Register(&disBuilder)
}

启动zrpc server的时候,会向etcd中注册对应的服务地址

func (ags keepAliveServer) Start(fn RegisterFn) error {
  // 注册服务地址
    if err := ags.registerEtcd(); err != nil {
        return err
    }
    // 启动服务
    return ags.Server.Start(fn)
}

启动zRPC客户端的时候,在gRPC内部会调用我们自定义resolver的Build方法,zRPC通过在Build方法内调用执行了resolver.ClientConn的UpdateState方法,该方法会把服务地址注册到gRPC客户端内部

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,
            })
        }
    // 向gRPC注册服务地址
        cc.UpdateState(resolver.State{
            Addresses: addrs,
        })
    }
  // 监听
    sub.AddListener(update)
    update()
    // 返回自定义的resolver.Resolver
    return &nopResolver{cc: cc}, nil
}

在discov中,通过调用load方法从etcd中获取指定服务的所有地址:

func (c *cluster) load(cli EtcdClient, key string) {
    var resp *clientv3.GetResponse
    for {
        var err error
        ctx, cancel := context.WithTimeout(c.context(cli), RequestTimeout)
    // 从etcd中获取指定服务的所有地址
        resp, err = cli.Get(ctx, makeKeyPrefix(key), clientv3.WithPrefix())
        cancel()
        if err == nil {
            break
        }

        logx.Error(err)
        time.Sleep(coolDownInterval)
    }

    var kvs []KV
    c.lock.Lock()
    for _, ev := range resp.Kvs {
        kvs = append(kvs, KV{
            Key: string(ev.Key),
            Val: string(ev.Value),
        })
    }
    c.lock.Unlock()

    c.handleChanges(key, kvs)
}

并通过watch监听服务地址变化

func (c *cluster) watch(cli EtcdClient, key string) {
    rch := cli.Watch(clientv3.WithRequireLeader(c.context(cli)), makeKeyPrefix(key), clientv3.WithPrefix())
    for {
        select {
        case wresp, ok := <-rch:
            if !ok {
                logx.Error("etcd monitor chan has been closed")
                return
            }
            if wresp.Canceled {
                logx.Error("etcd monitor chan has been canceled")
                return
            }
            if wresp.Err() != nil {
                logx.Error(fmt.Sprintf("etcd monitor chan error: %v", wresp.Err()))
                return
            }
            // 监听变化通知更新
            c.handleWatchEvents(key, wresp.Events)
        case <-c.done:
            return
        }
    }
}

balancer模块

image.png

避免过载是负载均衡策略的一个重要指标,好的负载均衡算法能很好的平衡服务端资源。常用的负载均衡算法有轮训、随机、Hash、加权轮训等。但为了应对各种复杂的场景,简单的负载均衡算法往往表现的不够好,比如轮训算法当服务响应时间变长就很容易导致负载不再平衡, 因此zRPC中自定义了默认负载均衡算法P2C(Power of Two Choices),和resolver类似,要想自定义balancer也需要实现gRPC定义的balancer.Builder接口

zRPC框架中默认的负载均衡算法为P2C,该算法的主要思想是:

  1. 从可用节点列表中做两次随机选择操作,得到节点A、B
  2. 比较A、B两个节点,选出负载最低的节点作为被选中的节点

image.png

代码实现: p2c 包里面对 gRPC 的 subConn 进行了包装,每个连接都有自己的统计数据

type subConn struct {
	addr     resolver.Address
	conn     balancer.SubConn
	lag      uint64	// 延迟
	inflight int64  // 节点当前正在处理的请求数
	success  uint64 // 请求成功率
	requests int64
	last     int64
	pick     int64
}

lag 是节点延迟,根据EWMA计算出来,此算法,是对观察值分别给予不同的权数,按不同权数求得移动平均值,并以最后的移动平均值为基础,确定预测值的方法。采用加权移动平均法,是因为观察期的近期观察值对预测值有较大影响,它更能反映近期变化的趋势

inflight 代表节点的当前正在处理的请求数,即反应了节点的拥塞程度

success 节点请求的成功率,初始化为 1000,当成功率低于 500 的时候,当前节点就会被判定为不健康

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
}

choose 方法对随机选择出来的节点进行负载比较,从而最终确定选择哪个节点

func (p *p2cPicker) choose(c1, c2 *subConn) *subConn {
	start := int64(timex.Now())
	if c2 == nil {
		atomic.StoreInt64(&c1.pick, start)
		return c1
	}

	if c1.load() > c2.load() {
		c1, c2 = c2, c1
	}

	pick := atomic.LoadInt64(&c2.pick)
	if start-pick > forcePick && atomic.CompareAndSwapInt64(&c2.pick, pick, start) {
		return c2
	} else {
		atomic.StoreInt64(&c1.pick, start)
		return c1
	}
}

// 节点的负载情况
// 大体为 延迟 * 负载
func (c *subConn) load() int64 {
	// plus one to avoid multiply zero
	lag := int64(math.Sqrt(float64(atomic.LoadUint64(&c.lag) + 1)))
	load := lag * (atomic.LoadInt64(&c.inflight) + 1)
	if load == 0 {
		return penalty
	} else {
		return load
	}
}

lag, success 在节点完成rpc调用之后的回调函数中执行

func (p *p2cPicker) buildDoneFunc(c *subConn) func(info balancer.DoneInfo) {
	start := int64(timex.Now())
	return func(info balancer.DoneInfo) {
		atomic.AddInt64(&c.inflight, -1)
    // 当前时间
		now := timex.Now()
    // 上一次调用的时间
		last := atomic.SwapInt64(&c.last, int64(now))
    // 获取时间间隔
		td := int64(now) - last
		if td < 0 {
			td = 0
		}
    // 获取时间衰减系数
		w := math.Exp(float64(-td) / float64(decayTime))
    // 获取调用延时
		lag := int64(now) - start
		if lag < 0 {
			lag = 0
		}
		olag := atomic.LoadUint64(&c.lag)
		if olag == 0 {
			w = 0
		}
    // 保存本地计算出的延迟数据
		atomic.StoreUint64(&c.lag, uint64(float64(olag)*w+float64(lag)*(1-w)))
    // success 的计算逻辑同上
		success := initSuccess
		if info.Err != nil && !codes.Acceptable(info.Err) {
			success = 0
		}
		osucc := atomic.LoadUint64(&c.success)
		atomic.StoreUint64(&c.success, uint64(float64(osucc)*w+float64(success)*(1-w)))

		stamp := p.stamp.Load()
		if now-stamp >= logInterval {
			if p.stamp.CompareAndSwap(stamp, now) {
				p.logStats()
			}
		}
	}
}

自定义的balancer是如何注册到gRPC的呢,resolver提供了Register方法来进行注册,同样balancer也提供了Register方法来进行注册:

func init() {
    balancer.Register(newBuilder())
}

func newBuilder() balancer.Builder {
    return base.NewBalancerBuilder(Name, new(p2cPickerBuilder))
}

需要使用配置项进行配置,在NewClient的时候通过grpc.WithBalancerName方法进行配置:

func NewClient(target string, opts ...ClientOption) (*client, error) {
    var cli client
    opts = append(opts, WithDialOption(grpc.WithBalancerName(p2c.Name)))
    if err := cli.dial(target, opts...); err != nil {
        return nil, err
    }

    return &cli, nil
}

参考文档:

zrpc: go-zero.dev/cn/docs/blo…

prometheus: github.com/zeromicro/g…

google sre breaker: sre.google/sre-book/ha…

p2c: wangzeping722.github.io/posts/go-ze…