
Go 实现指数退避重试:context 取消、抖动 jitter 与什么时候别重试「网络抖一下就失败」的代码,第一版通常是这样的:fori:0;i3;i{resp,err:http.Get(url)iferrnil{returnresp}time.Sleep(time.Second)}returnnil,errors.New(重试三次仍失败)这段代码能跑,但它在生产上有几处会直接变成事故:失败时定长 sleep 1 秒重试三次,意味着任何一个下游抖动都会让本服务的 goroutine 至少占住 3 秒;调用方传进来的context被完全无视,上游已经超时取消了,这里还在闷头重试;更糟的是三个重试请求几乎同时到达——因为等待时长一样,和别人的重试也对齐,对端刚缓过来又被一波同步的请求打趴下。这篇把退避重试写成一个能进生产的工具函数,把每个决策都说清楚。一、定长等待为什么不行先看定长重试的失败模式。假设某个下游有 100 个调用方,都在它偶尔超时的时候重试,且都等 1 秒:t0s 下游开始变慢,100 个请求超时 t1s 100 个重试同时到达 ← 全部对齐,瞬间又把它压垮 t2s 200 个请求(原始 第一次重试)一起超时 t3s 400 个重试 ← 雪崩重试本身是「用更多的请求量换取成功率」,如果不加控制,它会把一个局部的慢,放大成全网的雪崩。两个手段对付它:指数退避:每次失败后等待时间翻倍,给对端越来越长的恢复窗口,同时把本方的请求密度降下来。抖动(jitter):在等待时长上加随机扰动,打散所有调用方的重试节奏,破坏「对齐」这个前提。AWS 那篇著名的Exponential Backoff and Jitter给出的结论是:在负载较高时,加抖动的指数退避明显优于不加抖动,而「全抖动」(等待时间在[0, base*2^n]之间均匀随机)在多数指标上表现最好。下面按这个思路实现。二、一个带 context 和 jitter 的重试函数先写骨架,再逐条解释:packageretryimport(contexterrorsmathmath/randtime)// Retryable 标记一个错误是否值得重试。// 调用方应该给出明确的判断依据,而不是「所有错误都重试」。typeRetryablefunc(error)booltypeConfigstruct{MaxAttemptsint// 总尝试次数,含首次Base time.Duration// 首次退避基准MaxDelay time.Duration// 单次等待上限,防止退避到天荒地老Retryable Retryable// 什么错才重试Rand*rand.Rand// 注入随机源,便于测试复现}funcDo(ctx context.Context,cfg Config,fnfunc(ctx context.Context)error)error{ifcfg.MaxAttempts0{cfg.MaxAttempts1}ifcfg.Base0{cfg.Base100*time.Millisecond}ifcfg.MaxDelay0{cfg.MaxDelay5*time.Second}ifcfg.Randnil{cfg.Randrand.New(rand.NewSource(time.Now().UnixNano()))}varlastErrerrorforattempt:0;attemptcfg.MaxAttempts;attempt{iferr:ctx.Err();err!nil{// 上下文已经取消/超时,不要再发请求returnerrors.Join(lastErr,err)}lastErrfn(ctx)iflastErrnil{returnnil}// 最后一次尝试失败了也不该再等ifattemptcfg.MaxAttempts-1{break}ifcfg.Retryable!nil!cfg.Retryable(lastErr){returnlastErr}delay:backoff(attempt,cfg)timer:time.NewTimer(delay)select{case-ctx.Done():timer.Stop()returnerrors.Join(lastErr,ctx.Err())case-timer.C:}}returnlastErr}funcbackoff(attemptint,cfg Config)time.Duration{// 2^attempt 可能溢出,先算成 float 再夹到 MaxDelayexp:math.Pow(2,float64(attempt))d:time.Duration(float64(cfg.Base)*exp)ifd0||dcfg.MaxDelay{dcfg.MaxDelay}// full jitter:在 [0, d] 上均匀取值returntime.Duration(cfg.Rand.Int63n(int64(d)1))}调用方式:err:retry.Do(ctx,retry.Config{MaxAttempts:4,Base:100*time.Millisecond,MaxDelay:3*time.Second,Retryable:isTransient,},func(ctx context.Context)error{req,_:http.NewRequestWithContext(ctx,http.MethodGet,url,nil)resp,err:client.Do(req)iferr!nil{returnerr}deferresp.Body.Close()ifresp.StatusCode500{returnfmt.Errorf(server error: %d,resp.StatusCode)}returnnil})三、四个必须做对的地方1. context 取消要能立刻打断 sleep这是最容易做错的一处。time.Sleep(delay)是不可打断的——上游在 100ms 时取消了请求,这里仍会睡满 3 秒才检查,goroutine 白占资源,连接池里的连接也一直挂着。正确做法是select在time.After/ctx.Done()之间等:select{case-ctx.Done():returnerrors.Join(lastErr,ctx.Err())case-timer.C:}顺带一个性能细节:长期运行的循环里不要用time.After,它每次都会创建一个新 timer,在 timer 触发前不会被 GC 回收,循环次数多的时候会堆积。用time.NewTimer并在提前返回时Stop();Go 1.23 之后不再需要 drain channel,直接 Stop 就行。2. 每次尝试都要把 ctx 传下去上面调用示例里http.NewRequestWithContext(ctx, ...)是关键。如果重试函数里用的是http.NewRequest(不带 context),那select那段能感知取消,但正在飞行的那个请求不会被打断,依然会阻塞到超时。两者要一起做:等待阶段靠select,请求阶段靠 request 携带 context。3. 传递的应该是「可重试」这个判断,而不是重试所有错误fn返回的错误里,有一类是重试一万次也不会变的:参数非法、401 未授权、404 找不到、数据库唯一键冲突。对这些做重试纯属浪费——不仅白等,还可能放大副作用。把「值不值得重试」抽成Retryable函数,由调用方定义:funcisTransient(errerror)bool{varnetErr net.Erroriferrors.As(err,netErr){returntrue// 网络类错误:超时、连接重置,值得重试}varhttpErr*HTTPErroriferrors.As(err,httpErr){returnhttpErr.Code500// 5xx 值得,4xx 不值得}iferrors.Is(err,context.Canceled){returnfalse// 主动取消,重试没意义}returnfalse// 默认不重试,让调用方显式放行}这里的默认值是不重试。反过来(默认重试)会让忘记分类的错误全部进入重试链路,风险更大。errors.As而不是类型断言,是为了能穿透fmt.Errorf(...: %w, err)这样的包装——包装过的错误用类型断言会失败,用错误链才能匹配。4. 指数计算要防溢出并夹上限2^attempt在 attempt 到 63 的时候就已经溢出int64了。虽然正常配置不会重试那么多次,但如果MaxAttempts来自配置、被误填成一个大数,退避会算出负数,time.NewTimer(负数)会立即触发,重试变成无间隔的忙循环,直接把 CPU 打满。所以backoff里做了两件事:用math.Pow走浮点避免整数溢出,再把结果夹到MaxDelay。夹上限还有一个实际意义——等待时间不应该长到超过调用方自己的超时,否则第 5 次重试还没开始,上游已经报错了,等待纯属浪费。一般MaxDelay不要超过整体超时预算的一半。四、抖动:三种方案的实际取舍exponential: d base * 2^n equal jitter: d base*2^n/2 rand(0, base*2^n/2) ← 保留一半确定性 full jitter: d rand(0, base*2^n) ← 方差最大full jitter打散效果最好,但它的等待时间可能短到接近 0——理论上连续几次都抽到极小值,退避就失效了。要是你的下游非常脆弱、必须保证一个最小恢复窗口,用equal jitter更合适:至少等一半。另外,如果重试是多步操作的一部分(比如重试后再做一些本地校验),建议给 jitter 用可注入的随机源(上面Config.Rand),测试时传一个固定种子的rand.New,退避时间就完全可复现,不会出现「本地跑过、CI 偶发失败」这种折磨人的情况。五、这些场景不要重试非幂等操作。POST 创建订单、发起扣款,重试可能造成重复业务。真要重试,必须带幂等键(客户端生成唯一 ID,服务端按 ID 去重),而且这属于业务设计,不是加个重试循环就能解决的。参数或权限错误。400、401、403、404,重试不改变结果。已经超过了整体超时预算。如果上游给你的预算是 1 秒,而你的退避序列是 100ms 200ms 400ms 800ms,那实际只有前两次尝试有机会跑完。这种情况下要么减少MaxAttempts,要么缩短Base,别让重试反过来把正常路径挤没。在对端明确要求降速时硬重试。收到 429 且带Retry-After时,应该按它给的时间等,而不是按你自己的退避序列。这是对端在保护自己,也是在保护你。六、什么时候该用现成的库标准库里没有重试,自己写大约五十行。如果你需要更完整的能力——比如「连续失败 N 次后熔断」,或者基于滑动窗口失败率触发熔断——那就不该在重试函数里继续加逻辑了,那是熔断器(circuit breaker)的职责,用github.com/sony/gobreaker之类的库更合适。两者的分工很清楚:重试处理「偶发失败」:一次抖动能靠重试救回来。熔断处理「持续失败」:对端已经整体不可用了,快速失败比继续重试更保护双方。只有重试没有熔断,在长期故障时会表现为「每个请求都要痛苦地重试几次才失败」,把线程和连接都拖住;只有熔断没有重试,则会对一次性的网络抖动过于敏感。生产上这两个通常配套出现。小结定长等待 全量重试会放大故障,指数退避 抖动是基础要求。等待阶段用select监听ctx.Done(),保证取消能立刻打断 sleep;用time.NewTimer而不是time.After,避免 timer 堆积。每次尝试都要把ctx传进真正的请求,否则飞行中的请求不会因取消而中断。通过Retryable函数显式判断哪些错误值得重试,默认不重试;用errors.As/errors.Is穿透错误包装。2^attempt要防溢出并夹MaxDelay,MaxDelay不要超过整体超时预算的一半。非幂等操作、4xx、预算不足、对端给了Retry-After的情况,都不要按自己的节奏硬重试。重试解决偶发失败,持续故障要靠熔断,两者配套使用。一句话记忆点:重试的本质是「用更多请求换成功率」,所以它的每一个参数——等待多久、什么错才重试、最多重几次——本质上都是在回答「我愿意为此付出多少代价」。