3. Cluster 模块实战:多进程架构、负载均衡策略与进程间通信

说实话,单线程的 Node.js 跑得挺欢,但一旦遇到 CPU 密集型任务,或者流量突然暴涨,你就会发现它有点力不从心。这时候,Cluster 模块就是你的救星。

我最早接触 Cluster 是在一个实时聊天系统里。单进程扛不住 2000 并发,加了 Cluster 之后,直接撑到 8000+。嗯,今天我们就来聊聊这个模块怎么用,以及背后的门道。

3.1 多进程架构:为什么需要 Cluster?

Node.js 默认是单进程单线程的。这意味着,你 8 核的服务器,只有一个核在干活。其他 7 个核在旁边看热闹。你说浪费不浪费?

Cluster 模块的核心思想很简单:启动一个主进程(Master),然后 fork 出多个工作进程(Worker)。每个 Worker 独立运行,共享同一个端口。

说白了,就是让 Node.js 也能利用多核 CPU。我习惯把 Master 叫做「包工头」,Worker 叫做「工人」。包工头负责分配任务,工人负责干活。

核心概念:
  • Master 进程:负责管理 Worker,不处理具体请求
  • Worker 进程:实际处理请求的进程,每个 Worker 都是一个独立的 Node.js 实例
  • 共享端口:所有 Worker 监听同一个端口,由操作系统或 Cluster 模块分发请求

我在项目中遇到过一个问题:如果 Worker 数量等于 CPU 核心数,性能最好。但如果你有 I/O 密集型任务,可以适当多开几个。为什么?因为 I/O 操作不占 CPU,多开几个 Worker 能提高吞吐量。

3.2 负载均衡策略:请求怎么分?

Cluster 模块内置了两种负载均衡策略。你想想看,多个 Worker 抢一个端口,总得有个规则吧?

策略 模式 特点 适用场景
Round-Robin(轮询) cluster.SCHED_RR 按顺序分配,公平 Windows、大多数 Linux
操作系统默认 cluster.SCHED_NONE 由内核决定,性能高 Linux 下性能敏感场景

我个人习惯在 Linux 上用 Round-Robin。虽然操作系统默认策略性能更高,但轮询策略更可控。我曾经在生产环境遇到过一个问题:某个 Worker 因为 GC 卡住了,操作系统默认策略还往它那发请求,导致部分用户响应超时。换成轮询后,至少能保证请求均匀分布。

小技巧: 可以通过环境变量 NODE_CLUSTER_SCHED_POLICY 来切换策略。rr 表示轮询,none 表示操作系统默认。

3.3 进程间通信:Worker 之间怎么聊天?

Worker 之间不能直接通信。它们各自独立,内存不共享。那怎么办?通过 Master 进程转发。

Cluster 模块提供了两种通信方式:

  • Master 向 Worker 发消息:使用 worker.send()
  • Worker 向 Master 发消息:使用 process.send()
  • Worker 之间通信:必须经过 Master 中转

来看一个实际例子。假设我们有一个计数器,所有 Worker 共享这个计数。因为内存不共享,所以得通过 Master 来维护:

// master.js
const cluster = require('cluster');
const os = require('os');

if (cluster.isMaster) {
  let counter = 0;

  console.log(`Master ${process.pid} 启动`);

  // fork 出 4 个 Worker
  for (let i = 0; i < os.cpus().length; i++) {
    const worker = cluster.fork();

    // 监听 Worker 发来的消息
    worker.on('message', (msg) => {
      if (msg.type === 'increment') {
        counter++;
        console.log(`当前计数: ${counter}`);
      }
    });
  }

  // 定期广播计数给所有 Worker
  setInterval(() => {
    for (const id in cluster.workers) {
      cluster.workers[id].send({ type: 'counter', value: counter });
    }
  }, 5000);

} else {
  // Worker 进程
  process.on('message', (msg) => {
    if (msg.type === 'counter') {
      console.log(`Worker ${process.pid} 收到计数: ${msg.value}`);
    }
  });

  // 模拟请求,每 2 秒增加一次计数
  setInterval(() => {
    process.send({ type: 'increment' });
  }, 2000);
}

嗯,这里要注意:消息传递是异步的,而且有序列化开销。 如果你频繁传递大量数据,性能会下降。我曾经在日志聚合系统里踩过这个坑——每个请求都发一个完整对象给 Master,结果 Master 成了瓶颈。后来改成只发关键字段,性能提升了 3 倍。

