35.5 超时、重试与退避

一句话

超时给“等待”设上限,重试给“失败”再一次机会,两者相乘就是故障放大器——超时必须沿调用链递减、用剩余预算而不是固定次数做判据,重试必须幂等、有上限、带抖动、受预算约束,否则一次下游抖动会变成全站雪崩。

重试是乘法:一层的勇气,三层的灾难

每一层的重试次数是乘上去的,不是加上去的:

打到最下游的流量 = 入口流量 × ∏(1 + 第 i 层的重试次数)
链路形态 放大倍数 入口 1 万 QPS 时最下游看到
单层,不重试 1× 1 万
单层,重试 2 次 3× 3 万
两层,各重试 2 次 9× 9 万
三层,各重试 2 次 27× 27 万
三层,各重试 3 次 64× 64 万

关键在于:重试只在成功率高时是“补救”,在故障时是“加压”。而重试恰恰只在故障时触发——系统最脆弱的时刻,恰好是它给自己加 27 倍负载的时刻。这就是 34.6 性能反模式 里“重试风暴”的算术来源。

🔑 全链路只允许一层重试(通常是入口或客户端),其余层快速失败、把错误原样上抛。中间层重试没有信息优势:三层看到的是同一次故障,中间层多试两次,只是让最下游多挨两次打。

重试对哪类失败有用

失败原因 重试有效吗 原因
单次丢包、瞬时连接重置 有效 失败近似独立,第二次大概率成功
某实例宕机,LB 已把它摘掉 有效,1–2 次 换一个实例就好
下游过载 / 容量不足 无效,且有害 失败是相关的,重试让过载更过载
代码 bug、必然的 500 无效 必现错误,重试只是浪费预算
超时(对端可能已执行) 有风险 必须先幂等,见 25.4 幂等设计与去重
400 / 401 / 403 / 404 / 409 不要重试 参数、权限、状态错,重试一万次也不会变对

由此得到一条反直觉结论:“重试 3 次能把 99% 的成功率提到 99.9999%”这句话,连算术都不对。3 次重试是 4 次尝试,1 − 0.01^4 是 99.999999%(六个 9);四个 9 对应的是 3 次尝试(2 次重试)。就算次数数对了,它还有个隐含前提——每次失败相互独立;下游整体挂掉时失败完全相关,重试的边际收益是 0,边际成本是全链路放大。

超时不是一个数,是一条预算

超时的正确单位是剩余时间预算,不是“一个接口配一个毫秒数”。

网关(入口预算 1000ms)
  └─ 服务 A(给自己 900ms,留 100ms 做序列化/排队/日志)
       └─ 服务 B(给自己 600ms)
            └─ 服务 C(给自己 300ms)
层 拿到的预算 自己的超时 传给下游
网关 1000ms 950ms 900ms
服务 A 900ms 800ms 600ms
服务 B 600ms 450ms 300ms
服务 C 300ms 300ms —

每跳给自己留 10%–20%,给下游的是“剩余预算减去自己的开销”。如果下游超时大于上游剩余预算,下游那次调用即使成功也没有意义——结果没人接收,资源纯浪费。

三条硬规则:

  1. 下游超时 ≤ 上游剩余预算 − 本跳开销,否则超时形同虚设。
  2. 超时值要大于正常 P99。超时比 P99 还小,等于把 1% 的正常请求判成故障,再触发重试。
  3. 超时必须能真正取消。Go 里 context.WithTimeout 只是让 ctx.Done() 关闭,不会自动中断已经在跑的 RPC 或 SQL——要把 ctx 一路传下去,并在下游客户端里真正响应取消。

超时值怎么定

场景 正常 P99 建议超时 依据
同机房 RPC 20ms 100ms 5×P99,覆盖 GC 与调度抖动
跨机房 RPC 80ms 300ms 3–4×P99
第三方 API 200ms 1s,外加熔断 对方无 SLA,超时要大于它的承诺值
用户同步链路总额 — 1–2s 超过 2s 用户开始重试或离开
后台任务 / MQ 消费 秒级 30s 无人在等,宁可慢也不要误杀

⚠️ 超时太短的代价比太长更大:太长只是占住一个线程;太短会把“慢”判成“死”,触发重试和故障检测(见 35.4 故障检测),一次 GC 停顿就能引发全场摘流量。

