前端并发控制:别让 Promise.all 一次打满所有请求

批量请求在前端里很常见:一次上传很多文件、批量校验数据、批量拉取详情。这类需求最省事的写法就是把所有请求塞进 Promise.all 一次发出去,请求数量少时确实没问题,可数量一多就会打满浏览器连接、压垮服务端,或者引出一连串失败重试。

真正让我对这件事敏感起来的,是一个批量校验页面。用户一次导入几百行配置,前端要逐行调用校验接口,第一版为了快直接上了 Promise.all。本地测 20 条一切正常,上个月线上有人一次导入 300 条,页面立刻进入一种很尴尬的状态:接口大量超时,失败重试又继续把服务打满,最后用户看到的是一堆随机失败。那一下我才想清楚,批量任务的目标从来不是“同时发得越多越好”,而是让吞吐、失败恢复和用户反馈都可控。

后来排查日志才发现,那次线上事故还有一层更隐蔽的原因。浏览器对同一个域名的 HTTP/1.1 连接是有上限的,Chrome 大概是 6 个。也就是说你 Promise.all 发出去 300 个请求,真正在网络上同时跑的也就 6 个,剩下 294 个全在浏览器内部排队等连接。表面上看你“一次性发了 300 个”,实际是制造了一条又长又不可控的队列。更糟的是,排在队尾的请求等了十几秒才轮到自己,而后端的超时计时早就开始了——很多请求还没真正发出去,就已经注定要超时。我当时盯着 Network 面板看那一长串灰色 pending,才真正理解“发出去”和“被处理”完全是两码事。

如果走的是 HTTP/2,情况会好一些,多路复用让一个连接上能并行很多请求。但这反而更危险:浏览器这一层不再帮你兜底排队,压力会原封不动地砸到服务端。所以指望协议或浏览器帮你做并发控制是靠不住的,这件事必须前端自己显式地做。

Promise.all 的问题

下面这段代码很常见:

1await Promise.all(urls.map((url) => fetch(url)));

如果 urls 有 100 个,就会几乎同时发出 100 个请求。

这不是“更快”,而是把压力一次性推给浏览器和服务端。

Promise.all 还有一个容易忽略的行为:只要其中一个 Promise reject,整个 Promise.all 就 reject。对于“全部必须成功”的任务,这没问题。但对于批量上传、批量校验这种场景,用户往往更关心每一项的结果,而不是第一个失败就结束。

所以我现在会先区分两类任务:

1强一致任务:任何一步失败都应该整体失败
2批处理任务:允许部分成功,需要收集每一项结果

如果是第二类,Promise.allSettled 或自定义结果收集通常更合适。

我踩过一个很典型的坑:误以为 Promise.all 失败之后,那些没失败的请求就会停下来。其实不会。Promise.all 只是“提前告诉你结果”,它没有任何取消能力,已经发出去的请求该跑还是跑,该写库还是写库。我之前在一个批量删除的页面上吃过亏,其中一项校验失败,Promise.all 立刻 reject,前端弹了个“操作失败”,结果用户刷新一看,大部分数据其实已经删掉了——因为后端请求根本没停。从那以后我对“失败了等于没做”这种直觉很警惕,前端的 Promise 状态和后端的实际副作用是两个世界。

Promise.allSettled 解决了“收集每一项结果”的问题,但它不解决并发,依然是一把梭全发出去。所以下面要做的事,是把并发数和结果收集这两件事拆开各自处理。

并发控制的目标

并发控制不是让请求变慢,而是让系统更稳。

例如一次只允许 5 个请求同时执行:

  • 有请求完成后,再补上下一个
  • 始终保持最多 5 个并发
  • 所有任务最终都能执行完

这样整体吞吐更稳定,也更容易处理失败。

我特别想说清楚“限流”和“限并发”不是一回事,这两个概念我早期一直混着用。限并发管的是“同一时刻最多几个在跑”,比如最多 5 个;限流(rate limit)管的是“单位时间内最多发几个”,比如每秒最多 10 个。前者控制的是瞬时压力,后者控制的是累积速率。大多数前端场景用限并发就够了,但有些接口后端是按 QPS 严格限流的,这时候哪怕你并发只开 3 个,如果每个请求都很快返回,一秒内照样能发出去几十个,照样被限流挡掉。遇到这种接口我会在调度里再加一道闸:用一个时间窗口记录最近一秒发了几个,到了上限就 await 等一小会儿再放行。两道闸叠在一起,才能既不打满连接、又不超过服务端的速率约束。

