重试策略比较:常量 vs 指数退避 vs 抖动(使用 Go 模拟)

发布: (2026年2月1日 GMT+8 02:42)
11 min read
原文: Dev.to

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 RetryYes> 8 M> 50 s
Exponential BackoffYes~ 1 M> 40 s
Full JitterNo~ 8.5 k52 s
Decorrelated JitterNo~ 10.7 k~ 45 s
  • Constantexponential 回退会产生同步的峰值(经典的 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。这种方法在最小化群体效应的同时,使实现保持简洁。

Back to Blog

相关文章

阅读更多 »

Go 的秘密生活:Context 包

如何停止失控的 goroutine 并防止内存泄漏。第 16 章:了解何时退出。档案室寂静无声,只有机架中服务器的嗡鸣声……

Litestream 可写 VFS

请提供您希望翻译的具体摘录或摘要文本,我将为您翻译成简体中文。