Node.js 错误处理与全局异常捕获
Node.js 错误处理与全局异常捕获,别用一个 try/catch 包住整条调用链
写业务的时候,错误处理常常是最后才补的那块。接口先跑通,catch 留个空壳,日志打个 console.log(err),上线再说。问题是「再说」那天往往是线上 502 已经报出来、你盯着一堆没有上下文的堆栈发呆的时候。

这篇想把 Node 里几类错误彻底分清楚:同步抛的异常、Promise 的 rejection、异步回调里的错误——它们走的根本不是同一条路。然后聊聊 Express 5 的错误中间件怎么写对,最后再扒一扒 uncaughtException 和 unhandledRejection 这两个进程级兜底——注意,它们不是用来「续命」的,是用来体面地「死」的。
环境口径:Node.js 24,Express 5.2.1(npm 上 v5 已经是默认安装版本)。
三种错误,三条路
先把最容易混的地方摆出来。下面三段代码,乍一看都是「抛了个错」,但 Node 处理它们的机制完全不同。
// 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 5 帮你省掉的,和没帮你省的
Express 4 时代,异步路由的错误处理是出了名的恶心。你得给每个 async handler 套一层 asyncHandler 包装,否则 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 官方文档对它的默认行为描述:
“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.”
一个未捕获异常意味着程序进入了未定义状态。可能是某个连接池里的连接处于一半事务、某个全局变量被改了一半、某个锁拿了没放。你捂住异常让它接着跑,十次有九次没事,第十次数据就脏了。而且脏在哪你完全不知道,排查成本是崩溃的几十倍。
踩过的坑:空 handler 比没有 handler 更糟
之前一个 Node 服务,QPS 不高但很关键。某次发版后内存缓慢上涨,大概两三个小时 OOM 一次,被 k8s 重启,重启后又开始涨。监控上看不出明显的请求异常,日志里干干净净。
最后定位到,是一段给第三方推送消息的代码,用了 await 但整个调用没有任何 catch,而那个第三方接口偶发超时 reject。这些 reject 成了 unhandledRejection。当时进程里有个老代码写了 process.on('unhandledRejection', () => {})——一个空 handler,把所有 reject 全吞了。错误没了,但每个被吞掉的 rejection 关联的 Promise、闭包、请求上下文都没法回收,内存就这么一点点漏上去。
教训有两条:第一,空的兜底 handler 比没有 handler 还坏——它把本该暴露的问题藏起来,变成线上静默失败。第二,真正的修复不在兜底层,而是回到那段 await 加上 catch。兜底是最后一道防线,不是用来给烂代码擦屁股的。
那应该怎么用
uncaughtException 的正确用法,文档说得明白:做同步的资源清理,然后退出。
“If you ha ve 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 和它的演变
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 重新 throw,让它走 uncaughtException → 清理 → 退出 → 外部重启这条统一的路。一个进程级错误一个出口,别让两个 handler 各搞各的。
想监控但不想改变退出行为
有时候你只是想在崩之前把错误送到 Sentry 之类的地方,但不想接管退出逻辑(接管了就得自己负责退出,容易写错)。Node 给了个专门的事件 uncaughtExceptionMonitor:
process.on('uncaughtExceptionMonitor', (err, origin) => {
MyMonitoringTool.logSync(err, origin)
})
它在 uncaughtException 之前触发,但文档强调它不改变默认行为:
“This event does not change the default beha vior of the process; the process will still exit with a non-zero exit code.”
也就是说,只挂 monitor、不挂 uncaughtException,进程该崩还是崩、该退出还是退出,你只是顺手记了一笔。这是「我要日志,但退出这事交给 Node 默认逻辑」的最干净写法。
把这几层串起来
落到实际项目,分层大致是这样:
- 业务代码里,async 函数该
await就await,该 catch 就 catch,异步回调里的错误自己next(err)。这是第一道,也是最该用力的一道——绝大多数错误都该在这层被分类处理,根本到不了上面。 - Express 错误中间件兜住所有路由层漏出来的错误,做统一的状态码映射、脱敏、日志。它处理的是「这个请求失败了」,不影响别的请求。
- 进程级的
uncaughtException/unhandledRejection是最后一道,处理的是「这个进程已经不可信了」。它的职责不是续命,是同步清理 + 退出,把重启交给外部监控。
这三层别串味。最常见的错误就是想用第三层去补第一层的漏:业务代码懒得写 catch,指望进程级 handler 兜着。结果就是前面那个内存泄漏——错误是「兜」住了,代价是状态脏了、问题被藏了、最后以更难查的形式爆出来。
兜底的价值,恰恰在于它很少被触发。
参考来源
- Process | Node.js v24 官方文档:
uncaughtException、unhandledRejection、uncaughtExceptionMonitor事件行为,以及「It is not safe to resume normal operation after 'uncaughtException'」与拔电源比喻,采集于 2026-06-30 - Error Handling · Express.js 官方指南:Express 5 自动
next(value)、异步回调需手动next(err)、错误中间件四参数签名、内置默认错误处理器行为,采集于 2026-06-30 - Express@5.1.0: Now the Default on npm · Express.js Blog:Express 5 成为 npm 默认安装版本及 v5 版本线说明,采集于 2026-06-30
- express - npm:确认当前 Express 版本为 5.2.1,采集于 2026-06-30