上周线上聊天的客服系统出大问题了:用户在从公司 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 去重的?