Node.js 错误处理与全局异常捕获:别用一个 try/catch 包住整条调用链 业务开发中,错误处理常常被推迟到最后。接口先跑通,catch 留个空壳,日志只写 console.log(err),上线后再补。然而“再补”的时机往往是线上 502 已经出现,你盯着堆栈里毫无上下文的报错发呆。 本
业务开发中,错误处理常常被推迟到最后。接口先跑通,catch 留个空壳,日志只写 console.log(err),上线后再补。然而“再补”的时机往往是线上 502 已经出现,你盯着堆栈里毫无上下文的报错发呆。

长期稳定更新的攒劲资源: >>>点此立即查看<<<
本文旨在彻底理清 Node.js 中几类错误:同步抛出的异常、Promise 的 rejection、异步回调中的错误——它们走的路径完全不同。接着讨论 Express 5 错误中间件的正确写法,最后分析 uncaughtException 和 unhandledRejection 这两个进程级兜底机制——注意,它们不是用来“续命”的,而是用来体面“退出”的。
环境版本:Node.js 24,Express 5.2.1(npm 上 v5 已是默认安装版本)。
先理清最容易混淆的部分。下面三段代码看似都“抛了一个错误”,但 Node.js 的处理机制完全不同。
// 1. 同步异常:在当前调用栈上,try/catch 能捕获
function sync() { throw new Error('sync boom') }
try { sync() } catch (err) { // 进得来 }
// 2. Promise rejection:try/catch 无法捕获,需要 .catch 或 await + try/catch
async function asyncFn() { throw new Error('async boom') } // 等价于 return Promise.reject(...)
try { asyncFn() // 未 await,这里什么也抓不到
} catch (err) { // 永远进不来 }
// 3. 异步回调:错误抛在另一个事件循环 tick 里,调用栈已不存在
try {
setTimeout(() => { throw new Error('callback boom') }, 0)
} catch (err) { // 也进不来 }
第二个例子是新手最容易踩的坑。asyncFn() 返回一个 Promise,如果不 await 或不加 .catch,这个 reject 就变成“无人认领”的状态。try/catch 基于调用栈工作,而 Promise 的 reject 是异步落地的;等到它 reject 时,try 块早已出栈。正确写法只有两种:await asyncFn() 放在 try/catch 内,或者 asyncFn().catch(handler)。混用或遗漏,就会埋下隐患。
第三个例子更隐蔽。setTimeout 的回调在后续的 tick 执行,此时同步的 try 早已结束。这个错误无人能接住,会直接触发进程级的 uncaughtException。Express 官方文档也专门强调了这一点:
“Errors that occur in callbacks that are invoked by the application, rather than the framework, must be passed to
next(err).”
因此,关键的分界线在于:调用栈还在时,try/catch 有效;调用栈消失后,必须靠回调中自行捕获,或者用 Promise 链挂住。下一节讲 Express 时会进一步印证。
Express 4 时代,异步路由的错误处理很麻烦。每个 async handler 都需要套一层 asyncHandler 包装,否则 Promise reject 会掉入黑洞,请求直接挂起到超时。社区为此创造了大量 express-async-errors、express-async-handler 等库。
Express 5 将此功能收归框架。官方文档原话:
“Express 5 automatically calls
next(err)for you when an async route handler rejects.”
也就是说,只要 handler 是 async 函数(返回 Promise),其中 throw 或 reject 时,Express 5 会自动调用 next(err),将错误送入错误中间件。因此 v5 下以下写法是安全的:
import express from 'express'
const app = express()
app.get('/user/:id', async (req, res) => {
const user = await getUserById(req.params.id)
if (!user) {
const err = new Error('user not found')
err.status = 404
throw err // 这里 throw,Express 5 自动 next(err)
}
res.json(user)
})
文档还补充了一个细节:如果 reject 时未传值(如 Promise.reject()),Express 会使用一个默认的 Error 对象来 next,避免下游因 undefined 而崩溃。
但是——这里有一个 v5 也无力解决的问题,正好对应上一节的第三种错误。Express 只能捕获“handler 返回的那个 Promise”链上的 reject。如果在 handler 中又开启一个异步回调,错误抛在回调里,它和 handler 返回的 Promise 无关,Express 无法感知:
app.get('/', (req, res, next) => {
setTimeout(() => {
try { throw new Error('BROKEN') }
catch (err) { next(err) } // 必须自己接住再 next,少这层 try/catch 错误就漏了
}, 100)
})
官方文档对这个片段的注释是:
“Errors that occur in callbacks that are invoked by the application, rather than the framework, must be passed to
next(err). Otherwise, Express will not catch them.”
所以不要被“Express 5 自动捕获”这句话迷惑。它只捕获 async 边界内的 reject,并非代码中所有异步错误。事件发射器的 error 事件、裸 setTimeout、fs 的回调式 API,这些仍需自行处理。
Express 通过函数签名的参数个数来识别错误中间件。普通中间件有三个参数 (req, res, next),错误中间件必须有四个 (err, req, res, next)——少一个,Express 会把它当作普通中间件,错误不会被传进来。
// 注意:即使 next 用不到,也必须写满四个参数,否则 Express 不认
app.use((err, req, res, next) => {
// 实际项目中应区分“业务错误”和“意外错误”
const status = err.status || err.statusCode || 500
if (status >= 500) {
// 5xx 是我们的责任,带上下文写入日志系统
req.log?.error({ err, url: req.originalUrl, body: req.body }, 'server error')
}
res.status(status).json({
error: status >= 500 'internal server error' : err.message,
})
})
几个工程实践要点:
app.use 注册,这样才能兜底。放在前面的话,后面路由抛的错无法到达它。message 直接返回给客户端。err.message 可能包含数据库表名、SQL 片段、文件路径。通常只在 4xx 时显示 message(那是给用户看的校验信息),5xx 统一返回固定文案,详细信息写日志。next(err) 后没有自定义错误中间件,Express 会使用内置兜底处理器。文档明确说明其行为:res.statusCode 取自 err.status 或 err.statusCode,不在 4xx/5xx 范围则设为 500;响应体在生产环境下为状态码对应的 HTML,非生产环境直接输出 err.stack。换句话说,生产环境不应依赖这个默认处理器,因为它会泄露堆栈(只要 NODE_ENV 不是 production)。务必自己编写一个。到这一层,讨论的不再是“某个请求出错”,而是“整个进程出错了”。两个关键事件:uncaughtException 和 unhandledRejection。
uncaughtException 指的是一个同步异常一路冒泡到事件循环仍未被捕获。Node.js 官方文档描述其默认行为:
“By default, Node.js handles such exceptions by printing the stack trace and exiting with code 1, overriding any previously set process.exitCode.”
即:打印堆栈,以退出码 1 结束进程。一旦注册了 process.on('uncaughtException', …),这个默认的退出行为就被覆盖了——进程不会自动退出。
这正是最危险的地方。许多人这样写:
// 反面教材:千万不要这样做
process.on('uncaughtException', (err) => {
console.error('caught:', err)
// 然后……什么也不做,进程继续运行
})
写完感觉良好:异常被我“兜住”了,进程不挂了,稳如磐石。
文档对此态度非常强硬:
“It is not safe to resume normal operation after 'uncaughtException'. The application is in an unknown state.”
还配有一个形象的比喻:
“Attempting to resume normally after an uncaught exception is like yanking the power cord from a computer and then expecting it to continue where it left off.”
一个未捕获的异常意味着程序进入了未定义状态。可能连接池中的连接处于事务一半、某个全局变量修改了一半、某个锁已获取未释放。捂住异常让它继续运行,十次有九次没事,第十次数据就可能损坏。而且损坏位置完全未知,排查成本是直接崩溃的几十倍。
之前一个 Node 服务,QPS 不高但很关键。某次发布后内存缓慢增长,大约两三个小时 OOM 一次,被 k8s 重启,重启后继续增长。监控未显示明显请求异常,日志也干干净净。
最终定位到,一段向第三方推送消息的代码使用了 await 但没有任何 catch,而第三方接口偶发超时 reject。这些 reject 变成了 unhandledRejection。当时进程中有段旧代码写了 process.on('unhandledRejection', () => {})——一个空 handler,将所有 reject 全部吞掉。错误消失了,但每个被吞掉的 rejection 关联的 Promise、闭包、请求上下文都无法回收,内存就这样一点点泄漏上去。
教训有两条:第一,空的兜底 handler 比没有 handler 更坏——它把本来该暴露的问题隐藏起来,变成线上的静默失败。第二,真正的修复不在兜底层,而应回到那段 await 加上 catch。兜底是最后一道防线,不是给糟糕代码擦屁股的。
uncaughtException 的正确用法,文档说得很清楚:做同步的资源清理,然后退出。
“If you have an HTTP server, you might want to close the server, so that new connections are rejected and existing ones can finish gracefully.”
process.on('uncaughtException', (err, origin) => {
// 用同步写法写日志,不要用异步——进程马上要结束,异步回调可能执行不到
fs.writeSync(process.stderr.fd, `uncaught: ${err.stack}\norigin: ${origin}`)
// 尽量同步地关闭重要资源,然后退出
process.exit(1)
})
注意是 fs.writeSync,而不是 console.log 再加异步上报。进程即将终止时,await 一个日志上报很可能执行不完。要可靠地落盘,必须使用同步 API。
退出之后由谁来拉起?文档建议使用外部监控:
“It should be restarted by a supervisor or running it in a process manager.”
容器环境下是 k8s 的 liveness probe 加重启策略,传统部署则用 pm2、systemd,或前面讨论过的 cluster 主进程。原则一致:进程崩溃就重启一个干净的,而不是在脏进程中硬撑。
unhandledRejection 是 Promise 版本的“无人认领”。文档定义为:
“Emitted whenever a Promise rejection is not handled.”
这里有一个历史包袱值得了解。早期(Node 14 及之前)未处理的 rejection 只打一个 warning,进程继续运行。从 Node 15 开始默认行为改变,--unhandled-rejections 的默认模式变为 throw,文档也明确:未处理的 rejection 如果没有注册 handler,会被当作 uncaught exception 抛出。
“In Node.js 15 and later, the default mode for unhandledRejection is 'throw', which causes the process to exit with a non-zero exit code.”
所以在 Node 24 中,漏掉一个 reject 默认会让进程崩溃。这其实是好事——它迫使开发者把错误处理写完整,而不是让 rejection 悄悄堆积。你需要做的不是注册一个空 handler 去压制它,而是:
process.on('unhandledRejection', (reason) => {
// 记录错误,然后将其升级为 uncaughtException 的处理路径,统一退出
fs.writeSync(process.stderr.fd, `unhandledRejection: ${reason}`)
throw reason instanceof Error reason : new Error(String(reason))
})
把 rejection 再次抛出,让它走 uncaughtException → 清理 → 退出 → 外部重启这条统一路径。一个进程级错误只用一个出口,千万别让两个 handler 各搞各的。
有时候你只想在崩溃前将错误发送到 Sentry 等平台,但不想接管退出逻辑(接管后就必须自己负责退出,容易出错)。Node 提供了一个专门的事件 uncaughtExceptionMonitor:
process.on('uncaughtExceptionMonitor', (err, origin) => {
MyMonitoringTool.logSync(err, origin)
})
它在 uncaughtException 之前触发,但文档强调它不会改变默认行为:
“This event does not change the default behavior of the process; the process will still exit with a non-zero exit code.”
也就是说,只挂载 monitor、不挂载 uncaughtException,进程该崩溃还是崩溃、该退出还是退出,你只是顺手记录了一笔。这是“我要日志,但退出交给 Node 默认逻辑”的最干净写法。
落地到实际项目中,分层大致如下:
await 就 await,该 catch 就 catch,异步回调中的错误自己 next(err)。这是第一道防线,也是最该用力的——绝大多数错误都应该在这一层被分类处理,根本到不了上层。uncaughtException / unhandledRejection 是最后一道,处理的是“这个进程已经不可信了”。它的职责不是续命,而是同步清理 + 退出,重启交给外部监控。这三层不要混淆。最常见的错误是试图用第三层来补第一层的漏:业务代码懒得写 catch,指望进程级 handler 兜着。结果就是前面提到的内存泄漏——错误确实被“兜”住了,但代价是状态脏了、问题被藏了、最后以更难查的形式爆发。
兜底的价值,恰恰在于它很少被触发。
参考来源
uncaughtException、unhandledRejection、uncaughtExceptionMonitor 事件行为,以及“It is not safe to resume normal operation after 'uncaughtException'”和拔电源比喻,采集于 2026-06-30next(value)、异步回调需手动 next(err)、错误中间件四参数签名、内置默认错误处理器行为,采集于 2026-06-30侠游戏发布此文仅为了传递信息,不代表侠游戏网站认同其观点或证实其描述