一、为什么需要优雅停机和平滑重启

想象一下你正在运营一个在线商城,半夜里需要更新服务器代码。如果直接粗暴地关闭服务,正在下单的用户可能会看到支付失败的提示,这种体验就像在超市排队结账时突然被店员赶出门外。优雅停机就是为了避免这种尴尬场景,让服务能够体面地和用户说再见。

再比如你的服务流量突然暴增,需要增加服务器资源。平滑重启就像给行驶中的汽车换轮胎,既要保证服务不中断,又要完成更新操作。这两个机制是构建可靠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);
});

这个方案实现了几个关键点:

  1. 通过Set记录所有活跃连接
  2. 先停止接收新请求
  3. 给现有请求合理的时间完成
  4. 设置超时强制关闭机制
  5. 定期检查是否可以安全退出

三、进阶方案:使用进程管理工具

实际生产环境我们通常会使用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的工作流程更完善:

  1. 发送SIGINT信号通知应用准备关闭
  2. 等待应用发送进程就绪信号
  3. 如果在配置时间内未完成,则强制终止

四、实现零停机重启的几种方式

平滑重启的目标是在不中断服务的情况下完成更新。以下是三种常用方法:

方法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 "";
    }
}

操作步骤:

  1. 启动新版本服务在3001端口
  2. 测试新服务确认正常
  3. 修改Nginx配置将流量切到3001
  4. 优雅关闭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);
    }
});

六、最佳实践与注意事项

  1. 超时设置要合理:一般API服务建议10-30秒,批处理作业可能需要更长时间
  2. 状态保存要谨慎:确保关键操作如支付、订单等具有幂等性
  3. 监控不可少:记录优雅关机的成功率和耗时
  4. 版本回滚预案:新版本启动失败时要能快速回退
  5. 客户端重试机制:配合实现更好的用户体验

七、完整示例:电商服务优雅停机

// 技术栈: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服务可以:

  1. 在更新时不影响用户体验
  2. 在流量激增时无缝扩展
  3. 在意外崩溃时减少数据损失
  4. 在维护期间保持专业形象

记住,好的服务不仅要跑得快,更要停得稳。建议从简单方案开始,逐步根据业务需求添加更复杂的处理逻辑。每次部署时,不妨故意触发一次关机流程,验证你的停机机制是否真的"优雅"。