• 【重试】经验总结


    一、微服务架构下,为什么需要重试

      在微服务架构中,一个完整的服务被拆分成多个小的服务,小服务之间通过rpc进行调用,不可避免会出现暂时性的错误,包括网络抖动、访问资源超时、因gc或瞬时流量过大等原因导致的服务暂时不可用等等。这些错误都属于暂时性的,并且可以自己修复,通常重新请求一遍即可解决问题。由此看出,合理的重试确实可以提高服务的稳定性。

    二、现状分析

    情况一:

      无重试,下游服务的稳定性直接决定了上游服务的稳定性,且对网络抖动等暂时性错误也容错性较低。

    情况二:

      用for循环进行重试。

     1 func Retry(tryTimes int, sleep time.Duration, callback func() (map[string]interface{}, error)) map[string]interface{} {
     2     for i := 1; i <= tryTimes; i++ {
     3         ret, err := callback()
     4         if err == nil {
     5             return ret
     6         }
     7         if i == tryTimes {
     8             panic(fmt.Sprintf("error info: %s", err.Error()))
     9             return nil
    10         }
    11         time.Sleep(sleep)
    12     }
    13     return nil
    14 }

    情况三:

     1 func Retry(attempts int, sleep time.Duration, fn func() error) error {
     2     if err := fn(); err != nil {
     3         if s, ok := err.(stop); ok {
     4             return s.error
     5         }
     6 
     7         if attempts--; attempts > 0 {
     8             logger.Warnf("retry func error: %s. attemps #%d after %s.", err.Error(), attempts, sleep)
     9             time.Sleep(sleep)
    10             return Retry(attempts, 2*sleep, fn)
    11         }
    12         return err
    13     }
    14     return nil
    15 }
    16 
    17 type stop struct {
    18     error
    19 }
    20 
    21 func NoRetryError(err error) stop {
    22     return stop{err}
    23 }

    为什么有些代码不重试?——指数增长问题

      在很多代码中,会看到基本上都没有重试逻辑,大家为什么不写重试逻辑呢?主要是因为重试有放大故障的风险。

      举个例子,重试会增加下游服务的负担。假设A服务调用B服务,重试次数设置为R(包含首次请求),当B服务高负载时很可能调用失败,这时A服务调用失败会重试调用B,B服务的被调用量快速增大,最坏情况下会放大到R倍,很容易让B服务的负载继续升高,直到B服务挂掉。更可怕的是,重试还会存在链路放大的效应。如下图所示:

      假设现在的场景是ServiceA调用ServiceB,ServiceB又调用ServiceC。如果ServiceB调用ServiceC重试3次都失败了,这是ServiceB会给ServiceA返回失败。但是ServiceA也有重试逻辑,这样算起来,ServiceC就会被重试调用了9次,实际的调用量是指数级增长。假设正常访问量是x,整个链路一共有y层,每层的重试次数是R,那么最大访问量是x*R^(y-1)。这种指数级增长的效应很可怕,可能会导致链路上很多服务被打挂,从而导致整个服务雪崩。

      另外,写重试逻辑也会比较麻烦,在一些不得不重试的场景下(如调用第三方服务经常失败),我们可能只是写一个简单的for循环来实现重试逻辑,这样既不安全又不灵活,还降低了代码的可读性。并且每次调整都需要重新部署,重新上线。

    退避算法

      对于一些暂时性的错误,如网络抖动等,可能立即重试还是会失败,通常等待一小会儿再重试的话成功率会较高,决定多久之后再重试的方法叫做退避算法。主要的退避算法有以下几种:​

      线性退避:每次等待固定时间后重试​

      随机退避:在一定范围内等待一个随机时间后重试​

      指数退避:连续重试时,每次等待时间都是前一次的倍数​

      综合退避:如线性退避 + 随机抖动 或者 指数退避 + 随机抖动

    三、如何合理地重试

      谷歌的SRE的第254页是这样描述的:

      1、上游对下游的重试次数最多是 3 次;理论上 3 次没成功继续重试的概率很低。

      2、重试的次数最多占成功次数的 10%,也就是说如果成功调用的 QPS 是 100,那么最多能重试 10 次,下游最多承受平常 1.1 倍的 QPS。这能防止下游出问题,上游暴力重试效果不好,而且还可能造成雪崩。

      3、需要防止重试次数指数增长的问题。

      下面介绍一些go的常用重试代码库。

    Retry-go:https://github.com/giantswarm/retry-go

    Options

     1 type retryOptions struct {​ 
     2     Timeout         time.Duration         // 超时​ 
     3     MaxTries        int                   // 重试次数​
     4  ​   Checker         func(err error) bool  // 判断是否重试​ 
     5     Sleep           time.Duration         // 间隔​ 
     6     AfterRetry      func(err error)       // 每次Retry后调用​ ​   
     7     AfterRetryLimit func(err error)       // 最后一次Retry后执行(如果未失败不执行)​ 
     8 }​ 
     9// Defaults:​
    10//  Timeout = 15 seconds​ 
    11//  MaxRetries = 5​ 
    12//  Checker = errgo.Any​ 
    13 //  Sleep = No sleep

    特点:

    • 只支持线性退避算法。
    • 可以定义超时时间。
    • 可以在每次重试后执行自定义的逻辑,例如打印日志等。

    go-resty

      先看下go-resty在发送HTTP请求时,申请重试的实现:

     1 // Execute method performs the HTTP request with given HTTP method and URL
     2 // for current `Request`.
     3 //         resp, err := client.R().Execute(resty.GET, "http://httpbin.org/get")
     4 func (r *Request) Execute(method, url string) (*Response, error) {
     5     var addrs []*net.SRV
     6     var resp *Response
     7     var err error
     8 
     9     if r.isMultiPart && !(method == MethodPost || method == MethodPut || method == MethodPatch) {
    10         return nil, fmt.Errorf("multipart content is not allowed in HTTP verb [%v]", method)
    11     }
    12 
    13     if r.SRV != nil {
    14         _, addrs, err = net.LookupSRV(r.SRV.Service, "tcp", r.SRV.Domain)
    15         if err != nil {
    16             return nil, err
    17         }
    18     }
    19 
    20     r.Method = method
    21     r.URL = r.selectAddr(addrs, url, 0)
    22 
    23     if r.client.RetryCount == 0 {
    24         resp, err = r.client.execute(r)
    25         return resp, unwrapNoRetryErr(err)
    26     }
    27 
    28     attempt := 0
    29     err = Backoff(
    30         func() (*Response, error) {
    31             attempt++
    32 
    33             r.URL = r.selectAddr(addrs, url, attempt)
    34 
    35             resp, err = r.client.execute(r)
    36             if err != nil {
    37                 r.client.log.Errorf("%v, Attempt %v", err, attempt)
    38             }
    39 
    40             return resp, err
    41         },
    42         Retries(r.client.RetryCount),
    43         WaitTime(r.client.RetryWaitTime),
    44         MaxWaitTime(r.client.RetryMaxWaitTime),
    45         RetryConditions(r.client.RetryConditions),
    46     )
    47 
    48     return resp, unwrapNoRetryErr(err)
    49 }

    重试流程:

      梳理 Execute(method, url) 在申请时的重试流程:

      1、如果没有设置重试次数,执行 r.client.execute(r) :间接申请 Request , 返回 Response 和 error

      2、如果 r.client.RetryCount 不等于0 ,执行 Backoff() 函数

      3、Backoff() 办法接管一个解决函数参数,依据重试策略, 进行 attempt 次网络申请, 同时接管 Retries()、WaitTime()等函数参数

    Backoff函数

      重点看下 Backoff() 函数做了什么动作,代码如下:

     1 // Backoff retries with increasing timeout duration up until X amount of retries
     2 // (Default is 3 attempts, Override with option Retries(n))
     3 func Backoff(operation func() (*Response, error), options ...Option) error {
     4     // Defaults
     5     opts := Options{
     6         maxRetries:      defaultMaxRetries,
     7         waitTime:        defaultWaitTime,
     8         maxWaitTime:     defaultMaxWaitTime,
     9         retryConditions: []RetryConditionFunc{},
    10     }
    11 
    12     for _, o := range options {
    13         o(&opts)
    14     }
    15 
    16     var (
    17         resp *Response
    18         err  error
    19     )
    20 
    21     for attempt := 0; attempt <= opts.maxRetries; attempt++ {
    22         resp, err = operation()
    23         ctx := context.Background()
    24         if resp != nil && resp.Request.ctx != nil {
    25             ctx = resp.Request.ctx
    26         }
    27         if ctx.Err() != nil {
    28             return err
    29         }
    30 
    31         err1 := unwrapNoRetryErr(err)           // raw error, it used for return users callback.
    32         needsRetry := err != nil && err == err1 // retry on a few operation errors by default
    33 
    34         for _, condition := range opts.retryConditions {
    35             needsRetry = condition(resp, err1)
    36             if needsRetry {
    37                 break
    38             }
    39         }
    40 
    41         if !needsRetry {
    42             return err
    43         }
    44 
    45         waitTime, err2 := sleepDuration(resp, opts.waitTime, opts.maxWaitTime, attempt)
    46         if err2 != nil {
    47             if err == nil {
    48                 err = err2
    49             }
    50             return err
    51         }
    52 
    53         select {
    54         case <-time.After(waitTime):
    55         case <-ctx.Done():
    56             return ctx.Err()
    57         }
    58     }
    59 
    60     return err
    61 }

      梳理 Backoff() 函数的流程:

      1、Backoff() 接管 处理函数 和 可选的 Option 函数(retry optione) 作为参数

      2、默认策略3次重试, 通过 步骤一 预设的 Options, 自定义重试策略

      3、设置申请的 repsonse 和 error 变量

      4、开始进行 opts.maxRetries 次 HTTP 申请。

    一个简略的 Demo

     1 func getInfo() {
     2     request := client.DefaultClient().
     3         NewRestyRequest(ctx, "", client.RequestOptions{
     4             MaxTries:      3,
     5             RetryWaitTime: 500 * time.Millisecond,
     6             RetryConditionFunc: func(response *resty.Response) (b bool, err error) {
     7                 if !response.IsSuccess() {
     8                     return true, nil
     9                 }
    10                 return
    11             },
    12         }).SetAuthToken(args.Token)
    13     resp, err := request.Get(url)
    14     if err != nil {
    15         logger.Error(ctx, err)
    16     return 
    17     }
    18 
    19     body := resp.Body()
    20     if resp.StatusCode() != 200 {
    21     logger.Error(ctx, fmt.Sprintf("Request keycloak access token failed, messages:%s, body:%s","message", resp.Status(),string(body))),
    22         )
    23     return 
    24     }
    25   ...
    26 }

      依据以上梳理的 go-resty 的申请流程, 因为 RetryCount 大于0,所以会进行重试机制,重试次数为3。而后 request.Get(url) 进入到 Backoff() 流程,此时重试的边界条件是: !response.IsSuccess(), 直到请求成功。

  • 相关阅读:
    android xml解析添加到listview中的问题
    jquery重写一个对话框
    linq join多字段
    asp.net添加验证码
    基本知识点罗列
    wordpress添加文章浏览统计(刷新不重复)
    vue使用SockJS实现webSocket通信
    el-table的多选框表头增加全选字样
    js如何在一个日期上面加上几小时 几分钟 几秒
    高德地图-Vue-amap实现POI搜索+自定义点
  • 原文地址:https://www.cnblogs.com/baichunyu/p/15116861.html
Copyright © 2020-2023  润新知