重试的前提:幂等

非幂等操作不能盲重试。超时是一种“结果未知”的状态:请求可能没到,也可能到了并执行成功、只是响应在路上丢了。

操作 能否直接重试 正确做法
GET / PUT / DELETE 可以(HTTP 语义上幂等) 仍要注意 DELETE 的“已删除”是否算成功
POST 创建订单、扣款、发券 不可以 客户端生成幂等键,服务端按 (幂等键) 去重
MQ 消费 可以,但要求消费幂等 at-least-once 语义下重复投递是常态
状态机流转(支付→发货) 不可以 用状态 CAS:只有 待发货 才能变 已发货

注意“HTTP 幂等”不等于“重试安全”:一个幂等的 PUT 在超时后重试,结果是可预期的;但一次 POST /pay 超时后重试,可能扣两次钱。能不能重试不取决于方法名,取决于有没有幂等键。

退避:从固定到“指数 + 抖动”

以 base=100ms、最多重试 4 次为例:

策略 第 1 次等 第 2 次 第 3 次 第 4 次 累计
固定 100ms 100ms 100ms 100ms 100ms 400ms
线性 100ms 100ms 200ms 300ms 400ms 1.0s
指数 100ms 100ms 200ms 400ms 800ms 1.5s
指数 + full jitter 0–100ms 0–200ms 0–400ms 0–800ms 期望 750ms

指数退避解决的是总时长:让重试的节奏慢下来,给下游恢复的时间。但它解决不了同步——所有客户端在同一刻失败,指数退避之后仍然在同一刻重试,只是推迟了。真正把峰值削掉的是抖动:

抖动类型 公式 特点
无抖动 sleep = base × 2^n 同步,峰值不降
Full jitter sleep = rand(0, min(cap, base × 2^n)) 峰值最低,可能立刻重试(间隔为 0)
Equal jitter sleep = b/2 + rand(0, b/2),b = min(cap, base×2^n) 保留最小间隔,峰值高于 full
Decorrelated sleep = rand(base, prev × 3) 不需要记重试次数,适合状态少的客户端

实测(1000 个客户端同一刻失败,各重试 4 次,100ms 分桶统计峰值,代码见本节末尾):

固定 1s            总重试 4000 次,峰值 1000 次/100ms,末次重试在   4.0s
指数退避             总重试 4000 次,峰值 1000 次/100ms,末次重试在  15.0s
指数+full jitter   总重试 4000 次,峰值  164 次/100ms,末次重试在  14.1s

指数退避的峰值和固定退避一模一样(都是 1000),只是把末端从 4s 拖到 15s——不加抖动,指数退避只买到时间,没买到分散。加上 full jitter 后峰值降到 164,约为原来的 1/6。

🔑 抖动是重试设计里唯一能打破“同步”的手段。没有抖动的退避,等于把一次雪崩改成一次更晚的雪崩。

重试预算:让重试率随成功率自动收缩

重试的致命之处在于触发条件与放大方向一致:越失败越重试,越重试越失败。解决办法不是“少配几次”,而是给重试本身设一个与成功率挂钩的预算。

模型很简单:一个令牌桶,请求成功时按比例补令牌,每次重试消耗 1 个,令牌不足就不许重试。

参数 含义 例子
maxTokens 预算上限 10
tokenRatio 每次成功补多少 0.1
判定 令牌低于阈值时禁止重试 低于 1 个

算一下:下游成功率 99% 时,每 100 个成功请求补 10 个令牌,可持续的重试率约 10%;成功率掉到 50% 时,成功侧每秒只补 50 个令牌,而重试侧每秒要消耗 500 个——初始的 10 个令牌在 20ms 内耗尽,重试自动关停,无需人工干预。

gRPC 把这个机制做进了服务配置(retryThrottling: { maxTokens, tokenRatio });Envoy 也有按活跃请求比例限制重试的 retry budget。名字不同,思路都是“重试量必须在正常请求量的一定比例之内”。

⚠️ 重试预算的阈值不能省。只配 maxAttempts=3 的重试,在下游故障时等于把流量乘以 3;配上预算,同样的配置在故障时退化成“只重试一小部分”。

可运行的实现

