我图省事调了个异步函数发通知,既没 await 也没 catch,想着失败了无所谓,结果它一旦 reject 整个 Node 进程就因为未处理的 Promise 拒绝直接崩溃退出:一次 unhandled rejection 拖垮服务、误以为不关心结果就能不管错误的深度复盘
那次主服务"毫无征兆地整个挂掉",查了好久才发现祸根是一行我以为"无关紧要"的异步调用。我有个发通知的异步函数 sendNotification(user),在某个流程里要调它。我心想"通知嘛,发失败了也无所谓,不影响主流程",于是图省事写成了 fire-and-forget:sendNotification(user);——既没 await,也没 .catch(),调完就不管了。平时风平浪静。直到某次,通知服务的下游抽风,sendNotification 内部的 Promise reject 了,而我没有任何地方处理这个 reject。结果:整个 Node 进程因为 UnhandledPromiseRejection 直接崩溃退出了,把好端端的主服务也一起带挂了——一个"无所谓的通知",搞死了整个进程。复盘这件事,我才真正理解了 Promise 拒绝处理的机制,以及我犯的认知错误:问题出在我以为"我不关心这个异步操作的结果,就可以完全不管它"。一个 Promise 有两种结局:resolve(成功)和 reject(失败);reject 必须被某个地方"处理"(用 .catch(),或在 async/await 里用 try-catch 接住);如果一个 Promise reject 了、却没有任何 catch 处理它,就会产生一个"未处理的拒绝(unhandled rejection)";而 Node.js 对未处理拒绝的默认行为,从 v15 起变成了"直接让进程崩溃退出(以非零码)"(在那之前是警告)——因为未处理的错误意味着程序处于未知状态,继续运行更危险;我那行 fire-and-forget 的 sendNotification(user),把它返回的 Promise 完全丢弃了、没接住可能的 reject——平时它 resolve 没事,一旦 reject,这个"无人处理的拒绝"就掀翻了整个进程。根本原因是:Promise 的 reject 必须被处理(.catch 或 try-catch),未处理的拒绝会产生 unhandledRejection、在新版 Node 下直接让进程崩溃;我以为"不关心结果就能不管",fire-and-forget 时连失败也没接,导致一次 reject 拖垮整个服务。问题的根,是未处理的 Promise 拒绝——我 fire-and-forget 调用异步函数时既没 await 也没 catch,误以为不关心结果就能不管错误,而未处理的 reject 在新版 Node 下会直接崩溃进程。这篇就把这次"unhandled rejection"的坑,从头到尾复盘一遍。
故障现场:一个没人管的 reject,崩了整个进程
问题在于 fire-and-forget 的异步调用没有处理可能的 reject:
// 我图省事的写法(以为"通知失败无所谓", 就不管了):
function handleOrder(order) {
// ... 处理订单的主流程 ...
sendNotification(order.user); // ✗ 没await、没.catch() —— 返回的Promise被丢弃!
// 我的想法: "通知嘛, 失败了也不影响订单, 不用管"
return { ok: true };
}
async function sendNotification(user) {
const resp = await fetch(notifyUrl, {...}); // 某次下游抽风, 这里reject(网络错误/超时)
if (!resp.ok) throw new Error("通知发送失败"); // 或这里抛错 → Promise reject
}
/*
灾难是怎么发生的:
1. handleOrder 调 sendNotification(user), 不await不catch → 拿到一个Promise, 直接扔掉;
2. 平时 sendNotification 内部 resolve → 没事(那个被扔掉的Promise悄悄成功);
3. 某次下游抽风, sendNotification 内部 reject(fetch失败/throw);
4. 这个reject 没有任何 .catch() 或 try-catch 接住 → 成为"未处理的拒绝(unhandled rejection)";
5. Node.js (v15+) 对未处理拒绝的默认行为 = 打印错误 + 进程以非零码退出(崩溃)!
6. 于是: 一个"无所谓的通知失败" → 掀翻了整个Node进程 → 主服务一起挂掉。
为什么 Node 要这么"狠"(崩溃而非忽略):
- 一个未被处理的错误, 意味着"有异常情况发生了, 但没有任何代码知道、没人处理它";
- 程序此时处于"未知、可能不一致"的状态, 继续跑下去可能造成更隐蔽的破坏;
- 所以 Node 选择 fail-fast: 与其带病运行, 不如崩溃(让你立刻知道有未处理的错误)。
- (v15前是 UnhandledPromiseRejectionWarning 警告, 之后默认变为 crash, 越来越严格)
★ 我的认知错误: "我不关心这个异步操作成不成功, 就可以完全不管它(连失败也不管)。"
真相: 不关心"成功的结果", 不等于可以不管"失败的错误";
reject 是个必须被接住的东西——没人接, 它就成了unhandled rejection, 掀翻进程。
fire-and-forget 也必须 .catch(), 哪怕只是记个日志。
*/
看着监控里主进程"无故重启"的记录,顺着退出日志找到那行 UnhandledPromiseRejection,我又懊恼又后怕:"我以为'通知失败无所谓'就能甩手不管,谁知道那个没人接的 reject 能把整个进程掀翻……我不关心它成功,但我'不管它失败'这件事本身,才是真正的祸根。"这个坑最隐蔽的地方在于:它平时完全正常(Promise 都 resolve 时,fire-and-forget 毫无问题),给你"这样写没事"的错觉;只有在那个被你忽略的 reject 真的发生时才爆发,而且爆发形式极其严重(整个进程崩溃),还和那行不起眼的代码看起来八竿子打不着,极难定位。下面就来拆解,异步操作的错误到底该怎么管。
第一件事:搞懂 Promise 拒绝必须被处理
我顺着这次事故,把 Promise 的错误处理机制彻底理清了。
为什么未处理的 Promise 拒绝这么危险? 该怎么处理?
【核心: Promise的reject必须被.catch或try-catch处理; 未处理的拒绝=unhandledRejection, 新版Node默认
崩溃进程; "不关心成功结果"不等于"可以不管失败"; fire-and-forget也必须.catch, 哪怕只记日志】
1. Promise 的两种结局, reject 必须有人接:
- resolve(成功) / reject(失败); 处理成功用 .then 或 await 的返回值;
- 处理失败用 .catch(), 或在 async/await 里用 try-catch 包住 await;
- reject 若没有任何 catch 接住 → "未处理的拒绝(unhandled rejection)"。
2. 未处理拒绝的后果(随环境而异, 都不好):
- Node.js v15+: 默认让进程崩溃退出(fail-fast); v15前是警告;
- 浏览器: 触发 unhandledrejection 事件, 控制台报错(一般不崩页面但错误被忽略);
- 无论哪种: 错误被"无声地丢失"或"以严重方式爆发", 都是隐患。
3. 几种会漏掉 reject 的常见写法:
- fire-and-forget: doAsync(); // 不await不catch, 返回的Promise被丢弃(本次);
- 忘了await: 在async函数里调另一个async却没await, 错误不会进当前try-catch;
- .then没跟.catch: p.then(onOk); // 只处理成功, reject没人管;
- forEach里用async(同351篇): 回调返回的Promise被forEach丢弃, reject漏掉;
- await一组Promise但没整体catch。
4. 正确处理(每个异步操作的失败都要有归宿):
① await 的, 用 try-catch 包: try { await doAsync(); } catch(e) { handle(e); }
② fire-and-forget 的, 也要 .catch: doAsync().catch(e => log.error(e)); // 哪怕只记日志
③ .then 要跟 .catch: p.then(onOk).catch(onErr);
④ 并发的用 Promise.allSettled(看每个成败) 或 Promise.all(整体try-catch);
⑤ 设全局兜底: process.on('unhandledRejection', ...) 记录日志/告警(兜底, 不是借口)。
5. 一个关键区分: "不关心结果" ≠ "不处理错误"
- 你可以不关心"通知发成功了没"(不需要它的返回值);
- 但你必须"处理它万一失败的情况"(至少catch住记个日志, 别让它成为unhandled);
- 否则: 不是"无所谓", 而是"埋了颗会掀翻进程的雷"。
一句话: Promise的reject必须被.catch或try-catch接住; 未处理拒绝在新版Node会崩溃进程;
"不关心成功结果"不等于"可以不管失败的错误", fire-and-forget也必须.catch住(哪怕只记日志)。
这套认知,是整个坑的根。reject 必须有人接:处理失败用 .catch() 或 async/await 里的 try-catch;没人接就是 unhandled rejection。未处理拒绝的后果:Node v15+ 默认崩溃进程(fail-fast)、浏览器触发事件、错误被无声丢失——都是隐患。会漏掉 reject 的写法:fire-and-forget、忘了 await、.then 没跟 .catch、forEach 里用 async、并发没整体 catch。正确处理:await 的用 try-catch、fire-and-forget 也要 .catch、.then 跟 .catch、并发用 allSettled/整体 catch、设全局 unhandledRejection 兜底。关键区分:"不关心结果"≠"不处理错误"——可以不要返回值,但必须处理万一失败(至少 catch 记日志)。一句话:Promise 的 reject 必须被 .catch 或 try-catch 接住;未处理拒绝在新版 Node 会崩溃进程;"不关心成功结果"不等于"可以不管失败的错误",fire-and-forget 也必须 .catch 住(哪怕只记日志)。
第二件事:正解——每个异步操作的失败都要有归宿
知道了 reject 必须被处理,正解就清楚了:给每个异步操作的失败都安排一个"接住的地方"。
// 正解1: await 的, 用 try-catch 包住
async function handleOrder(order) {
try {
await sendNotification(order.user);
} catch (e) {
log.error("通知发送失败, 但不影响订单", e); // 处理失败(这里只记日志, 不中断主流程)
}
return { ok: true };
}
// 正解2: fire-and-forget 也必须 .catch(本次该做的)
function handleOrderV2(order) {
// 确实不想等通知, 但必须接住它可能的失败:
sendNotification(order.user).catch(e => log.error("通知失败", e)); // ✓ 不再是unhandled
return { ok: true };
}
// 正解3: .then 要跟 .catch
fetchData()
.then(data => render(data))
.catch(err => showError(err)); // 别只写.then不写.catch
// 正解4: 并发——用 allSettled 看每个成败, 或 all + 整体catch
const results = await Promise.allSettled([taskA(), taskB(), taskC()]);
results.forEach(r => {
if (r.status === "rejected") log.error("子任务失败", r.reason); // 每个失败都看到
});
// 或: try { await Promise.all([...]); } catch(e) {...} // 任一失败就进catch(但其他可能还在跑)
// 正解5: 全局兜底(最后一道防线, 不是不写catch的借口)
process.on("unhandledRejection", (reason, promise) => {
log.fatal("出现未处理的Promise拒绝!", reason); // 记录+告警, 别让它静默崩溃
// 视情况: 优雅关闭、上报监控; 但根治还是靠每处都正确catch
});
// 反例(别这样):
// sendNotification(user); // 裸调用, reject没人管 → 可能崩进程
// arr.forEach(async x => await f(x)); // forEach丢弃了async返回的Promise, reject漏掉(同351篇)
// 核心: 每个异步操作的失败都要有归宿——await配try-catch、fire-and-forget配.catch、
// .then配.catch、并发用allSettled; 全局unhandledRejection做兜底, 但不能替代逐处处理。
这套正解的关键,是让每一个异步操作的"失败"都有一个明确"接住它的地方",不留任何裸奔的 Promise。await 的用 try-catch:把可能 reject 的 await 包起来,失败时按需处理(本次我可以只记日志、不中断主流程)。fire-and-forget 也要 .catch:确实不想等结果,也必须 .catch() 接住失败,哪怕只记日志——这正是本次我该做的。.then 跟 .catch、并发用 allSettled:别只处理成功路径。全局 unhandledRejection 兜底:作为最后一道防线记录告警,但绝不是省略逐处 catch 的借口。
第三件事:其他几个"异步错误处理"的坑
顺着这次 unhandled rejection,我把异步错误处理相关的几个坑也一并理了:
几个异步错误处理的坑:
坑1: 忘了 await, 错误逃出 try-catch:
try { doAsync(); } catch(e) {...} // ✗ 没await! doAsync的reject不会进这个catch(它已经返回了)
正解: try { await doAsync(); } catch(e) {...}
坑2: forEach/map 里用 async(同351篇):
list.forEach(async x => { await save(x); }); // forEach丢弃返回的Promise, 错误漏掉、也不等待
正解: for...of + await 顺序; 或 await Promise.all(list.map(async x => save(x)))。
坑3: 在回调(非async上下文)里 throw:
setTimeout(() => { throw new Error(); }); // 这个错误不在任何Promise/try-catch里, 直接崩
正解: 回调内自己 try-catch; 或改用Promise化的API。
坑4: async 函数里同步抛错 vs 异步reject 混淆:
async函数无论同步throw还是await的reject, 都体现为返回的Promise reject → 都要catch。
坑5: 吞掉错误(同578篇裸except)——catch(e){} 空捕获, 错误被静默吃掉, 问题更难查;
正解: catch里至少记日志/上报, 别空着。
坑6: 只在最外层catch, 丢失上下文——一个大try包一切, 出错只知道"某处错了";
正解: 在合适的粒度catch, 保留"哪个操作失败"的上下文。
共同的根: 异步代码里, 错误不会"自动沿调用栈冒泡到你写的try-catch"(因为调用栈已经返回了);
它通过Promise的reject传播, 必须用.catch/await+try-catch显式接住; 每个异步操作的失败都要有归宿,
"发起了一个异步操作"就意味着"要为它的失败负责到底"。
这些坑看似不同,根却是同一个:异步代码里,错误不会自动沿调用栈冒泡到你的 try-catch(因为发起异步操作的那个调用栈早就返回了);它通过 Promise 的 reject 传播,必须用 .catch 或 await + try-catch 显式接住。认清这个根("异步错误要显式接、每个异步操作的失败都要有归宿"),才不会埋下"没人管的 reject"这种雷。
第四件事:异步错误的处理方式——两张对照表
我把几种异步调用方式、以及它们的错误该怎么接,整理成对照表,贴在了团队的 JS/Node 规范里:
| 调用方式 | reject 谁来接 | 漏接的后果 |
|---|---|---|
| await doAsync() | 外层 try-catch | 错误抛出,可能向上崩 |
| doAsync()(不 await) | 必须 .catch() | unhandled,Node 崩进程 |
| p.then(onOk) | 需跟 .catch(onErr) | reject 无人管 |
| Promise.all([...]) | 整体 try-catch | 任一失败抛出 |
| Promise.allSettled([...]) | 遍历看 rejected | —(不会抛,需自己查) |
| forEach(async ...) | (无法接,别这么写) | reject 全漏掉 |
| 需求 | 推荐写法 | 说明 |
|---|---|---|
| 要结果且要处理失败 | try { await } catch | 最常用 |
| 不要结果但不能崩 | doAsync().catch(log) | fire-and-forget 必加 catch |
| 并发且要每个成败 | Promise.allSettled | 不会因一个失败丢全部 |
| 并发且一败即停 | Promise.all + try-catch | 整体失败处理 |
| 顺序异步遍历 | for...of + await | 别用 forEach(async) |
| 全局最后防线 | process.on unhandledRejection | 记录告警,非省 catch 借口 |
这两张表的核心,第一张是每种异步调用方式都有"谁来接 reject"的明确答案——fire-and-forget 必须 .catch,forEach(async) 根本接不住别写;第二张是按需求选对写法,但无论哪种,失败都必须有归宿。记住一条:写下一个异步调用时,顺手问"这个 Promise 万一 reject,谁接?"——答不上来,就是个雷。
第五件事:关于异步错误的几组容易想当然的认知
这次事故也让我厘清了几组关于异步错误处理的、容易想当然的概念:
| 直觉以为 | 实际上 |
|---|---|
| 不关心结果就可以不管这个异步调用 | 不关心成功≠可以不管失败,reject 要接 |
| fire-and-forget 不加 catch 没事 | reject 时成 unhandled,新版 Node 崩进程 |
| 通知失败无所谓,不影响主流程 | 没接的 reject 能掀翻整个进程 |
| try-catch 能接住里面所有错误 | 没 await 的异步 reject 逃出 try-catch |
| forEach 里 await 能顺序等待 | forEach 丢弃 Promise,不等待也不接错误 |
| 设了全局 unhandledRejection 就够了 | 那是兜底,不能替代逐处正确处理 |
| 异步错误会冒泡到调用处 | 调用栈已返回,错误经 reject 传播需显式接 |
这张表里,我栽的是第一行和第二行:把"不关心通知成不成功"理解成了"可以完全不管这个调用",连它失败也不接,以为顶多是通知没发出去,没想到没人接的 reject 直接掀翻了进程。厘清这些,核心是一个意识:发起一个异步操作,就意味着要为它的所有结局(尤其是失败)负责;"不需要它的成功结果"和"不处理它的失败"是两码事——后者是埋雷。
第六件事:写异步调用时,我现在的自检习惯
现在每当我写下一个异步调用,我都会先按这张图问自己:
这张图的精髓,是"每个异步调用都问一句'它reject了谁接'、fire-and-forget 也要 catch、别忘 await"。核心一问:它 reject 了谁来接?要结果就try-catch、不要结果也.catch 记日志、链式跟 .catch、并发allSettled、别忘await。这套习惯,让我从"不关心结果就不管"变成了"每个异步操作的失败都有归宿"——核心始终是:Promise 的 reject 必须被 .catch 或 try-catch 接住;未处理拒绝在新版 Node 会崩溃进程;不关心成功结果不等于可以不管失败,fire-and-forget 也必须 .catch。
我立下的几条规矩
这场"没人管的 reject 崩了整个进程"的事故,换来了我写异步代码时,刻进骨子里的几条铁律:
- Promise 的 reject 必须被处理(.catch 或 async/await 的 try-catch),否则是 unhandled rejection。
- 未处理的拒绝在 Node v15+ 默认让进程崩溃退出,危害极大。
- "不关心成功结果" ≠ "可以不管失败的错误";fire-and-forget 也必须 .catch,哪怕只记日志。
- await 的异步调用要用 try-catch 包;没 await 的 reject 不会进 try-catch。
- .then 要跟 .catch;并发用 Promise.allSettled 看每个成败,或 all + 整体 try-catch。
- 别在 forEach 里用 async(丢弃 Promise、漏掉错误);用 for...of + await 或 Promise.all(map)。
- 设 process.on('unhandledRejection') 做全局兜底记录,但它不能替代逐处正确处理。
附:一个 fire-and-forget 的安全封装
借这次的坑,我写了个小工具,把"发起一个不等待结果的异步操作、但保证它的失败有归宿"封装起来,杜绝裸奔的 Promise。
// 安全的 fire-and-forget: 不等待结果, 但保证失败被记录, 不会成为 unhandled rejection
function fireAndForget(promiseFactory, label = "background task") {
Promise.resolve()
.then(promiseFactory) // 执行异步操作
.catch(err => {
// 失败有归宿: 记录日志 + 上报监控, 而非裸奔崩进程
log.error(`[${label}] 后台任务失败`, err);
metrics.increment("background_task_failed", { label });
});
}
// 用法: 想 fire-and-forget 时, 用它包一下, 失败自动被接住
function handleOrder(order) {
// ... 主流程 ...
fireAndForget(() => sendNotification(order.user), "发送订单通知"); // ✓ 失败只记日志, 不崩
return { ok: true };
}
// 配合全局兜底(双保险):
process.on("unhandledRejection", (reason) => {
log.fatal("仍有未处理的Promise拒绝, 说明有地方漏了catch!", reason);
// 记录下来, 作为"还有漏网之鱼"的告警信号
});
// 原则: 把"fire-and-forget 也要接住失败"这件容易忘的事, 封装成一个一眼就对的函数;
// 让"安全地不等待"成为顺手的默认, 而不是依赖每个人都记得手写 .catch。
这个封装很小,但它把"容易忘、忘了就埋雷"的事做成了"顺手就对":想 fire-and-forget 时,用 fireAndForget(...) 包一下,失败自动被记录、绝不会成为 unhandled rejection;再配合全局 unhandledRejection 兜底作为"还有漏网之鱼"的告警——双保险。让"安全地不等待"成为默认,比反复提醒"记得加 catch"可靠得多。
写在最后
回头看,这场由"一个没人处理的 Promise 拒绝"引发的整个进程崩溃,真正教给我的,远不止"fire-and-forget 也要 catch"这一个技巧。它让我对"'不在乎一件事的好结果' 和 '可以不管这件事的坏结果', 是两回事; 我常常因为'我不需要它成功'就以为'我可以彻底撒手不管', 却忘了——我发起的这件事, 它的'失败'本身也是一个需要有人负责的结果",有了一次刻骨的体会。我栽跟头,是因为我把"我不关心这个通知发没发成功"偷换成了"我可以对这个操作完全不闻不问"——我以为"不要它的成功"就等于"不必管它的一切";可我漏掉了一个关键: "失败"不会因为我不关心它就消失; 一个被发起的操作, 一旦失败, 这个失败总要有个去处——我不接住它, 它就会沿着系统的默认规则, 以我意想不到、也无法控制的方式爆发(在这里, 就是掀翻整个进程);我以为的"撒手不管, 顶多通知没发出去", 实际是"我放弃了对一个错误的处置权, 把它交给了最粗暴的默认处理(崩溃)"。这让我领悟到一个关于"责任、结果与善后"的深刻认知:当你发起一件事(一个异步操作、一个请求、一个承诺、一项委托)时,你就对它的所有可能结局(成功和失败)负有了责任——"我不需要它的好结果" 绝不等于 "我可以无视它的坏结果";"失败/错误/烂摊子" 是一种不会自己消失的东西: 你不主动接住、妥善处置, 它就会被默认规则接管, 而默认规则往往是最粗暴、代价最大的那种(崩溃、数据不一致、责任不清、影响扩散);真正的负责, 是"有头有尾"——发起了, 就要为它的每一种结局都安排好"谁来接、怎么处置", 而不是只享受顺利时的省事、却把出问题时的烂摊子留给系统/别人/未来去粗暴收场。这给了我一种做任何"发起型动作"时的自觉:每当我"发起一件事就想转身离开"时,要追问"这件事如果失败了, 由谁、以什么方式来收场?这个收场方式我能接受吗?"——哪怕我不在乎它成功, 也要为它的失败安排一个明确、可控的归宿(哪怕只是记一笔日志), 而不是让没人处理的错误去触发系统最粗暴的默认行为;"对自己发起的每件事的失败结局负责到底、为错误安排好归宿",是区分'真正可靠'与'只在顺境里省事'的关键。认清不关心成功不等于可以不管失败、错误不会自己消失没人接住就被粗暴默认接管、发起一件事要为它的所有结局负责——这,是我用一次 unhandled rejection 崩溃的事故,换来的、关于 JavaScript 异步、也关于如何对自己发起之事负责的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次写下一个 fire-and-forget 的异步调用时,顺手补上一个 .catch(e => log.error(e)),那我对着那次主进程"无故崩溃"排查到深夜的这段时间,就值了。
—— 别看了 · 2026