Skip to content

集群模式与零停机发布

定位:多核部署 + 发版不停机的核心章节。
前置:核心概念(fork/cluster 区别)、配置文件

为什么要集群:Node 的"单线程瓶颈"

Node 应用默认跑在 单进程、单线程 上:一个进程只能吃满一个 CPU 核。现代服务器动辄 4~32 核,单进程部署 = 浪费大部分算力。

解决方案就是 cluster(集群)模式:让多个 worker 进程共享同一个端口,外部请求被分发到不同 worker,从而并行利用多核。

实现机制:PM2 借助 Node 的 cluster 模块,让多个进程共享同一端口,并把连接(HTTP(S)/WebSocket/TCP/UDP)分发到各 worker;Linux 上默认 round-robin 轮询。

                    ┌───────────────────────────┐
   请求 ───────────▶ │   PM2 daemon             │ 负载分发(round-robin)
                    │ (借 Node cluster 实现)    │
                    └──────┬────────┬───────────┘
                        worker0  worker1 ... workerN
                        (同一份 app 代码,各自独立进程,共享端口)

PM2 里怎么开启(两种等价写法):

bash
# CLI:实例数 = CPU 核数
pm2 start app.js -i max
js
// ecosystem(推荐)
{
  name: 'api',
  script: 'app.js',
  exec_mode: 'cluster',
  instances: 'max',   // 或写具体数字 2/4/8
}

instances 的取值:数字 = 固定实例数;0/max = 全部 CPU 核数;-1 = 核数减 1。另:对 Node 应用设置 instances(>1)会自动启用 cluster,exec_mode: 'cluster' 可省略(写上更清晰)。

必须理解的三个 cluster 特性

负载分发是"进程级"的

PM2(借助 Node 的 cluster 模块)在多个 worker 间分发连接。Linux 上默认 round-robin 轮询。这意味着同一个客户端的连续请求可能落在不同 worker 上

  • 如果你的应用把会话/登录态存在进程内存里(如 global.session),第二次请求换到别的 worker 就"失忆"了;
  • 解决:把共享状态放到 Redis/数据库,或在外层(nginx/haproxy)做粘性会话(sticky session)——PM2 集群内部不提供粘性保证。

内存不共享,单例不单例

每个 worker 是独立进程:定时器、内存缓存、全局单例在 N 个 worker 里会跑 N 份。例如"每天 0 点清一次缓存"的 cron,集群下会执行 N 次。

处理套路:

js
// 只在第一个 worker 里做"只应做一次"的事
if (process.env.NODE_APP_INSTANCE === '0') {
  startDailyJob();          // 或干脆用外部 cron/队列
}

NODE_APP_INSTANCE 是 PM2 注入的实例序号环境变量(0、1、2…),变量名可用 ecosystem 的 instance_var 改。

平滑操作靠"逐个替换"

restart 是"全部先停再起"——有中断;而 reload逐个把旧 worker 换成新 worker:新 worker 起来并确认就绪后,才替换下一个。这就是零停机发布的基础(详见 §3)。

零停机:pm2 reload(与优雅重载)

bash
pm2 reload api          # 逐个重启 api 的 worker,0 秒停机(cluster)
pm2 reload all
# 旧版 CLI 另有 pm2 gracefulReload <name>,语义见下

官方口径要点:

  • reload 承诺 0 秒停机(针对网络型/cluster 应用):逐个替换 worker,而不是"全部停 → 全部起"。若某个 worker 在超时内未能完成替换,会自动回退为普通 restart,不会卡死发布。
  • 优雅重载的真谛在应用侧:只要应用截获 PM2 发来的停止信号(Linux 默认 SIGINT)并自行清理退出,pm2 reload 就会等旧 worker"处理完存量请求再下线"——官方文档称此时 reload 即 gracefulReload。历史上独立的 pm2 gracefulReload 命令即此语义;版本里没有它也没关系,用 reload + §3.1 的代码即可。

什么时候用:

场景用哪个
发新代码(无长连接/无状态)pm2 reload
有存量请求/长连接需要"处理完再下线"pm2 reload(配合 §3.1 的 SIGINT 优雅退出代码,即优雅重载)
fork 模式 / 无法接受任何抖动pm2 restart(无法零停机,属正常)

⚠️ reload 的 0 秒停机只在 cluster 模式有效。fork 模式 pm2 reload 退化为 restart(有中断);想真零停机请用 cluster。

