一、这个事件到底在解决什么问题
Node.js的Promise拒绝如果在一轮事件循环内没有被.catch()或.then()的第二个参数捕获,process对象就会触发'unhandledRejection'事件。这个事件的存在意义不是“让程序继续跑”,而是给进程一个之后的可观测窗口:在默认行为(将拒绝作为未捕获异常抛出)发生之前,你有机会记录现场、标记指标、决定是否优雅退出。
官方手册对该事件的描述有一个容易被忽略的限定词:“withinaturnoftheeventloop”。也就是说,拒绝发生后,事件循环走完一轮仍然没有处理程序附着,事件才会触发。这意味着在同一个tick内稍晚附加的.catch()仍然可能拦截住拒绝,不会触发该事件。
二、语法与参数:两个参数传递的信息量
process.on('unhandledRejection', (reason, promise) => {
// reason:拒绝原因,通常是 Error 实例,也可能是任意值
// promise:被拒绝的那个 Promise 对象本身
});
reason的类型标注为<Error>|<any>。这意味着你有可能收到字符串、数字、甚至undefined。生产环境的日志记录逻辑必须容忍reason不是Error实例的情况,不能直接写reason.stack。
promise参数在排查时比reason更有用。当一个Promise在深层调用链中被拒绝,reason的堆栈可能指向Promise.reject()被调用的位置,但promise对象所携带的上下文信息(比如它是从哪个业务模块创建的)往往需要结合日志中的标识来定位。一个实用的做法是在全局处理器中给promise打上符号标记,但要注意这会引入内存滞留风险。
三、运行时行为差异:Node.js版本演变
unhandledRejection的默认行为在Node.js15前后发生过一次关键变化。在Node.js15之前,未处理的Promise拒绝只打印警告(UnhandledPromiseRejectionWarning),进程继续运行。从Node.js15开始,默认行为改为将未处理的拒绝作为未捕获异常抛出,进程以非零码退出。
这个变化的影响是实质性的:如果你在Node.js14上运行的代码依赖“只告警不退出”的行为,升级到16/18/20/22后,同样的代码会在遇到未处理拒绝时直接崩溃。--unhandled-rejections标志可以调整这一行为(strict、warn、none、throw),但在生产环境中依赖命令行标志来“放宽”行为是一种危险的权宜之计。正确的做法是在业务代码层面消灭未处理拒绝,而不是在运行时层面掩盖它。
四、与uncaughtException的职责对比
这两个事件经常被并列讨论,但它们的语义不同。
| 维度 | unhandledRejection |
uncaughtException |
|---|---|---|
| 触发源 | Promise拒绝未被处理 | 同步异常冒泡至事件循环 |
| 进程状态 | 通常仍可安全清理 | 应用处于未定义状态 |
| 官方建议 | 记录并视情况退出 | 仅做同步清理后退出 |
| 是否可恢复 | 视具体上下文 | 不建议恢复运行 |
关键区别在于:一个未处理的Promise拒绝不一定意味着进程状态已经损坏。被拒绝的操作可能只是一个独立的后台任务,与主请求处理流程没有共享状态。但uncaughtException的官方立场非常明确:应用处于未定义状态,恢复运行可能带来数据损坏风险。unhandledRejection处理器中可以做一些轻量的指标上报和日志落盘,而uncaughtException处理器中只应该做同步的文件描述符关闭和退出。
五、一个真实的踩坑反思
在一个订单回调服务中,有一段代码向第三方物流接口推送状态更新:
// 有问题但不易察觉的写法
async function pushLogisticsStatus(orderId) {
const res = await fetch(`https://api.logistics.example/status/${orderId}`);
// 没有检查 res.ok,也没有 catch fetch 可能抛出的网络错误
}
调用方直接pushLogisticsStatus(id)而没有await,也没有.catch()。第三方接口偶发超时时,fetch产生的Promise拒绝变成了unhandledRejection。当时进程里有一个遗留的空处理器:
process.on('unhandledRejection', () => {}); // 吞掉一切
结果不是“错误被处理了”,而是每次拒绝关联的Promise、闭包、请求上下文都无法被GC回收。内存以缓慢但稳定的速率上涨,两三个小时后OOM重启。排查耗时远超预期,因为日志里干干净净——空处理器把唯一的线索掐断了。
这个教训指向两条规则:空的全局处理器比没有处理器更危险,因为它把可观测的故障变成了不可观测的缓慢腐化;全局处理器是发现问题的窗口,修复应该发生在业务代码的await调用处。
六、单元测试与边界测试
测试unhandledRejection的触发时机,需要把测试放在子进程或隔离的VM上下文中,否则全局处理器会影响同一测试进程中的其他用例。
// test/unhandledRejection.test.js(使用 node:test)
const { test } = require('node:test');
const { spawnSync } = require('node:child_process');
test('未处理的拒绝触发事件且 reason 可被捕获', () => {
const script = `
process.on('unhandledRejection', (reason, promise) => {
console.log('REASON:', reason.message);
console.log('IS_PROMISE:', promise instanceof Promise);
});
Promise.reject(new Error('test-boundary'));
`;
const result = spawnSync(process.execPath, ['-e', script], { encoding: 'utf8' });
console.log(result.stdout);
console.log(result.stderr);
});
边界测试应该覆盖的场景:
| 场景 | 预期结果 |
|---|---|
Promise.reject('string') |
事件触发,reason为字符串 |
拒绝后同一tick内附加.catch() |
事件不触发 |
拒绝后setTimeout附加.catch() |
事件触发(一轮事件循环已过) |
async函数中throw且调用方未await/.catch() |
事件触发 |
util.callbackify转换的函数拒绝falsy值 |
触发ERR_FALSY_VALUE_REJECTION相关行为 |
七、性能影响与监控策略
全局unhandledRejection处理器本身的运行时开销可以忽略不计。真正需要关注的是未处理拒绝的数量作为一项SLO指标。在生产环境中,建议将每次触发记录为结构化日志,字段至少包括:timestamp、reason.type、reason.message、process.pid、以及可选的promise创建时的追踪ID。
监控阈值可以这样设定:如果某个服务在5分钟窗口内unhandledRejection触发次数超过个位数,说明代码中存在系统性的await遗漏或错误处理缺失,需要立即排查。偶发的单次触发可能是第三方库的边界情况,但持续性触发一定是业务代码的问题。
八、常见误区速查
误区一:监听unhandledRejection就等于处理了拒绝。监听只是观测,拒绝本身的状态不会因为监听了就变成“已处理”。.catch()才是处理。
误区二:在全局处理器里写process.exit(0)。退出码0表示正常退出,会让进程管理工具误判。未处理拒绝导致的退出应该用非零码。
误区三:用unhandledRejection处理器替代业务层的try/catch。全局处理器丢失了请求上下文和调用栈的精确性,只能告诉你“有个Promise挂了”,无法告诉你“哪个用户的哪个请求在哪个步骤挂了”。
误区四:在uncaughtException处理器里做异步日志上报。进程即将退出,异步操作大概率执行不完。unhandledRejection处理器如果决定退出,同理需要用同步写入。
九、现在框架中的惯用写法
Express5已经原生支持async路由处理器的错误自动转发到错误中间件,不再需要手动包裹try/catch或依赖express-async-errors补丁。Fastify的setErrorHandler同样覆盖了async处理器的拒绝路径。这意味着在框架层面,未处理拒绝的主要来源正在从“路由处理器”收缩到“后台任务”和“事件回调”。
在Koa中,中间件洋葱模型中的awaitnext()如果抛出拒绝,会被Koa的错误捕获层拦截。但如果你在中间件里启动了一个不await的异步任务,它的拒绝仍然会落到process的全局事件上。框架能覆盖的是请求生命周期内的Promise,覆盖不了“发了不管”的fire-and-forget调用。
十、练习与思考
-
写一个Node.js脚本,分别用
setImmediate和setTimeout(fn,0)在被拒绝的Promise后附加.catch(),观察unhandledRejection是否触发,解释差异。 -
设计一个全局处理器:记录
reason的结构化日志,同时检查reason是否带有code属性(用于识别数据库连接错误等可分类场景),但不退出进程。在什么条件下你认为应该改为退出? -
用
AsyncLocalStorage在全局处理器中尝试恢复被拒绝Promise的请求上下文,验证可行性并记录局限性。
延伸阅读与参考文献
Node.js 官方文档,事件'unhandledRejection',https://url.nodejs.cn/api/v25/process/unhandledrejection.html。本文关于触发时机、参数签名和默认行为变更的核心依据,版本覆盖至v25。
Node.js 官方文档,ERR_FALSY_VALUE_REJECTION,https://nodejs.org/download/release/v24.20.0/docs/api/errors.html。说明util.callbackify转换后的函数拒绝falsy值时触发的特定错误码。
阿里云开发者社区,《Node.js错误处理与全局异常捕获》,https://developer.aliyun.com/article/1744543。关于uncaughtException与unhandledRejection不应作为“续命工具”的工程观点,以及空处理器导致内存泄漏的案例参考。
Node.js 官方博客,WeeklyUpdate2015-02-27,https://nodejs.org/fr/blog/weekly/weekly-update.2015-02-27。记录了unhandledRejection和rejectionHandled事件最初引入的版本信息。