重试是乘法,不是加法
熔断和限流解决的是“请求该不该发”,重试解决的是“失败后要不要再发一次”。这两件事经常被放在同一段代码里,结果是一层重试在三层架构中被乘起来。客户端重试 1 次、网关重试 2 次、服务内部重试 2 次,最坏情况下一个用户请求会变成 2 × 3 × 3 = 18 个下游请求。如果故障不是单点而是共享依赖变慢,这 18 倍会同时落到已经过载的依赖上。
已有文章讨论过限流与熔断 的阈值与状态机,本节只处理重试本身:怎样算清放大倍数、怎样用抖动打散重试波峰、怎样用预算限制总量,以及哪些错误根本不该重试。
三层重试的放大模型
设第 i 层允许 r_i 次重试(不含首次调用),最坏情况下的请求倍数是:
amplification = Π (r_i + 1)
客户端 1 次、网关 2 次、服务 2 次,就是 2 × 3 × 3 = 18。如果每层失败率为 p,假设各层独立,单层期望尝试次数是 1 + p + p² + ... + p^r,总期望请求数是各层因子的乘积。p = 0.5 时三层分别是 1.5、1.75、1.75,总期望 4.594 倍;故障率再高一点,放大倍数会迅速逼近 18。
| 层 | 重试次数 | 最坏尝试 | p=0.5 期望 |
|---|---|---|---|
| 客户端 | 1 | 2 | 1.50 |
| 网关 | 2 | 3 | 1.75 |
| 订单服务 | 2 | 3 | 1.75 |
| 合计 | 5 | 18 | 4.594 |
这张表最重要的用法不是算出一个精确数字,而是暴露“每层都加一次重试”的代价。更稳妥的策略是指定唯一重试点:入口最多重试一次,中间层不重试,服务内部只在明确可重试的依赖上重试一次。这样最坏倍数是 4,而不是 18。
指数退避与 full jitter
退避解决“马上重试会继续撞墙”,抖动解决“所有失败请求在同一时刻一起重试”。标准做法是:
cap = min(maxDelay, base × 2^retry)
delay = random(0, cap) // full jitter
full jitter 从 [0, cap] 均匀取值,而不是从 [base, cap] 取值。后者仍会把大量请求压到 base 附近,故障瞬间的波峰只是被推迟,没有被摊平。代价是少数请求可能几乎不等待就重试,因此它必须和重试预算、幂等键一起使用。
下面的 Policy 同时支持固定退避和 full jitter;Do 在永久错误、尝试耗尽或 context 取消时停止,并且不吞掉永久错误的原始信息。
// file: internal/platform/retry/retry.go
// tier: L1
// verify: go test ./internal/platform/retry/ -count=1
package retry
import (
"context"
"errors"
"math"
rand "math/rand/v2"
"time"
)
type Jitter int
const (
JitterNone Jitter = iota
JitterFull
)
// Policy describes one retry layer. MaxAttempts counts the first call: 3 means
// one original attempt plus two retries.
type Policy struct {
MaxAttempts int
Base time.Duration
Max time.Duration
Jitter Jitter
Rand func(n int64) int64
}
func (p Policy) random(n int64) int64 {
if n <= 1 {
return 0
}
if p.Rand != nil {
return p.Rand(n)
}
return rand.Int64N(n)
}
// Delay returns the wait before the given zero-based retry. Full jitter picks
// uniformly from [0, cap], which removes the synchronised retry wave.
func (p Policy) Delay(retry int) time.Duration {
if retry < 0 {
retry = 0
}
d := p.Base
if d <= 0 {
return 0
}
for i := 0; i < retry; i++ {
if p.Max > 0 && d >= p.Max {
break
}
d *= 2
}
if p.Max > 0 && d > p.Max {
d = p.Max
}
if p.Jitter == JitterFull {
return time.Duration(p.random(int64(d)))
}
return d
}
type permanentError struct{ err error }
func (e *permanentError) Error() string { return e.err.Error() }
func (e *permanentError) Unwrap() error { return e.err }
func Permanent(err error) error {
if err == nil {
return nil
}
return &permanentError{err: err}
}
func IsPermanent(err error) bool {
var target *permanentError
return errors.As(err, &target)
}
// Do runs fn until success, a permanent error, exhaustion or cancellation.
func Do(ctx context.Context, p Policy, fn func(context.Context, int) error) (int, error) {
if p.MaxAttempts < 1 {
p.MaxAttempts = 1
}
for attempt := 0; attempt < p.MaxAttempts; attempt++ {
err := fn(ctx, attempt+1)
if err == nil {
return attempt + 1, nil
}
if IsPermanent(err) || attempt == p.MaxAttempts-1 {
return attempt + 1, err
}
t := time.NewTimer(p.Delay(attempt))
select {
case <-ctx.Done():
t.Stop()
return attempt + 1, ctx.Err()
case <-t.C:
}
}
return p.MaxAttempts, nil
}
// Amplification is the worst-case multiplier when every layer retries every
// failure: the product of (retries + 1).
func Amplification(layers []int) int {
total := 1
for _, retries := range layers {
if retries < 0 {
retries = 0
}
total *= retries + 1
}
return total
}
// ExpectedRequests models independent per-layer failure: a layer with r
// retries contributes 1 + p + ... + p^r attempts for one inbound request.
func ExpectedRequests(failureRate float64, layers []int) float64 {
if failureRate < 0 {
failureRate = 0
}
if failureRate >= 1 {
failureRate = 1
}
total := 1.0
for _, retries := range layers {
factor := 0.0
for i := 0; i <= retries; i++ {
factor += math.Pow(failureRate, float64(i))
}
total *= factor
}
return total
}
Delay 的指数增长在 Max 处封顶,避免第 10 次重试等待几分钟。Do 每次都重新计算延迟,不使用共享的等待时间,这一点在多个 goroutine 同时失败时尤其重要:共享延迟会把抖动重新变回同步。
真实对照:同一故障序列,只改抖动
测试用虚拟时钟做离散模拟,避免真实等待把单次测试拖到几十秒。1000 个并发请求、每个请求最多 3 次尝试、初始失败率 0.5,失败序列由固定 seed 生成,因此“无抖动”和“full jitter”两种模式总请求数必须完全一致,差别只在重试发生的时间分布。峰值统计的是重试尝试(不含首次请求)在 1ms 桶内的最大数量。
// file: internal/platform/retry/retry_test.go
// tier: L1
// verify: go test -v ./internal/platform/retry/ -count=1
package retry
import (
"context"
"errors"
"math"
rand "math/rand/v2"
"testing"
"time"
)
func TestBackoffCapsAndJitterBounds(t *testing.T) {
p := Policy{Base: 100 * time.Millisecond, Max: time.Second}
for i, want := range []time.Duration{100, 200, 400, 800, 1000, 1000} {
if got := p.Delay(i); got != want*time.Millisecond {
t.Fatalf("retry %d: got=%s want=%s", i, got, want*time.Millisecond)
}
}
full := Policy{Base: 100 * time.Millisecond, Max: time.Second, Jitter: JitterFull,
Rand: func(n int64) int64 { return n - 1 }}
for retry := 0; retry < 5; retry++ {
cap := p.Delay(retry)
if got := full.Delay(retry); got < cap-time.Millisecond || got > cap {
t.Fatalf("full jitter retry %d: got=%s cap=%s", retry, got, cap)
}
}
}
func TestAmplificationAndExpectedLoad(t *testing.T) {
layers := []int{1, 2, 2}
want := 1.5 * 1.75 * 1.75
if got := Amplification(layers); got != 18 {
t.Fatalf("amplification=%d want 18", got)
}
if got := ExpectedRequests(0.5, layers); math.Abs(got-want) > 0.001 {
t.Fatalf("expected requests=%.5f want %.5f", got, want)
}
t.Logf("layers=%v worst_case=%dx expected_at_p50=%.3fx", layers, Amplification(layers), want)
}
func TestPermanentErrorStopsRetries(t *testing.T) {
calls := 0
n, err := Do(context.Background(), Policy{MaxAttempts: 4, Base: time.Millisecond},
func(context.Context, int) error {
calls++
return Permanent(errors.New("OUT_OF_STOCK"))
})
if !IsPermanent(err) || n != 1 || calls != 1 {
t.Fatalf("calls=%d n=%d err=%v", calls, n, err)
}
}
func splitmix64(x uint64) uint64 {
x += 0x9E3779B97F4A7C15
x = (x ^ (x >> 30)) * 0xBF58476D1CE4E5B9
x = (x ^ (x >> 27)) * 0x94D049BB133111EB
return x ^ (x >> 31)
}
func failed(seed uint64, id, attempt int, rate float64) bool {
h := splitmix64(seed + uint64(id)*0x9E3779B97F4A7C15 + uint64(attempt+1))
return float64(h%100000)/100000.0 < rate
}
// simulate advances a virtual clock in 1ms buckets. It counts retry attempts,
// excluding the initial call, so the peak measures the retry wave rather than
// baseline traffic.
func simulate(requests int, rate float64, p Policy, seed uint64) (total, retryPeak int, peakAt int64) {
buckets := map[int64]int{}
for id := 0; id < requests; id++ {
attempt := 0
at := int64(0)
for {
total++
if attempt > 0 {
buckets[at]++
if buckets[at] > retryPeak {
retryPeak, peakAt = buckets[at], at
}
}
if !failed(seed, id, attempt, rate) || attempt >= p.MaxAttempts-1 {
break
}
at += p.Delay(attempt).Milliseconds()
attempt++
}
}
return total, retryPeak, peakAt
}
func TestFullJitterReducesRetryPeak(t *testing.T) {
const requests = 1000
const rate = 0.5
none := Policy{MaxAttempts: 3, Base: 100 * time.Millisecond, Max: time.Second}
r := rand.New(rand.NewPCG(42, 7))
full := Policy{MaxAttempts: 3, Base: 100 * time.Millisecond, Max: time.Second,
Jitter: JitterFull, Rand: r.Int64N}
totalNone, peakNone, atNone := simulate(requests, rate, none, 42)
totalFull, peakFull, atFull := simulate(requests, rate, full, 42)
t.Logf("requests=%d failure_rate=%.1f no_jitter: total=%d amp=%.3fx retry_peak=%d@%dms full_jitter: total=%d amp=%.3fx retry_peak=%d@%dms",
requests, rate, totalNone, float64(totalNone)/requests, peakNone, atNone,
totalFull, float64(totalFull)/requests, peakFull, atFull)
if totalNone != totalFull {
t.Fatalf("failure sequence changed: %d vs %d", totalNone, totalFull)
}
if peakFull*5 > peakNone {
t.Fatalf("full jitter peak=%d did not spread no-jitter peak=%d", peakFull, peakNone)
}
}
真实运行:总请求不变,峰值从 495 降到 11
本机 macOS darwin/arm64、Go 1.26.0,命令 go test -v ./... -count=1,原始输出在 .local/reports/be-arch/be-arch-perf-07-2-retry-jitter-tests.txt。这是虚拟时钟模拟的单次运行,不代表线上吞吐:
=== RUN TestAmplificationAndExpectedLoad
retry_test.go:38: layers=[1 2 2] worst_case=18x expected_at_p50=4.594x
--- PASS: TestAmplificationAndExpectedLoad (0.00s)
=== RUN TestFullJitterReducesRetryPeak
retry_test.go:100: requests=1000 failure_rate=0.5 no_jitter: total=1737 amp=1.737x retry_peak=495@100ms full_jitter: total=1737 amp=1.737x retry_peak=11@84ms
--- PASS: TestFullJitterReducesRetryPeak (0.00s)
PASS
ok archlab/internal/platform/retry 0.234s
两种模式的总请求数都是 1737,放大 1.737 倍,说明抖动没有改变失败序列,只改变了时间分布。无抖动时 495 个重试在 100ms 这个桶里同时发生;full jitter 把峰值压到 11,约为原来的 1/45。对下游来说,前者是一次 495 并发的冲击,后者更接近持续的小流量。绝对峰值仍会随请求数和失败率线性增长,所以抖动不能替代重试预算。
重试预算:用成功流量给重试定价
仅有退避和抖动还不够。如果依赖持续失败,每个请求仍然会重试到上限,总流量会稳定在 1.737 倍甚至更高。重试预算的思路是:重试额度来自成功请求,而不是来自失败请求。例如每个成功请求累积 0.1 个令牌,每次重试消耗 1 个令牌,令牌上限为 100。正常流量下,1000 个成功请求最多支持 100 次重试;依赖整体故障时,预算很快耗尽,后续请求直接失败并返回 OVERLOADED 或 RATE_LIMITED,而不是继续放大。
arch-lab 的口径是按依赖分别设置预算:支付通道 5%,库存服务 10%,通知类异步任务可以放宽到 20%。预算耗尽不是错误隐藏,而是快速失败信号:它应该出现在指标里,并触发熔断或降级,而不是被重试循环吞掉。
哪些错误不能重试
| 错误 | 是否重试 | 原因 |
|---|---|---|
| 400/401/403/404/422 | 否 | 请求本身错误,重试只会重复失败 |
409 IDEMPOTENCY_CONFLICT | 否 | 同键不同参数,必须让调用方修正 |
INVALID_STATE | 否 | 状态机不允许该转换 |
OUT_OF_STOCK | 否 | 库存不足是业务结果,不是瞬时故障 |
| 支付被拒(insufficient funds) | 否 | 重试不会改变账户余额 |
| 500/502/503/504 | 有条件 | 仅幂等操作或带幂等键;遵守 Retry-After |
| 408/425/429 | 有条件 | 超时或限流,需退避并受预算约束 |
| 连接被重置 | 有条件 | 写操作必须带幂等键,否则可能重复扣款 |
CIRCUIT_OPEN | 否(立即) | 熔断期间重试只会加重故障 |
“有条件”统一指向两个前提:操作本身幂等,或者请求携带稳定的幂等键,且下游在去重窗口内能识别重复。没有这两个前提,超时后的重试可能在服务端已经成功、响应丢失的情况下二次扣库存或二次支付。
常见错误与边界
第一,每层都配置重试。客户端、网关、服务各自“只重试两次”,乘起来是 18 次;应该指定唯一重试点,其他层快速失败。
第二,只用固定退避没有抖动。所有实例在同一时刻失败,又在同一时刻重试,波峰只是从故障瞬间平移到了 base 之后。
第三,把 full jitter 当成 [base, cap] 的随机。这样仍有下限,起不到打散作用;full jitter 的下限必须是 0。
第四,重试不可重试的业务错误。OUT_OF_STOCK、INVALID_STATE、IDEMPOTENCY_CONFLICT 重试多少次结果都一样,只会浪费预算。
第五,没有重试预算。依赖持续故障时,重试会把 1 倍流量稳定放大到接近上限;预算耗尽后必须快速失败,并把信号交给熔断器。
小结
重试是乘法:三层各重试两次就是 18 倍最坏放大,失败率 0.5 时期望也有 4.594 倍。指数退避封顶、full jitter 从 [0, cap] 取值,可以在总请求数不变的前提下把重试峰值从 495 压到 11。重试预算把额度绑定到成功流量,幂等键则决定“超时后能不能重试”。下一节实现幂等键存储和去重窗口,并用故障注入验证重复请求不会二次扣库存或二次支付。
阅读导航:上一节:7.1 超时预算如何在调用链中分配 · 下一节:7.3 幂等键与去重窗口的实现与故障注入验证 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。