Skip to main content

Node 多进程与 cluster

Node 单进程只能用一个核。要吃满多核,就得开多进程 —— 但状态共享和无状态化必须同时想清楚。

一、单进程吃不满多核​

两个理由:一是单进程只能用一个核,多核机器上 CPU 打满一个核就到顶了;二是单进程崩溃即服务不可用,没有冗余。多进程让两者都得到缓解——但代价是引入了「进程间不共享内存」这个新约束,很多原本自然的写法(内存缓存、会话、计数器)会突然失效。

截至 2026 年 10 月,Node 24 是当前活跃 LTS、Node 22 已进入维护期(LTS 支持持续至 2027 年 4 月),新项目取核数建议用 os.availableParallelism() 而不是老旧的 os.cpus().length——前者返回调度器实际可并行的工作线程数,在容器限核的环境下比物理核数更准。多进程解决的是「用满 CPU」和「单点崩溃」,但把内存状态能不能跨进程这件事摆上了台面。

主进程 master不处理请求,负责分发worker 1独立内存worker 2独立内存worker 3独立内存worker 4独立内存共享的是端口,不是内存:进程之间不共享任何变量, session 放内存会直接失效。进程数按可并行核数配,不是越多越好;收到 SIGTERM 要主动收尾,否则在途请求会被中断。
图:cluster 共享端口但内存完全独立,状态类数据必须外置

二、cluster、子进程与多实例​

三种方式不该并列罗列,而要按「你要解决什么问题」来选:

  • cluster:目标是把单台机器的多核用满。主进程 fork 出多个工作进程,它们共享同一个端口——Linux / macOS 上由主进程按 round-robin 轮流分发(Windows 退化为共享句柄 + 系统调度)。适合「一个 Node 服务要吃满一台机器的所有核」这种场景。
  • child_process:目标是跑外部程序或隔离一段不可信 / 易崩的代码。每开一个子进程代价较重,不适合高并发的每次请求,适合「定时跑个脚本」「调个命令行工具」这类任务型需求。
  • worker_threads:目标是把 CPU 密集的 JS 计算移出主线程。它和主线程共享内存(可通过 SharedArrayBuffer),开销比进程小得多,适合图像处理、加解密、大数组计算这类纯计算。注意它不是用来「扩服务」的,是用来「不阻塞主线程」的。

按要解决的问题选:扩服务用 cluster,跑外部程序用 child_process,算重活用 worker_threads。三者解决的不是同一件事。

// CPU 密集任务移出主线程,用 worker_threads 而非开新进程
import { Worker } from 'node:worker_threads'
const worker = new Worker('./compute.js')
worker.on('message', (result) => {
// 主线程不被阻塞,结果回来再处理
})
worker.postMessage(payload)

三、共享状态怎么处理​

进程间不共享内存,所以把 session 或缓存放在进程内存里,会出现「这次请求有、下次请求没有」的诡异现象——因为两次请求被分发到了不同进程。解法只有两个:把状态外置(Redis 之类)或者让服务彻底无状态。这也是「上多进程之前先检查有没有内存状态」的原因。

// 多进程下,把 session 放进共享存储,而不是进程内存
import { createClient } from 'redis'
const redis = createClient()
await redis.connect()

// 任意工作进程都能读到同一份会话
const sid = ctx.cookies.get('sid')
let user = await redis.get('sess:' + sid)
if (!user) user = await loadUser(sid)

除了会话,还有两类常踩的坑:一是内存缓存——本地 Map 做热点缓存,在多进程下每个进程各一份,命中率骤降还浪费内存;二是定时任务——如果不加约束,每个工作进程都会各自跑一遍定时任务,要么抽一个专用进程跑,要么加分布式锁保证只跑一次。

四、重启时别丢请求​

容器编排(K8s 等)发布新版本时,会先发 SIGTERM 给旧容器,给你一个宽限期把进行中的请求处理完,超时再强杀。不处理这个信号,正在处理的请求会被直接切断,用户看到的就是发布期间的一批 5xx。

if (cluster.isPrimary) {
for (let i = 0; i < os.availableParallelism(); i++) cluster.fork()
cluster.on('exit', () => cluster.fork()) // 崩溃即补一个
} else {
const server = http.createServer(app).listen(3000)
process.on('SIGTERM', () => {
server.close(() => process.exit(0)) // 先停止接新请求,等存量请求跑完
setTimeout(() => process.exit(1), 10000) // 超时强杀,不让宽限期无限拖
})
}

完整顺序是:收到信号 → 停止监听新连接 → 等存量请求结束 → 退出。那个超时强杀不能省——万一有请求卡死,编排系统的宽限期到点也会强杀,不如自己先兜底。不处理就会中断进行中的请求,这是发布稳定性里最容易被忽略的一环。

线上通常不自己写 cluster,交给进程管理器,重启策略是重点:

# 按 CPU 核数起实例
pm2 start dist/server.js -i max

# reload 逐个替换,存量请求处理完才退出;restart 会一起断
pm2 reload dist/server.js

# 内存泄漏兜底:超过阈值自动重启
pm2 start dist/server.js --max-memory-restart 500M

五、进程守护的用法​

PM2 的价值是进程守护:崩溃自动重启、max-memory-restart 内存超限重启、日志聚合与分割、cluster 模式一键多进程。它把上面手工写的 cluster 和优雅退出封装好了,单机部署很省心。

但要注意和容器编排的取舍:在 K8s 这类编排里,多副本、健康检查、滚动发布、重启策略都已经由编排层负责,再叠一层 PM2 往往是多余的——一个容器里跑一个 PM2 管理多个进程,反而让编排系统误以为容器健康(容器活着 ≠ 进程都活着)。有编排时它常常是多余的,除非你明确需要它的日志与重启能力。无论用不用 PM2,崩溃不能「静默消失」,必须有监控告警,否则进程一直在重启你却不知道。

多进程下最隐蔽的一类 bug:本地缓存每个进程各一份,失效也互不可见:

// 错:进程内 Map 做热点缓存
const cache = new Map()
async function getUser(id) {
if (cache.has(id)) return cache.get(id)
const u = await db.getUser(id)
cache.set(id, u)
return u
}
// 4 个进程 = 4 份缓存,命中率降到 1/4;一个进程清了缓存,另外三个还在用旧值

// 改法:把缓存放到共享存储(Redis),或者干脆不在这一层做缓存

六、多进程带来的三个新问题​

cluster 不会自动共享状态,它只是共享端口,各进程的内存完全独立。

进程数也不是越多越好——超过核数会增加调度开销,通常按可并行核数来配。

容器里同样不能不管优雅退出:编排系统发出 SIGTERM 之后不会等人,不处理就会中断正在进行的请求。

多进程解决了用满 CPU 与单点崩溃,却把「内存里的东西还算不算数」这个问题摆到了台面上。

cluster 的分发策略与 worker 生命周期见 Node.js 官方 cluster 文档,调度算法那节解释了为什么负载会不均。