跳转至

串行、并行、重试、延迟 — 四个工程模式的实现账

这一章回答四个"怎么干"的问题: 互不依赖的请求怎样从挨个等变成同时飞; 会失败的请求重试几遍、隔多久、哪些错误不值得重试; 想"等两秒"怎么用一行 await 写出来; 以及怎样给请求限速,不冲垮服务器。 读完你得到的是一摞可以直接搬走的实现,和每条实现背后的权衡账。

1. 这一章讲什么

第 03-05 章给了零件,这一章装机器。四个模式,难度递增:

  1. 取数模式:Fetch API 的两种写法 + 串行/并行的判定(第 3 节);
  2. 重试模式:四版进化(第 4 节——本章核心);
  3. 延迟模式:把 setTimeout 包成 Promise(第 5 节);
  4. 限流与定时效果:RateLimiter、打字动画(第 6 节)。

2. 顶层全景:本章的判定树

  有一批异步操作
    ├─ 后面的要用前面的结果?── 是 → 串行(逐个 await / then 链)
    │                        └─ 否 → 并行(Promise.all,一次齐收)
    │
    └─ 单个操作可能失败?
         ├─ 重试,间隔怎么定?
         │    基础版(立刻) < 指数退避(1s/2s/4s) < 退避+抖动(±1s 随机)
         │    └─ 4xx 请求方的错?── 是 → 别重试,直接抛
         └─ 请求太多怕冲垮服务器?── 限流(排队,间隔放行)

3. 取数模式:Fetch 与串行/并行的编排

3.1 Fetch 的标准形状

fetch() 是浏览器内置的网络请求函数,返回 Promise。原书的天气取数骨架,两种写法同构1:

async function getWeatherData(city) {
  try {
    const response = await fetch(`/weather?city=${city}`);
    if (!response.ok) {                     // 404/500 不算网络错误,要自己判
      throw new Error('Network response was not ok');
    }
    const data = await response.json();     // 响应体还要再解析一次(把 JSON 文本转成对象,也是异步的)
    return data;
  } catch (error) {
    console.error('There was a problem fetching the weather data:', error);
  }
}

两个容易漏的点:response.json() 本身也是 Promise(JSON:网络传输常用的一种文本格式;这步把它转成对象,也要时间);response.ok 检查照旧不可省。

3.2 串行 vs 并行:判定与骨架

回到第 00 章的主线判定——依赖是唯一的标准:

// 串行:后一步要用前一步的 userData.id
const userData = await (await fetch(`/users/${userId}`)).json();
const postsData = await (await fetch(`/posts?userId=${userData.id}`)).json();

// 并行:互不依赖,一次齐发
const [userProfile, userPosts, userAnalytics] = await Promise.all([
  fetch(`/users/${userId}`).then(r => r.json()),
  fetch(`/posts?userId=${userId}`).then(r => r.json()),
  fetch(`/analytics/${userId}`).then(r => r.json())
]);

原书的多步表单例子(校验 → 建账号 → 建资料 → 发邮件)是串行的标准场景——每步依赖上一步的产物;仪表板例子(资料/帖子/统计)是并行的标准场景2。

真实应用常是混合编排,原书给了顺序:先串行取用户 → 再并行取帖子和好友 → 再串行处理3:

  ① await 取用户(串行起点)
  ② Promise.all([帖子, 好友])(并行)
  ③ await 处理汇总数据(串行收尾)

用第 00 章编的数看收益:三个接口各 1 秒,全串行 3 秒;中间两步并行后约 2 秒。省下的时间就是并行段的条数减一乘单条耗时——这就是为什么"判依赖"值得每次做。

3.4 串行走查:多步表单的完整时间线

3.2 给了判定,这里把串行的标准例子(校验 → 建账号 → 建资料 → 发邮件)走成一条完整时间线(各步耗时为演示编)11:

  t=0.0  validateUserData 发出      ← 必须最先:不合法的资料不该建账号
  t=0.5  校验通过,createUserAccount 发出
  t=1.2  账号建好(拿到 user.id),setupUserProfile 发出   ← 依赖 user.id
  t=1.8  资料建好,sendWelcomeEmail 发出                  ← 依赖 user.email
  t=2.3  邮件发出,返回 { user, profile }
  任一步失败:catch 接住,后续步骤不再发出(出错的 await 把函数甩进 catch)

串行的价值在这条时间线里看得很清楚:失败点之前的步骤已经完成的事,不会被撤销(校验白做了、半建的账号还在),所以真实流程往往还要"补偿"——建账号成功后资料失败,可能要调"删账号"接口回滚。书里没有展开补偿( saga 模式),但这是串行链在生产里逃不掉的一课;入门的处理是第 07 章 4.1 的思路:失败就抛,由上层决定重试还是回退。