并发数量也不是拍脑袋。一般要看:

  • 接口服务端限流策略
  • 浏览器和网络环境
  • 单个请求平均耗时
  • 是否有重试
  • 用户是否需要看到进度

比如上传文件和校验文本就不一样。上传更受带宽影响,校验更受服务端 QPS 影响。并发数不是一个全站常量,最好按任务类型配置。

我自己的经验是:别一上来就追求最优值,先从一个保守的小数字起步。校验类接口我一般默认 4 到 6,上传大文件压到 2 到 3,因为上传一旦并发高了,几个大文件抢带宽,反而每一个都慢,整体还更卡。真正合理的并发数我后来是这么估的:如果后端告诉你这个接口能扛 50 QPS,单个请求平均耗时 200ms,那理论上 50 × 0.2 = 10 个并发就能把配额吃满,再高只会触发限流。这个 并发数 ≈ 目标 QPS × 平均耗时 的小算式不精确,但比拍脑袋强很多。

还有一点容易忽略:并发数最好做成可以从配置下发的,而不是硬编码在前端。我们项目后来把它放进了接口返回的一个字段里,后端扩容或者限流策略变了,能直接调前端的并发上限,不用等前端发版。这个小改动在一次大促前救了我们一次。

一个简单实现

可以写一个通用的并发执行函数:

1async function runWithLimit(tasks, limit) {
2  const results = [];
3  const executing = [];
4
5  for (const task of tasks) {
6    const promise = Promise.resolve().then(task);
7    results.push(promise);
8    executing.push(promise);
9
10    promise.finally(() => {
11      const index = executing.indexOf(promise);
12      if (index >= 0) executing.splice(index, 1);
13    });
14
15    if (executing.length >= limit) {
16      await Promise.race(executing);
17    }
18  }
19
20  return Promise.all(results);
21}

使用时:

1await runWithLimit(
2  urls.map((url) => () => fetch(url)),
3  5
4);

这里传入的是任务函数,而不是已经开始执行的 Promise。

这个区别我必须强调一下,因为它是新手最容易写错的地方。如果你传进去的是 urls.map((url) => fetch(url)),那 fetch 在调用 map 的那一刻就已经执行了,所有请求全发出去了,你的并发限制等于没写。Promise 是“热”的,创建即执行,没有惰性求值这回事。所以必须包一层 () => fetch(url),让任务以函数的形式延迟到 worker 真正需要时才调用。我 review 别人代码时见过好几次这种 bug,跑起来功能正常,并发限制却悄悄失效了,不盯着 Network 面板根本发现不了。

runWithLimit 这个基于 Promise.race 的写法胜在短小,思路也直白:维护一个执行中的池子,满了就等最快的那个完成再补位。不过它有个隐患——如果某个任务自己 reject 了而你没在任务内部 catch,Promise.race 会把这个 reject 冒出来,最后的 Promise.all 也会整体 reject,剩下的任务就尴尬了。所以这个版本更适合“每个任务内部已经自己兜住错误”的场景。要稳妥地收集每一项成败,我更常用下面这个 worker 池的写法。

如果要保留结果顺序,可以把 index 一起带进去:

1async function runWithLimit(tasks, limit) {
2  const results = new Array(tasks.length);
3  let cursor = 0;
4
5  async function worker() {
6    while (cursor < tasks.length) {
7      const index = cursor++;
8
9      try {
10        results[index] = {
11          status: "fulfilled",
12          value: await tasks[index](),
13        };
14      } catch (error) {
15        results[index] = {
16          status: "rejected",
17          reason: error,
18        };
19      }
20    }
21  }
22
23  await Promise.all(
24    Array.from({ length: limit }, () => worker())
25  );
26
27  return results;
28}

这类写法更适合批处理页面,因为你可以把每一行的状态展示出来:等待中、处理中、成功、失败、可重试。

我现在基本只用这个 worker 池版本,原因有几个。一是它天然不会因为单个任务失败而拖垮整体,每个任务的 try/catch 都收在 worker 内部,结果按 index 写回原位,顺序天然就是对的。二是并发数就是 worker 的数量,语义特别清楚:开 5 个 worker,就是最多 5 个并发,每个 worker 干完一个立刻用 cursor++ 抢下一个。这个用游标抢任务的模型其实就是个简化的线程池,理解了它,后面加进度回调、加取消都顺理成章。

