WebSocket 实时通信实战:心跳、重连与消息可靠投递
前言
实时需求(站内消息、协作编辑、行情推送、任务进度)第一反应都是 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);
三个要点:
- 服务端主动 ping,浏览器自动回 pong,前端不用写心跳代码;
- 用
terminate()而非close():半开连接上 close 帧发不出去,会一直挂着占资源; - 心跳间隔 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 万连接不难,水平扩容后立刻遇到两个问题:
- 广播:用户 A 连实例 1、用户 B 连实例 2,业务要同时推给两者。解法是引入 Redis Pub/Sub(或 Kafka)做实例间广播,每个实例只向自己持有的连接下发;
- 定向推送:要知道目标用户在哪个实例。简单做法是广播给所有实例、各自查本地连接表;严谨做法是网关按用户 ID 哈希,保证同一用户落同一实例。
容量要提前算:默认 ulimit -n 只有 1024,长连接数受文件描述符限制,生产上要调到 65535,并按每连接 10~30KB 内核缓冲预留内存。
六、可靠投递:至少一次 + 幂等
WebSocket 只保证单连接内帧有序,不保证"业务消息必达"——断线期间的消息就是丢了。补齐要三样东西:
- 服务端消息带自增 seq,客户端记录已确认的最大 seq;
- 客户端 ack,服务端保留未 ack 消息,超时重发;
- 重连后按 seq 补拉,客户端按 seq 去重。
代价是语义变成"至少一次",所以消费侧必须幂等:按消息 ID 落去重表,或让业务天然幂等(例如状态置位而非累加)。
七、上线前自查清单
- [ ] Nginx / LB 已放行 Upgrade,超时大于心跳间隔
- [ ] 心跳 + terminate 逻辑生效(拔网线实测一次)
- [ ] 重连有指数退避和随机抖动
- [ ] 重连后自动重新订阅并补拉离线消息
- [ ] 鉴权票据一次性且有过期时间
- [ ] 广播走 Pub/Sub,不依赖单实例内存
- [ ] 监控接入:连接数、重连率、消息端到端延迟
总结
WebSocket 的难点从来不是"连上",而是连接的生命周期管理:握手阶段把代理和鉴权想清楚,运行期用心跳发现假活连接,断线后靠退避避免雪崩,业务上再用 seq + ack + 幂等补齐可靠性。这四件事做齐,实时功能才算能上生产。
- 点赞
- 收藏
- 关注作者
评论(0)