【重试】经验总结
一、微服务架构下,为什么需要重试
在微服务架构中,一个完整的服务被拆分成多个小的服务,小服务之间通过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(), 直到请求成功。