凌晨两点发布新版本,监控系统却报出一波502,这不是运气差,而是重启方式不对。Node.js默认收到kill信号后会立刻退出,正在处理的请求直接被掐断,TCP层面表现为连接重置,网关层自然返回错误。所谓优雅重启,就是让进程在退出前把该做的事做完:不再接新活,把手头的活干完,再安静地离开。本文围绕PM2的reload机制,把无感知发布的完整链路讲清楚。

一、优雅重启的本质:连接排空与信号处理
要理解优雅重启,先要理解Node.js进程收到退出信号时发生了什么。当PM2执行reload命令时,它会向worker进程发送SIGINT信号。如果代码里没有任何监听逻辑,Node.js默认行为是直接终止事件循环,正在执行的HTTP请求、数据库事务、消息队列消费任务全部半途而废。
优雅重启的标准流程分为三步。第一步,服务调用server.close(),这个方法的作用是停止监听端口,内核不再把新连接分发到该进程,但已经建立的连接不受影响。第二步,等待存量请求处理完毕,这需要业务侧自行判断,常见做法是维护一个计数器,每个请求进入时加一,响应结束时减一,归零即表示可以退出。第三步,真正调用process.exit()退出,把位置让给新版本的进程。
下面是一段实现了连接排空的典型代码:
const http = require('http');
let connections = 0;
const server = http.createServer((req, res) => {
connections++;
// 模拟一个耗时300ms的业务处理
setTimeout(() => {
res.end('ok');
connections--;
}, 300);
});
server.listen(3000);
// 收到PM2发来的信号,开始优雅退出
process.on('SIGINT', () => {
console.log('收到退出信号,停止接收新连接');
server.close(() => {
console.log('所有连接已排空');
process.exit(0);
});
// 兜底:8秒后强制退出,防止个别请求卡死
setTimeout(() => process.exit(0), 8000);
});这段代码里有一个容易被忽略的细节:setTimeout兜底退出。没有它的话,一旦某个请求挂死,进程就永远不会退出,PM2会认为reload超时并强制kill,反而退化成了暴力重启。所以生产代码中,优雅退出一定要带超时上限,一般设置为比网关超时时间略短的值。
二、PM2 reload的工作原理与cluster模式
很多人以为reload只是一个更温和的restart,这种理解只对了一半。在fork模式下,reload确实和restart几乎一样,进程就是被杀掉重建的。reload的威力要在cluster模式下才能体现。cluster模式下,PM2的master进程持有监听端口,多个worker共享这个端口,操作系统通过负载均衡把请求分发到各个worker。
执行pm2 reload app时,PM2并不是同时杀掉所有worker,而是逐个处理:先向一个worker发信号,等它完成连接排空并退出后,再拉起一个新版本worker,确认新worker状态正常后,才轮到下一个旧worker。这就是滚动重启,任意时刻都有足够的worker在服务,外部请求完全感知不到版本切换的过程。
实际操作中的命令组合如下:
# 以cluster模式启动4个实例 pm2 start app.js -i 4 # 优雅重启,逐个重启worker pm2 reload app # 如果代码没做优雅退出处理,可加update-env刷新环境变量 pm2 reload app --update-env
需要注意PM2的两个关键配置。kill_timeout决定了从发送信号到强制kill的等待时间,默认1600毫秒,如果业务请求普遍需要两三秒,务必调大这个值,否则排空还没完成进程就被强杀了。wait_ready配合process.send('ready')使用,可以让PM2等到应用真正初始化完成(比如预热了连接池、注册了路由缓存)后才认为worker就绪,避免新worker刚启动就接流量导致首波请求变慢。
三、健康检查与无感发布的完整方案
优雅重启解决的是存量请求不断的问题,但一套完整的无感发布还需要健康检查配合。思路是:在负载均衡层(Nginx或云厂商的SLB)配置健康检查接口,发布时先把待发布实例从负载池摘除,等流量彻底切走后再执行reload,这层保护对多机部署尤其重要。
健康检查接口的实现有讲究。如果只是简单返回200,进程假死时检测不出来,因为事件循环卡死后静态响应依然可能由内核缓冲返回。更可靠的做法是在接口里顺便报告事件循环延迟:
const { monitorEventLoopDelay } = require('perf_hooks');
const h = monitorEventLoopDelay({ resolution: 20 });
h.enable();
http.createServer((req, res) => {
if (req.url === '/healthz') {
const lag = h.mean();
// 事件循环延迟超过500ms视为不健康
if (lag > 500 * 1e6) {
res.writeHead(503);
return res.end('unhealthy');
}
return res.end('healthy');
}
// 正常业务逻辑
});有了健康检查,发布脚本就可以串起完整流程:从负载池摘除节点、等待连接排空(一般等一个负载均衡的drain时间)、执行pm2 reload、健康检查通过后重新加入负载池。这套流程配合CI/CD流水线,就实现了真正意义上的零停机发布。
四、WebSocket与keep-alive场景的坑
HTTP keep-alive是优雅重启最大的隐形杀手。server.close()只停止接收新连接,但keep-alive机制下,客户端的连接会一直保持空闲状态不关闭,server.close()的回调要等所有连接断开才触发,结果就是进程迟迟不退出,最后被PM2强杀。解决办法有两个:给每个连接设置空闲超时,server.keepAliveTimeout设为5秒左右;或者在退出时主动调用socket.end()强制断开空闲连接。
WebSocket的处境类似但更棘手。长连接意味着server.close()几乎永远等不到连接归零。业界通用做法是退出前给所有客户端广播一条重连通知,客户端收到后主动断开并重连到新实例,服务端再等待一个宽限期(比如10秒)后强制关闭剩余连接。客户端一定要实现指数退避重连,否则所有客户端同时重连会形成惊群效应,瞬间压垮服务。
// WebSocket服务退出前的通知逻辑
const wss = new (require('ws').Server)({ server });
process.on('SIGINT', () => {
server.close();
// 通知所有客户端:请重新连接
wss.clients.forEach(client => {
client.send(JSON.stringify({ type: 'reconnect' }));
});
// 给客户端10秒时间完成重连迁移
setTimeout(() => {
wss.clients.forEach(client => client.terminate());
process.exit(0);
}, 10000);
});总结一下落地要点:cluster模式是前提,kill_timeout要匹配业务的P99耗时,退出逻辑必须带超时兜底,keep-alive和长连接要主动处理,多机部署搭配负载均衡健康检查。把这五点做扎实,Node.js服务就能做到每一次发版都不丢一个请求。
Node.js优雅重启PM2 reload零停机发布修改时间:2026-09-09 03:30:37