
1000 个请求用 Promise.all,我把自己家的 API 打挂了
Promise.all 让代码变快,从来不是因为它高明,而是因为它把所有活儿一次性推了出去。而它压根不知道,你的系统能不能接得住。
咱们前端每天都在写异步。Promise.all 大概是最顺手的那个——一行搞定并发,代码还短。
我也是这么以为的,直到那天下午三点。
后端老王在群里 @ 我:「你们前端在压测吗?」
我说没有啊。
他又发了一条:「网关这会儿一片红,全是 429。」
我心里咯噔一下,第一反应是——上午刚上线了一个批量导入功能。点开监控看了一眼,请求曲线像被人拿刀往上劈了一下,几乎垂直。
我去问运营:「你刚才点了啥?」
她说:「我点了一下『一键同步』啊,就一下。」
那个功能是我写的,一共三行代码:
jsconst results = await Promise.all(
items.map(item => processItem(item))
);短,好读,比一条条慢慢跑快得多。code review 两个同事都点了通过。
问题是——我们测试环境里那个 items,从来没超过 20 条。
而运营那天导的表格,七千多行。
代码一个字都没改。变的只有数据。但这一下,性质完全不一样了。
下面这 7 个坑,是我把那次事故复盘完、又在本地反复测出来的。每一个我都配了业务场景和生活类比,尽量讲得让刚入行的同学也能看懂。看看你中了几条。
坑一:你以为在说"高效跑完",其实在说"全部立刻发出去"
📌 先看个场景
你要给 500 个用户批量发通知。小白通常这么写:
jsawait Promise.all(
users.map(user => sendNotification(user.id))
);刚写完看着没问题,对吧?功能能跑,比 for 循环快,review 也过了。
但问题来了。 Promise.all 有个特点,你写得越熟就越容易忘——
它根本不知道你的系统能扛多少。
它不认识你的数据库,不认识你的网关,也不关心对方有没有限流。你给它一个数组,它就把里面的活儿全部、同时、立刻发出去。
所以那三行代码,你以为你跟 JavaScript 说的是:
「帮我高效地把这些跑完。」
你实际说的是:
「现在,马上,全都给我发出去。」
这两句话写成代码长得一模一样。在 code review 里也看不出区别。
区别要等到数据库开始拒绝连接的那一刻,才会显出来。
打个比方
食堂有 20 个打饭窗口。 这是硬的,改不了。
Promise.all 干的事情是:把 5000 个学生同时推进食堂,一起冲向窗口。
结果不是"打饭变快了"。结果是所有人挤在窗口前谁也动不了,保安开始往外赶人(这就是 429),最后真正打上饭的人反而变少了。
正确的做法很朴素——排队。20 个窗口就放 20 个人进去,出来一个进去一个。
看起来"慢",但所有人都能吃上饭,而且总时间反而更短。
不信?我真去测了。
我在本地把这事复现了一遍
事故复盘完那天晚上,我起了个特别脆的 Node 服务,故意设成最多同时处理 100 个请求,多了直接返回 429。
然后打 1000 个请求过去,两种打法各来一次。
code❌ 一口气全推出去 ✅ 限制 50 个同时跑
───────────────── ─────────────────
成功 494 个 成功 1000 个
被拒 506 个 被拒 0 个
耗时 150ms 耗时 130ms我看到这组数字的时候愣了一下。
一半的请求直接没了,而且——限流的那版还更快。
先说清楚,这是个刻意做小的本地实验(Node 22,服务端每个请求 5ms 处理时间,回环网络无延迟)。换个网络环境、换套服务端架构,数字肯定不一样,你自己跑八成跟我不一致。
但它把最要命的那件事摆在明面上了:
并发开得更大,没换来更多活儿干完,只换来了堵车。
而且一旦服务端开始拒绝请求,那个「更快」的写法反而更慢地完成了任务。它只是更快地失败了而已。
💡 别误会,Promise.all 没做错什么
我不是来劝大家别用它的。恰恰相反,这是现代 JS 里最好用的工具之一。
页面加载时要拿三份数据,这么写毫无心理负担:
jsconst [用户信息, 偏好设置, 消息通知] = await Promise.all([
loadProfile(userId),
loadPreferences(userId),
loadNotifications(userId)
]);每个 200 毫秒,串行要 600 毫秒,一起发差不多 200 毫秒完事。三个人一起进食堂,20 个窗口绰绰有余。
真正的问题不是"用了并发",是把"并发"和"无限并发"当成了一回事。
坑二:危险的从来不是那个方法,是喂给它的数组
📌 先看个场景
你写了个拉取用户详情的函数:
jsawait Promise.all(
users.map(user => fetchUserData(user.id))
);这行代码人畜无害吧。测试环境跑得飞起,你放心提交了。
但问题来了。
users 有 10 条,没事。
1000 条,大概还行。
10 万条呢?
代码一个字没变。变的只有喂进去的数据。
这是异步 JavaScript 最阴的地方——一个并发 bug,可以完美地藏在语法完全正确的代码里。
函数是对的。测试是过的。接口是通的。ESLint 一个警告都不报。
然后某一天,生产环境来了一个更大的批次,一行代码瞬间放出去几千个请求。
真实事故: 我们那次就是。测试数据 20 条,生产数据 7000 条,中间没有任何一层拦着。上线三小时后炸的,而且炸的不是我们的服务——是被我们打挂的下游。
打个比方
这就像一个只按"平时二十来号人"设计的食堂。
平时确实够用,从来没出过事。直到有一天隔壁公司团建,一次涌进七千人。
食堂没变。菜也没变。变的只有门口那群人。
💡 小白记住一句话
看到 Promise.all(xxx.map(...)),先问一句:这个数组最大能到多少?
回答「一般就二十来条」,行,过。 回答「看客户数据量」——那就得停下来了。
坑三:Promise 层和资源层,是两个互不认识的世界
📌 先看个场景
你要把 5000 条记录批量写库:
jsawait Promise.all(
records.map(record => saveToDatabase(record))
);你的 Node 服务连接池配了 20。而你一口气造了 5000 个 Promise。
数据库不关心你的代码写得多漂亮,它只关心同时来了多少人:
code5000 条记录
│
▼
5000 个异步操作 ← Promise 层:没有上限
│
▼
┌──────────────┐
│ 连接池 (20) │ ← 资源层:硬上限
└──────┬───────┘
│
▼
排队 / 争抢 / 超时这张图是整件事的核心:
Promise 那一层和资源那一层,各有各的上限,而且它们互相不知道对方存在。
Promise 不代表你有无限的处理能力,它只代表"有件异步的事在跑"。底下那套系统该有几个窗口,还是几个窗口。
打个比方
这就像公司前台。
你在群里 @ 了 500 个人来公司开会,这是你的"Promise 层"——发消息不要钱,@ 多少个都行。
但前台只有 2 个登记窗口,会议室只能坐 30 人。这是"资源层"。
你 @ 人的时候爽,人到楼下的时候堵。这两件事发生在不同的地方,你在工位上是看不见的。
💡 判断一个人写了几年,就看他问什么
刚入行的人问:「这些操作能不能并发?」
干了几年的人问:「这个下游,安全地能吃下多少并发?」
后面这个问题有用得多。因为每个异步操作最后都会吃掉某样实实在在的东西——数据库连接、socket、内存、接口配额、浏览器的并发槽位。
并发不是免费的。它是一次资源分配。
坑四:矫枉过正,改成一个一个来
📌 先看个场景
发现这问题之后,很多人会一把方向盘打死:
js// 之前
await Promise.all(items.map(processItem));
// 改成
for (const item of items) {
await processItem(item);
}现在确实没人被打挂了。
但问题来了。 你也把有用的那部分并发一起扔了。20 个窗口,你非要一个人一个人进,另外 19 个窗口全空着。
假设每个操作 100 毫秒,100 个互不相关的任务:
code❌ 一个一个来 ❌ 一口气全推
────────────── ──────────────
100 × 100ms ≈ 10 秒 ≈ 0.1 秒
用户转圈圈等十秒 下游被打挂
✅ 限制 20 个并发
──────────────
≈ 0.5 秒
没人受伤用户在前面转着圈圈等十秒,就为了让后端舒服点——这买卖谁做谁亏。
高级工程师怎么做?
不用装什么库,一个小工作池就够了。
思路特别简单:雇 20 个工人,每人处理完一条就自己去领下一条,领不到就下班。 任何时刻最多 20 个在跑。
JavaScript 版:
js/**
* 带并发上限的批量处理
* @param {Array} items 要处理的数据
* @param {Function} worker 单条怎么处理
* @param {number} limit 最多同时跑几个
*/
async function mapWithLimit(items, worker, limit = 10) {
// ① limit 传成 0 会一个工人都不雇,然后"成功"返回一个全是空洞的数组
// 这种静默失败比报错可怕得多,所以先挡住
if (!Number.isInteger(limit) || limit < 1) {
throw new TypeError(`limit 必须是 >= 1 的整数,收到的是 ${limit}`);
}
const results = new Array(items.length);
let cursor = 0; // 公用的"取号机"
let aborted = false; // ② 有人翻车了,其他人就别再领新号了
async function runWorker() {
while (!aborted) {
const index = cursor++; // 领一个号
if (index >= items.length) return; // 没号了,下班
try {
results[index] = await worker(items[index], index);
} catch (err) {
aborted = true; // 通知其他工人收工
throw err;
}
}
}
// 同时雇 limit 个工人(数据不够就少雇点)
await Promise.all(
Array.from({ length: Math.min(limit, items.length) }, runWorker)
);
return results;
}TypeScript 版:
tstype Worker<T, R> = (item: T, index: number) => Promise<R>;
async function mapWithLimit<T, R>(
items: readonly T[],
worker: Worker<T, R>,
limit = 10
): Promise<R[]> {
if (!Number.isInteger(limit) || limit < 1) {
throw new TypeError(`limit 必须是 >= 1 的整数,收到的是 ${limit}`);
}
const results = new Array<R>(items.length);
let cursor = 0;
let aborted = false;
async function runWorker(): Promise<void> {
while (!aborted) {
const index = cursor++;
if (index >= items.length) return;
try {
results[index] = await worker(items[index], index);
} catch (err) {
aborted = true;
throw err;
}
}
}
await Promise.all(
Array.from({ length: Math.min(limit, items.length) }, runWorker)
);
return results;
}说个我自己翻的车
这段代码我第一版写出来是没有 aborted 那两行的。看着挺干净,也能跑。
然后我照着后面「坑六」的思路测了一下:故意让第一条任务抛错,看看剩下的会不会停。
codecatch 到错误那一刻 已启动 2 条 / 已完成 0 条
400 毫秒之后 已启动 10 条 / 已完成 9 条 ← 全跑完了我 catch 到错误、可以给用户弹"操作失败"了,而剩下 8 个工人还在闷头往下游发请求。
我写了一个专门用来防止打爆下游的工具,它自己在出错之后继续打爆下游。
limit = 0 那个更阴——一个工人都不雇,Promise.all([]) 立刻 resolve,函数"成功"返回一个全是空洞的数组,一条数据都没处理,还不报错。要是 limit 是从配置读的,配置写错一次,你的批量任务就会天天"成功"地什么都不干。
所以上面那版加了两处:limit 校验,和出错后不再领新号。
💡 顺带一提:如果你的 worker 有副作用(扣款、发信、写库),光加 aborted 还不够——已经在飞的那几条照样会落地。真要严谨,得往幂等和补偿上走。但至少,别让它继续放新的出去。
用法上只改一个地方:
js// 之前
await Promise.all(users.map(u => fetchUserData(u.id)));
// 之后
await mapWithLimit(users, u => fetchUserData(u.id), 20);并发还在,只是有了天花板。
有意思的是,Promise.all 在这里并没有消失——它还在最后一行,只不过现在它管的是 20 个工人,而不是 5000 个请求。
它终于回到了它擅长的尺度上。
打个比方
这就像超市开收银台。
全关了只留一个,队排到门外(串行)。
全部打开但只有 3 个收银员,剩下的台子空转还互相抢扫码枪(无限并发)。
开 20 个、配 20 个人、大家排队——看着最"笨",实际最快。
💡 别走极端
三五个请求的场景,直接 Promise.all 就行,别为了"规范"硬套工作池,那叫过度设计。
需要上闸门的,是数组长度不由你控制的场景:用户上传的表格、数据库查出来的列表、第三方返回的集合。
坑五:那个并发数,是拍脑袋定的
📌 先看个场景
你照着某篇文章抄了个工作池,里面写着 limit = 10。
于是你项目里所有批量操作都是 10。写库是 10,调第三方是 10,读文件也是 10。
但问题来了。 这是大多数教程翻车的地方——它们随手给你一个数字,然后就翻页了。
没有万能数字。 这个数取决于你在跟谁说话:
code数据库 → 连接池配了多少
第三方接口 → 人家限流阈值是多少
CPU 密集任务 → 你有几个核
文件读写 → 系统句柄上限
浏览器请求 → 同域并发限制一句话:这个数字应该来自你要保护的那个资源,而不是来自某篇文章(包括这篇)。
打个比方
这就像电梯里的限重牌。
那块牌子上写的数字,是根据这台电梯的钢缆算出来的,不是从隔壁楼抄来的。
你把隔壁楼的「限载 21 人」贴到自家电梯上,牌子看着很专业,电梯该掉还是掉。
高级工程师怎么做?
把并发当成一份预算,写进代码里,还给它一个名字:
js// ❌ 藏起来的决定:没人知道为什么,也没人敢改
await Promise.all(jobs.map(processJob));
// ✅ 摆在明面上的决定:review 的人可以问"为什么是 20"
const CONCURRENCY = 20; // 对齐数据库连接池大小
await mapWithLimit(jobs, processJob, CONCURRENCY);后面那种写法有个隐藏的好处:它让这个决定变得可以被质疑。
Promise.all(...) 把并发决策藏起来了,谁也问不出口。一个有名字的常量,会让下一个 review 的人停下来想一秒。
💡 这个数怎么试出来
别猜,压一遍。从小往大加,看的不是"快了多少",而是"什么时候开始丢东西"。
我拿前面那个「最多同时处理 100 个」的服务器,把并发数从 10 一路加到 1000,每次都打 1000 个请求:
code并发数 成功 被拒 耗时
10 1000 0 618ms
20 1000 0 317ms
50 1000 0 135ms
100 1000 0 73ms ← 拐点
200 682 318 42ms
500 526 474 47ms
1000 303 697 34ms看出来了吗?拐点正好落在 100,也就是服务端的处理上限。
在那之前,并发翻倍、耗时减半,线性收益,非常干净。
过了 100 之后,事情变得很诡异:耗时还在往下掉,但成功的请求越来越少。 到并发 1000 的时候,34 毫秒就"跑完"了——只成功了 303 条,剩下 697 条被扔了。
为什么会"越来越快"?因为 429 返回得飞快啊。服务器拒绝一个请求,比处理一个请求便宜得多。
这就是这篇文章最想说的那句话的实证:你不是让它变快了,你是让它更快地失败了。
所以压测的时候盯着耗时看,会得出完全相反的结论。要看的是"全部任务真正完成"需要多久,以及有多少条根本没完成。
那个拐点就是你要找的数字。具体数值每个系统都不一样,但曲线的形状是一样的。
想自己跑一遍?
这个实验很小,你复制粘贴就能复现(Node 18+ 即可,不用装任何依赖)。
先起一个「最多同时处理 100 个」的服务器,存成 server.mjs:
jsimport http from 'node:http';
const MAX = 100; // 服务器的处理上限
let inflight = 0;
http.createServer(async (req, res) => {
if (inflight >= MAX) { // 超了直接赶人
res.writeHead(429);
return res.end('Too Many Requests');
}
inflight++;
await new Promise(r => setTimeout(r, 5)); // 模拟 5ms 的 I/O
inflight--;
res.writeHead(200);
res.end('ok');
}).listen(3000, () => console.log('listening on 3000'));再写个压测脚本 bench.mjs:
jsconst URL = 'http://127.0.0.1:3000/';
const N = 1000;
const hit = async () => (await fetch(URL)).status;
// 把上面那个 mapWithLimit 复制过来
async function run(label, fn) {
const t0 = performance.now();
const codes = await fn();
const ms = performance.now() - t0;
const ok = codes.filter(c => c === 200).length;
console.log(`${label} 成功 ${ok}/${N} 耗时 ${ms.toFixed(0)}ms`);
}
await run('无限制 ', () => Promise.all(Array.from({ length: N }, hit)));
await run('限制 50 ', () => mapWithLimit(Array.from({ length: N }), hit, 50));
// 想看拐点,就把 50 换成 10 / 20 / 100 / 200 / 500 各跑一遍两个终端,一边 node server.mjs,一边 node bench.mjs。
跑完把你的数字发评论区,我挺想看看不同机器上的拐点差多少。
坑六:更阴的一个——它 reject 了,但钱已经扣了
📌 先看个场景
前面讲的都是「量」的问题。这个是「语义」的问题,踩的人少,但代价大得多。
jsawait Promise.all([
sendEmail(), // 发邮件
updateDatabase(), // 写库
chargeCustomer() // 扣款
]);看着挺合理吧?三件事一起干,有一件失败就整体失败,走 catch。
但问题来了。
如果发邮件失败了,Promise.all 会 reject。但这不代表另外两个会停下来。
MDN 写得很清楚:Promise.all 被拒绝之后,其余的 promise 照跑不误,只是结果不再返回给你了。
于是可能出现这种情况:
code扣款 → ✅ 成功了
写库 → ❌ 失败了
Promise.all → reject
前端收到 error → 提示用户「操作失败,请重试」
用户点了重试 → 又扣一次而第一笔钱,已经扣了。
这不是 Promise.all 的 bug。 这是并发副作用的必然结果。
打个比方
你同时派了三个快递员出门送件。
其中一个在半路摔了,你接到电话说"任务失败"。
但另外两个不会因为这通电话就凭空消失——他们已经把货送到了。
你在办公室里以为"这单没送成",客户那边已经签收了。
💡 那 allSettled 能解决吗?
能解决一半。
Promise.allSettled 会等所有的跑完,成功失败都告诉你,这确实有用:
jsconst results = await Promise.allSettled(items.map(processItem));
for (const r of results) {
if (r.status === "fulfilled") console.log("成功", r.value);
else console.error("失败", r.reason);
}但别搞混:它照样会一口气推出去几千个请求。
你有 10000 个请求,用它包一层,最后可能收获一份漂亮的、包含 10000 条失败记录的报告。服务器该崩还是崩,配额该烧还是烧。
它解决的是「我怎么知道每条的结果」,不是「我该放多少人进食堂」。
不同的问题,不同的工具。别拿它当限流用。
坑七:重试是往火上浇油
📌 先看个场景
请求偶尔会失败,你加了个重试,很合理:
jsfor (let i = 0; i < 5; i++) {
try {
return await fetchData();
} catch {
// 再试一次
}
}但问题来了。 重试本身没错,重试 + 无限并发才是灾难:
code1000 个请求
↓
服务端扛不住
↓
300 个失败
↓
300 个重试
↓
流量更大 ────┐
↓ │
更多失败 ────┘ ← 死循环,越滚越大你以为你在提高成功率。实际上你在给一个已经跪了的服务补刀。
打个比方
高速堵车的时候,所有人一起按喇叭。
喇叭按得越响,车流动得越快吗?
不会。只会让本来就烦躁的司机更烦躁,让本来就乱的现场更乱。
重试风暴就是代码版的一起按喇叭。
💡 生产系统里那一整套东西
并发上限、超时、指数退避、jitter、重试次数上限、幂等、熔断——
这些不是为了显得专业。就是为了掐断上面那个圈。
代码是会长一点。但系统会稳定得多。
特别提醒:AI 最擅长做的,恰好就是这个"优化"
这事在今年变得更有意思了,因为 AI 编程助手对"显而易见的优化"识别得特别准。
你给它这段:
jsfor (const user of users) {
await fetchUser(user);
}说一句「帮我优化下性能」。
它十有八九给你:
jsawait Promise.all(users.map(fetchUser));技术上完全正确。跑分也确实更快。
但它不知道:
- 你的接口限流是多少
- 你的连接池配了几个
- 这些操作有没有副作用
- 有没有重试逻辑
users在生产环境能有多长
结果就是——局部更快,全局更糟。
我现在 review AI 生成的代码,看到 Promise.all(xxx.map(...)) 会条件反射地停一下。
这大概是 AI 辅助编程给我的最大教训:
脱离系统上下文的优化,本质上只是一次猜测。
7 个坑背后,其实是同一件事
咱们回头看这 7 条——不知道系统上限、数组长度失控、连接池被无视、矫枉过正、拍脑袋定并发、失败语义没想清、重试放大——
表面五花八门,骨子里是同一个理念:
抽象藏起了成本。
Promise.all 是个特别好用的抽象,就像 map 帮你藏起了循环、fetch 帮你藏起了 DNS 和 TLS 握手、db.query() 帮你藏起了连接池。
抽象让我们写得快。这是好事。
但抽象也让我们看不见代价。系统小的时候,看不见没关系。系统大了,看不见就是事故。
这大概就是从「我会写 JavaScript」到「我知道我的 JavaScript 正在对系统做什么」的那道坎。
回到食堂那个比方——
性能优化的目标从来不是「让所有人同时冲进食堂」。
而是找到食堂真正能承受的人流,别让"快"变成"谁也吃不上饭"。
有时候,最快的代码不是把所有活儿一次推出去的代码。
是知道什么时候该排队的代码。
提交前的 10 秒自检清单
下次你(或者 AI)写下 Promise.all(items.map(...)),把这份清单过一遍:
- 数量:这个数组最大能到多少?由谁控制?
- 资源:每个操作会吃掉什么?连接?配额?内存?
- 上限:下游的安全并发量是多少?这个数字哪来的?
- 显式:并发上限有名字吗,还是藏在代码里?
- 失败:有一个挂了,其他的会继续跑吗?
- 副作用:继续跑的那些,会不会造成损失(扣款、发信、写库)?
- 重试:有重试吗?重试 + 并发会不会滚雪球?
- 降级:下游说"慢一点"(429/503)的时候,代码会退让吗?
- 观测:失败了多少条,你查得到吗?
- 压测:这个并发数是压出来的,还是抄来的?
前四条答不上来,就先别急着优化——先把工作负载搞明白。
💬 聊聊你们的并发上限
文章里我一直强调"那个数字要来自你保护的资源",但我知道现实里大部分人的写法是拍脑袋定个 10 或者 20,然后再也没动过。
我以前也是。
所以想问问:
- 你的项目里有没有显式的并发上限? 还是清一色的裸
Promise.all? - 如果有,那个数字是压测出来的,还是抄来的?
- 有没有被这事坑过? 打挂过谁家的接口,或者被谁家的接口打挂过?
评论区分享你的「翻车现场」,最惨的那几个我会整理进下一篇《前端异步防御性编程实战》,让更多人少踩一遍 👀
我是阿森,专注前端技术干货分享。觉得有用的话,点个「在看」让更多前端同行看到——也欢迎转发到你们的技术群,说不定就救了某个正准备点「一键同步」的运营同学。
如果这篇对你有帮助,欢迎关注公众号「前端达人」,每周更新实用前端干货。