下面这段代码包含两件事:① 一个受预算约束、带 full jitter、按剩余时间决定是否重试的重试器;② 一个虚拟时钟仿真,量化三种退避策略的重试峰值。

package main

import (
	"context"
	"errors"
	"fmt"
	"math/rand"
	"sort"
	"time"
)

// 只有「可重试」的错误才值得再试一次;400 这类业务错误重试一万次也不会变对。
var errUnavailable = errors.New("503 下游不可用")

// retryBudget 是客户端共享的重试预算:成功请求按比例补充令牌,每次重试消耗 1 个。
// 故障时成功变少 → 令牌耗尽 → 所有调用方一起停止重试,而不是一起放大流量。
type retryBudget struct {
	tokens     float64
	max        float64
	perSuccess float64
}

func newBudget(max, perSuccess float64) *retryBudget {
	return &retryBudget{tokens: max, max: max, perSuccess: perSuccess}
}
func (b *retryBudget) allow() bool { return b.tokens >= 1 }
func (b *retryBudget) take()       { b.tokens-- }
func (b *retryBudget) onSuccess()  { b.tokens = min(b.max, b.tokens+b.perSuccess) }

func retryable(err error) bool { return errors.Is(err, errUnavailable) }

type policy struct {
	maxAttempts int
	base, cap   time.Duration
	minCall     time.Duration // 一次调用至少要留的时间,留不够就不重试
	budget      *retryBudget
	rnd         *rand.Rand
}

// Do 在 ctx 的剩余预算内重试 fn,退避 = 指数 + full jitter。
func (p *policy) Do(ctx context.Context, fn func(context.Context) error) error {
	for attempt := 1; ; attempt++ {
		err := fn(ctx)
		if err == nil {
			p.budget.onSuccess()
			return nil
		}
		// 不可重试 / 次数用完 / 已被调用方取消 → 立刻返回,不再浪费预算
		if !retryable(err) || attempt >= p.maxAttempts || ctx.Err() != nil {
			return err
		}
		if !p.budget.allow() {
			return fmt.Errorf("%w(重试预算耗尽)", err)
		}
		// 指数退避 + full jitter:在 [0, backoff) 内均匀取值,避免所有客户端同一刻重试
		backoff := p.base << (attempt - 1)
		if backoff > p.cap || backoff <= 0 {
			backoff = p.cap
		}
		wait := time.Duration(p.rnd.Int63n(int64(backoff)))
		// 关键:剩余预算装不下「等待 + 再调一次」,就别等——等了也必然超时
		if dl, ok := ctx.Deadline(); ok && time.Until(dl) < wait+p.minCall {
			return fmt.Errorf("%w(剩余预算不够再调一次,停止重试)", err)
		}
		p.budget.take()
		fmt.Printf("    attempt %d 失败(%v),退避 %3dms\n", attempt, err, wait.Milliseconds())
		select {
		case <-time.After(wait):
		case <-ctx.Done():
			return ctx.Err()
		}
	}
}

// ---------- ② 重试风暴仿真(虚拟时钟,无 sleep) ----------

// simulate 返回:总重试次数、100ms 分桶后的峰值、以及重试完成所需最长时间(ms)。
func simulate(strategy string, clients int, rnd *rand.Rand) (int, int, int64) {
	const base, capBackoff = 1000, 30000 // ms
	var bins = map[int64]int{}
	totalRetries := 0
	var last int64
	for i := 0; i < clients; i++ {
		var at int64 // 当前重试时刻
		backoff := base
		for r := 0; r < 4; r++ { // 最多重试 4 次
			var wait int64
			switch strategy {
			case "固定 1s":
				wait = 1000
			case "指数退避":
				wait = int64(backoff)
				backoff *= 2
				if backoff > capBackoff {
					backoff = capBackoff
				}
			case "指数+full jitter":
				wait = rnd.Int63n(int64(backoff))
				backoff *= 2
				if backoff > capBackoff {
					backoff = capBackoff
				}
			}
			at += wait
			bins[at/100*100]++ // 每 100ms 一桶
			totalRetries++
			if at > last {
				last = at
			}
		}
	}
	var times []int64
	for t := range bins {
		times = append(times, t)
	}
	sort.Slice(times, func(i, j int) bool { return times[i] < times[j] })
	peak := 0
	for _, t := range times {
		if bins[t] > peak {
			peak = bins[t]
		}
	}
	return totalRetries, peak, last
}

