システムの信頼性を向上させる-自動再試行

前書き

再試行は、システムの可用性を向上させるための重要な手段です。ビジネスコードには多くの再試行ロジックが見られます。侵入せずに再試行を実装する方法があるかどうかにかかわらず、ビジネスコードは完全に認識していません。

ビジネスの再試行

一般的なビジネスコード

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)

限定

再試行する必要のあるメソッドシグニチャは制限されており、適用できるシグニチャ再試行メソッドは1つだけです。

ジェネリックメソッドのカプセル化

参照ブログ投稿

一般的な再試行メソッドを簡単に記述でき、エラーの判断と再試行時間のバックオフをわずかにカプセル化できます。

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の場合であり、最後の戻り値はエラーであると想定しています。

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)

ミドルウェアの再試行フラグをカスタマイズして、必要なシナリオのみを再試行できるようにするか、逆の定義を使用して特定のシナリオを再試行しないようにすることができます。

func retryFlag(ctx context.Context) bool {

   ...

   return true

}

判定フラグがある場合は、フラグを設定する必要があります。通常、シーンの入り口、つまりファーストタッチを要求するサービスでsetFlag操作を行います。

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

   return context.WithValue(ctx, flagKey, flag)

}

呼び出しリンクは非常に長いです。フラグを渡す方法は?サービス側はミドルウェアを追加する必要があり、rpcベースはフラグを渡す必要があります。

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