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%,给下游的是“剩余预算减去自己的开销”。如果下游超时大于上游剩余预算,下游那次调用即使成功也没有意义——结果没人接收,资源纯浪费。
三条硬规则:
- 下游超时 ≤ 上游剩余预算 − 本跳开销,否则超时形同虚设。
- 超时值要大于正常 P99。超时比 P99 还小,等于把 1% 的正常请求判成故障,再触发重试。
- 超时必须能真正取消。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 耗尽。之后所有重试被拒绝,调用方直接收到“重试预算耗尽”的原始错误——重试率被钉在成功率决定的水平上,而不是钉在配置的次数上。这正好符合故障时的目标:不给已经过载的下游加压,同时保留极少量重试给“偶发失败”留窗口。