《后端架构设计·高性能与高可用》7.2 重试放大与退避抖动

把客户端、网关、服务三层重试建成放大模型,实现指数退避与 full jitter,并在同一故障序列下真跑 1000 个请求:总请求同为 1737、放大 1.737 倍,无抖动重试峰值 495,full jitter 峰值 11;另给出重试预算、幂等前提和不可重试错误清单。

重试是乘法,不是加法

熔断和限流解决的是“请求该不该发”,重试解决的是“失败后要不要再发一次”。这两件事经常被放在同一段代码里,结果是一层重试在三层架构中被乘起来。客户端重试 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 期望
客户端121.50
网关231.75
订单服务231.75
合计5184.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 幂等键与去重窗口的实现与故障注入验证 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查