指数退避超时 - 防止无限重试循环

3 min read
Zekari
系统设计可靠性工程技术哲学

服务器返回错误。你的代码立即重试。

再次失败。再次重试。

继续失败。继续重试。

这看起来像是坚持,实际上是在制造灾难。

立即重试的陷阱

API 调用失败了。也许是网络抖动,也许是服务器过载。

最直观的处理方式:立即重试。失败了就再来一次,不行就再来一次。

async function fetchData() {
  let retries = 0
  while (retries < 10) {
    try {
      return await api.call()
    } catch (error) {
      retries++
      // 立即重试
    }
  }
  throw new Error('Failed after 10 retries')
}

这代码有逻辑,有限制。但它缺少一个关键的东西:时间。

1. 雪崩效应

  • 服务器过载导致请求失败
  • 所有客户端立即重试
  • 服务器压力瞬间翻倍
  • 更多请求失败,更多重试
  • 系统完全崩溃

2. 资源浪费

  • CPU 在无意义的循环中消耗
  • 网络带宽被失败请求占用
  • 连接池被阻塞
  • 其他正常请求无法执行

3. 无法自愈

  • 服务器需要时间恢复
  • 但重试不给它这个时间
  • 就像一个摔倒的人还没站稳,又被推倒
  • 系统永远无法恢复正常

失败后立即重试,是人的本能反应。但本能不总是对的。

时间的价值

有些失败,不是代码的问题,而是时机的问题。

服务器过载,需要几秒钟来处理积压的请求。数据库连接池满了,需要等待其他连接释放。

这些失败,不是错误,而是系统在说:「等一下,我需要时间。」

如果你不给它时间,只是一遍遍地敲门,门永远不会开。

指数退避的核心思想很简单:每次失败后,等待的时间翻倍。

async function fetchDataWithBackoff() {
  let retries = 0
  let delay = 100 // 初始延迟 100ms

  while (retries < 10) {
    try {
      return await api.call()
    } catch (error) {
      retries++

      // 等待,然后延迟时间翻倍
      await sleep(delay)
      delay *= 2

      // 100ms, 200ms, 400ms, 800ms, 1.6s, 3.2s...
    }
  }
  throw new Error('Failed after 10 retries')
}

💡 Click the maximize icon to view in fullscreen

第一次失败,等 100 毫秒。也许只是网络抖了一下。

第二次失败,等 200 毫秒。问题可能稍微严重一点。

第三次失败,等 400 毫秒。给系统更多时间。

每次失败,延迟翻倍。这不是在放弃,而是在给系统喘息的空间。

不是软弱,是智慧

有人会觉得,增加延迟是在「认输」。用户等待时间变长了,这不是体验变差了吗?

但反过来想:如果不增加延迟,系统会崩溃。所有用户的所有请求都会失败。

指数退避是在灾难和等待之间做选择。它选择让少数用户多等一会儿,而不是让所有用户的请求全部失败。

这是一种妥协,但也是唯一可行的妥协。

为什么需要随机性?

如果一千个客户端在同一时刻遇到失败,它们会在同一时刻开始重试。第二次重试也会在同一时刻发生。

这叫「惊群效应」(Thundering Herd)。

解决方法:在延迟时间上加一点随机性。

// 不是精确的 200ms,而是 150-250ms 之间的随机值
const jitter = delay * (0.5 + Math.random() * 0.5)
await sleep(jitter)

这样,重试请求会分散开来,而不是集中爆发。

完整的实现:

async function fetchWithExponentialBackoff(maxRetries = 5) {
  let retries = 0
  let baseDelay = 100

  while (retries < maxRetries) {
    try {
      return await api.call()
    } catch (error) {
      retries++

      if (retries >= maxRetries) {
        throw new Error(`Failed after ${maxRetries} retries`)
      }

      // 指数退避 + 随机抖动
      const exponentialDelay = baseDelay * Math.pow(2, retries - 1)
      const jitter = exponentialDelay * (0.5 + Math.random() * 0.5)
      const maxDelay = 30000 // 最长等待 30 秒
      const delay = Math.min(jitter, maxDelay)

      await sleep(delay)
    }
  }
}

