6.3 未捕获异常与进程稳定性


6.3 未捕获异常与进程稳定性

兜底层接链上的异常,app.on('error') 接越层的异常,还有一个层面没覆盖——进程级。uncaughtException、unhandledRejection、退出信号、句柄泄漏,这些事件发生时洋葱模型已经帮不上忙,能依靠的只有进程自身的纪律。本节是全章的收口:把进程级异常的策略定清楚,把优雅退出编排完整实现,再给长期运行立几条内存守则。

两类进程级事件的处置策略

Node.js 有两个"最后关口"事件,策略截然不同:

unhandledRejection——某个 Promise 被拒绝但没人处理。它不终止进程(默认行为只是告警),处置策略是记录但不退出:漏 await 的偶发 rejection 不值得杀死整个服务,记录下来回头修代码即可。

process.on('unhandledRejection', (reason) => { logger.error('unhandled_rejection', { message: reason instanceof Error ? reason.message : String(reason), stack: reason instanceof Error ? reason.stack : undefined, }); // 不调用 process.exit:单次泄漏的 rejection 不该连累在途请求 });

uncaughtException——同步代码抛出的异常逃到了事件循环顶层,此时进程状态已不可信(可能处于中间态:缓冲半写、事务半提交)。策略是记录后立刻退出,把善后交给进程管理器重启:

process.on('uncaughtException', (err) => { logger.error('uncaught_exception', { message: err.message, stack: err.stack }); process.exit(1); // 状态已不可信,快死快生;PM2 或容器编排负责拉起 });

两条策略的方向相反,逻辑却一致:rejection 是"有一个任务失败了",exception 是"进程本身不可信了"。常见反模式是给 uncaughtException 挂个监听器然后继续跑——服务会以不可预测的方式坏下去,比崩溃更难排查。

优雅退出的完整编排

第 2.1 节给过骨架,这里补成生产级实现。目标:收到退出信号后,停止接新请求、等在途请求收尾、限时强制兜底、最后释放资源。

const server = app.listen(process.env.PORT || 3000); server.keepAliveTimeout = 65_000; // 大于上游负载均衡的空闲超时,防 502 抖动 let shuttingDown = false; async function shutdown(signal) { if (shuttingDown) return; shuttingDown = true; logger.info('shutdown_begin', { signal }); // 第一步:停止接收新连接,健康检查同步下线(让负载均衡先摘流量) server.close(); // 第二步:给在途请求留收尾窗口(视业务定,通常 10 到 30 秒) await new Promise((resolve) => { const pending = () => server._connections || 0; const timer = setTimeout(resolve, 15_000); server.unref(); const check = setInterval(() => { logger.info('draining', { pending: pending() }); if (pending() === 0) { clearInterval(check); clearTimeout(timer); resolve(); } }, 500); }); // 第三步:释放共享资源 await Promise.allSettled([pool.end(), redisClient.quit()]); logger.info('shutdown_done'); process.exit(0); } process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT'));

五个细节都有出处。keepAliveTimeout 要大于上游(负载均衡、Nginx)的空闲超时,否则上游复用了已被服务端关闭的连接,产生随机 502——这是容器部署时代最经典的抖动来源。健康检查要配合关停动作:Kubernetes 场景下收到 SIGTERM 后应让 readiness 探针返回失败,流量摘除早于进程退出。draining 的轮询日志让"发布期间影响了几笔在途请求"变成可核对的数据。第三步的 allSettled 保证任何一个资源释放卡住不会阻塞其余释放。最后,shuttingDown 标志防重入——SIGTERM 与 SIGINT 连着来时不重复执行。

长期运行的守则:内存与句柄

进程稳定性的另一半是"跑得久不出事"。三个守则来自真实事故。

守则一:盯住全局集合。 第 2.3 节提过 ctx 不能进全局集合,这里给可执行的检查方法:模块顶层出现的 new Map()new Set()、数组 push,都问一句"谁删它"。缓存集合必须有淘汰策略(上限加 LRU,或定时清理);无上限的全局 Map 是 Koa 服务内存泄漏的第一嫌疑人。

// 反例:无上限的全局缓存,最终吃光内存 const cache = new Map(); app.use(async (ctx, next) => { const hit = cache.get(ctx.url); if (hit) { ctx.body = hit; return; } await next(); cache.set(ctx.url, ctx.body); // 只进不出 }); // 正解:带上限的 LRU(示意,生产可用 lru-cache 包) const MAX = 1000; if (cache.size >= MAX) cache.delete(cache.keys().next().value); // 淘汰最旧 cache.set(ctx.url, ctx.body);

守则二:句柄即责任。 每个 createServer、setInterval、打开的流,都要有对应的关闭路径。句柄泄漏的表象是"重启前内存缓慢上涨、连接数只增不减",定位靠 process._getActiveHandles()(或诊断报告)对照清单逐个核销。

守则三:内存不靠猜靠快照。 内存缓慢上涨时,凭感觉改代码是无底洞。正确路径:压测复现 → 用 --inspect 配合 Chrome DevTools 拍堆快照 → 对比两份快照的增长对象 → 顺着 retention 链找到没释放的引用。第 8.1 节的性能剖析工具链会把这个流程展开。

💡 关键直觉:进程稳定性的三条防线可以概括为一句排比——链上异常有兜底,进程异常有策略,长期运行有守则。三层各自独立又互相支撑:await 纪律减少异常产生,日志体系让异常可见,进程纪律让不可恢复的失败体面收场。

本节要点回顾

  • rejection 记录不退出,exception 记录即退出:前者是任务失败,后者是进程不可信;
  • 给 uncaughtException 挂监听后继续跑是反模式:带病运行比崩溃更难排查;
  • 优雅退出四步:停接新、等在途、限时兜底、释放资源,配 keepAliveTimeout 大于上游空闲超时;
  • 健康检查配合关停:先摘流量再退进程,发布不掐请求;
  • 内存三守则:全局集合必有淘汰、句柄必有核销、内存问题靠快照不靠猜。

层间语义全部讲完。下一章转入防护:把安全威胁画成图谱,给洋葱套上鉴权、HTTPS 与输入验证这几层甲。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U