上周线上聊天的客服系统出大问题了:用户在从公司 Wi-Fi 走到电梯切到 4G 网络时,WebSocket 连接其实已经断开了,但是浏览器的 ws.readyState 居然还傻乎乎地显示为 OPEN。这就导致服务器发的通知前端根本收不到,而前端发的消息也全部沉入大海。这种状态在网络术语里叫“半打开连接”或者“伪连接”。等用户好不容易重新连上网,之前的历史通知也全部丢光了,被客诉投诉到头秃。
要解决这个痛点,只靠浏览器自带的连接状态回调是绝对不行的,必须自己在客户端封装一套心跳检测与重连机制。我们不仅需要通过定时发送 Ping 包来主动排查伪连接,还需要实现指数退避(Exponential Backoff)算法避免重连时把后端网关冲崩溃,同时还要在本地维护一个“离线消息队列”,并在长连接彻底拉闸时自动降级到 HTTP 轮询。
下面是我们重构后的核心客户端 RobustWebSocket 类实现,支持自动心跳、指数退避重连、离线队列缓冲以及轮询 Fallback:
type MessagePayload = string | object;
export class RobustWebSocket { private ws: WebSocket | null = null; private heartbeatTimer: any; private serverTimeoutTimer: any; private reconnectTimer: any; private reconnectAttempts = 0; private maxAttempts = 5; private offlineQueue: string[] = []; private isFallback = false;
constructor(private url: string) { this.connect(); }
private connect() { if (this.isFallback) return this.startPolling(); try { this.ws = new WebSocket(this.url); this.ws.onopen = () => { this.reconnectAttempts = 0; this.startHeartbeat(); while (this.offlineQueue.length) { const data = this.offlineQueue.shift(); if (data) this.ws.send(data); } }; this.ws.onmessage = () => this.startHeartbeat(); this.ws.onclose = () => this.handleReconnect(); this.ws.onerror = () => this.ws?.close(); } catch { this.handleReconnect(); } }
private startHeartbeat() { clearTimeout(this.heartbeatTimer); clearTimeout(this.serverTimeoutTimer); this.heartbeatTimer = setTimeout(() => { if (this.ws?.readyState === WebSocket.OPEN) { this.ws.send("ping"); this.serverTimeoutTimer = setTimeout(() => this.ws?.close(), 5000); } }, 10000); }
private handleReconnect() { clearTimeout(this.heartbeatTimer); clearTimeout(this.serverTimeoutTimer); if (this.reconnectAttempts < this.maxAttempts) { const delay = Math.min(16000, 1000 * Math.pow(2, this.reconnectAttempts)); this.reconnectTimer = setTimeout(() => { this.reconnectAttempts++; this.connect(); }, delay); } else { this.isFallback = true; this.startPolling(); } }
public send(payload: MessagePayload) { const data = typeof payload === "string" ? payload : JSON.stringify(payload); if (this.ws?.readyState === WebSocket.OPEN) { this.ws.send(data); } else { this.offlineQueue.push(data); } }
private startPolling() { setInterval(async () => { try { const res = await fetch("/api/fallback/messages"); const messages = await res.json(); console.log("[Poll] 轮询获取:", messages); } catch (err) { console.error(err); } }, 5000); }}引入这套机制后,无论用户怎么切网、进出电梯,前端都可以做到在网络恢复后的几秒钟内快速自动重连。而离线消息队列则保障了在断网边缘状态下,用户点击发送 the 按钮不会因为连接中断而被无情丢弃,极大地降低了前台交互报错的概率。
不过,这里其实还剩下一个架构上的大问题:当 HTTP 轮询拉取历史消息时,如何与 WebSocket 恢复连接后的数据流进行去重,避免重复消费?大家在做多通道降级时是如何做序列号或者消息 ID 去重的?