我见过不少 Node.js 服务出过一种很有意思的“事故”:日志干干净净,进程却悄无声息地退出,像是被某种看不见的机制反复带走。查到最后,大多数时候都指向同一个根源——被忽略的 Promise 拒绝(unhandled promise rejection)。这篇内容想说的就是这件事:它怎么让你的进程“静默自杀”,为什么在容器和进程管理器里更难被察觉,以及从诊断到根治,我踩过哪些坑、攒下哪些可以直接抄走的经验。无论你是刚接触 async/await 的入门者,还是正在维护线上高并发服务的人,只要写过 Promise,都建议把这篇看完。
这玩意有个很欺骗人的特性:在你的视角里,代码并没有“立刻报错”,程序还在跑,页面还能打开,接口偶尔变慢,然后某一天凌晨,进程没了,重启策略把它拉起来,没过多久又没了。你想在日志里找异常,结果只看到最后一行普通业务日志。你会以为是机器问题、网络问题、依赖问题,唯独很难第一时间怀疑到某个没有被 catch 的 Promise 上。
1. 为什么说它是“静默自杀”:先搞懂 Node.js 对 Promise 拒绝的态度
1.1 一段“看着没问题”的代码
先给你看一段我在真实项目里见过很多次的写法:
async function fetchUserInfo(userId) { const res = await fetch(`/api/users/${userId}`); if (!res.ok) { throw new Error(`请求失败:${res.status}`); } return res.json(); } setTimeout(() => { fetchUserInfo(1024).then((user) => { console.log(user.name); }); }, 1000);这段代码的“问题”藏得很深:fetchUserInfo一旦 reject,.then里没有对应的.catch,在 setTimeout 的回调里也没有 try/catch。于是这个被拒绝的 Promise 成了一个没人管的“孤儿”。在很多人的认知里,“没报错就是没事”,但真相是 Node 很可能在某个时刻直接把整个进程终止。
更常见的版本是 Express 4 路由里写 async 中间件:
app.get('/api/order/:id', async (req, res) => { const order = await orderService.get(req.params.id); res.json(order); });Express 4 不会自动捕获 async 中间件抛出的异常。如果orderService.get内部 reject 了,这个请求的 Promise 就没人接收,连锁反应就是把一个 unhandledRejection 塞给了进程。
1.2 Node.js 默认策略的演变
很多新人可能不知道,Node.js 对未处理 Promise 拒绝的态度,在过去几年发生过一次从“宽容”到“狠心”的转变:
| Node 版本 | 默认行为 | 后果 |
|---|---|---|
| Node 12/14 及更早 | 打印UnhandledPromiseRejectionWarning,然后继续运行 | 错误被掩盖,服务“带伤运行”,数据可能已经错了 |
| Node 15 及更高 | 默认按throw模式处理,进程直接以非零状态退出 | 服务立刻中断,但如果你没有日志,依然不知道怎么死的 |
如果你还在用老版本,只会把问题藏起来:服务看起来活着,实际业务状态可能已经损坏,连接没释放,事务没回滚,缓存没更新。升级到 Node 15 之后,进程是“明明白白”地死了,但如果没有配套的日志和监控,你手里拿到的仍然是一具没有尸检报告的尸体。
1.3 “静默”到底体现在哪里
这东西和同步异常最大的不同是:同步异常会沿着调用栈一路向上抛,你能在堆栈里看到at xxx的行号;而 Promise 拒绝发生在异步边界,那一刻主流程可能早就走完了,事件循环把错误“存”到了一个角落里,等到微任务队列清空后才触发未处理检查。
于是它就有了三个非常难缠的特点:
- 错误发生时不会打断当前业务,你看到的是“某个功能没生效”,而不是“这里炸了”。
- 调用栈是断的,堆栈里往往只有底层网络库或数据库驱动的帧,很难直接看出是哪段业务代码惹的祸。
- 容器和进程管理器会掩盖现场,服务崩了之后被自动重启,内存里的诊断信息全没了,等你登录上去,看到的是一台“刚刚被抢救回来”的机器。
这就是我为什么把它叫“静默自杀”:不是没有死,而是死了之后现场被处理得太干净。
2. 日常代码里最常养出未处理拒绝的“高危现场”
2.1 async 函数被调用,结果没人接
第一类高危现场就是“调用了一个 async 函数,却没人接收它的结果”。最典型的场景是 Express 路由、定时任务、消息队列消费回调,以及 WebSocket 事件处理。
定时任务几乎是重灾区:
const cron = require('node-cron'); cron.schedule('0 3 * * *', async () => { await syncProducts(); // 如果 syncProducts 里抛错,没有人 catch });你以为定时任务会每天三点悄悄执行,实际上如果syncProducts中途失败,这个 rejected Promise 就挂在事件循环里无人认领。按 Node 15+ 的默认行为,进程会在某个时刻崩溃,然后 PM2 或容器把它拉起来。可问题是:明天的三点它还会不会接着跑?跑起来之后上次失败的半成品数据还在不在?这些问题远比“进程重启”更可怕。
消息队列的消费回调也是同样的逻辑:
queue.on('message', async (msg) => { await processMessage(msg); // 失败时无人处理 });这里还要小心一个概念区分:事件回调里如果直接抛同步异常,会顺着 EventEmitter 的异常机制走,甚至可能触发uncaughtException;但回调是 async 的,里面抛出的错误会变成 Promise rejection,EventEmitter 根本管不到它。很多刚入门的人分不清这两条路径,结果排查时总是往错误的方向找。
2.2 事件监听器里的“异步火种”
只要是一个 async 函数被当成事件监听器用,风险就存在。我再给一个典型例子:
queue.on('task', async (payload) => { await execTask(payload); // 万一失败,没有人 handle });很多同学会在这里写一个.catch(() => {})或者 try/catch 来兜底。但更隐蔽的是,你可能在一个“看起来跟异步没关系”的地方创建了 promise,然后把它默默丢掉了。比如:
client.once('connect', () => { initializeClient().then(() => { client.startHeartbeat(); }); });initializeClient()返回的 Promise 在.then之外没有 catch。一旦初始化失败,这个 rejection 就在事件回调的上下文里“泄漏”了。等到真正出事的时候,它和业务代码之间的距离已经隔了几层异步调用,定位成本大幅上升。
2.3 被“吞掉”的 Promise 链
还有一种情况,是代码里明明写了.catch,却依然出现 unhandled rejection。我见过很多次:
fetchData() .then(transform) .catch((err) => { console.log('忽略这个错误'); return fallbackData; }) .then(processFallback);问题出在最后那个.then(processFallback)。如果processFallback内部抛错,或者返回的 Promise 又 reject 了,这个链条的末端又是一个没有被接收的 rejected Promise。你以为已经用.catch兜住了,实际上.catch只兜住了它前面那一段的错误,链条尾部的错误照样变成孤儿。
更隐蔽的是,很多人习惯写这样的“空 catch”:
someAsyncTask().catch(() => {});这不是严格意义上的 unhandled rejection,因为错误确实被捕获了,但它把错误吞得干干净净。线上服务看起来稳定,数据已经错乱了,报警系统还一声不吭。我建议把这种写法当成和 unhandled rejection 一样严重的代码坏味道来处理。
2.4 游离资源和 Promise 构造函数的坑
底层资源调用也容易出问题。比如fs.promises.readFile、数据库驱动的连接池、child_process等,它们一旦失败会 reject,而你常常把它们放在定时器、流程控制或某个回调里,没来得及 catch。
还有一种更阴间的坑:手动new Promise的 executor 里,内部同步异常被 try/catch 捕获后,却忘了调用reject,导致 Promise 永远处于 pending。下面这个例子:
function readData() { return new Promise((resolve, reject) => { try { const data = doRead(); resolve(data); } catch (err) { // 忘了调用 reject(err),Promise 永远 pending console.error('读取出错', err); } }); }调用方await readData()会永远等下去,接口不返回,连接不释放。这种问题虽然不会直接触发 unhandled rejection,但在线上表现几乎一样恶劣,甚至更难查。排查的时候不要把眼睛只盯着“有没有 catch”,还要看 Promise 是否真正落到了终态。
3. 实战排查:从“进程消失”到“抓到真凶”
3.1 一个典型事故现场
之前处理过一个内部数据同步服务的故障,现象很典型:每天早上七点,主进程偶发退出,容器重启后恢复,过几天又复现。日志里干干净净,连一行 error 都没有。
查到最后发现,问题出在一个凌晨执行的同步任务。数据库在某个时刻因为连接数打满,拒绝新连接,任务里await db.query()返回的 Promise 被拒绝。因为整段逻辑外面没有包 try/catch,这个拒绝就顺着事件循环流到了 unhandled rejection 处理器,进程直接退出。重启后任务被调度器重新拉起或跳过,表面恢复了,但数据同步已经出现了缺口。
这种事故最恶心的地方在于:它看起来像偶发,实际上每次都指向同一个根源,只是触发条件苛刻。如果一开始就把 unhandled rejection 的日志抓到手,可能一小时就能定位;没有日志的话,就只能靠猜。
3.2 给进程装上“显微镜”
想要立刻看到这些“静默杀手”,第一步是注册一个全局的unhandledRejection处理器。下面是我在生产环境里用的一个候选模板:
if (process.env.NODE_ENV === 'production') { process.on('unhandledRejection', (reason, promise) => { const message = reason instanceof Error ? reason.message : String(reason); const stack = reason instanceof Error ? reason.stack : new Error(`非 Error 类型的 rejection: ${String(reason)}`).stack; const log = JSON.stringify({ time: new Date().toISOString(), type: 'unhandledRejection', message, stack, }); // 用同步写,确保进程退出前日志落盘 try { require('fs').writeSync(2, log + '\n'); } catch (_) { // 如果同步写也失败,至少尝试 console.error console.error(log); } // 恢复默认的“杀死进程”行为,防止带伤运行 process.exit(1); }); }这里有几个容易踩的细节,单独说一下:
reason不一定是 Error 对象,可能是字符串、数字、甚至 undefined。一定要做类型判断,否则你的日志里可能只有一行undefined。- 注册了
unhandledRejection监听器之后,Node 默认的“进程退出”行为就会被关闭。如果你只是打印日志而不退出,进程会带着已经损坏的内部状态继续跑,这往往比直接死掉更危险。 - 不要在这个事件里 throw,否则错误会被转到
uncaughtException,导致进程二次崩溃,日志链路被打断。 - 日志如果走异步写入,比如通过网络发送到日志服务,
process.exit(1)很可能赶在日志真正发出之前执行,造成“崩溃前日志丢失”。用fs.writeSync同步落盘最稳。
3.3 通过堆栈反推案发地点
开启了全局 handler 之后,下一次事故的日志会变成类似这样:
{"time":"2025-06-08T06:00:01.123Z","type":"unhandledRejection","message":"connect ECONNREFUSED 127.0.0.1:6379","stack":"Error: connect ECONNREFUSED 127.0.0.1:6379\n at TCPConnectWrap.afterConnect [as oncomplete] (net.js:1146:16)"}只看这个堆栈,你只能知道“Redis 连接被拒了”,却不知道是哪个业务方法发起的调用。这是因为异步 Promise 在 reject 的时候,调用栈往往只保存了底层库的帧,业务代码的上下文已经丢失。
为了把业务上下文也抓回来,我的建议是:在业务入口统一捕获,把错误和当时的请求 ID、任务名、业务参数关联起来。比如定时任务可以这样包一层:
function wrapTask(name, fn) { return async (...args) => { try { await fn(...args); } catch (err) { logger.error({ err, task: name }, '任务执行失败'); // 按业务决定是抛出、重试还是进死信队列 throw err; } }; } cron.schedule('0 3 * * *', wrapTask('syncProducts', syncProducts));这样至少能确认“是哪条业务链路先开始了这次连接”,再配合全局 handler 的日志,就能把两条线索拼起来。
3.4 定位辅助手段
如果现场已经被重启策略冲掉,可以临时开启 Node 的警告堆栈追踪:
node --trace-warnings server.js在低版本 Node 上,这会让UnhandledPromiseRejectionWarning带上更完整的调用栈。而在 Node 15+ 已经把 rejection 视为未捕获异常的环境里,你可以用--unhandled-rejections=strict让开发环境立刻暴露问题:
node --unhandled-rejections=strict server.js开发阶段我建议直接默认开启 strict 模式,别让任何未处理拒绝在本地测试时蒙混过关。否则等到生产环境才暴露,你的排查成本至少翻三倍。
4. 从源头治理:工程化方案与避坑清单
4.1 全局 handler 不是“免死金牌”
很多人把全局 handler 当成救命稻草,加完之后觉得“服务再也不会崩了”。这种理解非常危险。全局 handler 只是在进程层提供了一个观察点,它并不会让业务逻辑恢复到一个健康状态。一个 Promise 被拒绝,往往意味着某个关键操作没有完成,比如数据库写入失败、缓存更新失败、消息消费失败。如果你只是记录完日志然后让进程继续跑,这些失败操作留下的副作用会继续扩散。
我个人的策略是:记录并退出。在一个编排完善的环境里,进程崩溃后重启的成本是可以接受的;真正不能接受的是“带伤运行”。如果你确实因为服务无状态、重启会丢失长连接等原因不想退出,那至少要配合健康检查接口,把“最近是否发生过 unhandled rejection”作为一个可用性指标暴露出来,一旦发现就触发切流或重启。
4.2 路由层统一盾牌
如果你用的是 Express 4,路由这一层几乎是必改项。最直接的做法是写一个小包装器:
const asyncHandler = (fn) => (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next); }; app.post('/api/order', asyncHandler(async (req, res) => { const order = await createOrder(req.body); res.status(201).json(order); }));这只解决“路由层”的 unhandled rejection。像定时任务、队列消费、事件处理器这些地方,还是得靠业务代码自己做好异常处理。如果你已经准备用框架来解决,可以考虑 Koa 或 Fastify。Koa 对 async 中间件的错误会自动转发给ctx.onerror,Fastify 原生支持 async handler,错误会走到统一错误处理 hook,至少在路由层不会裸奔。
4.3 定时任务和消息队列的必试 catch 模式
定时任务和消息队列这两类场景,我建议强制要求一个“任务级 try/catch + 日志 + 明确重试策略”的模板:
cron.schedule('0 3 * * *', async () => { try { await syncProducts(); } catch (err) { logger.error({ err, task: 'syncProducts' }, '定时同步失败'); // 可选:发告警、写死信、上报指标 } });消息队列消费时要注意一个区别:消息从队列里被“ack”掉之后,即使处理失败,也不会重新投递。所以更严谨的做法是在消费回调里,根据业务决定是重试还是把消息标记为死信,而不是让 Promise 裸奔然后让进程去死。
这里有一个真实的教训:某团队用消息队列做订单状态同步,消费回调里漏了 catch。某次线上出现瞬时网络抖动,任务失败,Promise 被拒绝,Node 15 直接杀掉了进程。因为消息已经 ack 了,重启后的服务根本不知道这条消息的存在,订单状态就那样永远停留在“处理中”。这就是“静默自杀”最昂贵的副作用。
4.4 用 ESLint 和 TypeScript 把错误挡在提交前
代码评审很难保证每个人都记得给 async 调用加 catch,所以最好用工具把这个要求固化下来。
如果你在用 TypeScript,强烈建议开启@typescript-eslint的两个规则:
{ "plugins": ["@typescript-eslint"], "rules": { "@typescript-eslint/no-floating-promises": "error", "@typescript-eslint/no-misused-promises": "error" } }no-floating-promises会直接报出“有一个 Promise 没有被 await、也没有被 catch”;no-misused-promises会拦住把 Promise 当作 boolean 判断、把 async 函数当普通回调传递之类的隐式问题。
纯 JavaScript 项目想用工具查,可以把eslint-plugin-promise里的相关规则捡出来用,规则名在不同版本略有差异,但思路一致:凡是没有善终的 Promise,都应该在 lint 阶段被点名。我在团队里推行这个规则后,“裸 Promise”在代码评审里出现的频率直线下降。
4.5 Promise 组合的取舍
很多人会因为害怕 unhandled rejection,把Promise.all全部改成Promise.allSettled。这个做法我不太赞成,因为它会把“应该快速失败”的场景变成静默吞错。
Promise.all的语义是“全部成功才算成功”,适合关联性强的任务。比如创建订单时,库存扣减和订单创建必须同时成功,否则整体失败:
try { await Promise.all([deductStock(), createOrder(), recordPayment()]); } catch (err) { // 整体回滚或发起补偿 }Promise.allSettled的语义是“无论成败,等所有任务结束”,适合彼此独立、允许部分失败的任务:
const results = await Promise.allSettled([syncUsers(), syncOrders(), syncPayments()]); for (const r of results) { if (r.status === 'rejected') { logger.error({ reason: r.reason, task: 'syncOneOf' }, '部分同步失败'); } }关键是:选择什么 API 要和业务语义对齐。不要因为“怕崩溃”就无脑 allSettled,那样只是把一个错误换成了另一种形式的“静默不报”。
4.6 进程管理、监控与日志配套
治理 unhandled rejection,单靠代码还不够,工程配套也得跟上:
- 进程重启策略:PM2 或容器的
restart只是止血,它不能替代日志和告警。进程重启次数本身要作为监控指标。 - 日志落盘:崩溃前的日志必须同步落盘或由同机 agent 采集,不能依赖异步上报。
- 告警规则:把“进程退出码非零”“unhandledRejection 发生”作为 P1/P2 级告警,别等到用户反馈才知道。
- APM agent 的干扰问题:很多 APM agent 会在进程里自动注册 unhandledRejection 监听器,这会把 Node 默认的“崩溃行为”悄悄关掉。部署这类工具时,要确认它的处理策略,否则你以为服务在裸奔守卫,其实守护已经被接管了。
5. 常见问题速查与避坑经验
先整理一份速查表,方便你以后排查时按图索骥:
| 现象 | 常见原因 | 处理建议 |
|---|---|---|
| 进程没有任何 error 日志就退出 | Node 15+ 默认把未处理拒绝当成未捕获异常;日志没接到 stderr | 注册全局 handler + 同步落盘 + 进程重启告警 |
| 注册了 unhandledRejection 但服务拖着不挂 | handler 的注册会取消默认退出行为,你只打印了日志 | 检查是否忘了在记录后调用process.exit(1) |
| 老版本服务“一切正常”但业务数据错乱 | 错误被当成 warning 掩盖,服务带伤运行 | 升级 Node,或至少用--unhandled-rejections=strict |
.catch写了不少却仍然出现 unhandled rejection | catch 分支内部又抛出新错误,或链尾没被接收 | 检查每个 catch 回调体;链尾统一日志并终止 |
Promise.all其中一个 reject | 整个 Promise 变为 rejected,而调用处没有 catch | 在调用处统一 catch;按业务语义考虑allSettled |
| 全局 handler 里 throw | 会被转到 uncaughtException,导致崩溃行为异常 | handler 里不要 throw,用process.exitCode = 1或process.exit(1) |
然后分享几个我自己踩过的、比较有代表性的坑。
第一坑:只在生产环境挂了 handler。有一段时间,我把上面的全局 handler 只写在NODE_ENV === 'production'分支里,理由是开发环境想尽快看到原生崩溃。结果开发环境里一行 warning 也没有,错误全被感兴趣的依赖库接走了,直到提测阶段才暴雷。后来改成每个环境都挂,只是开发环境记录后不退出,测试和生产环境记录后退出。
第二坑:日志走异步管道,退出前没 flush。我最早用封装好的 logger 写日志,它内部是异步写的。进程崩溃重启之后,那条关键的 unhandledRejection 日志经常“没来得及”送到日志服务。换成fs.writeSync(2, ...)直接写 stderr 之后,问题才消失。这也提醒我:关键诊断日志,必须用同步方式落盘。
第三坑:把.then链尾的.catch当兜底,但兜底又抛了新错。有一次排查了很久,发现负责兜底的 catch 回调里调用了sentry.captureException(err),这个调用本身会返回一个 Promise,如果它 reject 了……是的,它又制造了一个新的 unhandled rejection。这属于“套娃式”问题,不把链尾看清楚,你永远不知道错误到底被谁吃掉了。
还有一个值得单独说的小技巧:排查这种问题的时候,先看最近的发布记录。很多 unhandled rejection 并不是发生在一开始,而是某个依赖更新、某个配置变更之后才被引出来的。把时间线和代码变更线对齐,往往能省下一大半定位时间。
最后分享一条我在实际项目里的体会:不要等到生产环境出事了才来补 unhandledRejection 的全局日志。找一个小版本发布窗口,先把全局 handler 加上,加上同步落盘,再定一个“出现 unhandledRejection 必须报 P1 告警”的规则,然后把高危的定时任务、队列消费、路由处理器逐个跑一遍异常路径。等这套机制稳定了,再考虑升级 Node 版本、开启 strict 模式。这个过程大概两三天,但它换来的不是“服务不死”,而是当最坏情况发生时,你能在五分钟内告诉团队:到底哪里被拒绝了,为什么被拒绝,下一步改哪一行代码。