func main() {
	// ① 预算链:入口只给 250ms,下游单次 60ms,故障时探活
	fmt.Println("① 截止时间传播(入口预算 250ms,单次调用 60ms,下游持续 503)")
	budget := newBudget(10, 0.1)
	p := &policy{maxAttempts: 5, base: 20 * time.Millisecond, cap: 200 * time.Millisecond,
		minCall: 60 * time.Millisecond, budget: budget, rnd: rand.New(rand.NewSource(7))}
	ctx, cancel := context.WithTimeout(context.Background(), 250*time.Millisecond)
	defer cancel()
	calls := 0
	err := p.Do(ctx, func(ctx context.Context) error {
		calls++
		time.Sleep(60 * time.Millisecond)
		return errUnavailable
	})
	fmt.Printf("    结果:%v,共发起 %d 次调用,预算剩余令牌 %.1f\n\n", err, calls, budget.tokens)

	// ② 1000 个客户端同时失败,三种退避下的重试峰值
	fmt.Println("② 1000 个客户端同一刻失败,各重试 4 次:100ms 内的重试峰值")
	rnd := rand.New(rand.NewSource(42))
	for _, s := range []string{"固定 1s", "指数退避", "指数+full jitter"} {
		total, peak, last := simulate(s, 1000, rnd)
		fmt.Printf("    %-16s 总重试 %d 次,峰值 %4d 次/100ms,末次重试在 %5.1fs\n",
			s, total, peak, float64(last)/1000)
	}
}

输出:

① 截止时间传播(入口预算 250ms,单次调用 60ms,下游持续 503)
    attempt 1 失败(503 下游不可用),退避  17ms
    attempt 2 失败(503 下游不可用),退避  12ms
    结果:503 下游不可用(剩余预算不够再调一次,停止重试),共发起 3 次调用,预算剩余令牌 8.0

② 1000 个客户端同一刻失败,各重试 4 次:100ms 内的重试峰值
    固定 1s            总重试 4000 次,峰值 1000 次/100ms,末次重试在   4.0s
    指数退避             总重试 4000 次,峰值 1000 次/100ms,末次重试在  15.0s
    指数+full jitter   总重试 4000 次,峰值  164 次/100ms,末次重试在  14.1s

读法(两部分都值得细看):

  • 250ms 预算、单次 60ms,只发出了 3 次调用:60 + 17 + 60 + 12 + 60 = 209ms,剩余 41ms 已经不够“等一次退避再调一次”,于是主动停止。判据是剩余预算,不是重试次数——这正是同步链路里最容易被忽略的一条:配 5 次重试,实际只跑得完 3 次。
  • 指数退避的峰值没有下降:1000 个客户端在同一刻失败,指数退避让每个客户端的第 n 次重试仍在同一刻到达,只是整体往后推。要削峰值只能靠抖动。

参数怎么给

你的情况 超时 重试 退避
用户同步请求,链路 3 跳 入口 1s,每跳按剩余预算切 只在入口重试 1–2 次 指数,100ms 起,full jitter
后台任务 / MQ 消费 单次 30s 8 次,超过进死信 指数,1s 起,cap 10min
第三方 API(无 SLA) 2s 2 次 1s、2s + 抖动,必须带幂等键
支付 / 扣款 按对方承诺 不盲重试 幂等键 + 查询确认结果
高扇出聚合(一次调 50 个下游) 每个下游单独超时 不重试,用降级 —

跨篇相关:RPC 层的超时/重试参数落在协议与拦截器上,见 42.5 超时、重试与幂等;重试把流量放大之后要靠限流兜住(35.7 限流);超时值本身又决定了故障检测的误判率,见 35.4 故障检测。

反模式

