前端 WebSocket 心跳重连与离线消息补偿机制:保障弱网高可用

在大型在线协作文档、IM 即时通讯或金融实时行情等高并发前端应用中,WebSocket 长连接是维系服务端与客户端双向实时通信的生命线。
然而,在移动端真实网络环境下,用户经常面临各种恶劣的“弱网与假死连接”:
- 手机进入电梯或地下车库,底层 4G/5G 信号瞬间中断;
- 手机合盖锁屏后,操作系统为了省电强制挂起浏览器的后台网络 Socket,但并没有触发 TCP FIN 挥手(形成幽灵假死连接);
- 当用户走出电梯网络重新恢复时,前端在断网期间错过了服务端下发的十几条关键状态推送,导致界面数据与服务端严重脱节。
一套真正高可用的前端 WebSocket 架构,绝不能仅仅调用一个裸 new WebSocket()。
本文拆解一套在线上高并发场景验证过的完整长连接防御体系:双向心跳自愈(Dual Heartbeat)、指数退避与随机扰动重连(Exponential Backoff with Jitter)、以及基于递增序号(SeqID)的离线消息滑动补偿机制。
高可用 WebSocket 弹性生命周期拓扑
[WebSocket 连接建立成功 (状态: CONNECTED)]
│
▼
【机制 1: 双向自适应心跳检测 (双重超时保险)】
– 客户端每 15 秒发送 Ping ──> 期望服务端 5 秒内回 Pong
– 若连续 2 次未收到 Pong ──> 判定为假死连接, 主动 destroy 并触发重连
│
▼ (遭遇断网或心跳失联)
【机制 2: 带随机抖动的指数退避重连 (Exponential Backoff + Jitter)】
– 重连间隔: 1s -> 2s -> 4s -> 8s -> max 15s (+ 0~500ms 随机扰动)
– 彻底杜绝数万客户端在网关恢复时同时发起重连风暴 (Thundering Herd)
│
▼ (连接重新握手成功)
【机制 3: 离线消息拉取与序号对齐 (Offline Message Sync)】
– 握手 Payload 附带客户端本地维护的 `last_seq_id: 1042`
– 服务端直接从 Redis 中补发 1043 ~ 1050 期间的未读增量事件
核心实现:生产级 RobustWebSocket 状态机
export interface WsMessagePayload {
seqId: number; // 全局单调递增消息序号
type: string;
data: any;
timestamp: number;
}
export interface RobustWebSocketConfig {
url: string;
heartbeatIntervalMs?: number; // 默认 15000ms
heartbeatTimeoutMs?: number; // 默认 5000ms
maxReconnectDelayMs?: number; // 最大重连间隔 15000ms
onMessage: (msg: WsMessagePayload) => void;
onStatusChange?: (status: 'CONNECTING' | 'OPEN' | 'RECONNECTING' | 'CLOSED') => void;
}
export class RobustWebSocket {
private ws: WebSocket | null = null;
private config: RobustWebSocketConfig;
private isExplicitlyClosed = false;
private reconnectAttempts = 0;
// 心跳定时器
private pingTimer: NodeJS.Timeout | null = null;
private pongTimeoutTimer: NodeJS.Timeout | null = null;
// 消息同步游标
private lastReceivedSeqId = 0;
constructor(config: RobustWebSocketConfig) {
this.config = {
heartbeatIntervalMs: 15000,
heartbeatTimeoutMs: 5000,
maxReconnectDelayMs: 15000,
…config,
};
// 从本地缓存恢复历史最后收到的序号
const cachedSeq = localStorage.getItem(`ws_last_seq_${config.url}`);
if (cachedSeq) {
this.lastReceivedSeqId = parseInt(cachedSeq, 10);
}
this.connect();
}
private connect() {
if (this.isExplicitlyClosed) return;
this.config.onStatusChange?.(this.reconnectAttempts === 0 ? 'CONNECTING' : 'RECONNECTING');
// 握手 URL 携带本地当前最后的 SeqID,服务端握手成功后自动补发离线消息
const connectUrl = `${this.config.url}?last_seq_id=${this.lastReceivedSeqId}`;
this.ws = new WebSocket(connectUrl);
this.ws.onopen = () => {
console.log('⚡ WebSocket 连接已建立,重置重连计数并启动心跳');
this.reconnectAttempts = 0;
this.config.onStatusChange?.('OPEN');
this.startHeartbeat();
};
this.ws.onmessage = (event) => {
this.resetHeartbeatWatchdog();
try {
const payload: WsMessagePayload = JSON.parse(event.data);
// 收到服务端心跳响应
if (payload.type === 'PONG') {
return;
}
// 序号去重与滑动游标推进
if (payload.seqId > this.lastReceivedSeqId) {
this.lastReceivedSeqId = payload.seqId;
localStorage.setItem(`ws_last_seq_${this.config.url}`, String(payload.seqId));
this.config.onMessage(payload);
}
} catch (err) {
console.error('[WS] 消息解析失败:', err);
}
};
this.ws.onclose = () => {
this.cleanupTimers();
if (!this.isExplicitlyClosed) {
this.scheduleReconnect();
} else {
this.config.onStatusChange?.('CLOSED');
}
};
this.ws.onerror = (err) => {
console.warn('[WS] 连接异常,关闭并触发重连', err);
this.ws?.close();
};
}
// 指数退避 + 随机打散重连算法
private scheduleReconnect() {
this.reconnectAttempts++;
// 指数增长: 1s, 2s, 4s, 8s, 15s…
const baseDelay = Math.min(
this.config.maxReconnectDelayMs!,
1000 * Math.pow(2, this.reconnectAttempts – 1)
);
// 注入 0~500ms 随机 Jitter,打散客户端瞬时重连洪峰
const jitter = Math.floor(Math.random() * 500);
const delay = baseDelay + jitter;
console.log(`⏳ 将在 ${delay}ms 后尝试第 ${this.reconnectAttempts} 次重连…`);
setTimeout(() => this.connect(), delay);
}
// 心跳保活与假死看门狗
private startHeartbeat() {
this.cleanupTimers();
this.pingTimer = setInterval(() => {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: 'PING', timestamp: Date.now() }));
// 开启 Pong 响应倒计时看门狗
this.pongTimeoutTimer = setTimeout(() => {
console.warn('🚨 心跳超时未收到 PONG,判定连接假死,强制断开!');
this.ws?.close();
}, this.config.heartbeatTimeoutMs);
}
}, this.config.heartbeatIntervalMs);
}
private resetHeartbeatWatchdog() {
if (this.pongTimeoutTimer) {
clearTimeout(this.pongTimeoutTimer);
this.pongTimeoutTimer = null;
}
}
private cleanupTimers() {
if (this.pingTimer) clearInterval(this.pingTimer);
if (this.pongTimeoutTimer) clearTimeout(this.pongTimeoutTimer);
}
public close() {
this.isExplicitlyClosed = true;
this.cleanupTimers();
this.ws?.close();
}
}




