提升系统可靠性--自动重试

引言

重试是提高系统可用性的重要手段,我们经常在业务代码中看到大量重试逻辑,是否有办法可以无侵入的实现重试,业务代码完全无感知。

业务重试

常见业务代码

func ExampleRPCSend(ctx context.Context, msg string) error {

   fmt.Printf("\nsend msg %v", msg)

   return errors.New("rpc err")

}



func RetrySend(ctx context.Context, msg string) error {

   var err error

   for i := 0; i < 3; i++ {

      err = ExampleRPCSend(ctx, msg)

      if err == nil {

         break

      } else {

         time.Sleep(10 * time.Millisecond)

      }

   }

   return err

}

使用示例

err := RetrySend(ctx, "example")

显然这是可以复用的通用逻辑,那么是否可以优雅的不在代码里显示使用呢。

简单的封装

func RetryFun(ctx context.Context, fn func(ctx context.Context) error) error {

   var err error

   for i := 0; i < 3; i++ {

      err = fn(ctx)

      if err == nil {

         break

      } else {

         time.Sleep(10 * time.Millisecond)

      }

   }

   return err

}

业务例子的使用示例

err = RetryFun(ctx, "example", ExampleRPCSend)

局限

需要重试的方法签名被限定了,只能适用一种签名的重试方法。

通用方法封装

参考博文

可以轻松写出通用重试的方法,对err的判断和重试时间退避稍微封装一下

func Decorate(decoPtr, f interface{}) error {

   fn := reflect.ValueOf(f)

   decoratedFunc := reflect.ValueOf(decoPtr).Elem()

   logicFunc := func(in []reflect.Value) []reflect.Value {

      ret := make([]reflect.Value, 0)

      for i := 0; i < 3; i++ {

         ret = fn.Call(in)

         if !needRetryErr(ret) {

            break

         } else {

            time.Sleep(10 * time.Millisecond)

         }

      }

      return ret

   }

   v := reflect.MakeFunc(fn.Type(), logicFunc)

   decoratedFunc.Set(v)

   return nil

}

对于错误判断单独封装,并不是所有错误都需要去重试,而是应该有选择的重试。后面还会说的这个判断有利于避免重试雪崩。

这里假设方法按go的通常情况,最后一个返回值是error

var RetryBizCode = []string{"err_01","err_02"}



func needRetryErr(out []reflect.Value) bool {

   // 框架返回的错误,网络错误

   if err, ok := out[len(out)-1].Interface().(error); ok && err != nil {

      return true

   }



   // BizCode业务错误码,需要重试的错误码

   if isContain(GetBizCode(out), RetryBizCode) {

      return true

   }

   

   return false

}

使用示例

retryFun := ExampleRPCSend

Decorate(&retryFun, ExampleRPCSend)

err := retryFun(ctx, "example")

相比业务重试和简单封装,这种使用方式更"丑"了。

中间件封装

可以把重试逻辑封装为中间件,直接在中间件里实现。

func RpcRetryMW(next endpoint.EndPoint) endpoint.EndPoint {

   return func(ctx context.Context, req interface{}) (resp interface{}, err error) {

      if !retryFlag(ctx) {

         return next(ctx, req)

      }

      // rpc装饰

      decoratorFunc := next

      if err := Decorate(&decoratorFunc, next); err != nil {

         return next(ctx, req)

      }

      return decoratorFunc(ctx, req)

   }

}

框架层面加上这个中间件即可实现rpc调用的重试

AddGlobalMiddleWares(RpcRetryMW)

中间件的retryflag可以自定义,这样就可以只重试需要的场景,或者反向定义,不重试某几个场景。

func retryFlag(ctx context.Context) bool {

   ...

   return true

}

有判断flag,就需要有设置flag,通常我们会在场景的入口,也就是请求第一个触达的服务上,进行setFlag操作

func SetFlag(ctx context.Context, flag string) context.Context {

   return context.WithValue(ctx, flagKey, flag)

}

调用链路很长,如何让flag传递呢,service端需要增加中间件,rpc的base上需要传递标记。

func CtxFlagMW(next endpoint.EndPoint) endpoint.EndPoint {

   return func(ctx context.Context, req interface{}) (resp interface{}, err error) {

      flag, ok := getFlagFromReq(ctx, req)

      if ok {

         setFlag(ctx, flag)

      }

      return next(ctx, req)

   }

}



Use(CtxFlagMW)

协助业务幂等

重试必须在下游满足幂等的情况下进行,否则会带来数据错乱。如果业务接口本身是幂等的,那么就可以直接使用,但是对于大部分业务接口都是不幂等的,如何介入自动重试呢

通过分析,我们业务不幂等主要是写库操作,重试如果能判断写库已操作过,跳过写库可以满足大部分场景的幂等。而判断写库已操作过,可以通过数据库的本地事务,在业务的库里创建一个本地事务日志表,记录已写的本地事务。

image.png

代码实现

func TransactionDecorator(ctx context.Context, txFunc func(ctx context.Context) error) error {

   // 未接入的场景,走原逻辑不变

   if retryFlag(ctx) {

      return TransactionManager(ctx, txFunc)

   }



   // 生成本地账本

   event := genEventFromCtx(ctx)



   // 若本地账本已存在,则进行空补偿,跳过写库逻辑

   if ExistEvent(ctx, event) {

      return nil

   }



   // 本地账本不存在,则事务写入业务数据和本地账本

   return TransactionManager(ctx, func(ctx context.Context) error {

      // 业务逻辑

      if err := txFunc(ctx); err != nil {

         return err

      }

      // 写入本地账本

      if err := SaveEvent(ctx, event); err != nil {

         return err

      }

      return nil

   })

}

业务幂等只能是协助加一些手段,具体接入还需要业务判断是否足够,不能说保证了写库操作唯一就是保证了幂等。

预防重试雪崩

重试最大的风险就是带来请求的累积,把下游压垮。我们会从以下几方面预防重试雪崩效应。

1. needRetryErr方法,把不需要重试的错误拦截掉,直接返回,避免业务逻辑错误重试。

2. retryFlag方法,针对需要重试的场景设置标志,有重试标志才进行重试,不是把服务所有请求都重试,可以避免无效请求。

3.非超时错误的情况下,保证请求不放大。

有两种做法,一种如上通用做法,在调用下游出错的点上重试,等重试结束才向上返回。

第二种做法是先向上返回成功,内部进行重试。第二种做法的局限性大一些,适用的场景更少。在通用方法封装上如何实现向上先返回成功,具体实现可参考博文

4.超时情况下,可能多个服务都同时感知到超时,如何保证请求不放大?

超时错误,例如A->B->C->D,A,B,C同时感知到错误,那么都会发起重试,显然就放大了请求。那么想办法只让一个服务重试呢

needRetryErr方法可以识别错错误类型,也就是可以感知到超时错误。

SetFlag方法本身是在入口场景调用,那么可以设置入口场景标记entranceFlag,而在中间件CtxFlagMW中,只传递retryFlag,不传递entranceFlag,那么就只有A服务会有entranceFlag标记

needRetryErr方法判断err类型为超时,则retryFlag判断ctx内有retryFlag 以及 entranceFlag ,两个标记都有才发起重试,则能保证整条链路只在入口服务A处发起重试。

综上超时情况下,请求也能保证不放大,只有1个服务在重试。

猜你喜欢

转载自juejin.im/post/7115344614025855012