시스템 안정성 향상 - 자동 재시도

소개

재시도는 시스템 가용성을 향상시키는 중요한 수단입니다. 비즈니스 코드에서 많은 재시도 논리를 볼 수 있습니다. 침입 없이 재시도를 구현하는 방법이 있는지 비즈니스 코드는 완전히 인식하지 못합니다.

사업 재시도

일반적인 비즈니스 코드

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의 일반적인 경우이고 마지막 반환 값이 오류라고 가정합니다.

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

}

판단 플래그 가 있는 경우 플래그 를 설정해야 하는데 일반적으로 장면의 입구, 즉 첫 번째 터치를 요청하는 서비스에서 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个服务在重试。

Supongo que te gusta

Origin juejin.im/post/7115344614025855012
Recomendado
Clasificación