async/await 正在偷偷谋杀你的 Node.js 服务:我用三次线上事故换来的教训

2026-07-14 5 0

先讲个真实的故事。

去年双十一前夜,凌晨两点,我同事老王的服务突然雪崩。所有请求全部超时,监控大屏一片红色告警,Redis连接池耗尽,数据库连接泄露,整个集群差点报废。

你猜原因是什么?

一个看似毫不起眼的 async 函数,没有 await,没有 .catch()。一个被遗忘了的 Promise,悄悄地 rejection 了,没有任何地方捕获它。这个 rejection 在 Node.js 事件循环里飘啊飘,最后内存泄漏,拖垮了整个服务。

这不是故事,这是事故报告里白纸黑字写的。

今天我要把 async/await 错误处理里那些「看起来很正确但实际是坟头」的写法,全部挖出来,做一次尸检。


陷阱一:async 函数调用了,但没有 await

这是最容易踩、也是最隐蔽的坑。来看一段代码:

async function getUserInfo(userId) {
  const user = await db.query('SELECT * FROM users WHERE id = ?', userId);
  if (!user) {
    throw new Error('用户不存在');
  }
  return user;
}

// 调用方
getUserInfo(123); // 忘了 await!
console.log('查询完成'); // 这行会立即执行

我见过至少十个新人这样写。他们觉得加了 async 函数就自动「安全」了。

错。getUserInfo(123) 这行代码会立即返回一个 Promise 对象,然后 console.log 立刻执行。如果数据库查询失败了,Promise 会被 reject,但这个 rejection 没有任何人接收——直接被丢进了 Node.js 的「未处理 Rejection 垃圾堆」。

在开发环境里,你可能只会看到一个不起眼的警告。但在线上生产环境里,这种未被捕获的 rejection 会慢慢积累,最终导致内存泄漏或者进程异常退出。

正确做法:要么用 await,要么用 .catch():

// 方案一:await
await getUserInfo(123);

// 方案二:.catch
getUserInfo(123).catch(err => {
  console.error('查询用户失败:', err);
  // 错误处理逻辑
});

没有第三种方案。别想着「我先调用,后续再处理」,后续往往意味着永远不会处理。


陷阱二:try-catch 捕不到 async 的 rejection

这是另一个高发坑。来看看代码:

async function fetchOrder(orderId) {
  try {
    const order = await orderService.get(orderId);
    return order;
  } catch (err) {
    console.error('获取订单失败', err);
    return null;
  }
}

这段代码看起来很标准对吧?但问题在于,如果你在调用 fetchOrder 的时候没有 await,这个 try-catch 就完全失效了:

async function process() {
  try {
    fetchOrder(456); // 这里没有 await!
  } catch (e) {
    console.log('永远不会执行到这行');
  }
}

记住一条铁律:try-catch 只能捕获被 await 到的 throw,不能捕获没有 await 的 rejection。

更隐蔽的情况是,如果 orderService.get() 本身返回了一个直接 rejected 的 Promise,而不是通过 throw 抛出错误,try-catch 同样无能为力。


陷阱三:Promise 链式调用里的幽灵 rejection

在 async/await 普及之前,大家都在用 .then().then().catch() 这种链式写法。现在虽然很多人转 async/await 了,但混用的情况还是很多。

来看一个典型的事故现场:

function processPayment(orderId, amount) {
  return validateOrder(orderId)
    .then(() => checkStock(orderId))
    .then(() => debitAccount(amount))
    .then(() => shipOrder(orderId))
    .then(() => ({ success: true, orderId }))
    // 没有 .catch()!
}

如果 checkStock 失败了,这个 rejection 会沿着 Promise 链往下传播。但因为链的末尾没有 .catch(),rejection 就会变成一个「幽灵」,没人处理。

正确做法是在链的末尾加上 .catch(),或者改写成 async/await 形式:

async function processPayment(orderId, amount) {
  try {
    await validateOrder(orderId);
    await checkStock(orderId);
    await debitAccount(amount);
    await shipOrder(orderId);
    return { success: true, orderId };
  } catch (err) {
    console.error('支付流程异常', err);
    // 统一错误处理
    throw err;
  }
}

统一入口,统一出口,错误一目了然。


陷阱四:Promise.all 一个失败,全部白干

有时候我们需要并发执行多个异步任务,比如同时查用户信息、订单信息、推荐商品:

async function getDashboardData(userId) {
  const [user, orders, recommendations] = await Promise.all([
    getUser(userId),
    getOrders(userId),
    getRecommendations(userId)
  ]);
  return { user, orders, recommendations };
}

看起来很优雅,对吧?

