在 Promise.all、Promise.race 与顺序等待之间如何选择
了解在何种情况下 Promise.all() 能提升 Node.js API 的性能,为何一旦出现拒绝就会立即失败,以及如何选择合适的异步模式的决策框架。
在 Node.js 中执行异步任务起初看起来似乎是个简单的优势:可以同时发起多个操作而无需依次等待,这样 API 的响应速度就会加快。但实际上,如果将 Promise.all() 当作默认习惯而非刻意选择,就可能会让原本的速度提升变成可靠性问题。了解何时可以安全地并行处理操作、某个操作出错时应如何处理,以及何时该使用其他工具,这些都与掌握语法同样重要。
1. 顺序处理方式
想象一个需要获取三类信息的 API:
- 用户信息
- 订单信息
- 支付信息
一种简单的初步实现方式可能如下所示:
const user = await getUser(userId);
const orders = await getOrders(userId);
const payments = await getPayments(userId);
return {
user,
orders,
payments
};
这段代码可以正常运行。但仔细观察其执行顺序:
getUser()
↓
getOrders()
↓
getPayments()
每一步都需要等待前一步完成。在用户数据获取完成之前,订单数据获取无法开始;在订单数据获取完成之前,支付数据获取也无法开始。如果每次调用的耗时大致相同:
getUser() = 200ms
getOrders() = 200ms
getPayments() = 200ms
那么总的请求时间加起来大约为:
200 + 200 + 200 = 600ms
如果这三次调用彼此之间毫无关联,那就是浪费时间。
2. 使用 Promise.all()
当各个操作之间不存在依赖关系时,可以同时启动它们:
const [user, orders, payments] = await Promise.all([
getUser(userId),
getOrders(userId),
getPayments(userId)
]);
return {
user,
orders,
payments
};
现在的执行流程更接近于这样:
getUser() ────────┐
│
getOrders() ────────┤
├──→ Promise.all()
getPayments() ────────┘
而不是承担以下成本:
A + B + C
你的总等待时间大约为:
max(A, B, C)
所以如果这三次调用每次仍需约200毫秒:
Sequential: ~600ms
Parallel: ~200ms
这确实带来了显著的收益。但有一个细节值得记住:
Promise.all()并不会加快任何单个操作的执行速度。
它只是让独立的操作能够同时运行,而非依次执行。
3. 真实世界的 API 示例
想象一个需要渲染内容的控制台界面:
Profile
Orders
Wishlist
Notifications
这些操作可能对应四次独立的数据库查询或服务调用。如果按顺序编写,代码可能会是这样的:
const profile = await getUserProfile(userId);
const orders = await getUserOrders(userId);const wishlist = await getUserWishlist(userId);const notifications = await getUserNotifications(userId);return {
profile,
orders,
wishlist,
notifications
};
现在将其与并行版本进行比较:
const [
profile,
orders,
wishlist,
notifications
] = await Promise.all([
getUserProfile(userId),
getUserOrders(userId),
getUserWishlist(userId),
getUserNotifications(userId)
]);
return {
profile,
orders,
wishlist,
notifications
}
假设这四次调用确实彼此之间没有依赖关系,这样的重写方式就能显著降低 API 的整体延迟。这是优化 Node.js 性能时最简单的改进方法之一。
但这还不是故事的结尾——在将这种模式应用到所有场景之前,有几点需要注意。
4. Promise.all() 会迅速失败
这就是常让人犯错的地方。看这个例子:
const results = await Promise.all([
getUser(),
getOrders(),
getPayments()
]);
如果 getPayments() 抛出拒绝异常会怎样?
无论其他调用结果如何,整个 Promise.all() 调用都会失败:
getUser() → SUCCESS
getOrders() → SUCCESS
getPayments() → ERROR
↓ Promise.all()
↓
REJECT
你不会得到部分成功的结果以及失败项的标记,而是会遇到异常,所有数据都无法传递出来:
try {
const [user, orders, payments] = await Promise.all([
getUser(userId),
getOrders(userId),
getPayments(userId)
]);
} catch (error) {
console.error(error);
}
当响应的有效性依赖于组内所有操作时,这种全有或全无的行为是合理的。但并非在所有情况下都适用。
5. 何时不宜使用 Promise.all()
假设某个控制面板需要显示:
- 个人资料
如果推荐服务暂时出现故障,是否意味着整个控制面板都无法加载?几乎不会。更好的处理方式应该是如下所示:
Profile → Available
Notifications → Available
Recommendations → Unavailable
这正是Promise.allSettled()设计的用途:
const results = await Promise.allSettled([
getUserProfile(userId),
getRecommendations(userId),
getNotifications(userId)
]);
它不会在遇到第一个拒绝就停止,而是会等待所有承诺状态确定后,再反馈所有结果:
[
{
status: "fulfilled",
value: profile
},
{
status: "rejected",
reason: error
},
{
status: "fulfilled",
value: notifications
}
]
之后,应用程序逻辑可以决定如何处理每一个单独的结果。其区别在于:
Promise.all()
One fails
↓
Everything rejects
与……相比:
Promise.allSettled()
One fails
↓
You still receive every result
这两种方法并无本质上的优劣之分——它们都是为解决不同问题而设计的,选择哪种方法取决于单个故障是否应被视为对整个系统的致命错误。
6. 不应强制将串行操作转为并行处理
还有另一个值得注意的陷阱。
假设你的工作流程需要执行以下操作:
- 创建用户
- 获取该用户的ID
- 为该用户创建订单
这些步骤是相互依赖的。
在用户记录创建之前,根本无法创建订单。
因此,像下面这样的写法是错误的:
await Promise.all([
createUser(),
createOrder()
]);
创建订单的步骤很可能需要用户的ID作为输入。
正确的做法是按顺序依次执行这些步骤:
const user = await createUser();
const order = await createOrder(user.id);
这里的指导原则很简单:
彼此之间没有关联的操作适合并行执行。
依赖彼此输出的结果的操作必须按顺序运行。
不要仅仅因为语言支持就盲目使用并行处理。
7. 无限制的并行处理可能会让系统不堪重负
还有一个容易被忽视的更微妙的问题。
看看这段代码:
await Promise.all(
users.map(user => sendEmail(user.email))
);
当有10个用户时,这不太可能引发任何问题。
但如果有10,000个用户,就会同时启动数千个操作。
更高的并发性并不一定意味着更好的性能。
你可能会遇到:
- 数据库连接限制
- API调用频率上限
- 内存压力
- 网络拥堵
与无限制的并行执行相比,你通常需要的是有限并发。
实现这一目标的一种方法是使用限制并发的库:
import pLimit from "p-limit";
const limit = pLimit(5);const results = await Promise.all(
users.map(user =>
limit(() => sendEmail(user.email))
)
);
通过这种设置,同时执行的操作不会超过五个。
从视觉上看,差异如下:
1000 tasks
↓Concurrency limit = 5 ↓5 tasks
5 tasks
5 tasks
5 tasks
...
这比一次性启动所有1,000个任务要慢。
但它能让你拥有更大的控制权。
在现实环境中,受控执行通常整体表现更好,因为它可以避免让操作所依赖的资源过载。
8. Promise.race()解决的是不同的问题
还有另一种方法常与Promise.all()混淆:
Promise.race()
Promise.race() 会在组中的第一个 Promise 解决(成功或失败)的瞬间立即决定自身的结果。
例如:
const result = await Promise.race([
serverA(),
serverB()
]);
直观来看:
Server A ───────────────→ 500ms
Server B ───────→ 200ms ↓
Promise.race()
↓
Result
这种模式有实际应用价值,比如让多个冗余请求相互竞争以确定结果,或实现超时功能。
但需注意:
Promise.race() 不会自动停止那些在竞争中落后的操作。
如果需要取消这些落后的操作,必须自行实现,通常可以使用 AbortController 等工具。
9. 别忘了重试机制
假设你在调用某个外部服务:
const result = await paymentService();
由于短暂的网络故障,该调用会失败。
如果将所有操作都包裹在大的 Promise.all() 中,并在失败时盲目重试,可能会引发新的问题。
这样做有可能导致重试风暴。
在重试之前,请考虑以下因素:
- 哪些操作确实适合重试?
- 允许多少次重试尝试?
- 每次尝试之间应等待多久?
- 该操作是否具有幂等性?
- 如果外部服务本身就已不堪重负怎么办?
处理临时性错误的常见方法是指数退避。
从概念上讲:
Attempt 1 → fail
↓
wait
↓
Attempt 2 → fail
↓
wait longer
↓
Attempt 3 → success
如果速度会损害可靠性,那它就没什么意义了。
10. 将相同思路应用于数据库查询
人们很容易认为,既然 JavaScript 支持并发的 Promise,那么数据库查询就应该始终并行执行。
但事实并非总是如此。
以这个例子为例:
await Promise.all([
database.users.findMany(),
database.orders.findMany(),
database.products.findMany(),
database.payments.findMany(),
database.notifications.findMany()
]);
这会同时启动大约五次数据库操作。
这种情况可能完全没问题。
但在流量高峰期,也可能会让数据库超出承载极限。
需要考虑的因素包括:
- 每个查询的复杂程度
- 数据库连接池的大小
- 正在运行的 API 实例数量
- 整体流量大小
- 是否存在合适的索引
- 每个查询的执行时间
- 数据库服务器的 CPU 和内存剩余容量
性能调优不能孤立进行。
您的 API 只是更大系统中的一个组成部分。
11. 判断何时使用 Promise.all() 的框架
在应用 Promise.all() 之前,先思考三个问题会很有帮助。
问题1:这些操作是否相互独立?
如果它们是独立的,那么并行执行可能很有意义。
如果不独立,则需保持它们依赖的顺序。
问题2:如果某个操作失败了应该怎么办?
如果单次失败就会使整个批次失效:
Promise.all()
很可能是合适的解决方案。
如果您希望收集所有成功执行的操作结果:
Promise.allSettled()
通常会更合适。
问题3:一次会启动多少个操作?
三个并发调用?
这通常是可以处理的。
一万个?
那就是完全不同的挑战了。
您可能需要引入:
- 并发限制
- 批量处理
- 队列机制
- 分页功能
- 速率限制
12. 选择方法的快速参考
| 情况 | 更佳方案 |
|---|---|
| 独立操作,所有操作都必须成功 | Promise.all() |
| 独立操作,部分成功即可 | Promise.allSettled() |
| 操作之间存在依赖关系 | 依次使用 await |
| 只需等待最先完成的操作 | Promise.race() |
| 许多需要限制并发数的任务 | p-limit 或分批处理 |
| 缓慢且非紧急的后台任务 | 队列或工作进程 |
| 容易发生短暂故障的外部调用 | 带有退避机制的重试逻辑 |
重要的不是记住这张表格。
而是理解每种选择背后的原理。
13. 核心要点
初次接触 Promise.all() 时,人们往往会认为:
“并行执行总是更快。”
但这种假设并不成立。
更准确的看法是:
只有当底层系统能够承受并发负载时,并行执行才能带来优势。
如果有三个独立的操作,每个都需要100毫秒:
Sequential → ~300ms
Parallel → ~100ms
其提升效果显而易见。
但如果将操作数量增加到10,000个,而数据库仅支持100个并发连接,无限制的并行处理反而会降低性能。
此时更值得思考的问题是:
"Can I run these in parallel?"Ask:"Should I run these in parallel?"
这种思维方式的转变意味着仅仅掌握 JavaScript 的语法并不足以让人真正理解后端系统在负载下的运行机制。
核心要点
Promise.all() 依然是 Node.js 中用于并行执行多个独立异步任务的最有力工具之一。
不过,它并不能保证一定提升速度。
在以下情况下可使用它:
- 这些操作之间不存在依赖关系
- 你确实需要所有的结果
- 你的系统能够承受额外的并发负载
当允许部分操作失败而不影响其他操作时,可使用 Promise.allSettled()。
当操作结果之间存在依赖关系时,应使用顺序的 await 调用。
在同时处理大量任务时,需考虑并发限制。
当任务无需在 HTTP 请求的生命周期内完成时,可以使用队列来处理。
目标不仅仅是让代码的执行时间缩短几毫秒。
目标是提升系统速度,同时避免其变得脆弱。
相关阅读
- 适用于生产环境的九种可靠异步 JavaScript Promise 模式 — 了解实用的 Promise 模式,包括并行请求、超时处理、重试机制、并发限制以及取消功能,从而构建具备弹性的生产级异步 JavaScript 程序。