关键点:

  1. 指数增长Math.pow(2, retries - 1) - 延迟时间指数增长
  2. 随机抖动0.5 + Math.random() * 0.5 - 在 50%-150% 范围内随机
  3. 最大限制Math.min(jitter, maxDelay) - 防止延迟无限增长
  4. 重试上限maxRetries - 避免永远重试下去

知道何时停止

指数退避不是万能的。它只是给系统时间恢复,但如果系统根本无法恢复呢?

你需要一个上限。重试五次、十次,还是不行,那就真的不行了。

继续重试不会改变结果,只会浪费资源。

知道何时停止,和知道如何重试一样重要。

1. 用户操作(如搜索、提交表单)

  • 重试次数:3-5 次
  • 初始延迟:100-200ms
  • 最大延迟:2-3秒
  • 原因:用户在等待,不能让他们等太久

2. 后台任务(如数据同步、报表生成)

  • 重试次数:10-20 次
  • 初始延迟:1-5秒
  • 最大延迟:5-10分钟
  • 原因:没有用户直接等待,可以多给系统时间

3. 消息队列处理

  • 重试次数:根据业务决定(可能是无限)
  • 初始延迟:100ms
  • 最大延迟:1小时
  • 原因:消息不能丢失,但也不能一直占用队列

4. 关键业务操作(如支付、订单)

  • 重试次数:较多(10-15次)
  • 初始延迟:500ms
  • 最大延迟:30秒
  • 原因:容错性要求高,但也要避免重复操作

5. 健康检查(Health Check)

  • 重试次数:3 次
  • 初始延迟:1秒
  • 最大延迟:5秒
  • 原因:快速判断服务是否可用,不需要长时间重试

对失败的尊重

指数退避的本质,不是技术问题,而是态度问题。

它是在说:失败不是敌人,失败是信号。它在告诉你,现在不是正确的时机。

立即重试,是对失败的无视。指数退避,是对失败的尊重。

你承认系统需要时间,承认有些事情不能强求,承认等待也是一种策略。

这不只适用于代码,也适用于生活中的很多事情。

最后

失败后的第一反应是再试一次。这是本能。

但有时候,最好的策略不是立即行动,而是等一等,给系统一点时间,给自己一点空间。

指数退避不是在放弃,而是在创造成功的条件。

延迟在增长,但这个增长是有意义的。它反映了你对系统压力的理解,对失败的尊重。


相关阅读:

博客内链

Related Posts

Articles you might also find interesting

错误隔离

3 min read

失败是必然的。真正的问题不是失败本身,而是失败如何蔓延。错误隔离不是为了消除失败,而是为了控制失败的范围。

系统设计可靠性工程
Read More

单例模式管理 Redis 连接

5 min read

连接不是技术细节,而是系统与外部世界的第一次握手,是可靠性的起点

系统设计后端架构
Read More

监控观察期法

3 min read

部署不是结束,而是验证的开始。修复代码只是假设,监控数据才是证明。48小时观察期:让错误主动暴露,让数据证明修复。

系统设计监控
Read More

监听 Redis 连接事件 - 让不可见的脆弱变得可见

4 min read

连接看起来应该是透明的。但当它断开时,你才意识到透明不等于可靠。监听不是多余,而是对脆弱性的承认。

系统设计Redis
Read More

幂等性检查

1 min read

在不确定的系统中,幂等性检查是对重复的容忍,是对稳定性的追求,也是对失败的预期与接纳

系统设计分布式系统
Read More

管理后台需要两次设计

3 min read

第一次设计回答"发生了什么",第二次设计回答"我能做什么"。在第一次就试图解决所有问题,结果是功能很多但都不够深入。

系统设计API 设计
Read More