反模式 后果
三层各配 3 次重试 下游最坏承受 64 倍流量,本来能扛的故障被放大成雪崩
超时写死 30s,且不随上游预算收缩 上游早就返回 504,下游还在跑;重试再来一轮,资源翻倍占用
重试不区分错误码 400 参数错误也重试 3 次,白烧 3 倍 QPS
对非幂等 POST 直接重试 重复下单、重复扣款、重复发券
指数退避但不加抖动 峰值不降,只是把雪崩推迟 15s
退避没有上限 最后一次重试等 1 小时,连接与 goroutine 长期悬挂
用重试次数代替剩余预算 配 5 次实际只跑 3 次,最后两次永远等不到
客户端无差别重试所有超时 下游过载时重试把过载翻倍,故障从 1 分钟拖成 10 分钟
重试没有预算上限 故障期间重试量与原流量同阶,等于主动 DDoS 自己
重试时不复用幂等键 每次重试都被当成新请求,去重完全失效

自检

Q1 三条链路 A→B→C,每层都配了“重试 2 次”。C 过载时,C 看到的流量是入口的多少倍?如果只在 A 重试呢?为什么说中间层重试没有信息优势?

▸ 答案:每层放大 3 倍,3×3×3 = 27 倍;只在 A 重试是 3 倍。三层观测到的是同一次故障(C 过载),B 和 C 的重试不掌握任何 A 不知道的信息,只是把同一份失败请求重新排队;而每次重试都在 C 已经过载时再加一份负载,所以中间层重试不仅无益,还会延长故障。正确做法是:中间层快速失败并原样上抛,把“要不要再试”的决策收敛到唯一有全局视角的一层。

Q2 入口预算 1s,服务 A 给 B 的超时是 800ms,B 内部对 C 重试 2 次、每次超时 300ms。用户实际会看到什么?哪里错了?

▸ 答案:B 最坏要花 300×3 = 900ms,而 A 在 800ms 就放弃了——A 返回超时后,B 还在为一次没人要的结果继续跑 100ms 以上;A 若重试,B 再跑一轮,C 承受双倍流量。错在B 用“次数”而不是“剩余预算”做判据:B 拿到 800ms 预算,扣掉自身开销后给 C 每次 300ms 没问题,但重试前必须检查“剩余预算 ≥ 退避 + 一次调用”,装不下就不该发起第二次。超时值本身没写错,错在重试循环没有读 deadline。

Q3 1000 个客户端同时失败,固定 1s 退避的峰值是 1000 次/100ms。改成指数退避(1s、2s、4s、8s)后峰值还是 1000,为什么?怎么改才能降下来?

▸ 答案:指数退避只改变等待时长,不改变等待的起点。1000 个客户端在同一毫秒失败,第 n 次重试的时刻都落在同一毫秒(各自 base×2^n 相同),所以每个重试点仍是 1000 个请求同时到达,只是从 1s 处挪到了 1s、3s、7s、15s 处。要打散必须引入随机:wait = rand(0, min(cap, base×2^n)),实测峰值从 1000 降到 164。代价是部分客户端会立刻重试(间隔可能为 0),换来的是整体峰值下降约 6 倍。

Q4 有人说“重试 3 次能把单次 99% 的成功率提升到 99.9999%”。这句话在什么条件下成立?你们的下游整机宕机时它成立吗?

▸ 答案:先纠算术:3 次重试 = 4 次尝试,1 − 0.01^4 = 99.999999%(六个 9);99.9999%(四个 9)对应 3 次尝试、2 次重试(1 − 0.01^3)。再纠前提:它只在每次尝试的失败相互独立时成立。下游整机宕机时失败完全相关,四次尝试都打在同一台坏机器(或同一批坏实例)上,成功率仍是 0,重试只贡献 4 倍流量。所以重试的有效区间是“瞬时、独立、可换路径”的失败(丢包、单实例故障且 LB 已摘除);对容量不足、依赖整体不可用这类相关失败,正确手段是熔断、限流、降级,见 35.6 熔断与降级。

Q5 下游 50% 失败、限流后入口 1000 QPS,maxTokens=10, tokenRatio=0.1 的重试预算大概多久耗尽?耗尽之后系统行为是什么?

▸ 答案:每秒成功 500 个,补 500×0.1 = 50 个令牌;每秒重试消耗约 500 个(失败请求都想重试),净流出 450/s,初始 10 个令牌约 20ms 耗尽。之后所有重试被拒绝,调用方直接收到“重试预算耗尽”的原始错误——重试率被钉在成功率决定的水平上,而不是钉在配置的次数上。这正好符合故障时的目标:不给已经过载的下游加压,同时保留极少量重试给“偶发失败”留窗口。