3.5 并行走查:一张票怎么造出来的

并行的标准例子是一次取多座城市的天气。它有一个值得拆开看的细节——票是怎么一次造出一组的12:

const weatherPromises = cities.map(city =>
  fetch(`/weather?city=${city}`)
    .then(response => response.json())
);
const weatherData = await Promise.all(weatherPromises);

cities.map(...) 对列表里的每个城市各调一次回调,每次回调发起一个 fetch 并立刻返回它的票——map 的返回值就是"一数组票",这一行结束时,所有请求已经同时在途。Promise.all 接住的正是这一数组。把这两行拆成三步看:

  cities = ['New York', 'London', 'Tokyo', 'Sydney']
    │ map:每个城市跑一次回调,每次回调立刻发出 fetch,返回票
    ▼
  weatherPromises = [票(NY), 票(London), 票(Tokyo), 票(Sydney)]   ← 此刻全部在途
    │ await Promise.all(...)
    ▼
  weatherData = [NY数据, London数据, Tokyo数据, Sydney数据]        ← 按城市顺序,不按到达顺序

第 08 章错误四(forEach+async)与本节的差别就在这里:map 把票收集成了数组,all 把数组收成结果;forEach 的返回值被丢弃,票飞了没人收。数组方法选 map 还是 forEach,在异步世界里是"收不收票"的选择,不是风格问题。

3.6 混合编排的时间线账

3.2 的三段式串并行混编,用数算一遍为什么值得(各接口 1 秒,为演示编):

  ① await 取用户(串行,必需)         1.0s
  ② Promise.all([帖子, 好友])(并行)  1.0s   ← 若写成两行 await 就是 2.0s
  ③ await 处理汇总(串行)             1.0s
  ─────────────────────────────────────
  混编总耗时:约 3.0s      全串行:约 4.0s      省下的 1s = 并行段条数-1 × 单条耗时

这段账应该刻进肌肉:每把一对互不依赖的 await 合并成一个 all,总耗时减去一份单条耗时。 反过来,把有依赖的两步误并行进 all,得到的不是快,是错(第二步拿不到第一步的输入,只能拿 undefined 去请求)。

4. 重试模式:四版进化账(本章核心)

网络会抖,服务会喘。重试模式 = 失败后再来一次,但"再来"的间隔是一门学问。 原书从基础版一路加到按错误类型分诊,每一版都精确修上一版的一个病。

4.1 主线例子设定

取一个不稳的接口,最多试 3 次(各次成败与耗时是为演示编的):第 1 次失败、第 2 次失败、第 3 次成功。

4.2 基础版:失败就立刻再来

async function fetchWithRetry(url, options = {}, maxRetries = 3) {
  for (let i = 0; i < maxRetries; i++) {
    try {
      const response = await fetch(url, options);
      if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
      return await response.json();
    } catch (error) {
      if (i === maxRetries - 1) throw error;   // 最后一次也败:把错误递出去
      console.log(`Attempt ${i + 1} failed. Retrying...`);
    }
  }
}

它的病:服务器只是"暂时喘不上气"(过载、重启),立刻重等于在人家最难受的时候追加一拳;而且所有调用方同时立刻重,冲击波同步放大——本来 1 秒的过载被重试潮续成 10 秒。

4.3 第二版:指数退避——等待每次翻倍

修"冲击同步":指数退避,就是每次失败后等待时长翻倍——1 秒、2 秒、4 秒。间隔拉开,重试流量自动错峰4:

const delay = Math.pow(2, i) * 1000;   // i=0→1s,i=1→2s,i=2→4s
await new Promise(resolve => setTimeout(resolve, delay));

主线例子走一遍(第 1、2 次失败,第 3 次成功):

  t=0.0s  第 1 次:失败 → 等 2⁰=1s
  t=1.0s  第 2 次:失败 → 等 2¹=2s
  t=3.0s  第 3 次:成功 → 返回
  总计约 3 秒(不含请求本身耗时);若 3 次全败,总等待 1+2=3 秒后抛错

它的病:等待翻倍是固定不变的——所有调用方会在同一个节拍上重试(t=1 秒处挤一波,t=2 秒处又挤一波)。错峰了,但没完全错开。

4.4 第三版:加抖动——等待再加随机量

修"节拍同步":在翻倍的基础上加一个随机量(jitter,抖动),比如 0-1 秒随机5:

const baseDelay = Math.pow(2, i) * 1000;
const jitter = Math.random() * 1000;
const delay = baseDelay + jitter;

