WebSocket 实时通信实战:心跳、重连与消息可靠投递

举报
yd_236430038 发表于 2026/11/02 21:15:11 2026/11/02
【摘要】 前言实时需求(站内消息、协作编辑、行情推送、任务进度)第一反应都是 WebSocket。但能连上只是开始,真正折磨人的是"半夜连接悄悄断了"“消息重复了”“集群下收不到广播”。本文按连接生命周期过一遍。 一、先选型:轮询 / SSE / WebSocket方案方向适用场景代价短轮询客户端拉低频状态查询大量空请求,延迟高长轮询客户端拉兼容性要求极高占连接,实现复杂SSE服务端推单向流(日志、...

前言

实时需求(站内消息、协作编辑、行情推送、任务进度)第一反应都是 WebSocket。但能连上只是开始,真正折磨人的是"半夜连接悄悄断了"“消息重复了”“集群下收不到广播”。本文按连接生命周期过一遍。

一、先选型:轮询 / SSE / WebSocket

方案 方向 适用场景 代价
短轮询 客户端拉 低频状态查询 大量空请求,延迟高
长轮询 客户端拉 兼容性要求极高 占连接,实现复杂
SSE 服务端推 单向流(日志、进度、通知) 不能上行,HTTP/1.1 下有连接数限制
WebSocket 双向 双向实时交互 有状态连接,网关与扩容要额外设计

一句话:只需要服务端推,SSE 更省事(自带重连、走 HTTP 语义、能过大多数代理);需要双向高频,才上 WebSocket。

二、握手:一次 HTTP 升级

GET /ws HTTP/1.1
Host: api.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

服务端返回 101 Switching Protocols 后,同一条 TCP 连接切换为帧协议。由此派生三个现实问题:

  • 反向代理必须放行 Upgrade,否则握手 400/502;
  • 超时必须大于心跳间隔,否则空闲连接被网关掐断;
  • 浏览器 WebSocket API 不支持自定义请求头,鉴权不能照搬 Header 方案。
location /ws {
    proxy_pass http://backend;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_read_timeout 300s;
}

鉴权常用三招,优先级从高到低:一次性 ticket(HTTP 接口换票,握手时带上,5 分钟有效)> 子协议携带 token > URL query 带 JWT(会进访问日志,慎用)。

三、心跳:唯一能发现"半开连接"的手段

对端断电、拔网线、NAT 表项老化后,本端连接仍可能停在 ESTABLISHED。只有应用层心跳能在可接受时间内发现它。

import { WebSocketServer } from 'ws';

const wss = new WebSocketServer({ port: 8080 });

wss.on('connection', (ws) => {
  ws.isAlive = true;
  ws.on('pong', () => { ws.isAlive = true; });
  ws.on('message', handleMessage);
});

setInterval(() => {
  for (const ws of wss.clients) {
    if (ws.isAlive === false) { ws.terminate(); continue; } // 上一轮没回 pong
    ws.isAlive = false;
    ws.ping();
  }
}, 30_000);

三个要点:

  1. 服务端主动 ping,浏览器自动回 pong,前端不用写心跳代码;
  2. 用 terminate() 而非 close():半开连接上 close 帧发不出去,会一直挂着占资源;
  3. 心跳间隔 30s 是经验值,必须小于链路上最短的空闲超时(Nginx、ELB、NAT)。

四、重连:指数退避 + 抖动

断线是常态,别写 setTimeout(connect, 1000)——服务端重启时所有客户端会在同一秒涌回来,把刚起来的实例再打挂。

let attempt = 0;

function connect() {
  const ws = new WebSocket(URL);

  ws.onopen = () => {
    attempt = 0;
    resubscribe();   // 重新订阅频道
    syncMissed();    // 按本地游标补拉断线期间的消息
  };

  ws.onclose = () => {
    const base = Math.min(30_000, 1000 * 2 ** attempt++);
    const delay = base * (0.5 + Math.random() / 2); // 抖动,打散重连风暴
    setTimeout(connect, delay);
  };
}

onclose 里不要依赖 wasClean,网络层断开几乎都是非正常关闭。页面从 bfcache 恢复、从后台切回前台时,也应主动校验一次连接状态。

五、集群:连接是有状态的

单机扛住 1 万连接不难,水平扩容后立刻遇到两个问题:

  1. 广播:用户 A 连实例 1、用户 B 连实例 2,业务要同时推给两者。解法是引入 Redis Pub/Sub(或 Kafka)做实例间广播,每个实例只向自己持有的连接下发;
  2. 定向推送:要知道目标用户在哪个实例。简单做法是广播给所有实例、各自查本地连接表;严谨做法是网关按用户 ID 哈希,保证同一用户落同一实例。

容量要提前算:默认 ulimit -n 只有 1024,长连接数受文件描述符限制,生产上要调到 65535,并按每连接 10~30KB 内核缓冲预留内存。

六、可靠投递:至少一次 + 幂等

WebSocket 只保证单连接内帧有序,不保证"业务消息必达"——断线期间的消息就是丢了。补齐要三样东西:

  1. 服务端消息带自增 seq,客户端记录已确认的最大 seq;
  2. 客户端 ack,服务端保留未 ack 消息,超时重发;
  3. 重连后按 seq 补拉,客户端按 seq 去重。

代价是语义变成"至少一次",所以消费侧必须幂等:按消息 ID 落去重表,或让业务天然幂等(例如状态置位而非累加)。

七、上线前自查清单

  • [ ] Nginx / LB 已放行 Upgrade,超时大于心跳间隔
  • [ ] 心跳 + terminate 逻辑生效(拔网线实测一次)
  • [ ] 重连有指数退避和随机抖动
  • [ ] 重连后自动重新订阅并补拉离线消息
  • [ ] 鉴权票据一次性且有过期时间
  • [ ] 广播走 Pub/Sub,不依赖单实例内存
  • [ ] 监控接入:连接数、重连率、消息端到端延迟

总结

WebSocket 的难点从来不是"连上",而是连接的生命周期管理:握手阶段把代理和鉴权想清楚,运行期用心跳发现假活连接,断线后靠退避避免雪崩,业务上再用 seq + ack + 幂等补齐可靠性。这四件事做齐,实时功能才算能上生产。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。