但问题来了:如果这三个请求里任意一个失败了——比如推荐服务临时不可用——整个 Promise.all 会立即 reject,其他两个请求的结果会被直接丢弃。用户看到的是「加载失败」,而实际上用户信息和订单信息可能已经查好了,只是被一个错误的请求给「连坐」了。

正确做法是用 Promise.allSettled:

async function getDashboardData(userId) {
  const results = await Promise.allSettled([
    getUser(userId),
    getOrders(userId),
    getRecommendations(userId)
  ]);

  const user = results[0].status === 'fulfilled' ? results[0].value : null;
  const orders = results[1].status === 'fulfilled' ? results[1].value : [];
  const recommendations = results[2].status === 'fulfilled' ? results[2].value : [];

  return { user, orders, recommendations };
}

Promise.allSettled 会等待所有 Promise 完成,无论成功还是失败,然后返回一个描述数组。这样你就能精确控制每个失败情况的处理逻辑,而不是一刀切地全部报错。


陷阱五:循环里的 async——最贵的性能杀手

我见过有人这样写批量操作:

async function sendEmails(userIds) {
  for (const userId of userIds) {
    await sendEmail(userId); // 串行执行!
  }
}

如果 userIds 里有 10000 个用户,这个函数会执行 10000 次串行的邮件发送,每次都要等上一次完成。如果每个邮件发送耗时 200ms,光这个函数就要跑 2000 秒——三十多分钟。

有人说我改成 Promise.all:

async function sendEmails(userIds) {
  await Promise.all(userIds.map(sendEmail)); // 全部并发
}

这样倒是快了——但问题是你瞬间发起了一万个并发请求,很可能把邮件服务打挂,触发限流,然后一万个全部失败,重新发一轮,恶性循环。

正确做法是用信号量控制并发数,这个模式有个名字叫「Semaphore」:

async function sendEmails(userIds, concurrency = 10) {
  const chunks = [];
  for (let i = 0; i < userIds.length; i += concurrency) {
    chunks.push(userIds.slice(i, i + concurrency));
  }

  for (const chunk of chunks) {
    await Promise.all(chunk.map(sendEmail));
  }
}

一次只并发十个,一批完了再下一批。简单,但管用。


陷阱六:unhandledRejection —— 生产环境的定时炸弹

这是最危险的一种情况。

在 Node.js 里,如果你的代码产生了没有被任何 .catch() 或 try-catch 捕获的 Promise rejection,Node.js 会触发一个 unhandledRejection 事件。

在 Node.js 15 之前,这只是一个警告。但从 Node.js 15 开始,这种 unhandled rejection 会导致进程直接崩溃退出。

什么意思?你的服务跑着跑着,突然就原地去世了,没有错误日志,没有堆栈信息,只有一个冷冰冰的「unhandledRejection」留在控制台里。

我之前说的老王那次凌晨两点的故障,就是这个原因——一个没人管的 rejection 慢慢积累,最后把进程撑爆了。

线上环境一定要加全局兜底:

process.on('unhandledRejection', (reason, promise) => {
  console.error('【严重】未处理的 Promise Rejection:', reason);
  // 记录日志,上报到监控系统
  // 发送告警
  alert('检测到未处理的异步错误,请立即处理!');
});

但要注意,全局 handler 只是最后一道防线,不是你偷懒不写 .catch() 的理由。


总结:async/await 错误处理的五条军规

说了这么多,给你一个可直接抄的清单:

  1. 调用 async 函数必须 await 或 .catch()。没有例外,没有「后续再处理」。
  2. try-catch 只捕获被 await 到的错误。没有被 await 的 rejection 它看不见。
  3. Promise 链末尾必须有 .catch()。链式调用的错误不会自己消失。
  4. 并发场景用 Promise.allSettled 而不是 Promise.all。除非你确定所有请求必须同时成功或同时失败。
  5. 批量异步操作必须控制并发数。不要一次性把服务打挂。

async/await 是 JavaScript 异步编程的革命,它让异步代码看起来像同步代码,降低了心智负担。但它也挖了很多新的坑,这些坑往往比传统的回调地狱更难发现,因为代码「看起来」太正常了。

下次写 async 代码的时候,问自己一个问题:这个 Promise 失败了,谁来接收它的 rejection?

如果你答不上来,那这篇文章就是为你写的。

相关文章

写了三年SQL,我决定把这些反模式拉出去枪毙
HTTP keep-alive:那个被你忽视却能让服务快3倍的东西
你的HTTP客户端可能在悄悄”泄漏”连接:一次教科书不会告诉你的保活真相
让你的API错误信息不再像算命先生的签文——HTTP状态码的精准表达与错误处理的艺术
省心省力:让AI工具秒速上线的神奇代部署服务
别让N+1查询悄悄偷走你的性能——一次线上血泪史

发布评论