第 2 次失败后的等待从固定的 2 秒变成 2.0-3.0 秒之间的随机值。每个调用方的重试节拍被彻底打散,服务器收到的是一片均匀的小流量,而不是几波大浪。

4.5 第四版:按错误类型分诊——4xx 别重试

修"白费劲":有些失败重试一万次也不会好。请求本身错了(4xx:参数错、没权限、东西不存在),立刻再试只会再收一个 4xx;只有服务器端问题(5xx)和网络抖动才值得等6:

if (response.status >= 400 && response.status < 500) {
  throw new Error(`Client error! status: ${response.status}`);  // 不重试,直接失败
}
throw new Error(`Server error! status: ${response.status}`);   // 5xx,值得等

四版的账,一张表收拢:

版本 修的病 付出的代价
基础版 一次失败=整体失败 冲击服务器、成功率低
指数退避 重试冲击同步放大 重试节拍仍然齐步
退避+抖动 齐步重试波 实现稍复杂
按类型分诊 4xx 白重试 要对错误分类,漏分类会误重试

判断(我们的,不是书里的): 这四版是从"能用"到"生产可用"的真实台阶,不是教学灌水——生产代码的默认起点应该是第三版(退避+抖动),第四版在有明确错误语义时补上。 反过来,书里没讲的两件事也要自己记住:重试要有总数上限与总时长上限(书给了次数上限);非幂等的写操作(下单、支付)盲目重试可能重复提交——这两笔账不在书里,也不在代码里。 如果错,会错在: 某些服务端明确文档化了"请立刻重试"(如限流响应带 Retry-After 头),那时退避反而误事——规则该听服务器的,书里的默认只是没有服务器明说时的默认。

4.6 四版的失败全路径:三次全败长什么样

4.2-4.5 讲的是"下一次成功"的乐观路径;工程上同样重要的是悲观路径——重试用尽,彻底失败。以第三版(退避+抖动,最多 5 次)为例走一遍全败路径(等待为演示编)13:

  第 1 次:失败 → 等 1.0s(+抖动 0-1s)
  第 2 次:失败 → 等 2.0s(+抖动 0-1s)
  第 3 次:失败 → 等 4.0s(+抖动 0-1s)
  第 4 次:失败 → 等 8.0s(+抖动 0-1s)
  第 5 次:失败 → 不再等,把最后一次的错误抛出去
  全败总耗时 ≈ 15-19 秒(仅等待) + 5 次请求本身

两个工程推论。其一,重试会显著放大尾部耗时——用户看到的是"页面转了 19 秒然后报错"。所以重试一定要配合"加载中"的界面反馈,并且上层要有"不等了"的逃生门(超时,第 13 章 withTimeout)。其二,退避间隔越长,越要把"服务端可能已经恢复"的信息用进去——很多限流响应会带"请 N 秒后再试"的提示,尊重它比机械翻倍更快也更礼貌(4.5 判断块已述)。

4.7 重试与其他模式的接线

重试不是孤岛,真实链路上它要和三层模式接起来:

  点击"刷新"
    → withTimeout(请求, 10s)        ← 先给单次尝试限个时(第 13 章)
    → fetchWithRetry(最多 3 次)      ← 超时/失败才触发重试
    → 成功:更新界面
    → 三次全败:catch → 缓存回退     ← 第 07 章 4.1 的旧数据顶上
    → 连缓存都没有:错误上报         ← 第 07 章 5.3

这个接线顺序有讲究:限时要包住单次尝试(不然一次 hanging 的请求会把重试永远堵在第一格);旧数据回退要放在重试之后(先试活的,活的不行了再用旧的);上报放在最外层(错误穿过所有恢复手段还活着,才值得惊动看板)。书把每个零件分散在各章,这条接线是它们的装配图。

4.8 基础版重试逐行解剖

基础版(4.2)只有九行,但每一行都对应一个决策,值得逐行过15:

async function fetchWithRetry(url, options = {}, maxRetries = 3) {
  for (let i = 0; i < maxRetries; i++) {
    try {
      const response = await fetch(url, options);
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }
      return await response.json();
    } catch (error) {
      if (i === maxRetries - 1) throw error;
      console.log(`Attempt ${i + 1} failed. Retrying...`);
    }
  }
}
  • 第 2 行:i < maxRetries——尝试总次数含第一次,所以 maxRetries=3 是"1 次原始 + 2 次重试",不是"1 次原始 + 3 次重试"。命名上这几乎是个行业惯例,调用前先确认。
  • 第 5-7 行:HTTP 错误手动抛——fetch 不认为 404 是失败,不抛的话 404 的响应体会被当成功结果返回(第 05 章 5.1 的纪律)。
  • 第 9 行:i === maxRetries - 1 才抛——最后一次的错误和中间错误的处理不同:中间的可以"记下来再试",最后的必须"递出去让上层知道"。throw error 把循环里 catch 的错误重新抛出,函数就此失败。
  • 没有的一行:没有 await delay(...)——基础版的"立刻再试"就缺在这里,这正是 4.3 要补的。

