在 Node.js 中,异步编程的核心是 Promise,而处理多个异步任务的并发执行则是实际开发中极其常见的需求。比如:同时查询多个数据库表、并发调用多个第三方 API、批量上传文件等。如果简单地用 for 循环配合 await 串行执行,总耗时将是所有任务耗时之和,并发优势完全无法体现。
还好,JavaScript 和 Node.js 提供了一系列工具方法来优雅地管理并发,同时社区也沉淀出了成熟的手动限流模式。本节我们就来梳理这些工具和方法,并给出可直接参考的实现。
11.2.1 Promise.all:全部成功才成功
Promise.all(iterable) 是最常用的并发工具。它接受一个 Promise 数组,返回一个新的 Promise。这个新 Promise 会在所有输入的 Promise 都成功时返回结果数组,只要有一个失败,就会立即拒绝,并以第一个拒绝的原因作为失败原因。
基本用法:
const [user, orders, settings] = await Promise.all([
fetchUser(userId),
fetchOrders(userId),
fetchSettings(userId),
]);
在这个例子中,三个请求并发执行,总耗时约等于最慢的那个请求的时间,而非三者之和。
需要注意的坑:
- 快速失败:如果一个请求先失败,
Promise.all立即拒绝,但其他请求并不会被自动取消,它们仍会在后台完成(或失败)。这可能导致资源浪费或状态不一致,需要根据场景使用 AbortController 或手动处理。 - 结果顺序:返回的结果数组严格与输入 Promise 的顺序对应,即使某个 Promise 先完成,它在结果中的位置仍然不变。
- 传入非 Promise 值:如果数组中有普通值,会被自动包装成
Promise.resolve(value)。
// 即使 fetchSettings 先完成,结果顺序依然是 [user, orders, settings]
const results = await Promise.all([
fetchUser(userId), // 位置 0
fetchOrders(userId), // 位置 1
fetchSettings(userId),// 位置 2
]);
适用场景:多个独立 I/O 操作,互相之间没有依赖,且要求全部成功才能继续后续逻辑。
11.2.2 Promise.allSettled:等待全部完成,不论成功或失败
Promise.allSettled(iterable) 是在 ES2020 中引入的。它与 Promise.all 的最大不同在于:永远不会拒绝。它会等待所有 Promise 都有了结果(成功或失败)后,返回一个对象数组,每个对象描述了对应 Promise 的最终状态。
返回结果结构:
- 成功:
{ status: 'fulfilled', value: 结果值 } - 失败:
{ status: 'rejected', reason: 错误对象 }
const results = await Promise.allSettled([
fetchUser(userId),
fetchOrders(userId),
fetchSettings(userId),
]);
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
console.log(`第 ${index} 个请求成功:`, result.value);
} else {
console.error(`第 ${index} 个请求失败:`, result.reason);
}
});
典型场景:
- 批量操作,希望收集成功和失败信息,而不是中途整体失败。例如:给一批用户发送通知,记录哪些成功哪些失败。
- 面板信息聚合:即使某个服务挂了,其他面板数据依然要展示,但需要标记对应模块异常。
与 Promise.all 对比:all 适用于“一个失败全盘皆输”的严格一致性场景;allSettled 适合“尽力而为,收集结果”的柔性场景。
11.2.3 Promise.race:竞速,取第一个完成的结果
Promise.race(iterable) 返回的 Promise 会与输入数组中最先完成的那个 Promise 状态和结果保持一致。如果第一个完成的是成功,则 race 成功;如果是失败,则 race 失败。
const result = await Promise.race([
fetchFromServerA(),
fetchFromServerB(),
timeout(2000), // 超时控制
]);
实用技巧:超时控制
可以使用 Promise.race 给一个耗时操作添加超时:
function withTimeout(promise, ms) {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error('请求超时')), ms)
);
return Promise.race([promise, timeout]);
}
当请求在指定时间内未完成,timeout 会先 reject,从而中断等待并抛出超时错误。
注意事项:
- 与
all一样,race 不会取消其他 Promise。超时后,原请求仍可能继续执行,只是结果被忽略。 - race 在竞争多个相同资源(如从多个镜像下载)时有用,但必须确保拿到第一个成功的值即可,不关心其他。
11.2.4 Promise.any:取第一个成功的,全部失败才失败
Promise.any(iterable)(ES2021)会等待直到有一个 Promise 成功,然后返回该成功值。如果所有 Promise 都失败了,则返回一个 AggregateError 对象聚合所有错误。
try {
const firstResult = await Promise.any([
fetchFromCDN1(),
fetchFromCDN2(),
fetchFromCDN3(),
]);
console.log('从最快的 CDN 获得数据:', firstResult);
} catch (e) {
console.error('所有 CDN 都失败了:', e.errors);
}
这与 Promise.race 的区别在于:race 只看速度,不管成败;any 看速度,但更偏好成功。这对于容错场景非常有用——你有多个副本或镜像,只需一个成功即可,失败的就重试下一个。
注意:如果传入空数组或全部失败,any 会 reject。
11.2.5 并发限流:避免“打爆”下游
在真实系统中,你不能无限制地同时发起成百上千个请求,否则可能瞬间耗尽文件描述符、压垮下游服务或触发频率限制。所以需要控制并发上限,让一定数量的任务并行执行,同时保持整体速度。
手动实现一个并发队列
下面是一个典型的生产级“任务池”模式,利用一个小型队列和 runner 来控制并发数:
async function asyncPool(limit, tasks, taskFn) {
const results = [];
const executing = new Set();
for (const task of tasks) {
const p = Promise.resolve().then(() => taskFn(task));
results.push(p);
executing.add(p);
const clean = () => executing.delete(p);
p.then(clean, clean); // 无论成功失败都移除
if (executing.size >= limit) {
await Promise.race(executing); // 等待至少一个完成
}
}
return Promise.all(results);
}
使用示例:
const userIds = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const results = await asyncPool(3, userIds, (id) => fetchUser(id));
这个函数保证同时只有最多 3 个 fetchUser 请求在执行,一个完成就会从队列中取出下一个任务开始执行。最终返回的结果数组仍然按原始顺序排列。
第三方库方案
- p-limit:一个极简的并发限制库,只关注限制并发数。
const pLimit = require('p-limit');
const limit = pLimit(3);
const tasks = urls.map(url => limit(() => fetch(url)));
const results = await Promise.all(tasks);
- bottleneck:功能完善,支持速率限制、重试、集群共享等。
- async 库的
queue和cargo:适用于更复杂的流控场景。
限流的真实考量
- 配合超时使用:即使限流,也可能因为慢任务长时间占用槽位,导致后续任务饿死。可以通过
Promise.race给任务设置最大执行时间。 - 错误处理及重试:限流中某个任务失败,通常不应中断整个队列,应像
allSettled一样收集结果并决定是否重试。 - 断路器模式:当下游大量返回 5xx,限流还不够,可能需熔断保护。
11.2.6 并发的实用最佳实践
- 分清串行与并行:如果任务间有依赖,必须先 A 后 B,那就只能 await 串行。若相互独立,优先用
Promise.all并发。 - 使用
allSettled做批量处理:尤其在不希望单个失败影响整体的场景,比如数据同步、通知推送。 - 善用
race+any提升鲁棒性:race用于超时控制,any用于多副本择优。 - 生产环境一定要限流:即便认为下游可以承受,也要设置合理的并发上限,保护自身资源,避免打开过多连接。
- 结合 AbortController 进行请求取消:Node.js 15+ 内置 AbortController,可以在超时或任务取消时终止 HTTP 请求,避免资源泄露。
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch(url, { signal: controller.signal });
clearTimeout(timeout);
// ...
} catch (err) {
if (err.name === 'AbortError') {
console.log('请求已取消');
}
}
- 监控并发行为的性能:利用
async_hooks或性能探测工具,观察并发任务对事件循环的占用,避免挤压定时器和其他回调。
掌握这些异步并发工具,你就能在 Node.js 中灵活地控制任务执行节奏,从最简单的全并发到精细化的流量控制,都有对应的解决方案。下一节我们将进入定时器机制,深入理解 setTimeout、setInterval、setImmediate 等如何在事件循环中调度,并进一步巩固对这些异步工具的理解。