Node.js Worker线程自动重启:从无声崩溃到生产级自愈方案
去年处理一个图片缩略图服务时我遇到了一个很诡异的事任务队列突然卡死积压了几万条消息主进程的CPU和内存却完全正常监控面板上唯一的异常是——某个worker线程不见了。最后排查发现worker里一段不起眼的图像处理代码抛了个未捕获异常整个worker直接退出而主进程根本没有感知到这个消息通道已经断了。这种场景做过Node.js多线程开发的人应该都不陌生。Worker Threads确实是处理CPU密集任务的利器但它有个容易被忽视的特性worker是独立于主线程的事件循环它内部的异常不会像主进程那样冒出来消息通道断了也就断了没人通知你。所以自动重启对你来说不是“优化项”而是“保命功能”。这篇文章不打算从“Node.js是干什么的”开始科普直接分享怎么把worker的自动重启做成生产级方案。内容包括生命周期原理、带指数退避和熔断的Supervisor实现、任务重放机制、关键参数选型以及我实际踩过的一系列坑。适合已经用worker_threads写过代码、但觉得“目前写法不够稳”的Node.js开发者。1. Worker线程为什么会“无声死亡”先搞懂生命周期1.1 Worker Threads解决的场景与代价Worker Threads在Node.js里解决的核心问题是单线程事件循环扛不住CPU密集型任务。典型场景包括图像处理、音视频转码、加解密、大型JSON解析、正则表达式重计算、前端打包编译等。这些任务一旦塞进主线程后果就是整个服务卡住所有请求排着队等一个倒霉的同步任务跑完。把任务丢给worker后主线程事件循环就解放了。但要注意worker并不是操作系统意义上的轻量线程也不是child_process那种独立进程。它是在同一个进程内由Node.js的libuv线程池支撑、每个worker拥有独立的V8实例和独立事件循环的执行单元。也就是说每个worker都有自己的堆、自己的全局对象、自己的运行时状态。这意味着什么每个worker的开销并不小。实际测量下来一个空白worker创建后内存占用也有十几到几十MB创建速度比我们想象的“线程”慢得多。所以生产环境绝对不能“来一个任务就new一个Worker”而是要做成复用式Worker池配合Supervisor统一管理。也正因为worker是复用资源某个worker一旦崩溃正在其上运行的一批任务都会跟着遭殃这进一步放大了自动重启的重要性。生活化一点理解把主进程想象成餐厅前厅worker是后厨的厨师。厨师突然撂挑子跑路如果店长不盯着后厨这桌订单就永远卡住顾客还浑然不知。Supervisor要做的就是那个盯后厨的店长看到厨师跑了马上补一个并把菜单重新分下去。1.2 一个worker从启动到退出的完整事件链要设计自动重启先得吃透Worker实例的核心事件。我列一下每个事件的触发时机和含义这些都是后面所有逻辑的地基事件含义触发时机onlineworker脚本已经启动消息管道就绪worker开始执行时message收到worker通过parentPort.postMessage发送的数据任意时刻messageerror收到无法反序列化的数据数据损坏时errorworker内产生未捕获异常或脚本加载失败异常情况exitworker退出回调参数为退出码任何退出路径很多人第一次写worker时有个误区以为worker里的异常会像主进程一样触发uncaughtException或者以为监听error事件就够了。实际上worker是独立的事件循环它内部抛出的异常不会传播到主线程。父进程能拿到的只有两个回调信息error事件里的错误对象和exit事件的退出码。更麻烦的是error和exit这两个事件的触发顺序和行为并不绝对可靠。worker运行过程中抛了未捕获异常时通常会先触发error随后触发exit但如果脚本文件本身不存在、路径写错了某些Node版本下可能只有error事件exit不一定触发。反过来如果你直接调用worker.terminate()强制终止可能触发exit但不会触发error。所以结论很明确这也是整个方案的核心原则不要把error和exit当作一种“保证可靠的事件对”把exit视为生命周期信号去驱动重启把error视为诊断信号去记录根因两个都监听但职责分工不同。除此之外还有个很多人忽略的细节退出码。worker正常执行完脚本后退出退出码一般是0异常崩溃时是非0。但看到这里先别急着写“code 0就正常code ! 0就重启”因为terminate()强制终止时退出码在不同Node版本里表现并不完全一致。我的经验是不要靠退出码单点判断而是引入一个内部状态标志区分“主动关闭”和“意外崩溃”这个后面代码里会展开。2. 自动重启的三层方案从保命到优雅2.1 第一层方案exit监听加无脑拉起网上很多教程教的自动重启长这样const { Worker } require(node:worker_threads); function spawnWorker() { const worker new Worker(./task-worker.js); worker.on(exit, (code) { console.error(worker exited with code ${code}); spawnWorker(); }); } spawnWorker();逻辑没错但只能说能用不能说可用。它最大的问题是没有节制的重启假设worker一启动就因为一个必现的异常崩溃这个函数会形成“崩溃→退出→新建→崩溃”的高频循环。如果每次循环耗时只有几毫秒你的Node.js进程会在几秒钟内创建几百个worker最终把自己拖到OOM。这已经不叫自动重启了叫故障放大器。它还丢任务。worker崩溃时正在执行的任务没有任何重发机制消息通道断了就断了任务直接消失。对正经线上服务来说这种数据丢失是不可接受的。另一个隐蔽问题是退出困难。应用关停时如果exit回调里还能拉起新的worker你的进程可能永远退不干净每次你试着优雅关闭它又倔强地拽起一个新worker。2.2 第二层方案Supervisor状态机引入退避与熔断既然第一层方案太“裸”我们需要把重启逻辑抽成一个独立的管理者业界常用做法是Supervisor模式。它的核心思想是把重启从“碰运气的递归调用”变成“有节制的状态转移”。Supervisor内部维护几个状态运行中、正在退避等待、已熔断、正在关闭。worker意外崩溃后Supervisor不急着立刻拉起新实例而是先进入退避等待状态计算一个延迟时间等时间到了才创建新worker。连续崩溃次数超过阈值后Supervisor进入熔断状态不再重启转为告警等待人工介入。这一层方案已经能解决启动风暴问题也不会在应用关停时反复拉起worker但对任务丢失还没有对策。2.3 第三层方案Worker池与任务重放把重启升级为故障转移再进一步把自动重启和任务队列结合起来。我们维护一个固定大小的Worker池每个worker由一个Supervisor管理。任务不直接发给某个worker而是先入队由调度器分发给当前空闲worker。某个worker崩溃后Supervisor负责拉起新实例同时调度器扫描任务状态把“已分配但还没确认完成”的任务打回pending状态重新入队分发给其他活着的worker。这套机制本质上就是故障转移。数据库有高可用切换worker虽然没有主从架构那么复杂但也需要“路由层”把流量从坏节点撤走。第三层方案在生产环境的价值最大它把“自动重启”从单点自救升级成了整个任务系统的自愈能力。三层方案对比一下方案能否保活是否有退避/熔断是否会丢任务生产可用性无脑拉起能否会差Supervisor能能会中等Supervisor 任务重放能能不会高3. 生产级实现手写带自动重启的Worker管理器3.1 核心代码WorkerSupervisor类先写一个完整的Supervisor类。这个类承担worker生命周期管理的全部职责。我建议不要直接在主业务代码里手写这些逻辑而是封装成独立模块方便在多个worker池中复用。// supervisor.js const { Worker } require(node:worker_threads); const EventEmitter require(node:events); const DEFAULT_OPTIONS { maxRestarts: 10, baseDelayMs: 500, maxDelayMs: 30000, stableMs: 60000, jitter: true, resourceLimits: { maxOldGenerationSizeMb: 256, maxYoungGenerationSizeMb: 64, stackSizeMb: 4, }, }; class WorkerSupervisor extends EventEmitter { constructor(taskPath, options {}) { super(); this.taskPath taskPath; this.options { ...DEFAULT_OPTIONS, ...options }; this.worker null; this.shuttingDown false; this.restartCount 0; this.dead false; this._onlineAt 0; this.start(); } start() { if (this.shuttingDown) { return; } let worker; try { worker new Worker(this.taskPath, { resourceLimits: this.options.resourceLimits, }); this.worker worker; } catch (err) { // new Worker 本身也可能抛错比如路径非法 this.emit(workerError, err); this._scheduleRestart(); return; } worker.on(online, () { this._onlineAt Date.now(); this.emit(online); }); worker.on(message, (msg) this.emit(message, msg)); worker.on(messageerror, (err) this.emit(messageerror, err)); worker.on(error, (err) { // error 只作为诊断信息记录不在这里直接触发重启 this.emit(workerError, err); }); worker.on(exit, (code) this._handleExit(code)); } _handleExit(code) { // 如果是我们主动发起了 stop就直接收尾不再重启 if (this.shuttingDown) { this.worker null; this.emit(stopped, code); return; } const aliveMs this._onlineAt ? Date.now() - this._onlineAt : 0; // 关键优化只有 worker 活过稳定阈值才把连续重启计数清零 // 否则一个“启动即崩溃”的 worker 会不断重置计数重启风暴依旧 if (aliveMs this.options.stableMs) { this.restartCount 0; } this.emit(crash, { code, aliveMs, restartCount: this.restartCount, }); if (this.restartCount this.options.maxRestarts) { this.dead true; this.worker null; this.emit(dead); return; } this._scheduleRestart(); } _scheduleRestart() { const delay this._calcDelay(this.restartCount); this.restartCount 1; this.emit(restart, delay, this.restartCount); // unref 保证如果没有其他事件引用进程不会因为这个定时器而拒绝退出 setTimeout(() this.start(), delay).unref?.(); } _calcDelay(attempt) { const exponentialDelay Math.min( this.options.baseDelayMs * Math.pow(2, attempt), this.options.maxDelayMs ); if (!this.options.jitter) { return exponentialDelay; } // 全抖动算法实际延迟落在 [exponentialDelay/2, exponentialDelay) 区间 return Math.floor( exponentialDelay / 2 Math.random() * (exponentialDelay / 2) ); } async stop() { this.shuttingDown true; if (this.worker) { const worker this.worker; this.worker null; await worker.terminate(); } this.emit(stopped); } } module.exports WorkerSupervisor;这个类里有几个细节值得展开说第一为什么在online事件里不直接重置restartCount我之前踩过这个坑。早先版本我在online回调里把restartCount重置为0看起来合理实际上有漏洞。如果一个worker每次只能活1秒它会反复进入“上线→崩溃→上线→崩溃”的循环每次上线都重置计数结果restartCount永远达不到maxRestarts熔断永远是摆设重启风暴照样发生。后来改成“存活超过stableMs才重置计数”才堵住这个问题。stableMs默认60秒你可以根据worker的任务长度调整通常比最长任务耗时再宽松一点。第二_handleExit里我用shuttingDown标志位区分主动关闭和意外崩溃。这是为了避免一个很尴尬的场景你调用了stop()想关停结果exit事件触发了代码误以为崩溃又拉起一个新worker。两个标志位可以配合shuttingDown为true时所有重启逻辑全部让路。第三setTimeout后面加了unref()。这个很多人不知道。如果整个进程只剩这个定时器还在挂起unref()能让进程自然退出否则即使你调用了process.exit()这个定时器也可能导致退出不及时。特别是配合优雅关闭场景这一行能省不少事。3.2 任务队列重放与幂等设计Supervisor只能解决“把worker拉起来”解决不了“任务跑一半丢了”。要真正做到故障转移需要一个任务队列跟踪每个任务的状态。我用一个简单的状态机来管理pending等待执行、running已分发、done已完成、failed失败待重试。// task-queue.js class TaskQueue { constructor(maxAttempts 3) { this.taskMap new Map(); this.nextId 1; this.maxAttempts maxAttempts; } push(payload) { const id this.nextId; this.taskMap.set(id, { id, payload, status: pending, attempts: 0, createdAt: Date.now(), }); return id; } next() { for (const task of this.taskMap.values()) { if (task.status pending task.attempts this.maxAttempts) { task.status running; task.startedAt Date.now(); return task; } } return null; } complete(id, result) { const task this.taskMap.get(id); if (task) { task.status done; task.result result; task.finishedAt Date.now(); } } fail(id, reason) { const task this.taskMap.get(id); if (task) { task.status pending; task.attempts 1; task.lastError reason; } } replayAllRunning() { for (const task of this.taskMap.values()) { if (task.status running) { task.status pending; task.attempts 1; } } } stats() { const stats { pending: 0, running: 0, done: 0, failed: 0 }; for (const task of this.taskMap.values()) { stats[task.status] 1; } return stats; } } module.exports TaskQueue;再把队列和Supervisor串起来// manager.js const path require(node:path); const WorkerSupervisor require(./supervisor); const TaskQueue require(./task-queue); const queue new TaskQueue(3); const supervisor new WorkerSupervisor(path.resolve(__dirname, worker.js)); supervisor.on(message, (msg) { if (msg.type done) { queue.complete(msg.taskId, msg.result); dispatchNext(); } if (msg.type failed) { queue.fail(msg.taskId, msg.error); dispatchNext(); } }); supervisor.on(crash, () { // worker 崩溃时把它手上还没确认完成的任务全部打回 pending queue.replayAllRunning(); dispatchNext(); }); supervisor.on(dead, () { // 熔断记录告警人工介入 console.error([manager] supervisor is dead, manual intervention required); }); function dispatchNext() { // 每次 worker 空闲就尝试从队列里取下一个任务分发 const task queue.next(); if (task supervisor.worker) { supervisor.worker.postMessage({ type: run, taskId: task.id, payload: task.payload, }); } }队列里最重要的设计是replayAllRunning方法。它在worker崩溃时把所有running状态的任务重置为pending这样新worker启动后调度器就会重新分发。但这里要敲个警钟重放不等于万事大吉。worker崩溃时某个任务可能已经执行了一半甚至已经写入了外部存储只是结果消息来不及发回来。这种“执行过但未确认”的任务被重放可能导致重复写入。所以任务处理端必须做幂等设计比如任务ID去重、数据库唯一约束、或事务性写入。任务重放机制必须有幂等兜底否则自动重启会给你带来数据重复问题。TaskQueue里我还加了maxAttempts限制一个任务最多重试3次超过后就不会再被next()取到。这是防止一个“永远崩溃”的任务拖垮整个worker池。对于超限任务可以另建一个死信队列落盘后续人工排查。3.3 优雅关闭避免重启风暴发生在下线圈这个坑也是我亲身经历过的。有一次我把服务部署到容器环境平台滚动更新时向进程发送SIGTERM结果容器等了很久都没退出最后被强制杀死。查了半天发现是worker的exit回调里还在拉起新worker导致进程处于“边关边开”的循环里。优雅关停的核心就是在收到退出信号时先把supervisor置为“停止重启”状态再终止所有worker最后退出主进程。// graceful-shutdown.js const supervisor require(./manager).supervisor; let shuttingDown false; async function shutdown(signal) { if (shuttingDown) { return; } shuttingDown true; console.log([manager] received ${signal}, shutting down workers...); try { await supervisor.stop(); console.log([manager] all workers stopped, exiting.); process.exit(0); } catch (err) { console.error([manager] error during shutdown, err); process.exit(1); } } process.on(SIGTERM, () shutdown(SIGTERM)); process.on(SIGINT, () shutdown(SIGINT));这里的顺序很重要先置shuttingDown为true再调stop()。如果反过来stop()触发的terminate会让worker触发exit而exit回调里shuttingDown还没置位就会误以为崩溃再次拉起worker。置位这个操作必须同步完成不能await之后再置位。另外一个细节如果还有其他非worker的资源数据库连接、HTTP服务也要在同一个shutdown函数里统一关闭顺序是先停流量再断依赖最后终止worker。因为worker可能还在往数据库写数据连接提前断了会导致重放任务再次失败。4. 参数调优与资源限制让worker“死得其所”4.1 指数退避参数的计算与选择自动重启最大的敌人是自己。如果没有退避和熔断一个本身就崩溃的worker会把系统拖垮。指数退避的核心公式是delay baseDelayMs * 2 ^ attempt我给出一个实际计算表假设baseDelayMs500连续失败次数计算delay加上抖动后的实际区间0500ms250ms - 500ms11000ms500ms - 1000ms22000ms1000ms - 2000ms34000ms2000ms - 4000ms48000ms4000ms - 8000ms516000ms8000ms - 16000ms630000ms封顶15000ms - 30000ms为什么要设maxDelayMs封顶因为如果worker连续失败次数多了退避时间会指数爆炸。30秒是一个经验值既不会让服务恢复太慢也足够触发告警让值班人员介入。如果你的系统对恢复速度要求高可以调小到10秒但切记要配合更灵敏的告警。为什么要加jitter抖动如果你的worker池有8个worker同时崩溃不加抖动的话8个定时器会同时触发瞬间创建8个workerCPU和内存都会出现一个尖峰。加了jitter后它们的重启时间被随机分散系统负载曲线就平滑很多。这就是为了避开“共振效应”。至于stableMs我建议不要小于30秒。它的意义是判断“这次崩溃是偶发还是必现”如果worker连30秒都活不过说明它处于启动即崩溃的恶性循环restartCount应该持续累积直到熔断而不是反复清零后不断尝试。4.2 resourceLimits给worker设置内存上限resourceLimits是我强烈建议每个worker都配置的参数。worker内存泄漏时如果不设上限它会默默涨到几百MB甚至1GB以上最后把整个进程拖垮。设了上限V8会在超过堆限制时终止worker我们的Supervisor检测到exit后会自动拉起新实例。相当于给每个worker装了一根保险丝。new Worker(./worker.js, { resourceLimits: { maxOldGenerationSizeMb: 256, maxYoungGenerationSizeMb: 64, stackSizeMb: 4, }, });参数含义分别是老生代堆大小上限、新生代堆大小上限、调用栈大小上限。对大多数CPU密集型任务来说256MB老生代是相对宽裕的起步值。但这不是拍脑袋定的实际操作顺序应该是先在worker里定时上报process.memoryUsage()跑几天统计出内存峰值比如是150MB再加50%到100%的buffer设成225MB-300MB。峰值观察期别急着设置资源限制先裸跑收集数据。还有一个需要注意的地方resourceLimits触发后的终止行为在不同Node.js版本略有差异有些版本会先抛一个RangeError有些版本直接终止。不要依赖这个行为做业务判断它只是兜底保险主逻辑仍然是Supervisor里的重启机制。4.3 数据通信方式对比workerData、postMessage与SharedArrayBuffer自动重启方案里还涉及一个关键选择worker与主进程之间用什么方式传数据。不同方式的开销和适用场景差别很大。通信方式开销适用场景注意事项workerData启动时一次性初始化配置、路径、阈值参数数据会被结构化克隆不能传函数postMessage每次消息序列化日常任务分发、结果回传大对象可通过transferList转移所有权SharedArrayBuffer共享内存无序列化高频、大数据量传输需要Atomics同步调试困难很多人忽视的一个细节是transferList。postMessage传Buffer或ArrayBuffer时默认会走结构化克隆也就是复制一份拷贝开销很大。但你可以在第三个参数里把buffer放进去表示“所有权转移”这样就不会有拷贝性能提升明显。代价是转移后原线程里的buffer就变成空了不能再访问。const { parentPort } require(node:worker_threads); // 主线程转移 buffer 所有权, 而不是复制 const buf Buffer.alloc(1024 * 1024); parentPort.postMessage({ data: buf }, [buf.buffer]);恰好是自动重启场景我再多提醒一句SharedArrayBuffer虽然性能好但一旦worker崩溃共享内存里的数据状态很难溯源。我的建议是默认用postMessage只有当性能瓶颈明确出在序列化上时才考虑SharedArrayBuffer并且要做好崩溃时数据一致性的预案。5. 实测踩坑重启风暴、僵尸线程与事件顺序5.1 重启风暴监控显示一秒钟拉起100个worker这是我最惨痛的一次线上经历。某天下午我加了一个feature需要在一个worker里动态加载一个配置文件。结果配置写错了文件路径不存在worker一启动就抛错退出。当时的Supervisor代码没有退避机制exit回调里同步创建新worker于是形成了毫秒级崩溃循环。短短几秒从监控上可以清楚看到worker创建数曲线直接冲上了天内存飙升到1.8GB最后主进程OOM被平台杀掉。那次教训让我意识到自动重启机制本身必须被当作生产依赖来对待而不是一个“锦上添花”的小工具。排查这类问题的路径通常是看日志里restart事件间隔如果大量restart事件间隔小于1秒基本可以断定是启动即崩溃的循环。看worker存活时间统计每次crash事件里的aliveMs如果中位数只有几十毫秒说明问题在初始化阶段。看瞬时worker数量正常的worker数等于池大小如果瞬时数持续激增说明存在无节制的创建。解决重启风暴靠两个机制指数退避兜底熔断保命。maxRestarts设成10第10次连续崩溃后worker进入dead状态不再拉起而是等人工介入。这样即使代码再有必现异常系统也能保持在一个“已知故障”的稳定态而不是一边崩溃一边疯狂自愈。5.2 假死worker与心跳检测有一类问题比崩溃更难缠worker没退出但也不干活了。比如它陷入了一个死循环或者等一个永远不会回来的Promise事件循环被彻底卡住。这时候自动重启派不上用场因为exit事件压根不会触发。我的解决办法是心跳检测。worker内部每隔几秒向主线程发一个心跳消息supervisor记录最后一次心跳时间。如果超过阈值没心跳就认为worker假死主动terminate它让exit逻辑接管重启。worker侧代码const { parentPort } require(node:worker_threads); setInterval(() { parentPort.postMessage({ type: heartbeat, ts: Date.now() }); }, 5000);supervisor侧注册const HEARTBEAT_INTERVAL 10000; const HEARTBEAT_TIMEOUT 5000; let lastHeartbeat Date.now(); supervisor.on(message, (msg) { if (msg.type heartbeat) { lastHeartbeat Date.now(); } }); setInterval(() { if (!supervisor.worker) { return; } if (Date.now() - lastHeartbeat HEARTBEAT_INTERVAL HEARTBEAT_TIMEOUT) { console.error([supervisor] worker heartbeat timeout, terminating...); supervisor.worker.terminate(); } }, HEARTBEAT_INTERVAL);这里有个反直觉的坑如果worker正在执行一个30秒的纯CPU任务它的事件循环根本没空执行setInterval心跳消息也会中断造成误杀。所以心跳检测必须和你的任务模型匹配如果任务可以拆片就拆成多个小分片每个分片之间用setImmediate让事件循环喘口气心跳就能正常发出。如果任务确实不能被拆开比如一个超大的同步加密计算那心跳阈值必须大于最长任务耗时否则就是自己人打自己人。更复杂的大流量场景可以在worker里用SharedArrayBuffer加Atomics在主线程读时间戳不依赖消息队列不过这会显著增加代码复杂度不是特别必要我建议先靠任务拆片解决。5.3 error、exit事件顺序陷阱与孤儿worker再回到事件顺序。有次排查问题时我发现某个worker脚本加载失败时父进程只收到了error事件exit事件一直没触发。而我的重启逻辑恰好挂在exit上结果worker挂掉之后什么都没发生和最初那个“无声死亡”的事故一模一样。所以Supervisor的设计里我做了双保险exit是重启的唯一信号源但新增了一个启动超时保护。具体做法是new Worker之后如果超过10秒既没有收到online事件也没有收到error事件就主动terminate。这覆盖了两类情况start() { // ... 创建 worker ... const startTimer setTimeout(() { if (!this._onlineAt) { console.error([supervisor] worker did not start in time, terminating); worker.terminate(); } }, 10000); startTimer.unref?.(); worker.on(online, () { this._onlineAt Date.now(); clearTimeout(startTimer); }); }这种“启动超时保护”可以兜住worker入口文件里藏着同步死循环的情况。入口文件一旦陷入同步死循环online永远不会触发不会崩溃也不会exit就悬在那里浪费内存。没有启动超时这种worker就是平台上的钉子户谁也拿它没办法。还有一个常见问题是孤儿worker的句柄管理。主进程重启或异常抛出时如果忘了调用supervisor.stop()Worker实例虽然不会阻止进程退出但它的底层线程不会立刻被回收。该终止的没终止会造成线程泄漏。所以我在第3章的优雅关闭代码里反复强调所有退出路径都要走stop()把terminate统一收口。5.4 常见问题速查表症状可能原因解决方法worker退出但主进程毫无感知只监听了message没监听exit/error用Supervisor统一注册生命周期事件启动几ms就崩溃并循环worker入口存在必现异常无退避本地先跑worker脚本验证配置指数退避内存持续上升直到被平台杀掉无限重启或worker内部泄漏resourceLimits加熔断泄漏靠监控定位worker没崩溃但不响应任务事件循环被同步任务卡死任务拆片配心跳检测任务“消失”崩溃时running状态任务未恢复任务状态机加重放机制进程收到SIGTERM后无法退出exit回调里无脑拉起workerstop()里先关重启逻辑再terminate加载路径错误时重启未触发只有error没有exit启动超时保护 exit双保险6. 可观测性把重启变成可查的数据6.1 至少要埋的指标自动重启做得再好如果没有可观测性你就是一个连自己系统崩溃都不知道的运维员。我推荐采集这几个基础指标worker_restart_total累计重启次数看趋势判断系统稳定度worker_restart_rate_5m5分钟内重启速率告警用worker_alive当前存活worker数量worker_uptime_seconds当前worker实例已存活时长task_queue_pending / task_queue_running任务积压情况task_restarted_total被重放的任务数重放太多说明worker不稳定最简单的埋点方式是在Supervisor事件回调里更新计数器然后暴露一个HTTP接口给监控系统抓取。const http require(node:http); const metrics { restarts: 0, lastRestartAt: 0, onlineAt: 0, pending: 0, running: 0, }; supervisor.on(crash, () { metrics.restarts 1; metrics.lastRestartAt Date.now(); }); supervisor.on(online, () { metrics.onlineAt Date.now(); }); setInterval(() { const stats queue.stats(); metrics.pending stats.pending; metrics.running stats.running; }, 5000); http .createServer((req, res) { if (req.url /healthz) { res.statusCode supervisor.dead ? 503 : 200; res.end(supervisor.dead ? supervisor dead\n : ok\n); return; } res.setHeader(content-type, text/plain; charsetutf-8); res.end( [ worker_restart_total ${metrics.restarts}, worker_alive ${supervisor.worker ? 1 : 0}, worker_uptime_seconds ${Math.floor( (Date.now() - metrics.onlineAt) / 1000 )}, task_queue_pending ${metrics.pending}, task_queue_running ${metrics.running}, ].join(\n) ); }) .listen(9090);这里面的一个关键思路是主进程活着不等于worker活着。容器探针如果只检查主进程端口就会漏掉“worker全灭但主进程还吊着”的情况这正是开头那次事故的根源。所以健康检查接口必须检查supervisor.dead状态返回503让容器调度器把它当成不健康实例处理掉重新拉起一个新实例。6.2 告警阈值与恢复策略指标采集出来后还需要合理的告警阈值。我给一套相对通用的起步值大家可以根据自己服务的繁忙程度调整5分钟内重启次数达到5次以上需要马上介入查看。这意味着系统存在持续不稳定的状态不是偶发抖动。supervisor进入dead状态按P0处理。worker同时全灭业务基本停摆。task_queue_pending持续增长且worker_alive为0说明没有worker在消费任务是业务性的“假死”必须告警。重放任务比例超过10%说明worker崩溃频率已经开始影响数据面需要深入排查。排查和恢复的先后顺序也很重要。不要一看到重启就先急着调大maxRestarts那样只会掩盖问题。正确顺序是先通过error事件的错误对象和crash事件的aliveMs定位崩溃原因再修复后重新上线。自动重启是兜底机制不是免死金牌只有结合告警才能形成闭环。最后说一个我自己的小习惯每次改完worker代码我都会先跑一遍“崩溃注入测试”。故意在worker里抛一个必现异常然后观察Supervisor是否能在预期时间内拉起新worker任务重放是否生效告警是否触发。这套测试跑通了才敢上生产环境。自动重启这东西不测过就跟没写一样因为它真正起作用的时候恰恰是你最慌了神、最需要它可靠的时候。