在理解了模块化、事件发布/订阅与中间件组合之后,我们还要让服务在出错时仍可控、可观测、不无声崩溃。
在 JavaScript 里,用 throw 抛出的错误会沿着调用栈向上冒泡,直到被某一层的 try/catch 接住,或者一路冒到顶层仍未被捕获,此时引擎会报告未捕获异常并终止执行。在同步代码中,这种机制是直观的:错误发生的位置与「栈顶」在同一轮执行里,因此包裹这段代码的 try/catch 能够在其所在的那一帧里捕获到异常。
一旦涉及异步,情况就变了。例如在回调函数里、在 setTimeout 或 setImmediate 的回调里、在 Promise 的 then 或 catch 回调里、或在 async 函数里 throw,错误发生的那一刻,当前的同步调用栈已经退出了;真正执行到「抛出错误」的那段代码时,调用栈上可能只有事件循环调度的回调帧,而不再包含你当初写 try/catch 的那一层。因此,在「发起异步操作」的那一行外面包一层 try/catch,无法捕获到「在异步回调内部才抛出的错误」。这种差异是「同步错误」与「异步错误」在可捕获性上的本质区别:同步错误活在当前调用栈里,异步错误活在事件循环后续触发的另一段调用栈里。
与此相关的是异步错误的归属。在回调风格里,错误通常通过 Error-first 约定作为回调的第一个参数传入,由调用方在回调内部判断并处理。在 Promise 里,reject 或 throw 会把错误交给该 Promise 的 catch、或链式调用的下一个 catch;若整条链上都没有 catch,就会变成未处理的 Promise 拒绝(unhandled rejection)。在 async/await 里,throw 会把当前 async 函数返回的 Promise 置为 rejected,同样需要在上层用 try/catch 包住 await 或在 Promise 链上接 catch,否则错误就会「漏」到进程级。因此,要保证服务稳定,除了在业务层和中间件管道里做好错误捕获与转换,还需要在进程级对「漏网之鱼」做兜底:未捕获的同步异常与未处理的 Promise 拒绝。

Node 进程提供了两个与「漏网之鱼」相关的全局事件:uncaughtException 与 unhandledRejection。当一段同步代码抛出了异常且未被任何 try/catch 捕获时,该异常会触发 process.on('uncaughtException', callback) 注册的回调;当某个 Promise 被 reject 且在其当前 tick 内没有任何 .catch() 或 try/catch 处理时,会触发 process.on('unhandledRejection', callback)。这两个钩子是你最后一道防线:在这里应当记录详细日志、上报监控、并决定是否让进程继续运行。
它们与中间件管道里的统一错误处理是不同层次。在 Express 里,业务或中间件中抛出的错误应通过 next(err) 交给四参数错误处理中间件,由该中间件决定返回给客户端的状态码和 body;在 Koa 里,通常在最外层包一个 try { await next(); } catch (e) { ... },把下游抛出的错误转成统一响应。这些都是在「请求—响应」这一层做的处理,只影响当前请求,不会直接导致进程退出。而 uncaughtException 和 unhandledRejection 处理的是「已经逃出请求上下文」的错误:例如在某个异步回调里抛错、且该回调并不在某个路由或中间件的 Promise 链上,这类错误不会自动被 next(err) 或最外层 try/catch 接到,只能靠进程级监听来兜底。
下面这段代码演示如何在进程入口处注册这两个全局处理器,并约定「记录日志后有序退出」:避免在未知状态下继续处理新请求,同时给运维留出重启与恢复的时间。
|process.on('uncaughtException', (err) => { console.error('uncaughtException', err); // 记日志、上报后,建议有序退出 process.exit(1); }); process.on('unhandledRejection', (reason, promise) => { console.error('unhandledRejection', reason); // 同上:记录后退出或按策略决定是否退出 process.exit(1); });

未捕获的同步异常在默认情况下会导致 Node 进程退出。引擎会打印错误栈并直接 process.exit(1),因此从外部看,服务就像「突然挂掉」了:没有返回 5xx、没有优雅关闭连接,只是进程消失。未处理的 Promise 拒绝在 Node 的早期版本里同样会导致进程退出;在较新版本里,默认行为改为打印警告而不立即退出,但运行时会标记该 Promise 为未处理,若长期忽略,仍可能在不同 Node 版本或环境下出现不可预期行为。因此,稳定的服务应当显式监听 unhandledRejection,在回调里记录日志并决定是「记录后继续运行」还是「有序退出」;若选择继续运行,必须确保不会在已损坏的状态下处理后续请求。
「有序退出」的含义是:在触发 uncaughtException 或 unhandledRejection 时,先完成必要的清理(例如关闭数据库连接、停止接收新请求、等待当前请求收尾),再调用 process.exit(1)。若在未做清理的情况下直接退出,可能留下半写状态或未关闭的连接;若选择不退出、仅打日志然后继续跑,则要接受「进程可能处于未知状态」的风险,一般只适合在明确知道错误可忽略或可恢复的场景使用。多数生产环境会采用「记录完整上下文后退出、由进程管理器(如 PM2、systemd)重启」的策略,这样既避免静默续跑带来的状态污染,又通过重启恢复服务可用性。

不要依赖「未处理的 Promise 拒绝可能不杀进程」的默认行为。应在进程入口处显式监听 uncaughtException 与 unhandledRejection,记录日志并决定退出策略;否则在不同 Node 版本或部署环境下,服务可能静默进入异常状态或突然退出,难以排查。
这节课我们围绕错误处理与服务稳定性展开了三块内容。在同步错误与异步错误中,我们说明了 try/catch 能捕获同步抛出的错误,而异步回调、Promise 与 async 中抛出的错误发生在另一段调用栈上,无法被「发起异步」那一层的 try/catch 捕获,因此需要进程级的 uncaughtException 与 unhandledRejection 作为兜底。
理解了错误在同步/异步与请求/进程不同层次上的传播方式之后,服务在出错时就能可控、可观测、可恢复。下一部分我们将讨论安全与认证基础:常见 Web 安全问题、Token 与 JWT 的原理以及鉴权中间件的设计,从而在「稳」的基础上再把接口守好。