深入剖析Promise.race、Promise.all和Promise.allSettled的区别
如果你写过一点异步代码,肯定遇到过这种场景:多个请求同时发出去,要么全等、要么等最快那个、要么不管成功失败都要拿结果。Promise.all、Promise.race、Promise.allSettled这三个就是干这事的,但很多人用的时候其实是“凭感觉选”——先随便挑一个,跑出问题再换。这不行。
我早期带项目的时候就吃过亏。一个并发拉取用户信息的模块,我用Promise.all去等五个接口,结果某个第三方接口偶发超时,整个页面的数据全部不渲染。后来换成Promise.allSettled,把失败的分支单独降级处理,问题才彻底解决。从那以后我就意识到,这三个 API 不是“选哪个顺手”的关系,而是“每个都有自己明确的适用边界”,选错了,轻则性能浪费,重则线上事故。这篇文章就把它们的底层语义、行为差异、常见误区和选型思路一次性讲透。
先说清楚适用人群:前端/Node.js 开发者,尤其是写过一段时间异步、但还没系统梳理过这几个 API 边界的人。读完你至少能回答三个问题:为什么Promise.all遇到一个 reject 就全盘放弃?Promise.race到底适合干什么、不适合干什么?Promise.allSettled和Promise.all的区别仅仅是“错误处理”吗?答案在下面。
1. 三个 API 的核心语义与设计思路
1.1Promise.all:全成功才成功,一个失败就全盘放弃
Promise.all接受一个可迭代对象(通常是数组),返回一个新的 Promise。这个新 Promise 的状态遵循以下规则:
- 所有输入的 Promise 都变为 fulfilled 时,它变为 fulfilled,结果是一个数组,顺序与输入顺序一致;
- 任何一个输入 Promise 变为 rejected,它立即变为 rejected,且 reject 的原因是第一个 reject 的那个 Promise 的原因。
这两条规则里最容易出问题的是第二条。看这段代码:
const p1 = Promise.resolve(1); const p2 = Promise.reject(new Error('boom')); const p3 = new Promise((resolve) => setTimeout(() => resolve(3), 3000)); Promise.all([p1, p2, p3]).then( (values) => console.log('resolved', values), (error) => console.log('rejected', error.message) ); // 立即输出:rejected boomp3 还在等待中,但Promise.all已经因为 p2 的失败而拒绝了。注意,p3 内部的定时器依然会继续执行,只是结果不再被Promise.all收集。更关键的是:如果 p3 内部也 reject,那个 reject 会成为“未处理的拒绝”(unhandled rejection),在 Node.js 里可能导致进程级警告,在浏览器里可能触发全局错误事件。“全成功才成功,一个失败就全盘放弃”这句话,很多人记住了前半句,忽略了后半句可能带来的连锁反应。
1.2Promise.race:第一个落定者决定一切
Promise.race的语义是“竞速”:所有输入 Promise 同时开始执行,哪个先落定(settled,无论是 fulfilled 还是 rejected),Promise.race就用那个结果作为自己的结果。
const slowButSure = new Promise((resolve) => setTimeout(() => resolve('slow'), 2000)); const quickFail = new Promise((_, reject) => setTimeout(() => reject(new Error('fast error')), 500)); Promise.race([slowButSure, quickFail]).then( (value) => console.log('winner:', value), (error) => console.log('failed:', error.message) ); // 输出:failed: fast error注意一个容易被忽略的点:race不在乎“谁赢”,只在乎“第一个落定”。落定可以是成功也可以是失败。所以它最适合的场景是“取最早的结果”,而不是“取最好的结果”。比如超时控制——给一个可能永远不 resolve 的 Promise 设置一个计时器,谁先到谁说了算。
1.3Promise.allSettled:等所有结果尘埃落定
Promise.allSettled提供的是“收集”语义:等待所有输入 Promise 全部落定,无论成功还是失败,最终返回一个数组,每个元素是描述每个 Promise 结果的对象。
const jobs = [ Promise.resolve(42), Promise.reject(new Error('task failed')), new Promise((resolve) => setTimeout(() => resolve('done'), 1000)) ]; const results = await Promise.allSettled(jobs); console.log(results); // [ // { status: 'fulfilled', value: 42 }, // { status: 'rejected', reason: Error: task failed }, // { status: 'fulfilled', value: 'done' } // ]落地结果对象的形状是标准化的:要么是{ status: 'fulfilled', value },要么是{ status: 'rejected', reason }。这个 API 不会因为某个 promise 失败而中断等待,也不会产生 unhandled rejection——因为每个 promise 的结果都被显式收集了。它的出现补齐了Promise.all在“一组独立任务,逐个统计成败”场景下的短板。
2. 深入Promise.all:成功路径、失败路径与边界情况
2.1 成功路径的细节:保持顺序,但不等串行
Promise.all的结果数组和输入数组的顺序是一一对应的,这个特性很重要。看例子:
const ids = ['u1', 'u2', 'u3']; const requests = ids.map((id) => mockFetchUser(id)); const users = await Promise.all(requests); // users[0] 一定是 u1 的结果,即使 u1 的请求是最后完成的为什么强调这一点?因为真实的网络请求返回顺序完全不可控,但业务代码往往需要“按顺序展示”或“按顺序处理”。Promise.all帮你把乱序的异步结果重新编排成与输入一致的有序数组,这省去了你自己做索引映射的麻烦。很多人没意识到这个“免费排序”的价值,还去手动做Promise.all(requests).then(items => items.sort(...)),纯属多此一举。
另一点是:Promise.all只是“等待”,并不负责“并发调度”。真正让请求同时发出的是你调用mockFetchUser(id)时函数立即执行、立即发起请求这件事,而不是Promise.all的魔法。如果你写成await一个个执行,那就是串行,和Promise.all无关。
2.2 失败路径的坑:短路行为、未处理拒绝、错误丢失
Promise.all最经典的坑就是“短路”。当输入数组中某个 promise 率先 reject,Promise.all直接拒绝,其他仍在进行中的 promise 的结果会被丢弃,但这个丢弃并不代表它们停止执行。如果这些 “被丢弃” 的 promise 内部分别有自己的 reject 兜底,那没问题;如果它们没有 reject 处理器,就会变成 unhandled rejection。
举个例子,这是我在某公司的日志系统里见过的真实写法:
try { await Promise.all([writeLogA(), writeLogB(), reportToMonitoring()]); } catch (error) { console.error('one task failed, abort everything', error); }表面上逻辑没问题:三个任务一个失败就整体失败。但writeLogA()内部的网络请求如果因超时而 reject,且没有被 catch,就会产生 unhandled rejection。更糟的是,reportToMonitoring()可能本来会成功,却因为writeLogA先失败而被 “连坐”——它的结果没人等了,但它内部尝试上报的副作用依然存在。
所以关于Promise.all,我的实操经验是三条:
- 确保每个输入 promise 都有自己的错误处理兜底,或者至少保证 reject 的情况已经被内层处理;
- 不要在
Promise.all里混入“可有可无”的任务,它会一粒老鼠屎坏一锅粥; - 如果你要降低“一个失败全部作废”的破坏性,考虑给每个 task 加
.catch()包裹,让失败被“降级”为 fulfilled。
2.3 何时用Promise.all:真正的强关联场景
Promise.all并不是一无是处。它的强关联表达力恰恰是它的价值。典型场景包括:
- 只有当所有数据都拿到才能渲染页面,缺一个都不能展示的聚合查询;
- 需要按顺序输出、但彼此独立可并行请求的列表数据;
- 多个前置条件必须同时满足才允许继续的流程(比如同时校验用户身份、设备权限、版本号)。
这些场景里,“全部成功”是业务前提,Promise.all的短路反而是一种保护,避免你在数据不完整时继续往下走,产生更难排查的问题。
3. 深入Promise.race:竞速语义、超时控制与常见误用
3.1 用Promise.race实现超时控制:真实可用的套路
超时控制是Promise.race最经典、也最安全的用途。给一个不可控的异步操作加一个超时惩罚,超时就按失败处理:
function withTimeout(promise, ms, message = 'request timed out') { const timeout = new Promise((_, reject) => { const timer = setTimeout(() => reject(new Error(message)), ms); // 这里存 timer 的引用,是为了避免定时器长时间悬挂 }); return Promise.race([promise, timeout]); }注意我上面没有把timer暴露出去清理,严格实现需要Promise.race包一层然后清理定时器:
function withTimeout(promise, ms, message = 'request timed out') { let timer; const timeout = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(message)), ms); }); return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); }finally里的clearTimeout很关键。如果不清理,每次超时竞争都会留下一个悬挂的定时器,高并发下定时器越积越多,内存和事件循环都会被拖累。这是我实际压测过的坑:某接口调用量达到每分钟几千次,漏清理定时器导致 Node 进程堆内存持续上涨。
3.2 常见误用:把race当“取最快且成功的结果”
这是新手最容易踩的坑。以为Promise.race会“自动忽略失败、取最快成功的那一个”,其实完全不是这样。看下面这个反例:
const promises = [queryA(), queryB(), queryC()]; const fastest = await Promise.race(promises); // 如果 queryB 最快但失败,fastest 就是失败结果,queryA 哪怕 50ms 后成功也不参与竞争如果你想实现“多个数据源,谁先成功用谁”,需要自己用.catch把失败转换成一种“不参与竞争”的状态。常见套路是给每个失败者一个特殊的标记,或者干脆让失败的 promise 永不落定,让赢家只可能是成功者。
function firstSuccessful(promises) { return Promise.race( promises.map((p) => p.then( (value) => value, // 成功则直接传递 () => new Promise(() => {}) // 失败则变成一个永不落定的 promise )) ); }但这个方法有个副作用:永不落定的 promise 会一直持有引用,积累少了无所谓,量大就可能成为内存泄漏源。所以实际生产环境我更推荐用计数的方式自己实现 first-success,而不是依赖race的死等技巧。
3.3 竞争与取消的区别:race不取消任何任务
必须再次强调:Promise.race只决定“结果”,并不取消其他输入 promise 的执行。很多人误以为 race 之后“慢的就不算了”,其实慢的那个 promise 仍然在后台执行,只是它的结果不再被观察。如果它执行的是有副作用的操作(写入数据库、发送请求、订阅事件),副作用依然会发生。这在某些场景下会带来不预期的影响:
const result = await Promise.race([sendToPrimary(), sendToBackup()]); // 如果 primary 先成功,backup 依然可能已经发出去了所以,如果业务上真的需要“取消”其他请求,Promise.race做不到,你需要借助 AbortController(浏览器/Node 都支持)或其他更重的机制。选型前先想清楚:你是要“选第一个结果”,还是“干掉其他操作”?前者用 race,后者得另想办法。
4. 深入Promise.allSettled:结果汇总、容错哲学与场景扩展
4.1 从“全军覆没”到“各看各的”:容错的思维方式转变
Promise.allSettled最大的价值,是改变了“错误必须中断整个流程”的思维方式。现实业务里,大部分并发任务并不需要“全有或全无”,而是要“把每一件事的结局都记录下,然后分类处理”。
一个非常典型的应用是批处理。比如你需要给 100 个用户发通知,某些用户的通知因为手机号格式错误而发送失败,但你不可能因为 3 个失败就中断剩下 97 个发送。用Promise.all,一个 reject 就终止;用Promise.allSettled,你拿到 100 个结果,筛选status === 'rejected'的,记录下来,入失败队列重试或人工处理。
const sendResults = await Promise.allSettled(users.map((user) => sendNotification(user))); const failed = sendResults.filter((r) => r.status === 'rejected'); const succeeded = sendResults.filter((r) => r.status === 'fulfilled'); console.log(`成功 ${succeeded.length} 条,失败 ${failed.length} 条`);这样写的可观测性非常好。你既没有丢失失败的细节(reason完整保留),也没有因为部分失败而阻塞主流程。我认为这是现代后端批处理任务最应该默认选用的模式,而不是一上来就Promise.all加try/catch。
4.2 性能陷阱:它不会并行执行,只是“等”得很全面
和Promise.all一样,Promise.allSettled接收的输入数组应该已经是“启动后”的 Promise。如果你用urls.map((url) => fetch(url)),fetch 在 map 过程中就已经并发执行了;如果你写urls.map((url) => await fetch(url)),那是串行。搞清楚这个,你就知道allSettled的性能特征:它等待所有任务落定,但并不会帮你并发,真正决定并发度的是任务启动的方式。
另外要提醒的是:如果某个任务永远不落定(比如一直 pending 的定时器),allSettled也会一直等。它解决的是“失败中断”问题,不是“永不结束”问题。对后者,你依然需要 combinerace的超时机制。
4.3 结果对象的统一结构:为什么status字段这么设计
allSettled返回的对象有两个固定形状,而不是一个形状带不同字段。这样设计的好处很明显:你可以用status做类型收窄(type narrowing),不需要自己再维护一个标志位。
for (const result of results) { if (result.status === 'fulfilled') { // result.value 类型已知 console.log('value:', result.value); } else { // result.reason 类型已知 console.error('reason:', result.reason); } }在 TypeScript 里,配合status字段,编译器能自动推断value和reason的类型,这让错误处理代码写起来非常优雅,而且不容易遗漏分支。
4.4 兼容性提示:环境支持情况
Promise.allSettled是 ES2020 新增的 API。在较老浏览器(比如 IE 11)和旧版 Node(低于 12.9)中不支持。如果项目需要兼容老旧运行环境,你得用 polyfill 或者手动实现一个等价版本。手动实现并不复杂,核心就是“把每个 promise 的结果都包装成败不定的 fulfilled”:
function allSettled(promises) { return Promise.all( promises.map((p) => Promise.resolve(p).then( (value) => ({ status: 'fulfilled', value }), (reason) => ({ status: 'rejected', reason }) ) ) ); }注意这里用Promise.all是安全的,因为内部的then已经把每个 promise 变成了“总是成功”的形态,外层Promise.all永远不会因为某个原始 promise 失败而中断。这个 polyfill 我曾在几个内部工具库中用过,简洁且可靠。
5. 三者横向对比:语义维度、适用场景与选型决策表
| 对比维度 | Promise.all | Promise.race | Promise.allSettled |
|---|---|---|---|
| 结果类型 | 数组,值与输入顺序一致 | 先落定的单个 Promise 的结果 | 对象数组,每项包含 status/value 或 reason |
| 失败处理 | 遇到第一个 reject 立即 reject | 遇到第一个落定(无论成败)即确定结果 | 等待全部落定,逐个记录成败 |
| 是否中断等待 | 立即中断 | 立即结束竞争,但不取消其他任务 | 不中断,始终等全部 |
| 典型场景 | 并行数据聚合,强依赖全成功 | 超时竞争、选择最快结果 | 批处理任务、需要逐个统计成败 |
| 是否对“失败”敏感 | 高度敏感 | 只关心“先到先得”,不区分成败 | 完全不敏感,只关心“都落定” |
| 结果的有序性 | 保持输入顺序 | 不保证任何顺序 | 保持输入顺序 |
| 是否容易产生 unhandled rejection | 容易,需给每个输入 promise 做内部兜底 | 容易,慢者若 reject 可能无人处理 | 不会,所有结果都有接收者 |
选型决策可以简化成一句话:如果一次任务必须全部成功才能继续,选Promise.all;如果只要最快的那个结果,选Promise.race;如果每个任务的结果都要记录,成功失败都要处理,选Promise.allSettled。
6. 常见误用、疑难场景与排查技巧实录
6.1 误用一:用Promise.all处理“部分失败也要继续”的任务
这是我在不少前端项目里都见过的模式。页面需要同时拉取用户信息、菜单列表、消息通知,如果消息通知接口偶发 500,整个页面就白屏。问题就出在把它写成了Promise.all([fetchUser(), fetchMenu(), fetchNotices()]),一个接口失败全部失败。正确做法是区分哪些数据是“必须的”、哪些是“增强的”,非必须的数据用allSettled拉取,成功再渲染。
6.2 误用二:把Promise.race用在“等待列表接口返回后执行列表渲染”
有些开发者想用Promise.race优化“竞速渲染”,以为只要有一个接口先回来就先渲染,其他接口回头补上。但race结果只能拿到一个,后续结果你根本拿不到。这就完全用错了工具。如果要做“渐进式渲染”,应该同时启动多个请求,各自.then渲染自己的区块,而不是返回一个整体结果。
6.3 误用三:忽略“未处理拒绝”的连锁反应
举一个实际排查过的线上问题。某 Node 服务启动时,会同时做初始化 A、B、C 三个任务,用Promise.all包裹。某个版本后 A 任务引入了新的依赖,偶尔会超时 reject。结果是:服务启动失败,这是预期的;但神奇的是,服务进程莫名多出几行UnhandledPromiseRejection的错误日志——原因就是 B 任务在 A reject 后仍然在执行,随后 B 也 reject,但没人处理它。
排查这类问题有个笨办法:在全局加process.on('unhandledRejection')打日志,再逐步加输入 promise 的内层 catch,看谁能“漂白”成功。我当时是在 B 任务的内部补了.catch()兜底,把 B 的失败记录到日志但不抛出来,同时保留了Promise.all对 A 的短路,问题解决。这个模式现在我会直接写进团队规范里:凡是进入Promise.all的每个 promise,都必须自带错误处理,没有例外。
6.4 疑难场景:如何让Promise.allSettled结果里保留“额外上下文”
很多人会遇到一个痛点:allSettled的结果只有 value/reason,没有关联的业务 ID。比如批量上传文件,结果想知道“哪个文件失败了”,只能靠数组下标去对应。这在代码里显得很笨拙。更优雅的做法是每个 promise 内部就把上下文嵌到结果中:
const uploadResults = await Promise.allSettled(files.map(async (file) => { try { const url = await upload(file.content); return { fileId: file.id, url }; } catch (error) { // 这里不抛错,直接把错误信息嵌入 fulfilled 结果 return { fileId: file.id, error: error.message, failed: true }; } })); const failedUploads = uploadResults.filter((r) => r.status === 'fulfilled' && r.value.failed);这个做法的精髓在于:你把业务错误转化为“业务上的失败值”,而不是触发 promise 的 rejected 状态。这样既保留了上下文,又绕开了allSettled结果只能通过 status 区分的问题。我在做数据同步工具时经常这么干,省去了大量“下标映射”的胶水代码。
6.5 疑难场景:多个接口的“超时+全量收集”组合需求
有时候业务既要“所有任务都执行并收集结果”,又要“每个任务都有超时上限”。这两个需求单独用allSettled和race都能解决,组合起来就需要一个封装。我常用的模式是:先用withTimeout给每个任务套上超时,再把它们全部交给allSettled。
function withTimeout(p, ms) { let timer; const timeout = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('timeout')), ms); }); return Promise.race([p, timeout]).finally(() => clearTimeout(timer)); } const results = await Promise.allSettled([ withTimeout(taskA, 3000), withTimeout(taskB, 5000) ]);这样既保证每个任务都不会永久挂起,又能拿到每个任务最终是成功还是超时失败的完整结果。这个模式在生产环境的批处理脚本里非常好用,推荐直接抄。
7. 实操案例:订单导出功能中的三者协同
讲一个我实际做过的模拟项目,把这个话题落地到完整场景。某管理后台需要一个“订单导出”功能:点击按钮后,从用户服务、订单服务、库存服务三个接口拉数据,聚合后生成 CSV 文件。需求如下:
- 三个服务的响应时间可能差异很大;
- 任何一个服务失败,导致数据不完整时,导出应立即停止,并提示用户失败;
- 三个服务超过 6 秒都算超时,按失败处理;
- 最终导出需要按用户 ID 排序,但接口返回顺序不确定。
这个需求同时用到了allSettled、race和all吗?其实是allSettled+race的组合,以及一个由all保证“全部成功才继续”的外层判断。
实现思路:
async function exportOrders(userId) { const guarded = [ withTimeout(fetchUser(userId), 6000), withTimeout(fetchOrders(userId), 6000), withTimeout(fetchInventory(userId), 6000) ]; const results = await Promise.allSettled(guarded); const failed = results.filter((r) => r.status === 'rejected'); if (failed.length > 0) { throw new Error('部分数据源不可用,已终止导出'); } const [user, orders, inventory] = results.map((r) => r.value); // 按 user.id 排序,生成 CSV // ... }这里的Promise.allSettled帮我保留了每个服务的具体失败原因,方便日志记录;withTimeout用race保证不会有服务无限挂起;外层的failed.length > 0判断实现了“数据不完整就终止导出”的业务约束。如果直接写Promise.all,那么在第一个 reject 发生时,其他两个服务的结果就丢了,日志里只有一条错误,排查起来会很困难。
8. 实用总结与选型自查清单
最后说一个我自己一直用的自查方法。接手的异步并发任务,先不问“用什么 API”,先问三个问题:
- 失败要不要影响整体流程?
- 我到底需要“一个结果”还是“全部结果”?
- 失败之后,其他任务的结果我还需要吗?
回答完这三个问题,选型基本自动浮现:
| 你的需求 | 推荐 API |
|---|---|
| 多个独立任务的结果都要用,且失败必须中断 | Promise.all |
| 多个候选只要最快成功/最快落定的那一个 | Promise.race |
| 多个任务的结果都要用,失败要继续,然后逐个处理 | Promise.allSettled |
| 既要全部结果,又要每个任务有超时上限 | allSettled+race封装 |
| 只要最快成功,失败的全忽略 | 需要自定义 race 包装,不能直接用race |
看下我上面的实操案例:一个看似复杂的导出功能,最终选型是“allSettled+race组合,外层判断是否全成功”。这比直接Promise.all多了错误细节、少了数据丢失,比纯allSettled多了超时保护,比纯race多了全量收集。这说明什么?选型从来不是三选一,而是根据需求把这三个原语像积木一样组装起来。
关于这三个 API 的经验,我最想强调的是:它们都不负责“并发”,只负责“等待和编排”。真正的并发是靠异步函数调用的时机决定的。搞清楚“时机”和“等待”的分界,绝大多数异步困惑都会迎刃而解。希望这篇文章能帮你少走点弯路。