Skip to content

retry:构建健壮系统的容错基石

在分布式系统与网络应用中,失败是常态。网络抖动、服务重启、数据库连接超时等问题无法完全避免。面对失败,简单的“错误提示”已不足以保障用户体验。

retry(重试机制)是一种优雅的容错策略:在可恢复的错误发生时,自动尝试重新执行操作,而非立即失败

但盲目重试可能适得其反——“重试风暴”会加剧系统负载,导致雪崩。因此,一个生产级的 retry 函数必须结合指数退避(Exponential Backoff)与随机抖动(Jitter),并智能判断重试条件。

为何需要指数退避?避免雪崩效应

问题:固定间隔重试的危险

假设 1000 个客户端同时请求一个短暂故障的服务,且都配置了 retry(fn, { retries: 3, delay: 1000 })

  • T+0s:全部失败,进入重试队列。
  • T+1s:1000 个请求同时重发。
  • T+2s:若仍失败,再次 1000 个请求并发。

结果:服务在恢复前被瞬间压垮,形成“重试风暴”,延长故障时间。

解法:指数退避(Exponential Backoff)

核心思想:每次重试的等待时间呈指数增长

公式:delay = base * (2 ^ retryCount)

例如,base = 1000ms

重试次数延迟时间
11s
22s
34s
48s

指数增长确保:

  • 初期快速重试(短延迟)。
  • 后期给予服务充足恢复时间(长延迟)。
  • 避免请求堆积,防止雪崩。

为何需要随机抖动?分散请求压力

即使使用指数退避,若所有客户端的退避时间完全一致,仍可能在某一时刻同步重试

问题:同步重试

1000 个客户端在 T+1s、T+3s、T+7s 同时发起重试,形成“脉冲式”流量,冲击服务。

解法:随机抖动(Jitter)

在计算出的退避时间上,加入一个随机偏移:

ts
const jitteredDelay = delay * (0.5 + Math.random() * 0.5) // 50%~100% 的 delay

或全随机模式:

ts
const jitteredDelay = Math.random() * delay

效果:原本在 T+4s 集中重试的 1000 个请求,现在分散在 T+2s 到 T+4s 之间随机发起。

这有效“抹平”了请求曲线,避免瞬时高峰,显著提升系统整体稳定性。

类比:就像人群过桥,如果所有人同时迈步,桥会剧烈晃动;而自然行走的随机步调,则让负载平稳分布。

实现策略:从简单到生产就绪

策略 1:固定间隔重试

最简单实现,适合内部稳定服务:

ts
const retryWithFixedDelay = async <T>(
  fn: () => Promise<T>,
  retries: number,
  delay: number
): Promise<T> => {
  try {
    return await fn()
  } catch (error) {
    if (retries <= 0) throw error
    await sleep(delay)
    return retryWithFixedDelay(fn, retries - 1, delay)
  }
}

策略 2:线性退避

延迟线性增长:delay = base * retryCount

ts
const linearBackoff = (base: number, count: number) => base * count

比固定延迟稍好,但退避力度不足。

策略 3:指数退避 + 抖动(推荐)

生产环境的黄金标准:

ts
const exponentialBackoffWithJitter = (base: number, count: number): number => {
  const delay = base * Math.pow(2, count)
  const jitter = delay * 0.5 * Math.random() // 0~50% 抖动
  return delay + jitter
}

完整 retry 实现:

ts
type RetryOptions = {
  retries?: number
  baseDelay?: number
  shouldRetry?: (error: unknown) => boolean
}

const retry = <T>(
  fn: () => Promise<T>,
  options: RetryOptions = {}
): Promise<T> => {
  const {
    retries = 3,
    baseDelay = 1000,
    shouldRetry = () => true
  } = options

  const attempt = async (remaining: number): Promise<T> => {
    try {
      return await fn()
    } catch (error) {
      if (remaining <= 0 || !shouldRetry(error)) {
        throw error
      }

      const delay = exponentialBackoffWithJitter(baseDelay, retries - remaining)
      await sleep(delay)
      return attempt(remaining - 1)
    }
  }

  return attempt(retries)
}

错误分类:哪些错误值得重试?

并非所有错误都应重试。盲目重试 404 或 401 错误只会浪费资源。

应重试的错误(可恢复)

  • 网络层:超时、连接中断、DNS 失败。
  • 服务端:HTTP 5xx(500, 502, 503)、数据库死锁、临时限流。
  • 语义:表示“服务暂时不可用”,可能在稍后恢复。

不应重试的错误(不可恢复)

  • 客户端错误:HTTP 4xx(400, 401, 403, 404)。
  • 语义:表示“请求本身有问题”,重试无意义。

实现 shouldRetry 判断

ts
const shouldRetry = (error: unknown): boolean => {
  if (error instanceof NetworkError) return true
  if (error instanceof TimeoutError) return true

  if (error instanceof HttpError) {
    // 5xx 服务端错误可重试
    return error.status >= 500
    // 4xx 客户端错误不重试
  }

  return false
}

// 使用
retry(fetchData, { shouldRetry })

类型安全:保持原函数的 Promise 类型

retry 的返回值必须与原函数 fnPromise 类型完全一致,包括泛型。

核心:ReturnType<T> 的传递

text
const retry = <T extends (...args: any[]) => Promise<any>>(
  fn: T,
  options?: RetryOptions
): ((...args: Parameters<T>) => ReturnType<T>) => {
  return (...args: Parameters<T>): ReturnType<T> => {
    // 调用 fn 并应用重试逻辑
    return attempt(() => fn(...args), options) as ReturnType<T>
  }
}

类型推导示例

ts
const fetchUser = (id: string): Promise<User> => {
  return axios.get(`/api/users/${id}`).then(res => res.data)
}

const retryFetchUser = retry(fetchUser, { retries: 3 })

// retryFetchUser 的类型被推导为:
// (id: string) => Promise<User>
// 与原函数完全一致

IDE 能正确提示参数和返回类型,实现无缝开发体验。

结语:重试是优雅的“韧性”设计

retry 不是简单的“多试几次”,而是一种系统韧性(Resilience)设计。

  • 指数退避:给予系统恢复的“呼吸空间”。
  • 随机抖动:避免群体行为导致的“共振崩溃”。
  • 错误分类:智能决策,避免无效操作。
  • 类型安全:无缝集成,不破坏类型契约。

当你为 API 调用封装 retry 策略时,你不仅提升了应用的健壮性,更在代码中注入了一种哲学:允许失败,但永不轻易放弃

这正是构建高可用系统的核心精神。