告警分级与响应时间

2 min read

不是所有问题都需要立即响应。RPC失败会在凌晨3点叫醒人。安全事件每15分钟检查一次。支付成功只记录,不告警。系统的响应时间应该匹配问题的紧急程度。

系统设计监控
Read More

文档标准是成本计算的前提

3 min read

API文档不只是写给开发者看的。它定义了系统的边界、成本结构和可维护性。统一的文档标准让隐性成本变得可见。

API文档
Read More

BullMQ 队列

3 min read

队列不是技术选型,而是对时间的承认,对顺序的尊重,对不确定性的应对

系统设计异步处理
Read More

BullMQ Worker

2 min read

Worker 的本质是对时间的重新分配,是对主线的解放,也是对专注的追求

系统设计异步处理
Read More

配置不会自动同步

2 min read

视频生成任务永远pending,代码完美部署,队列正确配置。问题不在代码,在于配置的独立性被低估。静默失败比错误更危险。

部署配置管理
Read More

CRUD 操作

2 min read

四个字母背后,是数据的生命周期,是权限的边界,也是系统设计的基础逻辑

系统设计软件工程
Read More

数据库参数国际化:从 13 个迁移学到的设计原则

3 min read

数据不该懂语言。当数据库参数嵌入中文标签时,系统的边界就被语言限制了。这篇文章从 13 个参数对齐迁移中提炼出设计原则——国际化不是功能,是系统设计的底层约束。

数据库国际化
Read More

Stripe Webhook中的防御性编程

2 min read

三个Bug揭示的真相:假设是代码中最危险的东西。API返回类型、环境配置、变量作用域——每个看似合理的假设都可能导致客户损失。

Web开发系统设计
Read More

双重验证:Stripe生产模式的防御性切换

7 min read

从测试到生产不是更换API keys,而是建立一套双重验证系统。每一步都在两个环境中验证,确保真实支付不会因假设而失败。

系统设计Stripe
Read More

在运行的系统上生长新功能

3 min read

扩展不是推倒重来,而是理解边界,找到生长点。管理层作为观察者和调节器,附着在核心系统上,监测它,影响它,但不改变它的运行逻辑。

系统设计架构
Read More

实现幂等性处理,忽略已处理的任务

3 min read

在代码层面识别和忽略已处理的任务,不是简单的布尔检查,而是对时序、并发和状态的深刻理解

系统设计并发控制
Read More

缺失值的级联效应

3 min read

一个NULL值如何在调用链中传播,最终导致错误的错误消息。理解防御层的设计,在失败传播前拦截。

系统设计防御性编程
Read More

Props Drilling

3 min read

数据在组件树中层层传递,每一层都不需要它,却必须经手。这不是技术债,是架构对真实需求的误解。

React组件设计
Read More

队列生产者实例的工厂函数

4 min read

工厂函数不是设计模式的炫技,而是对重复的拒绝,对集中管理的追求,对变化的准备

系统设计设计模式
Read More

资源不会消失,只会泄露

2 min read

在积分系统中,用户的钱不会凭空消失,但会因为两个时间窗口而泄露:并发请求之间的竞争,和回调永不到达的沉默。

系统设计并发控制
Read More

RPC函数的原子化处理

1 min read

当一个远程函数做太多事情,失败就变得难以理解

分布式系统RPC
Read More

RPC函数

2 min read

关于远程过程调用的本质思考:当你试图让远方看起来像眼前

分布式系统RPC
Read More

使用Secret Token验证回调请求的合法性

2 min read

在开放的网络中,信任不能被假设。Secret Token 是对身份的确认,对伪装的识别,也是对安全边界的坚守

Web 安全系统设计
Read More

第三方回调的状态映射完整性

5 min read

KIE.AI 视频生成的三个修复揭示了一个本质问题:回调不只是接收结果,是建立状态映射。没有 vendor_task_id,系统就失去了追溯能力。

Purikura 项目系统设计
Read More