# cluster
30 秒速记
cluster= 一个主进程fork出多个worker,让单线程的Node吃满多核- 每个
worker是独立进程,JS堆不共享 → 内存里的session、缓存、计数器都要挪到Redis/ 数据库 - 主进程只管监督和分发:监听
exit事件拉起新worker,重启要加退避,避免崩溃循环 - 定时任务放在
worker里会重复跑好几遍 → 只在主进程或单独进程跑,多机就上分布式锁 Node 16起cluster.isMaster改名cluster.isPrimary;容器化部署常改成一容器一进程,交给K8s扩副本
cluster 解决的是「Node 单线程只用得上一个 CPU 核」的问题:主进程 fork 出几个 worker,大家对外共用同一个端口。 说白了就是开了几家分店共用一个门牌号,前台负责把客人领到各家店。代价是每个 worker 都是独立进程,内存不互通,放在变量里的登录态、限流计数到了别的进程就没了。我上线前一定会检查三件事:状态有没有外置、worker 挂了能不能自动拉起、定时任务会不会被跑好几遍。
理解「cluster」时,要先明确输入、运行阶段和可观察输出。这里的交互实验会分别执行正常路径与失败路径,并把 Node.js 的关键状态变化和同屏代码对应起来;不能只记命令名称。
工程中还要验证退出码、资源清理和异常输入。属于 process 场景的配置应保持最小可复现,并在开发环境与 CI 中使用相同命令复跑,避免把平台差异或隐式默认值带到线上。
cluster 是 Node 内置的多进程方案。主进程调用 cluster.fork() 启动若干个 worker,每个 worker 里照常写 http.createServer().listen(3000),并不会报端口占用,因为真正绑定端口的是主进程,worker 只是领连接干活。
const cluster = require('node:cluster');
const http = require('node:http');
const os = require('node:os');
if (cluster.isPrimary) { // Node 16 之前写 cluster.isMaster
for (let i = 0; i < os.availableParallelism(); i++) cluster.fork();
cluster.on('exit', (worker, code) => {
console.log(`worker ${worker.process.pid} 退出,code=${code},拉起新的`);
cluster.fork();
});
} else {
http.createServer((req, res) => {
res.end(`handled by ${process.pid}\n`); // 多刷几次,pid 会轮着变
}).listen(3000);
}
os.availableParallelism() 是 Node 18.14 之后才有的,老版本用 os.cpus().length。这段代码能说明三件事:多个进程共享一个端口;请求会在 worker 之间轮流分配;某个 worker 崩了,主进程能感知并补上。工程上还要补的是重启退避、优雅退出(worker.disconnect() 后等连接处理完)和状态外置。线上多数团队不会手写这套,而是用 PM2 的 cluster 模式,它底层就是这个模块。
面试官追问
追问 18 核机器开了 8 个 worker,用户登录后刷新一下又变成未登录,为什么?
session 存在进程内存里,第二次请求被分到了另一个 worker,它那边没有这份数据。把 session 放到 Redis,或者改成无状态的 JWT,别指望靠粘性会话糊过去。
追问 2每天凌晨发一次日报邮件,上了 cluster 之后用户一天收到 4 封,怎么改?
每个 worker 都执行了同一段 setInterval / node-cron。单机就只在主进程里跑,或者判断 cluster.worker.id === 1 再启动;多台机器就要用 Redis 锁或者独立的任务服务,保证全局只有一个实例在跑。
追问 3worker 挂了自动重启,结果代码有问题一启动就崩,CPU 被打满,怎么防?
在 cluster.on('exit') 里别无脑 fork,记一下最近一段时间的重启次数,按 1s、2s、4s 退避,超过阈值就停下来告警。PM2 的 exp_backoff_restart_delay 做的就是这件事。
追问 4发版时想不断流量地替换所有 worker,怎么做?
一次换一个:先 fork 新 worker,等它发出 listening,再对旧的调 worker.disconnect(),旧进程不再接新连接,处理完手头请求后退出。全部换完就完成了滚动重启,中间始终有进程在接客。
追问 5已经在 K8s 里跑了,还要不要在容器里再开 cluster?
一般不开。Pod 副本数、健康检查、重启策略平台都管了,容器里再套一层 cluster 等于两套编排,资源限制和日志都更难看清。一个容器一个 Node 进程,按 CPU 配额扩副本更省心。
# 写在前面
30 秒速记
- 这一部分只回答两个问题:多个
worker怎么共用一个端口、连接怎么分给各个worker - 端口共享:
worker里的listen()被拦截,变成向主进程发queryServer,真正bind的只有主进程 - 请求分发:默认
round-robin,主进程accept连接后把socket句柄通过IPC发给某个worker Windows默认不走轮询,由系统直接分配;可以用cluster.schedulingPolicy或环境变量NODE_CLUSTER_SCHED_POLICY改- 读源码前先会用
cluster.fork()、isPrimary,否则master/worker两条代码线很容易看混
这部分讲 cluster 最常被追问的两件事:端口为什么不冲突,连接是怎么分出去的。 一句话版本是:worker 以为自己在 listen(3000),其实是请主进程代劳,主进程只绑定一次端口,然后把每个新连接的句柄转交给 worker。看源码时我建议在每段代码上标清楚它跑在主进程还是 worker,因为同名的 send、onmessage 两边都有,混了就看不懂了。
下文适合对cluster模块有一定了解的同学阅读。主要包含两部分内容:
- cluster模块如何实现端口共享
- cluster模块如何分发请求
理解「写在前面」时,要先明确输入、运行阶段和可观察输出。这里的交互实验会分别执行正常路径与失败路径,并把 Node.js 的关键状态变化和同屏代码对应起来;不能只记命令名称。
工程中还要验证退出码、资源清理和异常输入。属于 module 场景的配置应保持最小可复现,并在开发环境与 CI 中使用相同命令复跑,避免把平台差异或隐式默认值带到线上。
面试官追问
追问 14 个 worker 都 listen(3000),为什么不报 EADDRINUSE?
worker 里的 listen 不会真去绑端口,net 模块发现自己在 worker 里,就走 cluster._getServer 发消息给主进程。主进程对同一组地址参数只建一个监听,所以系统层面只有一次 bind。
追问 2那怎么证明真正占着 3000 端口的是主进程?
lsof -i :3000 或 ss -ltnp 看一下,监听那一行的 PID 是主进程。再在 worker 里打印 process.pid 并多刷几次请求,会看到处理请求的 pid 在轮换,但监听者始终只有一个。
追问 3默认的分发策略是什么,能改吗?
除 Windows 外默认是 cluster.SCHED_RR,主进程轮询分配;设成 cluster.SCHED_NONE 就交给操作系统,让各 worker 自己去抢连接。要在第一次 fork 之前设置,或者用 NODE_CLUSTER_SCHED_POLICY=none。
追问 4为什么 Node 默认不让操作系统直接分配?
让系统分配时,Linux 上常出现少数几个进程拿走大部分连接,负载很不均。主进程轮询多一次 IPC 转发,但分得均匀,所以 Node 选了后者。
追问 5worker 和主进程之间靠什么通信?
fork 时建立的 IPC 通道,process.send() 发消息,句柄(socket、server)也能跟着消息一起传。cluster 的内部消息会带 cmd: 'NODE_CLUSTER',走 internalMessage 事件,和业务自己发的 message 分开。
# 端口共享源码分析
30 秒速记
worker调server.listen(3000)→net模块转去cluster._getServer→ 通过IPC发act: 'queryServer'- 主进程按
address:port:addressType:fd:index拼key,同一个key只建一个RoundRobinHandle RoundRobinHandle里主进程自己
