手写实现 `Promise.retry`:带有指数退避(Exponential Backoff)策略的重试机制

手写实现 Promise.retry:带有指数退避(Exponential Backoff)策略的重试机制

大家好,今天我们来深入探讨一个在现代前端开发中非常实用的技术点:如何手写一个支持指数退避策略的 Promise.retry 方法。这个功能看似简单,实则蕴含了对异步编程、错误处理和系统稳定性的深刻理解。

无论你是刚接触 Promise 的新手,还是已经熟练使用 async/await 的资深开发者,这篇文章都会带你从零开始构建一个健壮、可配置、生产级可用的重试机制。


一、为什么要实现 Promise.retry

在实际项目中,我们经常会遇到这样的场景:

  • 调用第三方 API 失败(网络波动、服务暂时不可用)
  • 数据库连接超时或断开
  • 文件上传失败(比如 CDN 暂时无响应)

这些情况往往不是永久性的,而是短暂的、可恢复的错误。如果直接抛出异常或者让用户看到“请求失败”,用户体验会很差。

这时,“重试”就变得非常重要——自动尝试重新执行任务,直到成功或达到最大重试次数。

但注意:不能盲目重试!
频繁重试可能造成雪崩效应(如大量并发请求打爆服务器),也浪费资源。所以我们需要一种智能的重试策略——这就是 指数退避(Exponential Backoff) 的由来。


二、什么是指数退避?它为什么有效?

✅ 定义:

指数退避是一种重试策略,每次失败后等待的时间呈指数增长(通常是 2^n 秒),避免短时间内重复请求导致系统压力过大。

举个例子:
| 重试次数 | 等待时间(秒) |
|———-|—————-|
| 第1次 | 1 |
| 第2次 | 2 |
| 第3次 | 4 |
| 第4次 | 8 |
| 第5次 | 16 |

这种方式可以有效缓解服务器负载,同时给予足够的时间让临时故障自行恢复。

🔍 为什么有效?

  • 防止雪崩:不会瞬间发送大量请求。
  • 适应性好:第一次失败等1秒,第二次等2秒……越往后等待越长,给系统喘息空间。
  • 符合 TCP/IP 协议设计思想:很多底层协议(如 HTTP、TCP)都采用类似策略来应对拥塞控制。

三、目标:手写一个通用的 Promise.retry

我们要实现的方法签名如下:

Promise.retry(
  fn: () => Promise<any>,
  options?: {
    retries?: number;     // 最大重试次数,默认 3
    backoff?: (attempt: number) => number; // 自定义退避函数,默认指数退避
    shouldRetry?: (error: Error) => boolean; // 是否应该重试,默认判断是否为网络错误
  }
): Promise<any>

这个方法接受一个异步函数 fn,以及可选的配置项,最终返回一个新的 Promise。


四、核心逻辑拆解与代码实现

我们一步一步来构建这个函数,确保每一步都有明确的目的和测试依据。

Step 1:基础结构搭建

function retry(fn, options = {}) {
  const {
    retries = 3,
    backoff = (attempt) => Math.pow(2, attempt) * 1000, // 默认:2^attempt * 1s
    shouldRetry = (err) => err.name === 'NetworkError' || err.code === 'ECONNREFUSED'
  } = options;

  let attempt = 0;

  function execute() {
    return fn().catch(err => {
      if (attempt >= retries || !shouldRetry(err)) {
        throw err;
      }

      attempt++;
      const delay = backoff(attempt);
      console.log(`第 ${attempt} 次重试,延迟 ${delay / 1000}s`);

      return new Promise(resolve => setTimeout(resolve, delay))
        .then(() => execute()); // 递归调用自身进行下一次尝试
    });
  }

  return execute();
}

✅ 这是一个简洁而完整的版本,满足以下特性:

  • 支持自定义最大重试次数(默认3次)
  • 支持自定义退避函数(默认指数退避)
  • 支持自定义是否重试(默认只对网络类错误重试)
  • 使用递归 + setTimeout 实现非阻塞等待

Step 2:增强版 —— 加入更多控制选项

为了更贴近真实业务需求,我们可以扩展参数:

参数名 类型 默认值 描述
retries number 3 最大重试次数
backoff (attempt: number) => number (a) => 2^a * 1000 退避时间计算函数(单位毫秒)
shouldRetry (err: Error) => boolean 网络错误才重试 判断是否应继续重试
onRetry (attempt: number, error: Error) => void noop 每次重试前回调(可用于日志)

更新后的完整版本:

function retry(fn, options = {}) {
  const {
    retries = 3,
    backoff = (attempt) => Math.pow(2, attempt) * 1000,
    shouldRetry = (err) => err.name === 'NetworkError' || err.code === 'ECONNREFUSED',
    onRetry = () => {} // 可用于记录日志或埋点
  } = options;

  let attempt = 0;

  function execute() {
    return fn().catch(err => {
      if (attempt >= retries || !shouldRetry(err)) {
        return Promise.reject(err);
      }

      attempt++;
      onRetry(attempt, err);

      const delay = backoff(attempt);
      console.log(`[Retry] 第 ${attempt} 次失败,等待 ${delay / 1000}s 后重试`);

      return new Promise(resolve => setTimeout(resolve, delay))
        .then(() => execute());
    });
  }

  return execute();
}

五、实战示例:模拟真实场景

让我们用几个例子验证我们的 retry 函数是否工作正常。

示例 1:模拟网络请求失败(适合重试)

