WebSocket 实时通信与 优雅停机 应用
先把场景摆出来
我这边有个实时通知服务,前端用 WebSocket 收消息,后端 Node.js 挂着 ws。功能不复杂,平时也没啥存在感,直到每次发版:进程一重启,客户端就开始批量报“连接已断开”,有些页面还得手动刷新才能恢复。更烦的是,偶尔有消息正好卡在重启那几秒,发是发出去了,但客户端没收到,事后查日志只能看到一行“connection closed”。
后来我才认真把 WebSocket 的关闭流程和进程信号处理捋了一遍。优雅停机这事,说穿了就是别让进程走得太急,尤其 WebSocket 这种长连接,你直接 process.exit(),跟拔网线区别不大。
WebSocket 的关闭不是把进程杀掉就完事
HTTP 请求短,进程退出时顶多丢几个正在处理的请求。WebSocket 不一样,它是一条一直挂着的双向连接。服务端如果收到 SIGTERM 就立刻退出,客户端只会看到连接突然没了,然后开始重连。问题是所有客户端会同时重连,服务端刚起来就可能被一波重连打满。
我现在的做法是:收到信号后先进入 shuttingDown 状态,健康检查立刻返回 503,让负载均衡先把流量摘掉;然后不再接受新连接,给现有客户端发一个明确的关闭帧,比如 1001 或 1012,告诉它们“服务端要重启了,不是网络故障”;最后等一小段时间,让在途消息发完,再关 HTTP server。
服务端代码大概长这样,我用的是 Node.js 的 ws,别的语言思路差不多:
import http from 'node:http';
import { WebSocketServer } from 'ws';
const server = http.createServer((req, res) => {
if (req.url === '/healthz') {
res.writeHead(shuttingDown ? 503 : 200);
res.end(shuttingDown ? 'draining' : 'ok');
return;
}
res.writeHead(404);
res.end();
});
const wss = new WebSocketServer({ server, path: '/ws' });
const clients = new Set();
let shuttingDown = false;
wss.on('connection', (ws) => {
if (shuttingDown) {
ws.close(1012, 'server restarting');
return;
}
clients.add(ws);
ws.isAlive = true;
ws.on('pong', () => { ws.isAlive = true; });
ws.on('close', () => clients.delete(ws));
ws.on('message', (data) => {
// 这里正常处理消息,注意别在停机时还无限往队列里塞
ws.send(JSON.stringify({ type: 'ack', at: Date.now() }));
});
});
const heartbeat = setInterval(() => {
for (const ws of clients) {
if (ws.isAlive === false) {
ws.terminate();
clients.delete(ws);
continue;
}
ws.isAlive = false;
ws.ping();
}
}, 30000);
async function shutdown(signal) {
if (shuttingDown) return;
shuttingDown = true;
console.log(`收到 ${signal},开始优雅停机`);
clearInterval(heartbeat);
for (const ws of clients) {
ws.close(1001, 'server restarting');
}
const deadline = Date.now() + 10000;
while (clients.size > 0 && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
for (const ws of clients) ws.terminate();
server.close(() => {
console.log('HTTP server closed');
process.exit(0);
});
setTimeout(() => process.exit(1), 5000).unref();
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
这里有几个坑。一是 server.close() 不会自动帮你把 WebSocket 连接全关掉,得自己主动 close,不然它可能一直等。二是心跳定时器别忘了清,不然进程退不干净。三是关闭等待要有上限,不能客户端赖着不走你也跟着不走,最后被平台 SIGKILL,那就白优雅了。
客户端:重连要带点礼貌
服务端做得再细,客户端一通猛重连也白搭。我前端这边会给重连加指数退避和随机抖动,别让所有客户端卡着同一个秒数重连:
class RealtimeClient {
constructor(url) {
this.url = url;
this.retry = 0;
this.closedByUser = false;
this.connect();
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
this.retry = 0;
};
this.ws.onclose = (event) => {
if (this.closedByUser) return;
const base = Math.min(30000, 500 * 2 ** this.retry);
const delay = base + Math.random() * 1000;
if (event.code === 1001 || event.code === 1012) {
console.log('服务端在重启,稍后重连');
}
this.retry++;
setTimeout(() => this.connect(), delay);
};
}
}
另外消息最好带序号或者事件 ID,客户端重连后能补拉一次历史消息。别指望 WebSocket 断线期间的消息自己长脚跑回来,这玩意儿不负责持久化。
和 K8s、Nginx 对一下时间
如果服务跑在 K8s 里,terminationGracePeriodSeconds 得比应用自己的停机等待时间长。比如应用等 10 秒,平台给 30 秒,别应用还在关连接,平台已经一刀切了。preStop 里可以 sleep 几秒,等负载均衡把流量摘干净,再给应用发 SIGTERM。Nginx 那边如果是自己配的 upgrade,也顺手确认下 proxy_read_timeout,别让代理先断,应用还在傻等。
最后怎么验证
我一般会直接给进程发 kill -TERM $(pgrep -f "node server.js"),然后盯三件事:日志里有没有进入 draining、客户端有没有收到 1001/1012、重连是不是错峰发生的。别用 kill -9 测优雅停机,那不叫测试,叫火化。至少现在发版,客户端不会满屏报错了,卡在重启缝隙里的消息也少了很多。
评论
还没有评论。
发表评论
提交后评论将经过自动审核,审核通过后公开展示。