☰
Node.js Worker线程自动重启:从无声崩溃到生产级自愈方案
2026/10/10 9:37:31 网站建设 项目流程

去年处理一个图片缩略图服务时,我遇到了一个很诡异的事:任务队列突然卡死,积压了几万条消息,主进程的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

我给出一个实际计算表,假设baseDelayMs=500:

连续失败次数计算delay加上抖动后的实际区间
0500ms250ms - 500ms
11000ms500ms - 1000ms
22000ms1000ms - 2000ms
34000ms2000ms - 4000ms
48000ms4000ms - 8000ms
516000ms8000ms - 16000ms
630000ms(封顶)15000ms - 30000ms

为什么要设maxDelayMs封顶?因为如果worker连续失败次数多了,退避时间会指数爆炸。30秒是一个经验值:既不会让服务恢复太慢,也足够触发告警让值班人员介入。如果你的系统对恢复速度要求高,可以调小到10秒,但切记要配合更灵敏的告警。

为什么要加jitter(抖动)?如果你的worker池有8个worker同时崩溃,不加抖动的话,8个定时器会同时触发,瞬间创建8个worker,CPU和内存都会出现一个尖峰。加了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_5m:5分钟内重启速率,告警用
  • 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; charset=utf-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,任务重放是否生效,告警是否触发。这套测试跑通了,才敢上生产环境。自动重启这东西,不测过就跟没写一样,因为它真正起作用的时候,恰恰是你最慌了神、最需要它可靠的时候。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询