先讲个真实的故事。
去年双十一前夜,凌晨两点,我同事老王的服务突然雪崩。所有请求全部超时,监控大屏一片红色告警,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 错误处理的五条军规
说了这么多,给你一个可直接抄的清单:
- 调用 async 函数必须 await 或 .catch()。没有例外,没有「后续再处理」。
- try-catch 只捕获被 await 到的错误。没有被 await 的 rejection 它看不见。
- Promise 链末尾必须有 .catch()。链式调用的错误不会自己消失。
- 并发场景用 Promise.allSettled 而不是 Promise.all。除非你确定所有请求必须同时成功或同时失败。
- 批量异步操作必须控制并发数。不要一次性把服务打挂。
async/await 是 JavaScript 异步编程的革命,它让异步代码看起来像同步代码,降低了心智负担。但它也挖了很多新的坑,这些坑往往比传统的回调地狱更难发现,因为代码「看起来」太正常了。
下次写 async 代码的时候,问自己一个问题:这个 Promise 失败了,谁来接收它的 rejection?
如果你答不上来,那这篇文章就是为你写的。