重试策略比较:常量 vs 指数退避 vs 抖动(使用 Go 模拟)
I’m happy to help translate the article, but I need the full text you’d like translated. Could you please provide the content (excluding the source line you already included) that you want converted into Simplified Chinese?
震荡羊群问题
你的服务器宕机了。已经有 1 000 客户端 在等待。它恢复时会怎样?
想象一下,一个每秒可以处理 200 请求 / second 的服务器。一个依赖服务宕机约 10 seconds;所有正在进行的请求全部失败,所有客户端开始重试。
当服务器恢复时,所有 1 000 客户端会同时重试。这被称为 thundering herd problem,你的重试策略决定了服务器是几秒钟内恢复,还是在一波冗余/不必要的请求中被淹没。
大多数工程师都知道应该使用 exponential backoff,但很少人意识到它仍然可能导致同步的峰值。更少人见识过 decorrelated jitter 实际上对请求分布曲线的影响。
我构建的内容
我创建了一个 模拟,包括 1 000 个并发的 Go 客户端、一个固定容量的服务器,以及四种重试策略来争夺恢复机会。该模拟仿真了生产环境中的情形:
- 固定的服务器容量
- 严重的停机窗口
- 客户端会持续重试直至成功
每个请求都会按秒追踪,这样你可以看到“惊群”现象的形成、峰值以及(希望)逐渐消散的过程。
策略签名
所有策略共享相同的签名:
type Strategy func(attempt int, prevDelay time.Duration) time.Duration
Source: …
重试策略
1. 固定重试(无退避)
// Fixed delay, no backoff.
func constantRetry(_ int, _ time.Duration) time.Duration {
return 1 * time.Millisecond
}
公式: delay = 1 ms (constant)
最坏情况的模式:一个极小的固定延迟且没有退避。这就是工程师在没有考虑退避的情况下写快速重试循环时的表现。
2. 指数退避
// Double the delay on each attempt, starting at 100 ms and capped at 10 s.
func exponentialBackoff(attempt int, _ time.Duration) time.Duration {
d := time.Duration(float64(baseSleep) * math.Pow(2, float64(attempt)))
if d > capSleep {
d = capSleep
}
return d
}
公式: delay = min(base * 2^attempt, cap)
比固定重试好一些,但有一个陷阱。所有 1 000 个客户端同时开始,所以它们在 t = 100 ms 时都进入第 1 次尝试,在 t = 300 ms 时都进入第 2 次尝试,在 t = 700 ms 时都进入第 3 次尝试,依此类推。
3. 完全抖动(Full Jitter)
// Randomize the delay between 0 and the exponential backoff value.
func fullJitter(attempt int, _ time.Duration) time.Duration {
d := time.Duration(float64(baseSleep) * math.Pow(2, float64(attempt)))
if d > capSleep {
d = capSleep
}
return time.Duration(rand.Int63n(int64(d)))
}
公式: delay = random(0, min(base * 2^attempt, cap))
该策略在 AWS Architecture Blog 中有介绍,通常能产生最少的总调用次数。因为每个客户端都会随机选择延迟,它们不再在同一时间重试。于是不再是 1 000 个客户端一次性冲向服务器,而是分散开来。
4. 去相关抖动(Decorrelated Jitter)
// Each delay is random between base and 3 * previous_delay.
func decorrelatedJitter(_ int, prev time.Duration) time.Duration {
if prev capSleep {
d = capSleep
}
return d
}
公式: delay = random(base, prev * 3) (capped)
这同样来源于 AWS 的博客。它不使用尝试次数,而是让每一次的延迟依赖于前一次的延迟。乘数 3 是 AWS 实际使用的取值,用来控制延迟增长的速度。取 3 时,平均延迟大约以 1.5× 的速度增长(即 random(base, prev*3) 的中点)。这足以把客户端分散开来,又不会让它们等待太久。你也可以使用 2 或 4,只是会产生不同的权衡。
服务器模型
type Server struct {
mu sync.Mutex
capacity int // requests per second the server can handle
downFor time.Duration // outage duration
start time.Time
requests map[int]int // second -> total request count
accepted map[int]int // second -> accepted request count
}
服务器会统计每秒收到的请求数量。如果仍处于停机窗口 或 已经达到容量上限,则会拒绝该请求。
客户端循环
func clientLoop(srv *Server, strategy Strategy, metrics *Metrics) {
start := time.Now()
attempt := 0
prevDelay := baseSleep
for {
if srv.Do() { // success
metrics.Record(time.Since(start), attempt)
return
}
delay := strategy(attempt, prevDelay)
prevDelay = delay
attempt++
time.Sleep(delay)
}
}
每个客户端在各自的 goroutine 中运行,并持续重试,直至成功。
仿真参数
| 参数 | 值 |
|---|---|
| 客户端数量 | 1 000(全部同时启动) |
| 服务器容量 | 200 req/s |
| 中断时长 | 10 秒 |
| 请求处理方式 | 在中断期间 或 达到容量时拒绝 |
| 收集的指标 | 每秒请求数(直方图)、总浪费请求数、p99 延迟 |
仿真在终端绘制柱状图,您可以直观看到“羊群效应”。
观察
恒定重试
- 每个客户端每 1 ms 重试一次。
- 当有 1 000 个客户端时,服务器每秒收到 数十万请求,但只能处理 200 个。
- 在测试运行期间,我们观察到为服务这 1 000 个客户端产生了 >800 万次无效请求。
- 服务器恢复后,洪流仍会持续数秒;只有当每个客户端最终成功时才会停止。
直方图: 在第 0、1、3、6、12、22、32、42、52 秒出现峰值,期间没有请求。所有客户端以相同的速率将延迟翻倍,导致同步的小规模群体。
指数退避
- 与恒定重试类似的峰值模式,但峰值之间的间隔逐渐增大。
- 仍然存在明显的群体效应;总的无效请求虽有下降,但仍然很高。
完全抖动
- 直方图呈现 平滑曲线,从第 0 秒的约 5 000 请求下降到第 19 秒的约 13 请求。
- 没有尖锐的峰值——客户端请求分散。
- **无效请求:**约 8 468(≈恒定重试情况的 0.1 %)。
- **p99 延迟:**52 秒(仍然偏高,因为部分客户端不幸遭遇了很长的延迟)。
去相关抖动
- 直方图同样平滑,类似于完全抖动,只是噪声稍多。
- **无效请求:**约 10 695(略高于完全抖动)。
- **超出容量的请求:**137(由于偶尔出现的短延迟导致连锁效应)。
为什么? 如果客户端随机获得了一个短延迟,则下一个延迟基于该短值,这使得客户端在抖动扩大之前能够快速重试几次。
Source: …
要点
| 策略 | 峰值? | 总浪费请求数 | p99 延迟 |
|---|---|---|---|
| Constant Retry | Yes | > 8 M | > 50 s |
| Exponential Backoff | Yes | ~ 1 M | > 40 s |
| Full Jitter | No | ~ 8.5 k | 52 s |
| Decorrelated Jitter | No | ~ 10.7 k | ~ 45 s |
- Constant 和 exponential 回退会产生同步的峰值(经典的 thundering herd)。
- Full jitter 消除峰值,但仍可能导致某些客户端出现非常长的延迟,增加尾部延迟。
- Decorrelated jitter 在保持平均延迟增长适中的同时分散重试,提供请求速率平滑与延迟之间的良好折中。
参考文献
- AWS Architecture Blog – “Exponential Backoff & Jitter”(涵盖完整抖动和去相关抖动)。
- Google Cloud Blog – “Retry Strategies for Distributed Systems”。
随意将代码和参数适配到您自己的环境中。核心思想很简单:添加随机性到重试间隔,以打破同步的突发请求。
Overview
全抖动在此模拟中表现优于所有其他策略。
然而,该测试代表了一个最坏情况,即 所有 1,000 个客户端同时失败——这在生产环境中是不太可能出现的模式。在实际部署中,客户端故障是分散发生的,去相关抖动 更能优雅地处理这种情况,因为每个客户端自行决定重试节奏,而不是遵循单一的、同步的时间表。
策略概述
| 策略 | 生产可行性 | 备注 |
|---|---|---|
| Constant retry | ❌ 绝不推荐在生产环境使用 | 会导致大规模的惊群(thundering‑herd)问题。 |
| Exponential backoff (no jitter) | ⚠️ 仅适用于极少数客户端 | 当客户端数量增多时会出现同步问题。 |
| Full jitter | ✅ 默认选择 | 在仿真中效果最佳;实现最简单。 |
| Decorrelated jitter | ✅ 推荐用于分散的故障 | 与全抖动性能相似,但根据每个客户端的历史自行调整,在故障分布在时间上更为稳健。 |
如何运行模拟器
# Clone the repository
git clone https://github.com/RafaelPanisset/retry-strategies-simulator
cd retry-strategies-simulator
# Run each strategy (each execution takes ~15–60 seconds)
go run main.go -strategy=constant
go run main.go -strategy=backoff
go run main.go -strategy=jitter
go run main.go -strategy=decorrelated
在执行期间,程序会实时打印 ASCII 直方图,以可视化所选策略的重试间隔分布。
附加参考
Marc Brooker, “Exponential Backoff and Jitter”
Marc Brooker, “Timeouts, retries, and backoff with jitter”
Google Cloud, “Retry Strategy”
要点: 使用 full jitter 作为首选的重试策略,但当您预期客户端故障会随时间分散时,可考虑 decorrelated jitter。这种方法在最小化群体效应的同时,使实现保持简洁。