一聚教程网:一个值得你收藏的教程网站

最新下载

热门教程

Node.js 错误处理及全局异常捕获

时间:2026-07-01 10:13:01 编辑:袖梨 来源:一聚教程网

Node.js 错误处理与全局异常捕获,别用一个 try/catch 包住整条调用链

写业务的时候,错误处理常常是最后才补的那块。接口先跑通,catch 留个空壳,日志打个 console.log(err),上线再说。问题是「再说」那天往往是线上 502 已经报出来、你盯着一堆没有上下文的堆栈发呆的时候。

Node.js 错误处理与全局异常捕获

这篇想把 Node 里几类错误彻底分清楚:同步抛的异常、Promise 的 rejection、异步回调里的错误,它们走的根本不是同一条路。然后把 Express 5 的错误中间件写对,最后聊 uncaughtExceptionunhandledRejection 这两个进程级兜底——它们不是用来「续命」的,是用来体面地死。

环境口径: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/catchasync function asyncFn() { throw new Error('async boom') // 等价于 return Promise.reject(...)}try { asyncFn() // 没 await,这里啥也抓不到} catch (err) { // 永远进不来}// 3. 异步回调:错误抛在另一个事件循环 tick 里,栈已经不在了try { setTimeout(() => { throw new Error('callback boom') // 直接冲到 uncaughtException}, 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 官方文档也专门强调过:

记住这条分界线:栈还在,try/catch 管用;栈没了,得靠回调里自己接或者 Promise 链接住。后面 Express 那段会再用到。

Express 5 帮你省掉的,和没帮你省的

Express 4 时代,异步路由的错误处理是出了名的恶心。你得给每个 async handler 套一层 asyncHandler 包装,否则 reject 掉进黑洞,请求直接挂起到超时。社区为此造了一堆 express-async-errorsexpress-async-handler 之类的轮子。

Express 5 把这件事收进了框架。官方文档原话:

意思是,只要你的 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)// 这里 reject,Express 5 自动 next(err)if (!user) { const err = new Error('user not found')err.status = 404throw err // throw 也会被接住}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)})

官方文档对这段的注解是:

所以别被「Express 5 自动捕获」这句话忽悠了。它捕获的是 async 边界内的 reject,不是你代码里所有的异步错误。事件发射器的 error 事件、裸 setTimeoutfs 的回调式 API,这些都得自己处理。

错误中间件:四个参数,一个都不能少

Express 靠函数签名的参数个数来识别错误中间件。普通中间件三个参数 (req, res, next),错误中间件必须是四个 (err, req, res, next)——少一个,Express 当它是普通中间件,错误压根不会进来。

// 注意:即使 next 用不到,也得写满四个参数,否则 Express 不认app.use((err, req, res, next) => { // 真实项目里我会区分「业务错误」和「意外错误」const status = err.status || err.statusCode || 500if (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.statuserr.statusCode,不在 4xx/5xx 范围就设成 500;响应体在生产环境是状态码对应的 HTML,非生产环境直接吐 err.stack。换句话说,生产环境别依赖这个默认处理器,它会把堆栈泄露给用户(只要 NODE_ENV 不是 production)。务必自己写一个。

进程级兜底:它不是用来续命的

到这一层,讨论的已经不是「某个请求出错」,而是「整个进程出错了」。两个事件:uncaughtExceptionunhandledRejection

uncaughtException,顾名思义,一个同步异常一路冒泡到事件循环都没人接。Node 官方文档对它的默认行为描述:

也就是:打印堆栈、以退出码 1 结束进程。你一旦注册了 process.on('uncaughtException', ...),这个默认的「退出」行为就被你覆盖了——进程不会自动退出。

这正是最危险的地方,也是我想重点说的。很多人这么写:

// 反面教材:千万别这么干process.on('uncaughtException', (err) => { console.error('caught:', err)// 然后……什么都不做,进程继续跑})

写完自我感觉良好:崩溃被我「兜住」了,进程不挂了,稳如老狗。

文档对此的态度非常硬:

还配了个我很喜欢的比喻:

