实时数据更新落地路径(WebSocket 长连接与重连补数策略)

前端实时更新靠四个机件配合:一个全局唯一的 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。叠加一个随机抖动把回连时刻打散,这一行代码的收益在故障恢复时才显现。

四、落地步骤

  1. 后端确定帧结构,为每次推送分配单调递增序号并持久化水位;
  2. 前端封装连接类,把心跳、退避重连、状态广播收在一处;
  3. 首屏走 REST 拉取快照,同时记录快照对应的序号作为初始水位;
  4. 建立长连接并上行订阅帧,声明关心的平台与热度阈值;
  5. 收帧后按序号去重,丢弃不大于当前水位的重复帧;
  6. 帧进缓冲队列,按固定间隔批量提交状态,减少渲染次数;
  7. 重连成功后按水位调用增量接口补数,补完再消费实时帧;
  8. 监听网络状态与页面可见性,隐藏时降频、恢复时立即回连;
  9. 界面常驻连接状态提示,断线期间明确告知数据可能滞后。

五、三类一致性问题的处理

5.1 重复帧

网络重传与补数流程都可能送来已处理的帧。序号水位线是简单可靠的去重手段,序号不大于水位即丢弃,不需要维护额外的去重集合。

5.2 丢帧

断线期间服务端推的帧到不了客户端。仅靠长连接无法弥补,必须配一个按序号取增量的 REST 接口。重连后先补数再消费新帧,顺序不能颠倒,否则旧数据会覆盖新状态。

5.3 乱序

批量提交会把一批帧一起写入状态。同一条记录在一批里出现多次时,按序号取序号更大的那次结果,避免旧值覆盖新值。

六、页面生命周期与后端广播

浏览器标签切到后台后,定时器会被节流,心跳可能失准导致连接被判死。监听可见性事件,页面恢复可见时主动校验连接状态,必要时立刻重连并补数。监听网络上线事件同理,网络恢复的瞬间重置退避直接回连,比等下一次退避窗口快得多。

后端多实例部署时,一条热点只被其中一个实例处理,但连接分散在所有实例上。需要引入发布订阅通道把事件广播到全部实例,各实例再推给自己持有的连接。负载均衡侧则要保证连接的会话粘滞,避免协议升级请求被分发到不同实例。

常见问题(FAQ)

Q1:断线期间的新热点会丢吗?

不会。重连后按已处理序号调用增量接口补齐,补完再消费实时帧。

Q2:心跳间隔设多久合适?

取反向代理空闲超时的一半左右,并为应答设置独立超时判定假死连接。

Q3:多实例部署推送不全怎么解决?

用发布订阅通道把事件广播到所有实例,负载均衡开启会话粘滞。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 qiqicto@qq.com 举报,一经查实,本站将立刻删除。
赞 (0)
赵其鑫的头像赵其鑫管理团队

相关推荐

返回顶部