如果想让外部能实时拿到进度,给它加个回调就行,改动很小:

1async function runWithLimit(tasks, limit, onProgress) {
2  const results = new Array(tasks.length);
3  let cursor = 0;
4  let done = 0;
5
6  async function worker() {
7    while (cursor < tasks.length) {
8      const index = cursor++;
9      try {
10        results[index] = { status: "fulfilled", value: await tasks[index]() };
11      } catch (error) {
12        results[index] = { status: "rejected", reason: error };
13      } finally {
14        done++;
15        onProgress?.({ done, total: tasks.length, results });
16      }
17    }
18  }
19
20  await Promise.all(Array.from({ length: limit }, () => worker()));
21  return results;
22}

onProgress 里我会做个节流,几百个任务如果每完成一个都触发一次 React 重渲染,进度条本身反而成了性能瓶颈。这个坑我也撞过,本来跑得好好的批量任务,加了进度展示之后界面卡成幻灯片,最后用 requestAnimationFrame 把更新频率压下来才好。

实际项目还要考虑失败

批量任务里,失败处理很重要。

你需要决定:

  • 一个失败是否中断全部
  • 是否允许部分成功
  • 是否需要重试
  • 重试次数是多少
  • 结果顺序是否要保持

这些规则要根据业务定,不要只写一个看起来通用的工具就结束。

我一开始也想着写个“万能批量工具”一劳永逸,后来发现根本不存在。同样是失败,批量上传里某张图传失败,用户大概希望保留其他成功的,单独重传这一张;而批量审批里如果有一条没过,可能整批都不该提交。这两种语义完全相反,硬塞进一个工具只会让参数越来越多,最后没人敢动。我现在的做法是把 runWithLimit 这种纯粹的“并发调度”和上层的“业务失败策略”分开:调度层只管按并发数跑任务、收集结果,怎么解读这些结果是业务自己的事。

还有个区分错误类型的细节值得一提。失败不该被一视同仁地重试。网络抖动、503、超时这类是可以重试的;但 400 参数错误、401 没权限、422 校验不过,重试一百次也是同样的结果,只是白白浪费配额。我会在重试逻辑里先判一下错误码,业务校验失败直接标红给用户,让他改数据,而不是傻乎乎地重试。

取消和重试要一起设计

批量任务还有一个现实问题:用户可能会取消。

比如导入校验跑到一半,用户发现文件选错了。这时候前端应该停止继续派发新任务,已经在路上的请求如果支持取消,也应该尽量取消。

1const controller = new AbortController();
2
3function createTask(row) {
4  return () => fetch("/api/validate", {
5    method: "POST",
6    body: JSON.stringify(row),
7    signal: controller.signal,
8  });
9}
10
11function cancel() {
12  controller.abort();
13}

关于取消还有个容易忘的点:所有任务最好共用同一个 AbortController。我一开始给每个任务都建了一个 controller,想着粒度更细,结果取消的时候要遍历一遍挨个 abort,还得维护一张表,纯属给自己找麻烦。批量任务的取消通常是“一键全停”,共用一个 controller,controller.abort() 一调,所有还在路上、监听了这个 signal 的请求一起断掉,已经被 fetch 接收的会抛 AbortError,还没派发的任务在 worker 里判一下 signal.aborted 就跳过了。要注意 AbortController 是一次性的,abort 过的不能复用,重新开始得新建一个。

重试也不要无脑立刻重试。服务端已经压力很大时,立刻重试只会更糟。更稳的做法是限制次数,并加一点退避:

1async function retry(task, times = 2) {
2  let lastError;
3
4  for (let attempt = 0; attempt <= times; attempt++) {
5    try {
6      return await task();
7    } catch (error) {
8      lastError = error;
9      await new Promise((resolve) =>
10        setTimeout(resolve, 300 * (attempt + 1))
11      );
12    }
13  }
14
15  throw lastError;
16}

退避这里我后来还加了一点随机抖动(jitter)。如果一批请求是被同一个 503 一起打回来的,它们的重试计时也几乎同步,退避 300ms 之后又会在同一时刻一起涌向服务端,相当于制造了一波又一波的“重试脉冲”。给退避时间加个随机量,比如 300 * (attempt + 1) + Math.random() * 200,把这些重试在时间上打散,服务端会舒服很多。这个细节平时看不出差别,只有在真出问题、大量请求同时重试时才显出价值。