3.1 让 reload 真正"零停机":就绪与优雅退出

PM2 保证的是"进程管理层面"不中断;要业务也不中断,应用必须配合两件事:

(1) 告诉 PM2“我准备好接客了”(配合 wait_ready: true

js
// app.js(cluster 下每个 worker 都会跑)
const server = require('http').createServer((req, res) => {
  res.end('ok from worker ' + process.env.NODE_APP_INSTANCE);
});

server.listen(3000, () => {
  // 通知 PM2:本 worker 已可对外服务
  if (process.send) process.send('ready');
  console.log('worker', process.env.NODE_APP_INSTANCE, 'ready');
});

(2) 优雅退出:收到停止信号时,先停止接收新连接、处理完存量请求再退出

js
let shuttingDown = false;
async function shutdown(signal) {
  if (shuttingDown) return;
  shuttingDown = true;
  console.log(signal, 'received, draining...');
  server.close(() => {          // 停止接收新连接,等存量请求完成
    console.log('closed, bye');
    process.exit(0);
  });
  // 兜底:万一存量请求一直不完,强制退出,别让 PM2 干等
  setTimeout(() => process.exit(1), 10000).unref();
}
process.on('SIGINT', () => shutdown('SIGINT'));    // PM2 默认停止信号
process.on('SIGTERM', () => shutdown('SIGTERM'));

对应的 ecosystem:

js
{
  name: 'api',
  script: 'app.js',
  exec_mode: 'cluster',
  instances: 'max',
  wait_ready: true,       // 等 app 发 ready 才算已就绪(默认 false)
  listen_timeout: 5000,   // 等就绪的最长时间(默认 3000ms)
  kill_timeout: 10000,    // 优雅退出宽限(默认 1600ms/1.6s),超时 SIGKILL
}

关键参数速记

参数回答的问题默认
wait_ready: true是否改为等应用发 ready 消息才算就绪false(否则默认听 listening 事件)
listen_timeout等就绪最多等多久,超时视为失败3000ms
kill_timeout旧 worker 优雅退出最多等多久,超时强杀 SIGKILL1600ms(1.6s)
kill_signal让进程退出的首信号SIGINT(全局可用环境变量 PM2_KILL_SIGNAL 换 SIGTERM 等)

信号说明(Linux):PM2 停止/替换进程时先发 SIGINT,应用收到后应停止接新连接、排空存量请求再自行退出;超时未退则收 SIGKILL。官方仅在 Windows(信号不可用)场景建议用 shutdown_with_message: true + 监听 process.on('message') 中值为 'shutdown' 的消息——Linux 直接处理 SIGINT 即可。

检查零停机效果:pm2 reload api 的同时开另一个终端不停 curl,观察是否出现连接失败;失败就去查是否忘了 wait_ready/优雅退出代码。

扩容缩容:pm2 scale

bash
pm2 scale api 8        # 扩/缩到 8 个实例
pm2 scale api +2       # 增加 2 个
pm2 scale api -3       # 减少 3 个(多余的 worker 会被停掉)
pm2 scale api max      # 扩到 CPU 核数

scale 仅对 cluster 模式生效(fork 单实例无法扩)。缩容会停掉部分 worker(正在处理的请求会受影响)——流量低峰做更稳。

什么时候"别用 cluster"

别用 cluster 的场景原因替代
小流量单核即可多进程徒增内存/复杂度fork 单实例
大量进程内存态状态又无法外部化worker 间不共享内存先做状态外置(Redis/DB)
强粘性会话且外层无法做 stickyround-robin 会丢会话nginx/网关 ip_hash
一次性任务/脚本不需要常驻fork + --no-autorestart

Node 应用想横向扩展,优先考虑"多实例(PM2 cluster)+ 共享存储";若单机都不够,再谈多机(前面加负载均衡器,PM2 退居单机托管)。

本课演练(建议亲手跑一遍)

bash
# 1) 用上文的 app.js
pm2 start app.js -i max --name api-demo
pm2 ls                      # 看到 mode=cluster、多个 id、相同 name

# 2) 观察 worker 分布
for i in $(seq 1 8); do curl -s http://127.0.0.1:3000; done
# 输出里的 worker 编号应在 0..N-1 间轮换 —— 负载均衡在工作

# 3) 零停机演练
while true; do curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3000; sleep 0.2; done
# 另开终端:
pm2 reload api-demo         # 观察上方循环不应出现失败
pm2 delete api-demo

参考链接