小Cの已经记不起来的博客

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 测优雅停机,那不叫测试,叫火化。至少现在发版,客户端不会满屏报错了,卡在重启缝隙里的消息也少了很多。

评论

还没有评论。

发表评论

提交后评论将经过自动审核,审核通过后公开展示。

未在播放