即便代码逻辑再严密,异步操作中的错误仍有可能从局部作用域“泄漏”出去,导致整个 Node.js 进程崩溃。例如,一个未被 try/catch 包住的同步抛出,或是一个没有 .catch() 处理的 Promise 拒绝,都会造成进程直接退出。在生产环境中,这种情况绝对不能接受。Node.js 提供了两个进程级事件用于兜底捕获这类“漏网”异常:uncaughtException 和 unhandledRejection。
uncaughtException:捕获同步代码和回调中的未处理异常
当 JavaScript 代码抛出错误,且该错误一直冒泡到事件循环的顶层而没有被任何 try/catch 捕获时,就会触发 process 对象上的 uncaughtException 事件。典型的场景包括:
- 在回调函数内部同步抛出的错误
JSON.parse解析非法字符串- 访问未定义变量的属性
用法示例:
process.on('uncaughtException', (err) => {
console.error('捕获到未处理的异常:', err.message);
// 记录日志、发送告警、尝试清理资源
// 注意:不推荐在此之后继续正常运行!
process.exit(1);
});
// 模拟一个未被捕获的异常
setTimeout(() => {
throw new Error('意外的错误');
}, 1000);
如果没有这个监听器,上面的代码将导致进程打印错误栈并退出(exit code 非 0)。加上监听后,我们可以在进程中执行最后的应急操作,如写入日志、通知监控系统,然后优雅退出。
关键注意点:
- 在
uncaughtException回调执行时,应用程序可能已经处于不稳定状态(部分变量被修改、资源未释放等)。Node.js 官方文档明确建议:捕获后应当记录错误并执行必要的清理,然后手动退出进程(process.exit),并由进程管理工具(如 PM2、Kubernetes)重启,而不是忽略错误继续运行。 - 不要将此事件当作“安全网”来掩饰有问题的代码,它只应该作为最后的防御手段。
unhandledRejection:捕获未处理的 Promise 拒绝
现代 Node.js 大量使用 Promise 和 async/await,异步错误通常以 Promise 拒绝的形式出现。如果一个 Promise 被拒绝(rejected),但没有在任何地方提供 .catch() 处理或 try/catch(配合 await),该拒绝就会变为“未处理的 Promise 拒绝”,触发 process 上的 unhandledRejection 事件。
process.on('unhandledRejection', (reason, promise) => {
console.error('未处理的 Promise 拒绝:', reason.message);
// 记录日志、发出告警
// 注意:从 Node.js 15 开始,未处理的拒绝默认会终止进程
});
// 模拟一个没有 catch 的 rejected promise
async function riskyOperation() {
throw new Error('数据库连接失败');
}
riskyOperation(); // 没有 await,也没有 .catch
在 Node.js 的早期版本(<15)中,未处理的 Promise 拒绝只会打印一个警告,进程不会退出。但从 Node.js 15+ 开始,行为发生了变化:未处理的拒绝默认会导致进程以非零 exit code 退出。这一调整为的是让开发者不再忽视 Promise 中的错误。
重要细节:
unhandledRejection事件回调接收两个参数:reason(拒绝原因,通常是 Error 对象)和promise(被拒绝的 Promise 对象)。- 在某些情况下,Promise 可能会在拒绝之后又“延迟”添加了
.catch,此时还会触发rejectionHandled事件。这两种事件可用于调试未处理拒绝发生的时间点。 - 与
uncaughtException类似,捕获到该事件后最好还是记录错误并考虑退出进程,因为一个 Promise 失败可能意味着业务逻辑已经中断,继续运行可能引发更多未知问题。
生产环境最佳实践
1. 兜底捕获 + 优雅退出
在应用入口文件中注册这两个事件,统一格式记录错误日志,并主动调用 process.exit(1)。让容器编排或 PM2 自动重新拉起进程,恢复服务。
// server.js 入口顶部
const logger = require('./logger');
process.on('uncaughtException', (err) => {
logger.fatal(`uncaughtException: ${err.message}\n${err.stack}`);
// 给一些时间写日志,然后退出
setTimeout(() => process.exit(1), 1000);
});
process.on('unhandledRejection', (reason) => {
logger.error(`unhandledRejection: ${reason?.message || reason}`);
// 可以视业务情况选择退出或继续,但退出更安全
// 注意从 Node 15 起默认会退出,显式调用更清晰
process.exit(1);
});
// 正常启动服务
app.listen(3000, () => {
console.log('Server started');
});
2. 不要在这里恢复应用
全局异常处理应执行“最后晚餐”而非“复活术”。例如,可以在捕获后尝试关闭服务器、释放数据库连接池、清空队列等清理动作,但绝不要尝试“吞掉”错误并继续处理新请求,因为 JavaScript 执行栈可能已经损坏。
3. 结合日志系统与告警
uncaughtException 和 unhandledRejection 通常意味着严重的代码缺陷或环境故障,需要通知开发者及时介入。应通过 winston、pino 等日志库记录完整错误栈,并接入 Sentry、企业微信告警等监控平台。
4. 优先在业务层面处理错误
全局捕获是最后一道防线,不能替代业务代码中对每一个异步操作、每一个 Promise 的错误处理。务必将错误处理下沉到路由、数据库查询、外部 API 调用等具体位置,确保只有极少数真正“意外”的错误才会抵达全局事件。
两者的区别与协同
| 事件 | 捕获对象 | 是否默认退出 | 恢复建议 |
|------|----------|--------------|----------|
| uncaughtException | 同步抛出的未捕获异常 | 是,如果不监听进程会退出 | 记录→清理→退出 |
| unhandledRejection | 未被处理的 Promise 拒绝 | Node 15+ 默认退出 | 记录→视情况清理→退出 |
两者并不冲突,可以同时注册。在一个成熟的 Node.js 应用中,这两个监听器是标准的“安全绳”。有了它们,即使存在未被覆盖的代码路径,也不会让进程默默崩溃而不留痕迹,从而显著提高系统的可观测性和可靠性。