一、为什么需要优雅停机和平滑重启
想象一下你正在运营一个在线商城,半夜里需要更新服务器代码。如果直接粗暴地关闭服务,正在下单的用户可能会看到支付失败的提示,这种体验就像在超市排队结账时突然被店员赶出门外。优雅停机就是为了避免这种尴尬场景,让服务能够体面地和用户说再见。
再比如你的服务流量突然暴增,需要增加服务器资源。平滑重启就像给行驶中的汽车换轮胎,既要保证服务不中断,又要完成更新操作。这两个机制是构建可靠Node.js服务的必备技能。
二、实现优雅停机的基础方案
我们先从最简单的场景开始。Node.js的http服务器提供了close()方法,但直接使用会有问题。来看个典型例子:
// 技术栈:Node.js v16+
const http = require('http');
const server = http.createServer((req, res) => {
// 模拟长时间请求
if (req.url === '/long') {
setTimeout(() => res.end('Done'), 5000);
return;
}
res.end('OK');
});
server.listen(3000);
// 错误示范:直接关闭会导致已有连接中断
process.on('SIGTERM', () => {
console.log('收到终止信号');
server.close(); // 这不会等待已有请求完成
});
这个方案的问题在于:当收到终止信号时,正在处理的5秒长请求会被强制中断。我们需要改进方案:
// 改进版:记录活跃连接
const connections = new Set();
server.on('connection', (socket) => {
connections.add(socket);
socket.on('close', () => connections.delete(socket));
});
process.on('SIGTERM', () => {
console.log('开始优雅停机');
// 停止接收新请求
server.close(() => {
console.log('所有新请求已停止');
});
// 设置关机超时
const shutdownTimer = setTimeout(() => {
console.warn('强制关闭剩余连接');
connections.forEach(sock => sock.destroy());
}, 10000);
// 等待现有请求完成
const cleanup = () => {
clearTimeout(shutdownTimer);
if (connections.size === 0) {
console.log('所有请求处理完成');
process.exit(0);
}
};
// 主动关闭空闲连接
connections.forEach(sock => {
if (sock._idleTimeout) sock.end();
});
// 每秒钟检查一次
const checker = setInterval(cleanup, 1000);
});
这个方案实现了几个关键点:
- 通过Set记录所有活跃连接
- 先停止接收新请求
- 给现有请求合理的时间完成
- 设置超时强制关闭机制
- 定期检查是否可以安全退出
三、进阶方案:使用进程管理工具
实际生产环境我们通常会使用PM2这样的进程管理器。它内置了更完善的优雅停机机制:
// 技术栈:PM2 + Node.js
// app.js
process.on('SIGINT', async () => {
await saveCurrentState(); // 保存应用状态
await closeDatabaseConnections(); // 关闭数据库连接
server.close(); // 关闭服务器
});
// PM2配置 ecosystem.config.js
module.exports = {
apps: [{
name: 'api',
script: './app.js',
kill_timeout: 15000, // 等待优雅退出的最长时间
wait_ready: true, // 等待进程发送ready信号
listen_timeout: 5000 // 等待ready信号的时间
}]
};
PM2的工作流程更完善:
- 发送SIGINT信号通知应用准备关闭
- 等待应用发送进程就绪信号
- 如果在配置时间内未完成,则强制终止
四、实现零停机重启的几种方式
平滑重启的目标是在不中断服务的情况下完成更新。以下是三种常用方法:
方法1:集群模式热更新
// 技术栈:Node.js集群模块
const cluster = require('cluster');
const numCPUs = require('os').cpus().length;
if (cluster.isMaster) {
// 启动工作进程
for (let i = 0; i < numCPUs; i++) {
cluster.fork();
}
// 收到重启信号时逐个替换worker
process.on('SIGUSR2', () => {
const workers = Object.values(cluster.workers);
function restartWorker(i) {
if (i >= workers.length) return;
const worker = workers[i];
console.log(`重启worker ${worker.id}`);
// 启动新进程
const newWorker = cluster.fork();
newWorker.on('listening', () => {
// 新进程就绪后终止旧进程
worker.kill();
restartWorker(i + 1);
});
}
restartWorker(0);
});
} else {
// 工作进程代码
require('./app');
}
方法2:使用反向代理切换
// 技术栈:Nginx + Node.js
// nginx配置示例
upstream node_app {
server 127.0.0.1:3000;
server 127.0.0.1:3001 backup;
}
server {
listen 80;
location / {
proxy_pass http://node_app;
# 关键配置:允许保持长连接
proxy_http_version 1.1;
proxy_set_header Connection "";
}
}
操作步骤:
- 启动新版本服务在3001端口
- 测试新服务确认正常
- 修改Nginx配置将流量切到3001
- 优雅关闭3000端口的旧服务
方法3:使用负载均衡器
云服务商如AWS的ALB、Azure的Load Balancer都支持权重调整,可以逐步将流量从旧实例迁移到新实例。
五、常见问题与解决方案
问题1:长时间运行的连接怎么处理?
WebSocket或SSE这类持久连接需要特殊处理:
// WebSocket优雅关闭示例
const wss = new WebSocket.Server({ server });
process.on('SIGTERM', () => {
// 通知客户端准备断开
wss.clients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify({
type: 'shutdown_notice',
data: '服务器即将维护,请保存当前工作'
}));
}
});
// 设置关闭超时
setTimeout(() => {
wss.close();
server.close();
}, 5000);
});
问题2:数据库连接池怎么处理?
// Sequelize示例
const sequelize = new Sequelize(/*...*/);
process.on('SIGTERM', async () => {
try {
// 停止接受新查询
await sequelize.close();
console.log('数据库连接已关闭');
} catch (err) {
console.error('关闭数据库连接失败', err);
}
});
六、最佳实践与注意事项
- 超时设置要合理:一般API服务建议10-30秒,批处理作业可能需要更长时间
- 状态保存要谨慎:确保关键操作如支付、订单等具有幂等性
- 监控不可少:记录优雅关机的成功率和耗时
- 版本回滚预案:新版本启动失败时要能快速回退
- 客户端重试机制:配合实现更好的用户体验
七、完整示例:电商服务优雅停机
// 技术栈:Express + Node.js
const express = require('express');
const app = express();
const server = require('http').createServer(app);
const connections = new Set();
// 记录活跃连接
server.on('connection', (socket) => {
connections.add(socket);
socket.on('close', () => connections.delete(socket));
});
// 模拟订单处理
app.post('/orders', async (req, res) => {
// 这里应该是订单创建逻辑
await new Promise(resolve => setTimeout(resolve, 2000));
res.json({ status: 'created' });
});
// 健康检查端点
app.get('/health', (req, res) => {
res.status(200).end();
});
// 优雅停机处理
function gracefulShutdown() {
console.log('开始停机流程');
// 标记为不健康(用于负载均衡器检测)
isShuttingDown = true;
// 关闭服务器
server.close(() => {
console.log('HTTP服务器已关闭');
});
// 关闭所有活跃连接
connections.forEach(socket => {
if (!socket.destroyed) {
socket.end(() => {
console.log(`连接 ${socket.remoteAddress} 已关闭`);
});
}
});
// 强制退出超时
setTimeout(() => {
console.error('强制退出');
process.exit(1);
}, 15000).unref();
}
process.on('SIGTERM', gracefulShutdown);
process.on('SIGINT', gracefulShutdown);
server.listen(3000, () => {
console.log('服务已启动');
});
八、总结
实现优雅停机和平滑重启就像学习如何安全地降落飞机——起飞固然重要,但平稳着陆才是真本事。通过本文介绍的技术方案,你的Node.js服务可以:
- 在更新时不影响用户体验
- 在流量激增时无缝扩展
- 在意外崩溃时减少数据损失
- 在维护期间保持专业形象
记住,好的服务不仅要跑得快,更要停得稳。建议从简单方案开始,逐步根据业务需求添加更复杂的处理逻辑。每次部署时,不妨故意触发一次关机流程,验证你的停机机制是否真的"优雅"。
评论