这个结构(循环 + try/catch + 末次再抛)是后面三个版本共同的地基:退避版改 catch 块,抖动版再改一行,jitter 与分诊都只动"等待与判断",主干不动。学会读这个九行骨架,四个版本就是四次小改款。

5. 延迟模式:把"等一会儿"写成一等公民

5.1 sleep 函数

setTimeout 是回调接口,不好塞进 async 流程;包一层 Promise 就能 await 它——这是回调转 Promise 的最小示范7:

function delay(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}
// 用法
await delay(2000);   // 等 2 秒,接着往下走

原书还给了按秒计数的 sleep(1) 变体和 5-4-3-2-1 倒计时用法。重试模式里每一版都在用这把钥匙——await delay(...) 就是退避的"等待"本身。

5.2 定时重试

把 delay 接进重试循环,固定间隔版 1 秒一次8。和 4.3 的差别只在等待公式:delayMs 固定 vs 2^i × 1000 翻倍。

5.3 打字动画

await sleep(150) 放在逐字循环里,就得到一个打字机效果9——延迟模式不只服务网络,也服务界面时序。

5.5 sleep 的两种收法与倒计时走查

5.1 给了 delay(毫秒),5.2 给了 sleep(秒),两个函数只差一层单位换算,但收参数的方式值得说清16:

function delay(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));   // 收毫秒,直接喂 setTimeout
}
async function sleep(seconds) {
  await new Promise(resolve => setTimeout(resolve, seconds * 1000));  // 收秒,先 ×1000
}

倒计时是检验"await 串成节奏"的最小例子:

async function countdownWithSleep() {
  for (let i = 5; i > 0; i--) {
    console.log(i);
    await sleep(1);          // 每秒一个数
  }
  console.log('Liftoff!');
}

输出节奏是 5(停一秒)4(停一秒)…1(停一秒)Liftoff。把 await sleep(1) 挪到 console.log(i) 之前,节奏变成"先停一秒再报数"——await 写在哪一行,哪一行之后的代码就被排到"那一刻之后"。这个感知是第 10 章"自己调自己"式重试能读懂的基础。

5.6 定时重试:把 delay 焊进重试循环

5.2 的定时重试把 delay 与重试焊在一起,它其实是 4.2 基础版 + 一行等待17:

async function fetchWithTimedRetry(url, options = {}, maxRetries = 3, delayMs = 1000) {
  for (let i = 0; i < maxRetries; i++) {
    try {
      const response = await fetch(url, options);
      if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
      return await response.json();
    } catch (error) {
      if (i === maxRetries - 1) throw error;
      console.log(`Attempt ${i + 1} failed. Retrying in ${delayMs}ms...`);
      await delay(delayMs);          // ← 唯一的新行:固定等待
    }
  }
}

对照 4.8 的逐行解剖,只有 catch 块里多了一行 await delay(delayMs)——"定时重试 = 基础重试 + 一行等待"。而 4.3 的指数退避 = 把这一行的 delayMs 换成 Math.pow(2, i) * 1000。整个重试家族的代码增量加起来不到十行,但每一行都修一个真实的故障场景——这是本章"版本进化账"教法的最好注脚。

6. 限流与放行:RateLimiter

6.1 主线例子:每秒一个请求

要连续发 5 个请求,但服务器要求每秒最多 1 个。原书的 RateLimiter 类:请求先进队,按固定间隔逐个放行10:

class RateLimiter {
  constructor(limit, interval) {
    this.limit = limit; this.interval = interval;
    this.queue = []; this.lastRun = Date.now();
  }
  async run(fn) {
    this.queue.push(fn);
    await this.process();
  }
  async process() {
    if (this.queue.length === 0) return;
    const now = Date.now();
    if (now - this.lastRun < this.interval) {
      await delay(this.interval - (now - this.lastRun));   // 距上次不足间隔 → 补齐差额
    }
    const fn = this.queue.shift();
    this.lastRun = Date.now();
    await fn();
    if (this.queue.length > 0) await this.process();      // 队列非空 → 继续放行下一个
  }
}
const limiter = new RateLimiter(1, 1000);   // 每秒 1 个

5 个请求走一遍(假设每个请求本身瞬间完成,时刻是为演示编的):

  t=0.0s  请求1放行(lastRun=0)
  t=1.0s  请求2放行(距上次 1000ms,刚好达标)
  t=2.0s  请求3放行……以此类推,请求5 在 t=4.0s

