JavaScript is required
Back
To Top

把重试机制做稳:退避、抖动和幂等

2026/06/05 5 min

把重试机制做稳:退避、抖动和幂等

摘要:重试是工程系统里最常见的容错手段,但也是最容易被滥用的手段。只会“再试一次”通常不够,真正稳定的重试需要区分错误类型、控制节奏、避免重复副作用。本文从退避、抖动和幂等三个角度,整理一套更可靠的重试思路。


为什么重试不能只写一个 while

很多系统在遇到瞬时故障时,第一反应都是重试。数据库短暂抖动、上游超时、网络丢包、冷启动阶段的连接失败,确实都可能通过重试恢复。

问题在于,简单粗暴的重试会把故障放大:

  • 所有请求同时重试,会把上游打得更满
  • 没有等待时间,会在短时间内疯狂重放
  • 没有终止条件,会把永久错误当成临时错误
  • 没有幂等保护,会重复扣款、重复发信、重复写库

所以,重试不是“失败后的默认动作”,而是一个需要设计的策略。

先分清能不能重试

不是所有错误都适合重试。判断之前,先把错误分成两类:

适合重试的临时错误

  • 连接超时
  • 读写超时
  • 503、502 之类的短暂服务不可用
  • DNS 抖动或短暂网络中断
  • 下游限流,但系统允许稍后再试

不适合重试的永久错误

  • 参数校验失败
  • 鉴权失败
  • 资源不存在
  • 逻辑错误
  • 已经确定不会成功的业务请求

如果把永久错误也纳入重试,只会浪费时间,还会掩盖真正的问题。

一个实用原则是:只对“稍后有可能成功”的失败重试

退避比立即重试更重要

最朴素的重试是失败后立刻再来一次。这个做法在低并发环境下看似有效,但一旦大面积故障发生,就会把所有客户端的压力瞬间推到同一个时间点。

更稳的方式是指数退避:

text
第一次失败后等 200ms
第二次失败后等 400ms
第三次失败后等 800ms
第四次失败后等 1600ms

它的作用不是“等久一点”,而是主动给下游恢复时间。

常见做法是:

ts
const delay = base * 2 ** attempt

但纯指数退避还有一个问题:所有客户端还是可能在同一时刻醒来。于是就需要抖动。

抖动能避免同步风暴

如果一批请求在同一时刻失败,它们在相同的退避策略下也会在相同时间点重试。这会形成新的尖峰。

抖动的作用,就是给每次等待时间加一点随机扰动,让重试分散开。

常见方式有三种:

  • full jitter:每次随机等待 0 ~ backoff
  • equal jitter:在 backoff / 2 ~ backoff 之间随机
  • decorrelated jitter:下一次等待时间参考上一次,但保留随机性

对于大多数业务系统,full jitter 已经足够实用:

ts
const wait = Math.random() * backoff

它简单,效果也足够明显。

幂等性是重试的底座

重试真正危险的地方,不是“多试了一次”,而是“多执行了一次副作用”。

比如:

  • 下单接口重试后创建了两笔订单
  • 支付接口重试后扣了两次钱
  • 发送消息接口重试后发了两条

要避免这类问题,接口设计必须考虑幂等性。

常见做法有几种:

  1. 让客户端带上幂等键
  2. 服务端把幂等键和结果缓存起来
  3. 数据库层增加唯一约束
  4. 写入流程先查后写,或者直接用幂等写法

如果一个操作天然不能幂等,那它就不适合简单重试。要么改协议,要么拆分流程,把“触发动作”和“最终落库”分开。

一个可复用的重试实现

下面是一版比较克制的 TypeScript 重试封装。它做了三件事:

  • 限制最大尝试次数
  • 使用指数退避加抖动
  • 只重试指定错误
ts
type RetryOptions = {
  retries: number
  baseDelayMs?: number
  shouldRetry?: (error: unknown) => boolean
}

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))

export async function retry<T>(
  task: () => Promise<T>,
  options: RetryOptions,
): Promise<T> {
  const {
    retries,
    baseDelayMs = 200,
    shouldRetry = () => true,
  } = options

  let lastError: unknown

  for (let attempt = 0; attempt <= retries; attempt++) {
    try {
      return await task()
    } catch (error) {
      lastError = error

      if (attempt === retries || !shouldRetry(error)) {
        throw error
      }

      const backoff = baseDelayMs * 2 ** attempt
      const wait = Math.random() * backoff
      await sleep(wait)
    }
  }

  throw lastError
}

这段代码的重点不是语法,而是边界:

  • 重试次数有上限
  • 失败条件可定制
  • 等待时间不是固定值

如果你在 Node.js 里处理 HTTP 请求,还可以把 AbortSignal 接进来,让调用方在超时或取消时直接终止重试。

别把重试当成补锅

重试应该处理的是短暂波动,不应该掩盖架构问题。

如果一个接口经常需要三四次才能成功,通常说明下面至少有一个问题:

  • 超时阈值设得太激进
  • 下游稳定性不够
  • 调用链太长
  • 缓存没有命中
  • 并发控制缺失

这时候继续加重试,只是在延迟问题暴露的时间。

更好的做法是把重试和观测一起看:

  • 记录每次重试的原因
  • 统计最终成功率和失败率
  • 关注重试后的尾延迟
  • 对高频失败的错误单独报警

重试本身不是目标,稳定才是目标。

一个实用检查表

上线前可以用这几条快速检查重试策略:

  • 只重试临时性错误
  • 有最大重试次数
  • 有退避
  • 有抖动
  • 有幂等保护
  • 有超时上限
  • 有日志和指标

如果这七项里少了两三项,重试大概率只是“看起来更稳”,不是“真的更稳”。

总结

重试不是把失败再做一遍,而是用受控的方式给系统一次恢复机会。

真正可靠的重试,至少要同时考虑三件事:错误是不是值得重试、等待节奏会不会放大故障、重复执行会不会产生副作用。

把退避、抖动和幂等一起设计进去,重试才会从“碰碰运气”变成“可预测的容错策略”。

💬 评论区