首页 / 文章 / 在 Promise.all、Promise.race 与顺序等待之间如何选择

在 Promise.all、Promise.race 与顺序等待之间如何选择

了解在何种情况下 Promise.all() 能提升 Node.js API 的性能,为何一旦出现拒绝就会立即失败,以及如何选择合适的异步模式的决策框架。

2267 词

在 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. 不应强制将串行操作转为并行处理

    还有另一个值得注意的陷阱。

    假设你的工作流程需要执行以下操作:

    1. 创建用户
    2. 获取该用户的ID
    3. 为该用户创建订单

    这些步骤是相互依赖的。

    在用户记录创建之前,根本无法创建订单。

    因此,像下面这样的写法是错误的:

    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 请求的生命周期内完成时,可以使用队列来处理。

    目标不仅仅是让代码的执行时间缩短几毫秒。

    目标是提升系统速度,同时避免其变得脆弱。

    相关阅读

  • 修复 Node.js 生产环境代码中的 Async/Await 错误处理漏洞 — 了解 JavaScript 和 Node.js 中五种常见的 Async/await 错误处理错误,这些错误会导致隐性故障和竞态条件,并提供具体的修复方法。
  • Node.js 并发机制详解:libuv、事件循环与线程池 — 了解 Node.js 如何利用 libuv 的操作系统原语及工作线程池来处理异步 I/O,以及常见的线程池问题与优化技巧。
  • 为 Node.js 工作负载选择 EC2、ECS 还是 EKS —— 对比 EC2、配备 Fargate 的 ECS 以及 EKS 在运行 Node.js 应用方面的差异,帮助您根据团队的规模和技能水平选择合适的 AWS 计算服务。
  • 利用 AsyncLocalStorage 关联异步调用中的日志 —— 了解 Node.js 的 AsyncLocalStorage 如何在无需手动在每个函数中传递上下文的情况下,跨 await 语句跟踪如 requestId 这样的请求级上下文。