首页 / Node.js 教程 / cluster 集群

Node.js 教程

cluster 集群

本教程共 76 篇 · 第 34 篇 · 更新于 2026-07-25 · 约 7 分钟阅读

Node.jscluster多进程负载均衡多核

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() 会等待现有连接处理完,不再接受新连接。加一个超时兜底,防止有长连接一直拖着不走。

Warning

worker 之间不共享内存。如果你在进程里存了 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,记住这几点:

  1. worker 数量通常等于 CPU 核数,不是越多越好。进程多了上下文切换反而拖累性能。
  2. 不要信任进程内状态,session、缓存全走 Redis。
  3. 处理好 exitedAfterDisconnect,区分正常退出和异常崩溃,避免优雅关机时无限重启。
  4. 监听 SIGTERMSIGINT,容器和 systemd 发的是这两个信号。