【重试】经验总结

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

  在微服务架构中,一个完整的服务被拆分成多个小的服务,小服务之间通过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(), 直到请求成功。

posted @ 2021-08-09 00:21  白春雨  阅读(501)  评论(0编辑  收藏  举报