const fakeApiCall = () => {
  const success = Math.random() > 0.7; // 30% 成功率
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (success) {
        resolve({ data: "OK" });
      } else {
        reject(new Error("NetworkError"));
      }
    }, 100);
  });
};

retry(fakeApiCall, {
  retries: 5,
  shouldRetry: (err) => err.message.includes("Network")
}).then(result => {
  console.log("✅ 请求成功:", result);
}).catch(err => {
  console.error("❌ 最终失败:", err.message);
});

运行结果可能是:

[Retry] 第 1 次失败,等待 1s 后重试
[Retry] 第 2 次失败,等待 2s 后重试
[Retry] 第 3 次失败,等待 4s 后重试
✅ 请求成功: { data: "OK" }

说明:即使前几次失败,只要不超过最大重试次数,最终也能成功!


示例 2:手动控制重试行为(比如某些错误不应该重试)

const dangerousApiCall = () => {
  return new Promise((_, reject) => {
    setTimeout(() => {
      reject(new Error("InvalidInput")); // 不是网络问题,不应重试
    }, 100);
  });
};

retry(dangerousApiCall, {
  retries: 3,
  shouldRetry: (err) => err.message.includes("Network") // 明确只对网络错误重试
}).then(result => {
  console.log("✅ 成功");
}).catch(err => {
  console.error("❌ 不应该重试,直接失败:", err.message); // 输出 InvalidInput
});

输出:

❌ 不应该重试,直接失败: InvalidInput

✅ 正确跳过了重试流程,避免无效操作。


六、高级技巧:自定义退避策略

有时候我们不希望简单的 2^n,而是想加一点随机抖动(Jitter),防止多个客户端同时重试同一时刻造成雪崩。

✅ 加入随机抖动的指数退避

function jitteredBackoff(attempt) {
  const baseDelay = Math.pow(2, attempt) * 1000;
  const jitter = Math.random() * 1000; // ±1s 随机抖动
  return baseDelay + jitter;
}

retry(apiCall, {
  retries: 5,
  backoff: jitteredBackoff,
  onRetry: (attempt, err) => console.log(`⚠️ 第${attempt}次重试`)
});

这样可以进一步提升系统的弹性和稳定性。


七、性能考量与最佳实践

虽然 retry 很有用,但也有一些坑需要注意:

注意事项 建议做法
❗ 不要无限重试 设置合理的 retries 上限(通常 3~5 次)
❗ 不要对所有错误都重试 使用 shouldRetry 过滤非临时错误(如权限不足、数据格式错误)
❗ 不要忽略失败原因 onRetry 中打印日志,便于排查问题
❗ 不要滥用 如果某个接口经常失败,说明它本身有问题,应该优化而不是靠重试解决

💡 小贴士:对于关键业务(如支付、注册),建议结合熔断机制(Circuit Breaker)一起使用,防止单点故障扩散。


八、对比其他方案(为什么不用第三方库?)

很多人可能会问:“为什么不直接用 p-retrybluebird-retry?”
确实有成熟的库可用,但自己实现的好处在于:

  • 完全可控:你可以根据业务定制退避规则、日志、监控等
  • 轻量无依赖:不需要引入额外包,适合小型项目或嵌入式环境
  • 学习价值高:掌握异步控制流的本质,提升工程素养

当然,如果你团队已有成熟工具链(如 Sentry、Prometheus),也可以封装成插件复用。


九、总结:你现在已经掌握了什么?

今天我们完成了以下内容:

✅ 理解了指数退避的核心原理及其适用场景
✅ 手写了完整的 Promise.retry 实现,包含灵活配置
✅ 提供了多种实际应用场景的测试案例
✅ 探讨了高级玩法(随机抖动、熔断配合)
✅ 给出了性能优化和最佳实践建议

现在你可以把这个函数直接放进你的工具库中,在任何地方调用它来优雅地处理临时失败的异步任务。


十、附录:完整代码清单(可直接复制使用)

/**
 * 带指数退避的 Promise 重试机制
 */
function retry(fn, options = {}) {
  const {
    retries = 3,
    backoff = (attempt) => Math.pow(2, attempt) * 1000,
    shouldRetry = (err) => err.name === 'NetworkError' || err.code === 'ECONNREFUSED',
    onRetry = () => {}
  } = options;

  let attempt = 0;

  function execute() {
    return fn().catch(err => {
      if (attempt >= retries || !shouldRetry(err)) {
        return Promise.reject(err);
      }

      attempt++;
      onRetry(attempt, err);

      const delay = backoff(attempt);
      console.log(`[Retry] 第 ${attempt} 次失败,等待 ${delay / 1000}s 后重试`);

      return new Promise(resolve => setTimeout(resolve, delay))
        .then(() => execute());
    });
  }

  return execute();
}

📌 使用方式:

retry(async () => {
  const res = await fetch('/api/data');
  if (!res.ok) throw new Error('NetworkError');
  return res.json();
}, {
  retries: 3,
  shouldRetry: (err) => err.message.includes('Network'),
  onRetry: (attempts, err) => console.warn(`重试 ${attempts} 次`, err)
}).then(data => console.log('成功:', data));

希望这篇讲解能帮你真正理解并应用 Promise.retry,不仅是在面试中加分,更是在日常开发中写出更鲁棒的代码。记住:好的程序员不是只会用框架的人,而是懂得底层原理、敢于动手实现的人。

谢谢大家!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注