一个未捕获异常意味着程序进入了未定义状态。可能是某个连接池里的连接处于一半事务、某个全局变量被改了一半、某个锁拿了没放。你捂住异常让它接着跑,十次有九次没事,第十次数据就脏了。而且脏在哪你完全不知道,排查成本是崩溃的几十倍。

我踩过的那个坑

之前一个 Node 服务,QPS 不高但很关键。某次发版后内存缓慢上涨,大概两三个小时 OOM 一次,被 k8s 重启,重启后又开始涨。监控上看不出明显的请求异常,日志里干干净净。

最后定位到,是一段给第三方推送消息的代码,用了 await 但整个调用没有任何 catch,而那个第三方接口偶发超时 reject。这些 reject 成了 unhandledRejection。当时进程里有个老代码写了 process.on('unhandledRejection', () => {})——一个空 handler,把所有 reject 全吞了。错误没了,但每个被吞掉的 rejection 关联的 Promise、闭包、请求上下文都没法回收,内存就这么一点点漏上去。

教训有两条。其一,空的兜底 handler 比没有 handler 还坏:它把本该暴露的问题藏起来,变成线上静默失败。其二,真正的修复不在兜底层,而是回到那段 await 加上 catch。兜底是最后一道防线,不是用来给烂代码擦屁股的。

那应该怎么用

uncaughtException 的正确用法,文档说得明白:做同步的资源清理,然后退出。

process.on('uncaughtException', (err, origin) => { // 用同步写法落日志,别用异步——进程马上要没了,异步回调可能执行不到fs.writeSync(process.stderr.fd, `uncaught: ${ err.stack}origin: ${ origin}`)// 尽量同步地关掉要紧的资源,然后退出process.exit(1)})

注意是 fs.writeSync 不是 console.log 后者再接异步上报。进程都要死了,你 await 一个日志上报,大概率执行不完。要可靠地落盘就用同步 API。

退出之后谁来拉起?文档的答复是外部监控:

容器环境里就是 k8s 的 liveness probe 重启策略,传统部署就是 pm2、systemd、或者前面文章聊过的 cluster 主进程。原则一致:进程崩了就重启一个干净的,而不是在脏进程里硬扛。

unhandledRejection 和它的演变

unhandledRejection 是 Promise 版本的「无人认领」。文档定义:

这里有个历史包袱值得知道。早年(Node 14 及之前)未处理的 rejection 只打个 warning,进程照跑。从 Node 15 开始默认行为变了,--unhandled-rejections 的默认模式是 throw,文档里也明确:未处理的 rejection 如果你没注册 handler,会被当成 uncaught exception 抛出来。

所以在 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 之前触发,但文档强调它不改变默认行为:

也就是说,只挂 monitor、不挂 uncaughtException,进程该崩还是崩、该退出还是退出,你只是顺手记了一笔。这是「我要日志,但退出这事交给 Node 默认逻辑」的最干净写法。

把这几层串起来

落到实际项目,我的分层大致是这样:

业务代码里,async 函数该 awaitawait,该 catch 就 catch,异步回调里的错误自己 next(err)。这是第一道,也是最该用力的一道——绝大多数错误都该在这层被分类处理,根本到不了上面。

Express 错误中间件兜住所有路由层漏出来的错误,做统一的状态码映射、脱敏、日志。它处理的是「这个请求失败了」,不影响别的请求。

进程级的 uncaughtException / unhandledRejection 是最后一道,处理的是「这个进程已经不可信了」。它的职责不是续命,是同步清理 退出,把重启交给外部监控。

这三层别串味。最常见的错误就是想用第三层去补第一层的漏:业务代码懒得写 catch,指望进程级 handler 兜着。结果就是我前面那个内存泄漏——错误是「兜」住了,代价是状态脏了、问题被藏了、最后以更难查的形式爆出来。

兜底的价值,恰恰在于它很少被触发。

参考来源

Process | Node.js v24 官方文档:uncaughtExceptionunhandledRejectionuncaughtExceptionMonitor 事件行为,以及「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 [email protected]: Now the Default on npm · Express.js Blog:Express 5 成为 npm 默认安装版本及 v5 版本线说明,采集于 2026-06-30 express - npm:确认当前 Express 版本为 5.2.1,采集于 2026-06-30

热门栏目