避坑指南: 我曾经在 Worker 之间共享 Session 时犯过错误。因为内存不共享,每个 Worker 都有自己的 Session 数据。用户第一次请求落在 Worker A,第二次可能落在 Worker B,Session 就丢了。解决方案:用 Redis 等外部存储共享 Session。

3.4 优雅退出与进程守护

Worker 进程随时可能挂掉。内存泄漏、未捕获异常、系统资源耗尽……原因很多。Cluster 模块提供了事件监听,让我们可以优雅处理:

// Master 进程监听 Worker 退出事件
cluster.on('exit', (worker, code, signal) => {
  console.log(`Worker ${worker.process.pid} 挂了,退出码: ${code}`);

  // 自动重启
  const newWorker = cluster.fork();
  console.log(`新 Worker ${newWorker.process.pid} 已启动`);
});

// Worker 进程优雅退出
process.on('SIGTERM', () => {
  console.log('收到 SIGTERM 信号,开始优雅退出...');

  // 停止接收新请求
  server.close(() => {
    console.log('所有连接已关闭,进程退出');
    process.exit(0);
  });

  // 强制退出,防止一直挂起
  setTimeout(() => {
    console.error('强制退出');
    process.exit(1);
  }, 10000);
});

我习惯在 Master 里加一个心跳检测。每隔 5 秒向 Worker 发一个 ping,如果 Worker 没回应,就认为它挂了,直接重启。这样能及时发现「假死」的 Worker。

生产环境建议: 不要直接用 cluster 模块的自动重启。配合 PM2 或 Docker 的容器编排,管理更灵活。PM2 的 cluster 模式其实底层就是用的 cluster 模块,但多了日志管理、监控等能力。

3.5 实战:构建一个高可用的 HTTP 服务

把上面的知识点串起来,写一个完整的例子:

const cluster = require('cluster');
const http = require('http');
const os = require('os');

const PORT = 3000;
const WORKER_COUNT = os.cpus().length;

if (cluster.isMaster) {
  console.log(`Master ${process.pid} 启动,将 fork ${WORKER_COUNT} 个 Worker`);

  // 创建 Worker
  for (let i = 0; i < WORKER_COUNT; i++) {
    const worker = cluster.fork();

    // 监听 Worker 消息
    worker.on('message', (msg) => {
      if (msg.type === 'request_log') {
        console.log(`[Master] Worker ${worker.process.pid} 处理了请求: ${msg.url}`);
      }
    });
  }

  // Worker 退出时自动重启
  cluster.on('exit', (worker, code, signal) => {
    console.log(`Worker ${worker.process.pid} 挂了,退出码: ${code}`);
    const newWorker = cluster.fork();
    console.log(`新 Worker ${newWorker.process.pid} 已启动`);
  });

  // 优雅退出 Master
  process.on('SIGTERM', () => {
    console.log('Master 收到 SIGTERM,通知所有 Worker 退出');
    for (const id in cluster.workers) {
      cluster.workers[id].kill('SIGTERM');
    }
    process.exit(0);
  });

} else {
  // Worker 进程
  const server = http.createServer((req, res) => {
    // 模拟 CPU 密集型任务
    const start = Date.now();
    while (Date.now() - start < 100) {
      // 模拟 100ms 的计算
    }

    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.end(`Worker ${process.pid} 处理完成\n`);

    // 通知 Master 记录日志
    process.send({ type: 'request_log', url: req.url });
  });

  server.listen(PORT, () => {
    console.log(`Worker ${process.pid} 开始监听端口 ${PORT}`);
  });

  // 优雅退出 Worker
  process.on('SIGTERM', () => {
    console.log(`Worker ${process.pid} 收到 SIGTERM,开始优雅退出`);
    server.close(() => {
      console.log(`Worker ${process.pid} 所有连接已关闭`);
      process.exit(0);
    });
  });
}

这个例子包含了:多进程架构、负载均衡、进程间通信、优雅退出、自动重启。你可以在本地跑一下,看看效果。

最后说一句:Cluster 模块不是银弹。 如果你的应用主要是 I/O 操作,单进程配合异步 I/O 已经够用。Cluster 的真正价值在于利用多核 CPU 处理 CPU 密集型任务,以及提高可用性——一个 Worker 挂了,还有其他 Worker 顶着。

嗯,下一章我们会聊聊更高级的话题:如何用 Cluster 配合 Redis 实现跨进程的 Session 共享。到时候见。