前端并发控制:别让 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-limit、p-queue 这些库都很成熟,为什么还要自己写。
我的态度是:原理一定要自己手写一遍弄明白,但生产里我多半还是用库。p-limit 就是上面 runWithLimit 的加强版,p-queue 还顺手把限流、优先级、超时、暂停恢复都给你做了。自己从零造这些,极端情况能让你 debug 到怀疑人生——比如任务在队列里又被取消、重试和限流叠加时的计数、空任务列表的处理。这些坑前人都踩平了,没必要重复造轮子。
但我反对的是“连原理都不懂就直接 import”。有一次线上并发明显不对,排查半天发现是有人把 p-limit 的返回值用错了——limit(fn) 返回的是包裹后的函数,得调用它才会进队列,他直接把函数本身塞进了 Promise.all。这种 bug 你要是没手写过一遍并发控制,光看库文档是很难一眼看出来的。理解了 () => fetch() 这层惰性包装,再用任何库都不会犯迷糊。
Promise.all 适合少量任务,不适合无脑处理大批量请求。
当请求数量较多时,并发控制能让前端、浏览器和服务端都更稳定。关键是限制同时执行数量,并把失败、重试和结果顺序设计清楚。
从那次 300 条导入打挂服务,到把并发、限流、取消、重试、进度一整套补齐,“批量”这件事本质上不是一个性能优化问题,而是一个稳定性和确定性问题:你要让系统在压力下不崩,要让用户在出错时知道发生了什么、能怎么补救。Promise.all 给你的是一个非黑即白的承诺,而真实的批量任务永远活在灰色地带——总有几项会慢、会失败、会被中途取消。把这些灰色地带处理好,比单纯追求“发得快”重要得多。