← response.writableFinished 属性 process.arch 属性 →

Node.js unhandledRejection事件详解:Promise拒绝未处理的捕获、监控与工程实践

著
原创 2026-10-19 Node.js 已有人查阅

一、这个事件到底在解决什么问题

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调用。

十、练习与思考

  1. 写一个Node.js脚本,分别用setImmediate和setTimeout(fn,0)在被拒绝的Promise后附加.catch(),观察unhandledRejection是否触发,解释差异。

  2. 设计一个全局处理器:记录reason的结构化日志,同时检查reason是否带有code属性(用于识别数据库连接错误等可分类场景),但不退出进程。在什么条件下你认为应该改为退出?

  3. 用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事件最初引入的版本信息。

← response.writableFinished 属性 process.arch 属性 →
分享笔记 (共有 篇笔记)
验证码:
微信公众号