cluster 集群
本教程共 76 篇 · 第 34 篇 · 更新于 2026-07-25 · 约 7 分钟阅读
34. cluster 集群
本节目标:用 cluster 模块利用多核 CPU,实现多进程负载均衡。
你的服务器有 8 核 CPU,但 Node.js 进程只跑在一个核上,剩下的 7 个核干看着。这就像开了一家餐厅,只有一个厨师在干活,其他灶台全空着。cluster 模块就是来帮你把剩下的灶台也点起来的。
为什么要用 cluster
Node.js 是单线程模型,一个进程只能利用一个 CPU 核心。就算你的机器有 32 个核,一个 Node.js 进程也只能吃满其中一个。对于 I/O 密集型的 HTTP 服务来说,这意味着大量的计算资源被浪费了。
cluster 模块的思路很直接:启动一个主进程(primary),由它来 fork 多个工作进程(worker),每个工作进程跑一份完整的应用代码,独立监听同一个端口。操作系统把 incoming 连接分发给不同的工作进程,这样你就用上了多核。
Note
cluster的底层其实是child_process.fork(),所以它本质上是多进程,不是多线程。进程之间内存隔离,一个 worker 挂了不会影响其他 worker。
最简示例
下面这段代码,在主进程里 fork 出和 CPU 核数一样多的 worker,每个 worker 都启动一个 HTTP 服务器:
import cluster from 'node:cluster';
import http from 'node:http';
import { availableParallelism } from 'node:os';
if (cluster.isPrimary) {
const numCPUs = availableParallelism();
console.log(`主进程 ${process.pid} 启动,准备 fork ${numCPUs} 个 worker`);
for (let i = 0; i < numCPUs; i++) {
cluster.fork();
}
cluster.on('exit', (worker, code, signal) => {
if (worker.exitedAfterDisconnect) {
console.log(`worker ${worker.process.pid} 正常退出,不重启`);
return;
}
console.log(`worker ${worker.process.pid} 异常退出,重启一个`);
cluster.fork();
});
} else {
http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end(`来自 worker ${process.pid} 的响应\n`);
}).listen(3000);
console.log(`worker ${process.pid} 启动,监听 3000`);
}
把这段代码存为 cluster-server.mjs,然后运行:
node cluster-server.mjs
你会看到一条主进程启动日志,紧接着多条 worker 启动日志。用 curl http://localhost:3000 多请求几次,你会发现响应来自不同的 PID。
Tip老代码里常见
os.cpus().length,v24 推荐用availableParallelism(),它更准确地反映系统真正能并行执行的线程数(考虑了 cgroups 限制等)。
负载均衡是怎么做的
多个 worker 同时监听同一个端口,不会端口冲突吗?不会。cluster 模块在主进程里做了手脚:主进程先绑定端口,然后以 round-robin(轮询)的方式把连接分发给各个 worker。这是除 Windows 外的默认策略。
在 Windows 上,默认策略是 SCHED_NONE,由操作系统把连接交给最先抢到的 worker。如果你希望所有平台统一行为,可以手动设置:
cluster.schedulingPolicy = cluster.SCHED_RR; // 轮询
// 或者
cluster.schedulingPolicy = cluster.SCHED_NONE; // 由 OS 决定
主进程和 worker 通信
进程之间内存不共享,但可以通过 IPC(进程间通信)传消息。比如你想统计每个 worker 处理了多少请求:
import cluster from 'node:cluster';
import http from 'node:http';
import { availableParallelism } from 'node:os';
if (cluster.isPrimary) {
const stats = new Map();
for (let i = 0; i < availableParallelism(); i++) {
const worker = cluster.fork();
stats.set(worker.id, 0);
worker.on('message', (msg) => {
if (msg.type === 'request') {
stats.set(worker.id, (stats.get(worker.id) || 0) + 1);
}
});
}
// 每 10 秒打印一次统计
setInterval(() => {
console.log('--- 请求统计 ---');
for (const [id, count] of stats) {
console.log(` worker ${id}: ${count} 次`);
}
}, 10000);
cluster.on('exit', (worker) => {
if (!worker.exitedAfterDisconnect) {
cluster.fork();
}
});
} else {
http.createServer((req, res) => {
process.send({ type: 'request' });
res.end(`worker ${process.pid}\n`);
}).listen(3000);
}
worker 里调用 process.send() 给主进程发消息,主进程通过 worker.on('message') 接收。注意消息会被结构化克隆算法序列化,所以不能传函数或循环引用的对象。
优雅退出
生产环境不能随便杀进程。正在处理的请求如果被中断,用户就会看到 502。cluster 支持优雅退出:先停止接收新连接,等现有请求处理完再退出。
import cluster from 'node:cluster';
import http from 'node:http';
import { availableParallelism } from 'node:os';
if (cluster.isPrimary) {
for (let i = 0; i < availableParallelism(); i++) {
cluster.fork();
}
// 收到 SIGTERM 时优雅关闭
process.on('SIGTERM', () => {
console.log('主进程收到 SIGTERM,开始优雅关闭');
for (const id in cluster.workers) {
cluster.workers[id].send({ type: 'shutdown' });
}
});
} else {
const server = http.createServer((req, res) => {
res.end('ok\n');
});
server.listen(3000);
process.on('message', (msg) => {
if (msg.type === 'shutdown') {
console.log(`worker ${process.pid} 收到关闭信号`);
server.close(() => {
console.log(`worker ${process.pid} 已关闭连接,退出`);
process.exit(0);
});
// 兜底:5 秒后强制退出
setTimeout(() => process.exit(1), 5000);
}
});
}
server.close() 会等待现有连接处理完,不再接受新连接。加一个超时兜底,防止有长连接一直拖着不走。
Warningworker 之间不共享内存。如果你在进程里存了 session、缓存或者限流计数器,换了一个 worker 处理请求就失效了。生产环境这类状态应该放到 Redis 这类外部存储里。
零停机重启
部署新版本时,如果直接杀掉所有 worker 再启动新的,会有短暂的服务不可用。用 cluster 可以实现逐个替换 worker:
import cluster from 'node:cluster';
import http from 'node:http';
import { availableParallelism } from 'node:os';
if (cluster.isPrimary) {
const workers = [];
for (let i = 0; i < availableParallelism(); i++) {
workers.push(cluster.fork());
}
function reload() {
let idx = 0;
function replace() {
if (idx >= workers.length) {
console.log('全部 worker 已替换');
return;
}
const oldWorker = workers[idx];
const newWorker = cluster.fork();
newWorker.on('listening', () => {
oldWorker.send({ type: 'shutdown' });
workers[idx] = newWorker;
idx++;
setTimeout(replace, 1000);
});
}
replace();
}
// 模拟收到部署信号后触发 reload
setTimeout(reload, 30000);
cluster.on('exit', (worker, code, signal) => {
if (!worker.exitedAfterDisconnect) {
console.log('worker 异常退出,重启');
cluster.fork();
}
});
} else {
const server = http.createServer((req, res) => {
res.end(`version 2, pid ${process.pid}\n`);
}).listen(3000);
process.on('message', (msg) => {
if (msg.type === 'shutdown') {
server.close(() => process.exit(0));
setTimeout(() => process.exit(1), 5000);
}
});
}
新 worker 启动并监听端口后,再让旧 worker 退出。这样任意时刻都有 worker 在提供服务。
生产环境的建议
手写的 cluster 代码适合理解原理,但生产环境我更推荐用 PM2。它能自动 cluster、自动重启、内置日志收集、零停机部署,省去你维护一堆 IPC 逻辑的心力。
如果你确实要自己用 cluster,记住这几点:
- worker 数量通常等于 CPU 核数,不是越多越好。进程多了上下文切换反而拖累性能。
- 不要信任进程内状态,session、缓存全走 Redis。
- 处理好
exitedAfterDisconnect,区分正常退出和异常崩溃,避免优雅关机时无限重启。 - 监听
SIGTERM和SIGINT,容器和 systemd 发的是这两个信号。