Node.js 的 cluster 模块怎样实现多进程部署?PM2 做了什么?
cluster 让主进程 fork 多个工作进程共享同一个端口,默认(Windows 除外)由主进程按轮询把连接分给工作进程。工作进程不共享内存,会话等状态要放外部存储;进程数量和重启要应用自己管理,PM2 这类进程管理器的 cluster 模式就是在 cluster 模块之上做了这些事。
简答
一个 Node.js 进程里的 JavaScript 只跑在一个主线程上,机器有 8 核,单进程的 HTTP 服务也只能用满一核。cluster 模块的做法是让一个主进程 fork 出多个工作进程,每个工作进程跑同一份服务代码,并且都能监听同一个端口。
连接怎么分?除 Windows 外的平台默认用轮询:主进程自己监听端口、接收连接,再轮流交给工作进程。另一种方式是让工作进程直接接收连接,可官方文档说实际分配会很不均衡。
好处是用满多核,而且一个工作进程崩溃不影响其他进程,主进程收到
exit事件后可以再 fork 一个。PM2 的 cluster 模式就是基于这个模块,再加上按 CPU 数量启动实例和零停机的reload。要注意两点。工作进程之间不共享内存,会话、登录状态、限流计数要放到 Redis 这类外部存储。还有,Node.js 不会自动维护工作进程的数量,崩溃后重启要应用或进程管理器自己做。
最小的 cluster 服务长什么样
下面的写法来自 Node.js 官方文档,主进程按可用并行度 fork 工作进程,工作进程各自创建 HTTP 服务并监听 8000 端口。
import cluster from 'node:cluster';
import http from 'node:http';
import { availableParallelism } from 'node:os';
import process from 'node:process';
if (cluster.isPrimary) {
for (let i = 0; i < availableParallelism(); i++) {
cluster.fork();
}
cluster.on('exit', (worker, code, signal) => {
console.log(`工作进程 ${worker.process.pid} 退出,重新启动一个`);
cluster.fork();
});
} else {
http
.createServer((req, res) => {
res.end(`由 ${process.pid} 处理\n`);
})
.listen(8000);
}
同一份文件会执行多次。第一次执行时 cluster.isPrimary 为真,进入主进程分支;cluster.fork() 底层用的是 child_process.fork(),新进程重新执行这个文件,这时 isPrimary 为假,走工作进程分支。
多个进程都调用 listen(8000),为什么不报端口占用?因为工作进程里的 server.listen() 会把大部分工作交给主进程,监听 socket 由主进程创建;在默认的轮询方式下,工作进程拿到的是主进程转交的连接。
exit 事件里重新 fork 这一步必须自己写。官方文档明确说 Node.js 不会自动管理工作进程数量,进程全部退出后,已有连接会断开,新连接会被拒绝。
连接是怎样分到各个工作进程的
cluster 支持两种分配方式,对应 cluster.schedulingPolicy 的两个值。SCHED_RR 是轮询,主进程接收连接后轮流发给工作进程,这是除 Windows 外所有平台的默认值。SCHED_NONE 是把监听 socket 交给工作进程,由它们直接抢连接,交给操作系统调度。
第二种理论上更快,可官方文档记录过,8 个进程里超过 70% 的连接落到了其中 2 个进程上,所以默认用轮询。这个策略也能通过环境变量 NODE_CLUSTER_SCHED_POLICY 设置为 rr 或 none,在第一个工作进程启动后就不能再改。
为什么状态不能放在进程内存里
每个工作进程是独立的进程,内存完全隔离。如果把会话存在某个进程的一个对象里,用户下一次请求被分到另一个进程,就找不到这个会话了。官方文档也提醒,Node.js 不提供路由逻辑,应用不要过度依赖内存里的会话和登录数据。
所以多进程部署时,会话、缓存、限流计数要放进 Redis 或数据库;需要在进程之间传消息时,可以用工作进程和主进程之间的 IPC(worker.send() 和 process.on('message')),但它只在同一台机器上有效。
PM2 在 cluster 之上做了什么
PM2 是常见的 Node.js 进程管理器。它的文档说明,cluster 模式依赖 Node.js 的 cluster 模块,用 pm2 start app.js -i max 就能按 CPU 数量启动多个实例,应用代码不用改。
它还提供 reload:和先杀掉再启动的 restart 不同,reload 做到零停机重载;重载超时会退回普通的 restart。进程数量管理和不停机发布,正是手写 cluster 时要自己处理的部分。
选型时要知道 PM2 采用 AGPL-3.0 许可。另外在 Kubernetes 这类容器平台上,常见做法是每个容器只跑一个进程,扩容和重启交给平台,详见《Node.js 的子进程、Worker 和集群应该怎样选择?》。
常见追问
cluster 和 Worker Threads 有什么区别?
cluster 复制的是整个服务进程,用来让 HTTP 服务用满多核,进程之间内存隔离。Worker Threads 是同一进程里的线程,用来把一段 CPU 密集的计算挪出主线程。前者解决吞吐,后者解决单个请求里的重计算。
发布新版本时怎样不中断服务?
思路是逐个替换:先启动新的工作进程,等它开始监听,再让旧进程调用
worker.disconnect()停止接收新连接,处理完已有请求后退出。具体的退出细节见《Node.js 服务如何实现优雅退出?》。