核心机制两件事:排队(没有窗口就进 this.queue)与补差额(距上次放行不足 interval 就等剩余部分)。这比"每次请求前都 sleep 固定时长"精确——它按上次放行的实际时刻算,不受请求本身耗时的影响。

边界说清: 这是单机单进程的限流器(队列活在内存里);多实例部署或服务端强制限流,要另想办法(服务器侧的许可限流、网关限流)。书里到单机版为止。

5.4 限流器的边界:请求本身很慢时会发生什么

6.1 的时间线假设"请求瞬间完成"。把假设去掉,看一个更真实的场景(时刻为演示编):限流间隔 1 秒,但每个请求本身要 2 秒:

  t=0.0  请求1放行,开始执行(要到 t=2.0 才完)
  t=2.0  请求1完成 → process 进入下一轮,发现距 lastRun(t=0)已过 2s > 1s → 不等待,请求2立刻放行
  t=4.0  请求2完成 → 请求3放行……

请求2 并没有在 t=1.0 被放行——因为限流器的 process 是在上一个请求完成之后才轮到下一轮的,而"补差额"用的是"距上次放行"而非"距上次完成"。这个行为的实际含义:它限的是"放行的节奏",不是"在途的数量"——慢请求会自然把放行串起来,但一大批快请求之间一定隔着 interval。要"同时最多 N 个在途"的限流(并发上限),这个类不够用,得换信号量一类的结构(第 09 章边界提到,书未展开)。

6.2 打字动画:延迟模式的界面版

5.3 的打字动画值得走完,因为它是"延迟即节奏"的最直观演示14:

  输入:"Hi!"(3 个字符),每个字符间隔 150ms
  t=0    显示 "H"
  t=150  显示 "Hi"
  t=300  显示 "Hi!"
  循环体每次只做一个字符,做完就 await sleep

如果把 await sleep(150) 拿掉,循环会在一微秒内跑完,三个字符同时出现——动画的本质就是"循环 + 每次让出流水线一瞬"。同理可推:调速就是改 sleep 的时长;逐词而非逐字,就是把循环体里的一次 += 换成 += 一个词。这套"用 sleep 给一切排队"的思路,是限流器(6.1)、重试退避(4.3)、轮询刷新共同的底层。

6.3 本章四个模式的选型卡

收尾一张选型卡,遇到需求先对号再翻细节:

需求 模式 关键参数 本章哪节
取一个接口的数据 Fetch 骨架 response.ok 必查 3.1
后一步要前一步的结果 串行 await 注意失败不自动回滚 3.2、3.4
互不依赖的一批请求 map 造票 + all 收票 顺序按输入不按到达 3.5
又有依赖又有一窝并行 三段式混编 并行段合并省耗时 3.6
请求偶尔失败 基础重试 次数上限、末次再抛 4.2、4.8
失败会砸瘫服务器 指数退避+抖动 1s/2s/4s + 0-1s 随机 4.3、4.4
有些错重试无意义 按状态码分诊 4xx 直接抛 4.5
要"等一会儿" delay/sleep setTimeout 包 Promise 5.1
请求太密会被封 RateLimiter 间隔放行、补差额 6.1
要节奏感(动画/轮询) 循环 + sleep 每轮让出流水线 5.3、6.2

这张表也是对后文的预告:第 07 章给"失败之后"补三层防线,第 08 章给"哪里错了"补调试手段,第 10 章把前四行装进完整应用。

6.4 模式之间的三个常见误接

最后记三个反模式的接法,评审时见到可以直接指出来:

其一,重试里不复位抖动。 连续两版都失败后,等待应该是"重新从 1s 起翻倍"还是"接着上次的 4s 继续翻倍"?书里每版都从 baseDelay 起算(每次重试是独立的新循环)——一旦把上次的等待时长存下来接着翻,几次失败之后等待会滚成天文数字。

其二,限流器和重试各说各话。 限流器说"每秒 1 个",重试说"失败后立刻再来"——两个机制同时存在时,重试的间隔必须不小于限流间隔,否则重试会撞上自己的限速。装配时让一个参数(比如 LIMIT_INTERVAL)同时喂给两处。

其三,delay 传给 setTimeout 的单位错位。 setTimeout 按毫秒计,sleep(1) 按秒计——混用两者的代码里,一次"忘了 ×1000"就是 1000 倍的等待。书里两个函数并存(delay 收毫秒、sleep 收秒),调用处看一眼单位再敲。

6.5 RateLimiter 逐行解剖

6.1 给了实现,逐行说清它在管什么18:

class RateLimiter {
  constructor(limit, interval) {
    this.limit = limit; this.interval = interval;
    this.queue = []; this.lastRun = Date.now();   // 两件家当:等待的队伍 + 上次放行时刻
  }
  async run(fn) {
    this.queue.push(fn);       // ① 不执行,先排队
    await this.process();      // ② 触发一轮放行(可能等)
  }
  async process() {
    if (this.queue.length === 0) return;
    const now = Date.now();
    if (now - this.lastRun < this.interval) {
      await delay(this.interval - (now - this.lastRun));   // ③ 距上次不足间隔 → 补齐差额
    }
    const fn = this.queue.shift();   // ④ 出队,放行
    this.lastRun = Date.now();       // ⑤ 记下放行时刻(不是完成时刻!)
    await fn();                       // ⑥ 执行这个请求
    if (this.queue.length > 0) await this.process();   // ⑦ 队列不空 → 继续放行下一轮
  }
}

五个容易看漏的设计决策:

  • ①②分离:run 只负责"入队 + 唤起",真正的放行逻辑全在 process——这让多次 run 调用天然共享同一个队列和同一个时钟,不会各自为政;
  • ③补差额而非固定等待:等多久取决于"现在距上次放行差多少",所以请求本身耗时不规则时,放行节奏依然稳定(对比"每次 sleep 固定时长"会被请求耗时带歪);
  • ⑤记放行时刻:lastRun 在放行那一刻更新,不在完成那一刻——所以间隔量的是"放行与放行的间距",这正是"每秒 N 个"里"每秒"的定义;
  • ⑦自己调自己而非循环:用自我调用让"放行 → 等 → 放行"的链条让出流水线(每次 process 内部的 await 都让别人先跑),一条永远 while 的忙循环会把界面卡死(第 08 章错误五);
  • limit 参数其实没用上:构造器收了 limit(每秒几个),但 process 每轮只放行一个——想真的"每秒 N 个",要在 interval 里连放 N 次再歇。书里的实现按"一次一个、间隔放行"工作,limit 留作扩展位。读源码时见到这种"收了没用的参数",先查注释再猜意图。

6.6 指数退避的完整实现与抖动版

4.3 的指数退避实现,把 catch 块与 4.8 基础版的差异标出来19:

async function fetchWithExponentialBackoff(url, options = {}, maxRetries = 5) {
  for (let i = 0; i < maxRetries; i++) {
    try {
      const response = await fetch(url, options);
      if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
      return await response.json();
    } catch (error) {
      if (i === maxRetries - 1) throw error;
      const delay = Math.pow(2, i) * 1000;      // ← 唯一的关键行:2 的 i 次方秒
      console.log(`Attempt ${i + 1} failed. Retrying in ${delay}ms...`);
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }
}

Math.pow(2, i) * 1000 这行是全章的核心公式:i 是已失败的次数(0 起),所以第 1 次失败后等 2⁰=1 秒,第 2 次等 2¹=2 秒,第 3 次等 2²=4 秒。指数的意思就是"指数函数式增长"——比"每次加 1 秒"快得多,也比固定的"每次 1 秒"更能给服务器喘息。默认值 maxRetries=5 时,纯等待最多 1+2+4+8=15 秒。

抖动版在此基础上加两行(4.4):先算基数 baseDelay,再算一个 0-1 秒的随机量 jitter = Math.random() * 1000,最后 delay = baseDelay + jitter。注意是"加"不是"乘"——乘会把等待的量级拉乱,加只扰动尾部。对照两版,所谓"加抖动"就是在公式后面拼一个随机尾巴,改动两行,治的是"所有调用方齐步重试"的节拍同步病。生产里也有人用"随机取 0.5×~1.5×"的乘式扰动,那是另一种约定;书里的加式是最小改动,读懂它再谈变体。

6.7 本章主线例子总复盘:一次取数的完整决策链

把本章串起来,看一个真实请求从发起到落库要经过多少道本章教的决策(仍以天气主线为例,数字为演示编):

  用户输入城市名,点"查询"
    ① 防抖:停手 300ms 才真正发起(用户连敲不刷屏)      ← 第 00 章的防抖
    ② 三个接口互不依赖 → Promise.all 同时发出           ← 3.2 的判定
    ③ 单个请求挂 withTimeout(10s)                      ← 第 13 章的限时
    ④ 任一失败 → fetchWithRetry(退避+抖动,最多 3 次)    ← 第 4 节
    ⑤ 三次全败 → 缓存回退(昨天的天气顶上)               ← 第 07 章
    ⑥ 全齐 → 渲染;全程 Loading 动画在转                  ← 第 10 章的界面状态

每一环都是本章或前后章的一个模式,每一环都有参数要调(防抖 300ms、超时 10s、重试 3 次、退避 1s 起)。学模式的价值不在于会写任何一环,而在于看到需求时知道一共需要几环、环与环谁先谁后。 这张决策链就是 6.3 选型卡的串连版,建议对着自己的项目画一张同款。

7. 作者的判断与证据

本章的判断是版本递进本身:重试从基础版一路修到分诊版,作者把"什么时候该用哪版"写进了版本史456。证据是每版代码配一句"这一版的病"(我们的表格把它显式化了)。

RateLimiter 一节是全书第一次出现"类"级抽象,值得注意它的教学时机:前面所有模式都是函数,限流需要状态(队列、上次放行时刻),状态一出现,函数就不够装了——这是"什么时候该造对象"的自然演示,虽然作者没点破。

8. 边界与局限

  • 重试四版都没提"总时长上限"(maxElapsedTime),只有次数上限;跨时钟的等待在页面休眠时也不可靠;
  • 限流器是串行放行,没有"同时最多 N 个"的并发上限模式,失败后也不会重新排队;
  • 防抖(debounce)在引言和第 12 章出现,本章没展开实现(第 13 章速查表有 Promise 版);
  • 所有模式假设请求幂等;写操作重试的安全性全书未讨论。

9. 可带走的

  1. 判定串并行只看依赖:后面要用前面的结果→串行;互不依赖→Promise.all;
  2. 真实流程常是混合编排:串行取起点 → 并行取分支 → 串行收尾;
  3. 重试生产起点 = 指数退避+抖动;等待表 1s/2s/4s,每档加 0-1s 随机;
  4. 4xx 请求方的错误别重试,5xx 和网络错误才等;
  5. delay = ms => new Promise(r => setTimeout(r, ms)) 一行,是回调转 Promise 的最小范本,退避、限流、动画全靠它;
  6. 限流 = 排队 + 补差额(距上次放行不足间隔就等剩余),别用固定 sleep;
  7. 限流器是单机内存版;多实例与服务端限流是另一层问题。

10. 原文地图

主题 原书章 原文位置
本章四个模式 Chapter 6: Common Patterns and Use Cases text/09-ch06-chapter-6-common-patterns-and-use-cases.txt:7(搜「four such patterns」)
Fetch 取数骨架 同上 text/09-ch06-chapter-6-common-patterns-and-use-cases.txt:17(搜「getWeatherData」) · text/09-ch06-chapter-6-common-patterns-and-use-cases.txt:48(搜「async function」)
串行调用 同上 text/09-ch06-chapter-6-common-patterns-and-use-cases.txt:76(搜「Sequential」)
并行调用 同上 text/09-ch06-chapter-6-common-patterns-and-use-cases.txt:110(搜「multiple cities」)
混合编排 同上 text/09-ch06-chapter-6-common-patterns-and-use-cases.txt:247(搜「processUserData」)
基础重试 同上 text/09-ch06-chapter-6-common-patterns-and-use-cases.txt:306(搜「fetchWithRetry」)
指数退避 同上 text/09-ch06-chapter-6-common-patterns-and-use-cases.txt:337(搜「Exponential Backoff」) · text/09-ch06-chapter-6-common-patterns-and-use-cases.txt:348(搜「Math.pow」)
抖动 同上 text/09-ch06-chapter-6-common-patterns-and-use-cases.txt:371(搜「Jitter」)
按类型分诊 同上 text/09-ch06-chapter-6-common-patterns-and-use-cases.txt:403(搜「Conditional Retries」) · text/09-ch06-chapter-6-common-patterns-and-use-cases.txt:414(搜「400」)
delay / sleep 同上 text/09-ch06-chapter-6-common-patterns-and-use-cases.txt:459(搜「function delay」) · text/09-ch06-chapter-6-common-patterns-and-use-cases.txt:480(搜「sleep」)
RateLimiter 同上 text/09-ch06-chapter-6-common-patterns-and-use-cases.txt:533(搜「RateLimiter」) · text/09-ch06-chapter-6-common-patterns-and-use-cases.txt:565(搜「1 request per second」)
打字动画 同上 text/09-ch06-chapter-6-common-patterns-and-use-cases.txt:585(搜「typeText」)


  1. 出处:「Chapter 6: Common Patterns and Use Cases」第 17-66 段(text/09-ch06-chapter-6-common-patterns-and-use-cases.txt:17,搜「getWeatherData」)。Promise 版与 async/await 版的取数骨架照原书。 ↩

  2. 出处:同章第 135-239 段(text/09-ch06-chapter-6-common-patterns-and-use-cases.txt:149,搜「submitMultiStepForm」;:208,搜「fetchDashboardData」)。串行多步表单与并行仪表板例子照原书。 ↩

  3. 出处:同章第 245-296 段(text/09-ch06-chapter-6-common-patterns-and-use-cases.txt:247,搜「processUserData」)。先取用户、并行取帖子与好友、再处理的三段式照原书。 ↩

  4. 出处:同章第 335-365 段(text/09-ch06-chapter-6-common-patterns-and-use-cases.txt:337,搜「Exponential Backoff」;:348,搜「Math.pow」)。原文:"The first retry happens after 1 second, the second after 2 seconds, the third after 4 seconds"。 ↩↩

  5. 出处:同章第 369-401 段(text/09-ch06-chapter-6-common-patterns-and-use-cases.txt:371,搜「Jitter」)。原文:adds a random jitter of up to 1 second,helping to distribute retry attempts more evenly。 ↩↩

  6. 出处:同章第 405-439 段(text/09-ch06-chapter-6-common-patterns-and-use-cases.txt:403,搜「Conditional Retries」;:414,搜「400」)。4xx 直接抛、5xx 与网络错误才重试的分诊照原书。 ↩↩

  7. 出处:同章第 457-495 段(text/09-ch06-chapter-6-common-patterns-and-use-cases.txt:459,搜「function delay」)。delay 函数与按秒 sleep 变体照原书。 ↩

  8. 出处:同章第 499-527 段(text/09-ch06-chapter-6-common-patterns-and-use-cases.txt:501,搜「fetchWithTimedRetry」)。固定间隔定时重试照原书。 ↩

  9. 出处:同章第 583-598 段(text/09-ch06-chapter-6-common-patterns-and-use-cases.txt:585,搜「typeText」)。逐字输出+sleep 的打字动画照原书。 ↩

  10. 出处:同章第 531-579 段(text/09-ch06-chapter-6-common-patterns-and-use-cases.txt:533,搜「RateLimiter」;:565,搜「1 request per second」)。类的实现与"每秒 1 个"的用法照原书。 ↩

  11. 出处:「Chapter 6: Common Patterns and Use Cases」第 147-194 段(text/09-ch06-chapter-6-common-patterns-and-use-cases.txt:149,搜「submitMultiStepForm」)。多步表单的四个步骤与错误处理照原书。 ↩

  12. 出处:「Chapter 6: Common Patterns and Use Cases」第 108-131 段(text/09-ch06-chapter-6-common-patterns-and-use-cases.txt:112,搜「weatherPromises」)。cities.map 造票+all 收票的写法照原书。 ↩

  13. 出处:「Chapter 6: Common Patterns and Use Cases」第 337-363 段(text/09-ch06-chapter-6-common-patterns-and-use-cases.txt:348,搜「Math.pow」)。等待公式照原书;全败路径与总耗时推演是按该公式逐步计算的。 ↩

  14. 出处:「Chapter 6: Common Patterns and Use Cases」第 581-598 段(text/09-ch06-chapter-6-common-patterns-and-use-cases.txt:585,搜「typeText」)。typeText 的逐字符循环照原书。 ↩

  15. 出处:「Chapter 6: Common Patterns and Use Cases」第 302-331 段(text/09-ch06-chapter-6-common-patterns-and-use-cases.txt:306,搜「fetchWithRetry」)。基础版实现照原书,默认参数 maxRetries = 3。 ↩

  16. 出处:「Chapter 6: Common Patterns and Use Cases」第 457-495 段(text/09-ch06-chapter-6-common-patterns-and-use-cases.txt:459,搜「function delay」;:480,搜「sleep」)。delay/sleep 两个函数与倒计时用法照原书。 ↩

  17. 出处:「Chapter 6: Common Patterns and Use Cases」第 497-527 段(text/09-ch06-chapter-6-common-patterns-and-use-cases.txt:501,搜「fetchWithTimedRetry」)。固定间隔定时重试照原书。 ↩

  18. 出处:「Chapter 6: Common Patterns and Use Cases」第 531-579 段(text/09-ch06-chapter-6-common-patterns-and-use-cases.txt:533,搜「RateLimiter」)。类的实现与"1 request per second"用法照原书。 ↩

  19. 出处:「Chapter 6: Common Patterns and Use Cases」第 337-401 段(text/09-ch06-chapter-6-common-patterns-and-use-cases.txt:348,搜「Math.pow」;:382,搜「jitter」)。两版 catch 块照原书。 本章是全书模式的军火库,后面的实战章(10)与最佳实践章(11)会反复回来取货。 (本章完:军火库上货完毕,取货请对照 6.3 选型卡。) (本章完:军火库与选型卡,随查随用。) ↩