集群模式与零停机发布
为什么要集群: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 里怎么开启(两种等价写法):
# CLI:实例数 = CPU 核数
pm2 start app.js -i max// 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 次。
处理套路:
// 只在第一个 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(与优雅重载)
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)
// 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) 优雅退出:收到停止信号时,先停止接收新连接、处理完存量请求再退出
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:
{
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 优雅退出最多等多久,超时强杀 SIGKILL | 1600ms(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
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) |
| 强粘性会话且外层无法做 sticky | round-robin 会丢会话 | nginx/网关 ip_hash |
| 一次性任务/脚本 | 不需要常驻 | fork + --no-autorestart |
Node 应用想横向扩展,优先考虑"多实例(PM2 cluster)+ 共享存储";若单机都不够,再谈多机(前面加负载均衡器,PM2 退居单机托管)。
本课演练(建议亲手跑一遍)
# 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参考链接
- 官方集群模式:https://pm2.keymetrics.io/docs/usage/cluster-mode/
- 官方优雅启停(signals & clean restart):https://pm2.keymetrics.io/docs/usage/signals-clean-restart/
