前端实时更新靠四个机件配合:一个全局唯一的 WebSocket 连接负责收帧,心跳探活识别假死连接,指数退避加随机抖动的重连避免雪崩,重连成功后按序号向 REST 接口补齐断线期间的增量。收到的帧不直接触发渲染,先进缓冲区按固定节奏批量提交状态。AI 热点监控工具的前端用这套组合替换了早期的定时轮询,新热点从入库到界面可见的延迟降到秒级,页面在网络抖动后能自动恢复且不丢数据。下面给出协议设计、连接实现与补数逻辑。
一、四种实时方案的取舍
| 方案 | 通信方向 | 延迟 | 服务端开销 | 适用判断 |
|---|---|---|---|---|
| 定时轮询 | 客户端拉取 | 取决于间隔 | 空请求多,浪费明显 | 数据变化慢、实时性要求低 |
| 长轮询 | 客户端拉取 | 较低 | 连接反复建立 | 无法使用长连接的兜底场景 |
| SSE | 服务端单向推送 | 低 | 单向流,实现简单 | 只需下行推送、纯文本 |
| WebSocket | 全双工 | 低 | 需管理连接状态 | 需要上行控制帧与二进制 |
这套系统选 WebSocket 的原因是前端要上行发送订阅条件与心跳。客户端切换平台筛选或热度阈值时,通过控制帧告知服务端调整推送范围,服务端不必给每个客户端推全量数据再由前端过滤。若只有下行推送需求,SSE 的实现成本更低,浏览器还自带重连。
二、消息协议的三个约定
协议决定了容错能力的上限,设计时定下三条。
每帧带类型标识。前端按类型分发到不同的状态动作,类型用判别联合声明,新增帧类型时漏处理的分支在编译期暴露。
每帧带单调递增序号。序号是断线补数与乱序检测的依据,前端记录已处理的水位线。
心跳独立成帧。客户端定时上行探活帧,服务端回应答帧;超时未收到应答即判定连接假死,主动关闭后重连,不等操作系统的超时。
type ServerFrame =
| { type: 'hot.created'; seq: number; payload: HotItem }
| { type: 'hot.updated'; seq: number; payload: { uid: string; heatScore: number } }
| { type: 'hot.removed'; seq: number; payload: { uid: string } }
| { type: 'pong'; seq: number };
type ClientFrame =
| { type: 'ping' }
| { type: 'subscribe'; payload: { sources: string[]; minHeat: number } };
三、连接管理的实现
原生 WebSocket 断开后不会自行恢复,重连、心跳、状态广播都要自己写。把这些收进一个类,对外只暴露连接、发送、订阅事件三个入口。
type Listener = (frame: ServerFrame) => void;
export class RealtimeClient {
private ws?: WebSocket;
private listeners = new Set<Listener>();
private retryDelay = 1000;
private readonly maxDelay = 30000;
private heartbeatTimer?: number;
private pongTimer?: number;
private closedByUser = false;
lastSeq = 0;
constructor(private url: string, private onStateChange: (s: string) => void) {}
connect() {
this.closedByUser = false;
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
this.retryDelay = 1000; // 成功后重置退避
this.onStateChange('online');
this.startHeartbeat();
void this.backfill(); // 补齐断线期间的增量
};
this.ws.onmessage = (e) => {
const frame = JSON.parse(e.data) as ServerFrame;
if (frame.type === 'pong') {
window.clearTimeout(this.pongTimer);
return;
}
if (frame.seq <= this.lastSeq) return; // 重复帧丢弃
this.lastSeq = frame.seq;
this.listeners.forEach((fn) => fn(frame));
};
this.ws.onclose = () => {
this.stopHeartbeat();
if (this.closedByUser) return;
this.onStateChange('reconnecting');
this.scheduleReconnect();
};
this.ws.onerror = () => this.ws?.close();
}
private scheduleReconnect() {
// 指数退避叠加随机抖动,避免大量客户端同时回连
const jitter = Math.random() * 400;
window.setTimeout(() => this.connect(), this.retryDelay + jitter);
this.retryDelay = Math.min(this.retryDelay * 2, this.maxDelay);
}
private startHeartbeat() {
this.heartbeatTimer = window.setInterval(() => {
this.send({ type: 'ping' });
// 应答超时即判定假死,主动断开走重连
this.pongTimer = window.setTimeout(() => this.ws?.close(), 5000);
}, 25000);
}
private stopHeartbeat() {
window.clearInterval(this.heartbeatTimer);
window.clearTimeout(this.pongTimer);
}
private async backfill() {
if (this.lastSeq === 0) return;
const res = await fetch(`/api/hot/increment?since_seq=${this.lastSeq}`);
const frames: ServerFrame[] = await res.json();
for (const f of frames) {
if ('seq' in f && f.seq > this.lastSeq) {
this.lastSeq = f.seq;
this.listeners.forEach((fn) => fn(f));
}
}
}
send(frame: ClientFrame) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(frame));
}
}
on(fn: Listener) {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
}
close() {
this.closedByUser = true;
this.stopHeartbeat();
this.ws?.close();
}
}
3.1 心跳间隔要短于代理超时
反向代理对空闲连接有超时阈值,超过就断。心跳间隔取代理超时的一半左右,既能保活又不至于制造多余流量。代理侧还需关闭对该路径的响应缓冲,否则消息会被攒着不发,实时性直接失效。
3.2 退避加抖动缺一不可
只有指数退避,服务端重启后所有客户端会在同一时刻齐刷刷回连,把刚起来的服务再打down。叠加一个随机抖动把回连时刻打散,这一行代码的收益在故障恢复时才显现。
四、落地步骤
- 后端确定帧结构,为每次推送分配单调递增序号并持久化水位;
- 前端封装连接类,把心跳、退避重连、状态广播收在一处;
- 首屏走 REST 拉取快照,同时记录快照对应的序号作为初始水位;
- 建立长连接并上行订阅帧,声明关心的平台与热度阈值;
- 收帧后按序号去重,丢弃不大于当前水位的重复帧;
- 帧进缓冲队列,按固定间隔批量提交状态,减少渲染次数;
- 重连成功后按水位调用增量接口补数,补完再消费实时帧;
- 监听网络状态与页面可见性,隐藏时降频、恢复时立即回连;
- 界面常驻连接状态提示,断线期间明确告知数据可能滞后。
五、三类一致性问题的处理
5.1 重复帧
网络重传与补数流程都可能送来已处理的帧。序号水位线是简单可靠的去重手段,序号不大于水位即丢弃,不需要维护额外的去重集合。
5.2 丢帧
断线期间服务端推的帧到不了客户端。仅靠长连接无法弥补,必须配一个按序号取增量的 REST 接口。重连后先补数再消费新帧,顺序不能颠倒,否则旧数据会覆盖新状态。
5.3 乱序
批量提交会把一批帧一起写入状态。同一条记录在一批里出现多次时,按序号取序号更大的那次结果,避免旧值覆盖新值。
六、页面生命周期与后端广播
浏览器标签切到后台后,定时器会被节流,心跳可能失准导致连接被判死。监听可见性事件,页面恢复可见时主动校验连接状态,必要时立刻重连并补数。监听网络上线事件同理,网络恢复的瞬间重置退避直接回连,比等下一次退避窗口快得多。
后端多实例部署时,一条热点只被其中一个实例处理,但连接分散在所有实例上。需要引入发布订阅通道把事件广播到全部实例,各实例再推给自己持有的连接。负载均衡侧则要保证连接的会话粘滞,避免协议升级请求被分发到不同实例。
常见问题(FAQ)
Q1:断线期间的新热点会丢吗?
不会。重连后按已处理序号调用增量接口补齐,补完再消费实时帧。
Q2:心跳间隔设多久合适?
取反向代理空闲超时的一半左右,并为应答设置独立超时判定假死连接。
Q3:多实例部署推送不全怎么解决?
用发布订阅通道把事件广播到所有实例,负载均衡开启会话粘滞。