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

WebSocket 实时通信与 蓝绿部署 应用

前阵子给公司内部的一个实时看板升级后端,这玩意儿走的是 WebSocket 长连接,平时倒也相安无事,结果一发布就出事:旧实例一停,几百个用户的连接瞬间全断,前端虽然写了重连逻辑,但大家都是同一秒断的,重连请求跟约好了似的挤在同一秒打过来,新实例刚起来就被怼了一波,CPU 直接拉满。后来才想明白,这就是典型的重连风暴。普通 HTTP 服务用滚动发布没事儿,是因为请求短平快,切过去就完了,WebSocket 不一样,连接一挂就是几个小时,你把实例一停,等于把人家的电话线拔了。

蓝绿部署为什么合适

蓝绿的思路其实很简单:不在线上那套环境上动刀,而是另起一套一模一样的绿环境,新版本先部署到绿,自测没问题后把流量一次性切过去,蓝环境留着观察,随时能切回来。比滚动发布踏实的地方在于,切换的那一刻服务是整体替换的,不存在新旧版本同时对外服务、消息格式对不上的尴尬。

但对 WebSocket 来说,蓝绿只解决了一半问题。新连接切到绿环境很容易,难的是蓝环境上还挂着的存量连接怎么办——总不能切完流量直接把蓝 kill 了,那用户连接照样断,跟没做蓝绿一样。

实际操作:先切新,再排旧

第一步,绿环境起来后,改 Nginx 的 upstream,把指向从蓝换到绿,然后 reload:

upstream ws_backend {
    server 127.0.0.1:8002; # 绿,原来是 8001
}

这里有个小细节,nginx -s reload 是平滑的,老的 worker 进程不会马上退,会等手上的连接结束,所以已经建立的 WebSocket 连接其实还挂在蓝环境上,新来的连接才会进绿。要是不放心,可以加个 worker_shutdown_timeout 兜底,免得老 worker 一直赖着不走。

第二步就是排水(draining)。蓝环境这边不再接新连接,然后主动给存量连接发一条“我要重启了,请择机重连”的消息,客户端收到后别立刻重连,随机延迟几秒再断开重连,把重连请求摊开:

wss.clients.forEach((client) => {
  client.send(JSON.stringify({
    type: 'server_restarting',
    delay: 3000 + Math.random() * 7000 // 打散重连时间
  }));
});

前端收到后定时关闭连接,走正常重连流程就行:

ws.onmessage = (e) => {
  const msg = JSON.parse(e.data);
  if (msg.type === 'server_restarting') {
    setTimeout(() => ws.close(), msg.delay);
  }
};

之后就盯着蓝环境的连接数,等它慢慢掉到 0 再停掉。一般几分钟就排干净了,运气不好碰上几个挂机不动的页面,那就等 worker_shutdown_timeout 强制收尾吧,反正消息不丢就行(前提是下一节的东西你做了)。

别把状态留在实例里

这一步不做,前面全白搭。之前偷懒把用户会话状态直接放在内存的 Map 里,连接从蓝迁到绿的时候状态全丢,用户重连上来还得重新拉一遍数据,等于变相重启。后来老老实实把状态挪到 Redis,握手时按 userId 写进去,蓝绿两边共享,谁持有连接谁负责发消息:

// 收到业务消息时只写 Redis
await redis.publish('ws:push', JSON.stringify({ userId, payload }));

// 每个实例自己订阅,只给持有的连接发
sub.subscribe('ws:push');
sub.on('message', (channel, raw) => {
  const { userId, payload } = JSON.parse(raw);
  const client = onlineMap.get(userId);
  if (client) client.send(payload);
});

这样连接落在哪台实例上根本不重要了,蓝绿切换、扩缩容都随便来,状态在 Redis 里躺着呢。

最后的 Nginx 配置

顺便贴一下 WebSocket 反代的完整配置,少一个头都不行,我第一次就是漏了 proxy_http_version 1.1,握死在 400 上查了半天:

location /ws {
    proxy_pass http://ws_backend;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_read_timeout 600s; # 默认只有60s,连接会被掐
}

整套流程跑下来,发布的时候用户基本无感,顶多状态栏闪一下“重连中”。要说多优雅倒也谈不上,但至少不用再半夜挑人少的时候发版了...

评论

还没有评论。

发表评论

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

未在播放