1const base = 300 * (attempt + 1);
2const jitter = Math.random() * 200;
3await new Promise((resolve) => setTimeout(resolve, base + jitter));

把重试接回并发调度也有个小讲究:重试应该发生在 worker 内部、占用的是同一个并发名额,而不是失败后另起一套并发去重试。否则你以为限制了 5 个并发,重试的请求又偷偷开了一批,并发数就又失控了。所以我一般直接把 retry 包在任务函数里:() => retry(() => fetch(...)),这样无论重试几次,对调度层来说它始终只是“一个任务”。

并发控制、取消、重试、进度反馈应该一起看。只做其中一个,批量任务仍然很容易粗糙。

用户需要看到进度

批量请求如果需要几秒甚至几十秒,页面不能只给一个 loading。

我会尽量展示:

  • 总数
  • 已完成数量
  • 成功数量
  • 失败数量
  • 当前是否可取消
  • 失败项是否可单独重试

这对 B 端页面很重要。用户导入配置、批量校验、批量上传时,不怕等,怕的是不知道系统在干什么。

进度展示还有个隐性好处:它逼着你把“失败项”做成可定位、可单独操作的。一旦页面上能列出“第 47 行失败,原因是手机号格式不对,点这里重试”,整个批量功能的体验就从“黑盒赌运气”变成了“可控的流水线”。我们那个出过事故的校验页面,最后重构成这样之后,客诉基本就没了——不是因为它变快了,而是因为出错时用户知道哪里错、怎么补。

别忽略前端自己这一侧的开销

聊并发控制时大家很容易只盯着网络和服务端,其实前端本身也有瓶颈。

我做过一个需要对几千条数据做本地校验加哈希计算的批量任务,每条都是纯 CPU 活。这种任务即使你把“并发”开到 50,也没有任何意义——JS 是单线程的,它们只会在主线程上排队执行,并发数对纯计算任务约等于摆设,反而会因为一次性占满调用栈把界面卡死。后来这块我是拆出去用 Web Worker 跑的,主线程只负责调度和展示进度。

所以我现在会先分清任务到底是 IO 密集还是 CPU 密集。IO 密集(等接口、等上传)才是 runWithLimit 这套并发控制真正的用武之地;CPU 密集要么分片让出主线程,要么丢进 Worker,光调并发数解决不了问题。这两种瓶颈混在一起时最难排查,因为现象都是“卡”,但药方完全不同。

要不要直接上现成的库

写到这儿可能有人会问,社区里 p-limitp-queue 这些库都很成熟,为什么还要自己写。

我的态度是:原理一定要自己手写一遍弄明白,但生产里我多半还是用库。p-limit 就是上面 runWithLimit 的加强版,p-queue 还顺手把限流、优先级、超时、暂停恢复都给你做了。自己从零造这些,极端情况能让你 debug 到怀疑人生——比如任务在队列里又被取消、重试和限流叠加时的计数、空任务列表的处理。这些坑前人都踩平了,没必要重复造轮子。

但我反对的是“连原理都不懂就直接 import”。有一次线上并发明显不对,排查半天发现是有人把 p-limit 的返回值用错了——limit(fn) 返回的是包裹后的函数,得调用它才会进队列,他直接把函数本身塞进了 Promise.all。这种 bug 你要是没手写过一遍并发控制,光看库文档是很难一眼看出来的。理解了 () => fetch() 这层惰性包装,再用任何库都不会犯迷糊。

Promise.all 适合少量任务,不适合无脑处理大批量请求。

当请求数量较多时,并发控制能让前端、浏览器和服务端都更稳定。关键是限制同时执行数量,并把失败、重试和结果顺序设计清楚。

从那次 300 条导入打挂服务,到把并发、限流、取消、重试、进度一整套补齐,“批量”这件事本质上不是一个性能优化问题,而是一个稳定性和确定性问题:你要让系统在压力下不崩,要让用户在出错时知道发生了什么、能怎么补救。Promise.all 给你的是一个非黑即白的承诺,而真实的批量任务永远活在灰色地带——总有几项会慢、会失败、会被中途取消。把这些灰色地带处理好,比单纯追求“发得快”重要得多。