6. 数据大屏 WebSocket 稳定性优化:心跳检测、断点续传和消息去重

数据大屏 WebSocket 稳定性优化:心跳检测、断点续传和消息去重

前言

前面几篇文章主要在聊地图大量点位的渲染优化:

text 复制代码
分层渲染
  ↓
聚合展示
  ↓
增量更新
  ↓
只更新变化点

这些优化解决的是:

text 复制代码
前端拿到数据之后,怎么更快地渲染到地图上。

但真实的大屏项目里,还有另一个更容易被忽略的问题:

text 复制代码
数据怎么稳定、低延迟地到达前端?

尤其是在内网环境、弱网环境、数据大屏这类场景下,我们一般希望实时数据延迟控制在 200ms 以内。正常连接时,WebSocket 可以做到很低的延迟。

真正麻烦的是网络波动:

text 复制代码
网络抖动
  ↓
WebSocket 断开
  ↓
前端重连
  ↓
断线期间消息可能丢失
  ↓
重连后服务端可能补发
  ↓
前端又可能重复消费

如果这套链路没有设计好,大屏上就会出现:

text 复制代码
无人机位置跳变
事件点状态回退
地图点位重复
统计数字忽大忽小
页面突然全量刷新闪烁

所以这一篇继续沿着前面的思路,聊一下实时大屏里的 WebSocket 应该怎么优化。

本文重点不是"怎么创建一个 WebSocket",而是:

text 复制代码
在网络会波动、数据不能丢、页面不能闪的情况下,WebSocket 应该怎么设计?

最终方案是:

text 复制代码
心跳检测 + 退避重连 + 断点续传 + 消息去重 + 增量消费

现有写法的问题

项目里原来的 WebSocket 封装大概是这样的:

js 复制代码
import io from "socket.io-client";

class SocketManager {
  constructor() {
    this.socket = null;
  }

  connect(url, options = {}) {
    const defaultOptions = {
      path: "/web.socket-new",
      autoConnect: true,
      transports: ["websocket"],
    };

    const socketOptions = { ...defaultOptions, ...options };
    this.socket = io(url, socketOptions);

    return this;
  }

  disconnect() {
    this.socket.disconnect();
  }

  login() {
    if (this.socket) {
      this.socket.emit("login", {
        token: window.localStorage.getItem("thirdToken"),
        clientId: Math.random(),
        uavSwitch: true,
      });
    }
  }

  on(event, handler) {
    if (this.socket) {
      this.socket.on(event, handler);

      if (event === "connect" && this.socket.connected) {
        handler();
      }
    }
  }

  emit(event, data) {
    if (this.socket) {
      this.socket.emit(event, data);
    }
  }
}

const socketManager = new SocketManager();

export default socketManager;

页面里使用:

js 复制代码
mounted() {
  socketManager.connect("http://shangcheng.cldeye.com");

  socketManager.on("connect", () => {
    socketManager.login();
  });

  socketManager.on("dockMessage", (data) => {
    this.hangarData = data.message;
    this.initHangarMarker();
  });

  socketManager.on("uavMessage", (data) => {
    this.uavData = data.message;
    this.initUavMarker();
  });
}

这个版本能跑,但是在实时大屏里有几个隐患。

踩过的坑

简单重连

最容易想到的写法是:

js 复制代码
socket.onclose = () => {
  connect();
};

或者断开之后立即重新调用 connect

这个方案的问题是:

text 复制代码
如果网络持续不稳定,前端会疯狂重连。

几十个大屏客户端同时在线时,断网恢复的一瞬间,所有客户端一起重连,服务端压力会非常大。

所以重连不能是固定间隔,更不能是立即重连,而应该使用:

text 复制代码
指数退避 + 随机抖动

比如:

text 复制代码
第 1 次重连:1s 左右
第 2 次重连:2s 左右
第 3 次重连:4s 左右
第 4 次重连:8s 左右
最大不超过 30s
每次再加一点随机抖动

这样可以避免所有客户端在同一时间打到服务端。

全量刷新

有些项目在重连成功后,会重新请求一次全量数据:

text 复制代码
WebSocket 断开
  ↓
重连成功
  ↓
重新拉取全部无人机、机场、事件数据
  ↓
清空地图
  ↓
重新渲染

这在数据少的时候没问题。

但如果地图上有几千个点,甚至上万个点,就会出现:

text 复制代码
页面闪烁
地图点位重建
图标重新加载
聚合重新计算
用户看到大屏抖动

前面我们已经做了地图增量更新,所以 WebSocket 重连后也不能再走全量刷新。

正确思路应该是:

text 复制代码
重连后告诉服务端:我最后消费到哪条消息了
服务端只补发断线期间缺失的消息
前端继续按增量 patch 合并

消息丢失

WebSocket 断开期间,服务端仍然可能产生数据:

text 复制代码
t1:前端收到 seq = 100
t2:网络断开
t3:服务端产生 seq = 101, 102, 103
t4:前端重连成功

如果没有断点续传,前端很可能直接从最新消息开始接:

text 复制代码
前端下一条收到 seq = 104

那么中间的:

text 复制代码
101, 102, 103

就永久丢失了。

地图上表现出来可能就是:

text 复制代码
无人机少走了一段轨迹
事件状态没有更新
机场状态停留在旧值
统计数字和真实数据不一致

重复消费

重连后服务端为了保证不丢消息,可能会从某个 seq 开始补发。

比如前端最后确认消费到:

text 复制代码
seq = 100

服务端为了保险,从:

text 复制代码
seq = 98

开始补发。

这样前端会再次收到:

text 复制代码
98, 99, 100

如果前端没有去重,就会重复消费。

对于大屏来说,重复消费的风险很高:

text 复制代码
同一个事件被 add 两次
同一个统计计数累加两次
同一个无人机轨迹点重复插入
同一个删除消息重复执行导致状态异常

所以前端必须具备:

text 复制代码
消息级去重能力

最终选型

我最后会把 WebSocket 优化成这一套:

text 复制代码
Socket.IO 连接层
  ↓
业务心跳检测
  ↓
指数退避重连
  ↓
登录时携带 lastSeq
  ↓
服务端补发 lastSeq 之后的消息
  ↓
前端按 messageId / seq 去重
  ↓
按业务类型转成地图 patch
  ↓
交给地图增量更新逻辑

这里有一个点要注意。

项目里用的是:

js 复制代码
socket.io-client

它不是原生 WebSocket,而是 Socket.IO

Socket.IO 自带连接管理、底层 ping/pong、自动重连等能力。那为什么前端还要做心跳和断点续传?

原因是:

text 复制代码
Socket.IO 的心跳主要证明连接还活着;
业务心跳证明数据链路和业务服务还正常;
Socket.IO 的重连只恢复连接;
断点续传恢复的是断线期间丢失的业务消息。

所以这两个不是一回事。

我的选择是:

text 复制代码
连接层继续用 Socket.IO
业务稳定性自己补齐

这样改造成本最低,也最适合已有项目。

消息协议怎么设计

要做断点续传和消息去重,后端推送的数据不能只是:

js 复制代码
{
  message: {}
}

最好统一成这种结构:

js 复制代码
{
  messageId: "uav_1720000000000_1001",
  seq: 1001,
  type: "uavMessage",
  timestamp: 1720000000000,
  action: "upsert",
  message: {
    id: "uav-001",
    longitude: 120.12,
    latitude: 30.25,
    height: 80,
    status: "online"
  }
}

几个字段很关键。

messageId

text 复制代码
消息唯一 id,用来去重。

seq

text 复制代码
服务端递增序号,用来判断断点。

type

text 复制代码
业务消息类型,比如 uavMessage、dockMessage、eventMessage。

action

text 复制代码
当前消息是新增、更新还是删除。

message

text 复制代码
真正的业务数据。

如果后端暂时不能提供 messageId,也可以先用:

js 复制代码
const messageId = `${type}:${seq}`;

但更建议由后端统一生成,因为后端最清楚消息的全局顺序和唯一性。

为什么要有 seq

很多同学会问:

text 复制代码
有 messageId 去重了,为什么还需要 seq?

因为它们解决的问题不一样。

messageId 解决:

text 复制代码
这条消息我有没有消费过?

seq 解决:

text 复制代码
我现在消费到哪了?
断线后应该从哪里继续?
消息中间有没有断档?

比如:

text 复制代码
前端最近收到 seq = 100
下一条直接收到 seq = 104

这说明中间可能漏了:

text 复制代码
101, 102, 103

这时候前端就可以触发补偿:

text 复制代码
向服务端请求 100 之后的消息

所以实时系统里,我一般会要求服务端推送消息带上递增 seq。

前端需要保存什么状态

前端至少要保存 4 类状态。

连接状态

js 复制代码
connected: false
connecting: false
manualClose: false
reconnectTimes: 0

用来判断当前是否连接中、是否是用户主动关闭、是否需要重连。

断点状态

js 复制代码
lastSeq: 0

每消费成功一条消息,就更新一次:

js 复制代码
this.lastSeq = Math.max(this.lastSeq, message.seq);

为了刷新页面之后也能续传,可以写入 localStorage

js 复制代码
localStorage.setItem("screen:lastSeq", String(this.lastSeq));

去重状态

js 复制代码
messageIdSet: new Set()
messageIdQueue: []

不能让 Set 无限增长,所以要做一个 LRU 窗口。

比如只保留最近 5000 条消息 id:

js 复制代码
rememberMessageId(messageId) {
  if (this.messageIdSet.has(messageId)) {
    return false;
  }

  this.messageIdSet.add(messageId);
  this.messageIdQueue.push(messageId);

  if (this.messageIdQueue.length > this.maxMessageCache) {
    const expiredId = this.messageIdQueue.shift();
    this.messageIdSet.delete(expiredId);
  }

  return true;
}

业务数据状态

这就是上一篇文章里的:

text 复制代码
markerDataMap
markerEntityMap
markerSnapshotMap

WebSocket 不应该直接清空地图重新画,而应该把消息转成 patch:

js 复制代码
{
  upserts: [],
  removeIds: []
}

然后交给地图组件:

js 复制代码
this.$refs.cesiumMap.applyMarkerPatch(patch);

前端 SocketManager 怎么改

下面是一个更适合大屏的封装版本。

它主要做了几件事:

text 复制代码
1. 连接时固定 clientId,避免每次 login 都生成新身份
2. 登录时携带 lastSeq,让服务端知道从哪里补发
3. 增加业务心跳,检测业务链路是否正常
4. 增加指数退避重连,避免断网时疯狂重连
5. 增加消息去重,避免重连补发导致重复消费
6. 增加 off,避免页面销毁后监听残留

代码示例:

这里需要注意一个兼容点:当前项目页面里已经写了 connect 之后手动 login(),所以封装层不再自动 login(),避免同一次连接重复登录。后续如果所有页面统一收敛到 SocketManager 内部登录,再把 login() 挪进 connect 回调也可以。

js 复制代码
import io from "socket.io-client";

const LAST_SEQ_KEY = "screen:last-message-seq";
const CLIENT_ID_KEY = "screen:client-id";

function getClientId() {
  let clientId = window.localStorage.getItem(CLIENT_ID_KEY);

  if (!clientId) {
    clientId = `${Date.now()}_${Math.random().toString(16).slice(2)}`;
    window.localStorage.setItem(CLIENT_ID_KEY, clientId);
  }

  return clientId;
}

class SocketManager {
  constructor() {
    this.socket = null;
    this.url = "";
    this.options = {};

    this.clientId = getClientId();
    this.lastSeq = Number(window.localStorage.getItem(LAST_SEQ_KEY) || 0);

    this.manualClose = false;
    this.reconnectTimes = 0;
    this.reconnectTimer = null;

  //心跳机制
    this.heartbeatTimer = null;
    this.lastPongAt = Date.now();
    this.heartbeatInterval = 10000;
    this.heartbeatTimeout = 30000;
    this.enableBusinessHeartbeat = false;
    this.pingEventName = "clientPing";
    this.pongEventName = "serverPong";


    //主要用于数据去重
    this.maxMessageCache = 5000;
    this.messageIdSet = new Set();
    this.messageIdQueue = [];

    this.eventHandlers = new Map();
  }

  connect(url, options = {}) {
    this.url = url;
    this.options = options;
    this.manualClose = false;

    this.clearReconnectTimer();
    this.createSocket();

    return this;
  }

  createSocket() {
    if (this.socket) {
      this.socket.removeAllListeners();
      this.socket.disconnect();
      this.socket = null;
    }

    const defaultOptions = {
      path: "/web.socket-new",
      transports: ["websocket"],
      autoConnect: true,

      // 这里关闭 Socket.IO 默认无限重连,统一走业务自己的退避重连。
      // 也可以保留 Socket.IO reconnection,但不要再手写一套立即重连。
      reconnection: false,
    };

    const socketOptions = {
      ...defaultOptions,
      ...this.options,
    };

    this.enableBusinessHeartbeat = Boolean(
      socketOptions.enableBusinessHeartbeat
    );
    this.pingEventName = socketOptions.pingEventName || this.pingEventName;
    this.pongEventName = socketOptions.pongEventName || this.pongEventName;

    delete socketOptions.enableBusinessHeartbeat;
    delete socketOptions.pingEventName;
    delete socketOptions.pongEventName;

    this.socket = io(this.url, socketOptions);

    this.bindBaseEvents();
    this.bindRegisteredEvents();
  }

  bindBaseEvents() {
    this.socket.on("connect", () => {
      this.reconnectTimes = 0;
      this.lastPongAt = Date.now();
      this.startHeartbeat();
    });

    this.socket.on("disconnect", () => {
      this.stopHeartbeat();

      if (!this.manualClose) {
        this.scheduleReconnect();
      }
    });

    this.socket.on("connect_error", () => {
      this.stopHeartbeat();

      if (!this.manualClose) {
        this.scheduleReconnect();
      }
    });

    // 业务心跳返回。事件名需要和后端约定,不建议直接占用底层 ping/pong。
    this.socket.on(this.pongEventName, () => {
      this.lastPongAt = Date.now();
    });
  }

  bindRegisteredEvents() {
    this.eventHandlers.forEach((handlers, event) => {
      handlers.forEach((handler) => {
        this.socket.on(event, handler);
      });
    });
  }

  login() {
    if (!this.socket) {
      return;
    }

    this.socket.emit("login", {
      token: window.localStorage.getItem("thirdToken"),
      clientId: this.clientId,
      uavSwitch: true,

      // 关键点:告诉后端我已经消费到哪条消息了。
      lastSeq: this.lastSeq,
    });
  }

  startHeartbeat() {
    if (!this.enableBusinessHeartbeat) {
      return;
    }

    this.stopHeartbeat();

    this.heartbeatTimer = window.setInterval(() => {
      if (!this.socket || !this.socket.connected) {
        return;
      }

      const now = Date.now();

      if (now - this.lastPongAt > this.heartbeatTimeout) {
        this.socket.disconnect();
        this.scheduleReconnect();
        return;
      }

      this.socket.emit(this.pingEventName, {
        clientId: this.clientId,
        lastSeq: this.lastSeq,
        timestamp: now,
      });
    }, this.heartbeatInterval);
  }

  stopHeartbeat() {
    if (this.heartbeatTimer) {
      window.clearInterval(this.heartbeatTimer);
      this.heartbeatTimer = null;
    }
  }

  scheduleReconnect() {
    if (this.reconnectTimer) {
      return;
    }

    const baseDelay = 1000;
    const maxDelay = 30000;
    const delay = Math.min(baseDelay * 2 ** this.reconnectTimes, maxDelay);
    const jitter = Math.floor(Math.random() * 1000);

    this.reconnectTimes += 1;

    this.reconnectTimer = window.setTimeout(() => {
      this.reconnectTimer = null;
      this.createSocket();
    }, delay + jitter);
  }

  clearReconnectTimer() {
    if (this.reconnectTimer) {
      window.clearTimeout(this.reconnectTimer);
      this.reconnectTimer = null;
    }
  }

  on(event, handler) {
    if (!this.eventHandlers.has(event)) {
      this.eventHandlers.set(event, new Set());
    }

    this.eventHandlers.get(event).add(handler);

    if (this.socket) {
      this.socket.on(event, handler);
    }
  }

  off(event, handler) {
    const handlers = this.eventHandlers.get(event);

    if (handlers) {
      handlers.delete(handler);
    }

    if (this.socket) {
      if (typeof this.socket.off === "function") {
        this.socket.off(event, handler);
      } else {
        this.socket.removeListener(event, handler);
      }
    }
  }

  emit(event, data) {
    if (this.socket) {
      this.socket.emit(event, data);
    }
  }

  disconnect() {
    this.manualClose = true;
    this.stopHeartbeat();
    this.clearReconnectTimer();
    this.eventHandlers.clear();

    if (this.socket) {
      this.socket.removeAllListeners();
      this.socket.disconnect();
      this.socket = null;
    }
  }

  rememberMessage(message) {
    const messageId = message.messageId || `${message.type}:${message.seq}`;

    if (!messageId) {
      return true;
    }

    if (this.messageIdSet.has(messageId)) {
      return false;
    }

    this.messageIdSet.add(messageId);
    this.messageIdQueue.push(messageId);

    if (this.messageIdQueue.length > this.maxMessageCache) {
      const expiredId = this.messageIdQueue.shift();
      this.messageIdSet.delete(expiredId);
    }

    if (Number(message.seq) > this.lastSeq) {
      this.lastSeq = Number(message.seq);
      window.localStorage.setItem(LAST_SEQ_KEY, String(this.lastSeq));
    }

    return true;
  }
}

const socketManager = new SocketManager();

export default socketManager;

这段代码不一定要完全照搬,但核心思想是:

text 复制代码
连接只是第一步;
真正重要的是连接断开之后,怎么恢复到正确的数据状态。

页面消费怎么改

原来的页面里是直接消费消息:

js 复制代码
socketManager.on("uavMessage", (data) => {
  this.uavData = data.message;
  this.initUavMarker();
});

这个写法的问题是:

text 复制代码
收到一条消息就重新初始化一次 marker。

如果消息频率高,地图就会频繁重建。

优化后应该变成:

js 复制代码
mounted() {
  socketManager.connect("http://shangcheng.cldeye.com");

  this.handleSocketConnect = () => {
    socketManager.login();
  };

  this.handleUavMessage = (packet) => {
    if (!socketManager.rememberMessage(packet)) {
      return;
    }

    const patch = this.createMarkerPatchFromSocket(packet);

    if (patch) {
      this.$refs.cesiumMap.applyMarkerPatch(patch);
    }
  };

  this.handleDockMessage = (packet) => {
    if (!socketManager.rememberMessage(packet)) {
      return;
    }

    const patch = this.createMarkerPatchFromSocket(packet);

    if (patch) {
      this.$refs.cesiumMap.applyMarkerPatch(patch);
    }
  };

  socketManager.on("connect", this.handleSocketConnect);
  socketManager.on("uavMessage", this.handleUavMessage);
  socketManager.on("dockMessage", this.handleDockMessage);
},

beforeDestroy() {
  socketManager.off("connect", this.handleSocketConnect);
  socketManager.off("uavMessage", this.handleUavMessage);
  socketManager.off("dockMessage", this.handleDockMessage);
}

然后把不同业务消息转成统一 patch:

js 复制代码
methods: {
  createMarkerPatchFromSocket(packet) {
    const data = packet.message;

    if (!data) {
      return null;
    }

    if (packet.action === "delete") {
      return {
        removeIds: [data.id],
      };
    }

    return {
      upserts: [
        {
          id: data.id,
          type: packet.type === "uavMessage" ? "uav" : "airport",
          status: data.status,
          longitude: data.longitude,
          latitude: data.latitude,
          height: data.height,
          raw: data,
        },
      ],
    };
  },
}

这样 WebSocket 和地图渲染就打通了:

text 复制代码
WebSocket 消息
  ↓
消息去重
  ↓
转换成 patch
  ↓
地图 applyMarkerPatch
  ↓
只更新变化点

为什么不要重连后全量刷新

重连后全量刷新看起来最稳:

text 复制代码
反正断过线,不如重新拉一遍。

但它的问题是体验和性能都不好。

地图大屏最怕的是:

text 复制代码
清空再重画

因为用户能明显看到点位闪烁。

而断点续传的思路是:

text 复制代码
我不假设前端状态已经失效。
我只补齐断线期间缺少的消息。

这样可以保留当前地图状态,只把缺失的变化补上。

前面做的 markerDataMapapplyMarkerPatch 正好可以承接这件事:

text 复制代码
断线前地图是什么状态
  ↓
重连后补发缺失 patch
  ↓
前端合并 patch
  ↓
地图局部更新

页面不会闪,数据也能追平。

为什么需要业务心跳

Socket.IO 自己有 ping/pong,那还要业务心跳吗?

我觉得在大屏场景里有必要。

因为底层连接活着,只能说明:

text 复制代码
客户端和 Socket.IO 服务之间的连接还在。

但业务链路还可能有问题:

text 复制代码
业务服务卡住了
消息队列堆积了
服务端没有继续推业务数据
代理层连接还在但后端不可用

所以业务心跳最好携带:

js 复制代码
{
  clientId,
  lastSeq,
  timestamp
}

它至少能做三件事:

text 复制代码
1. 让服务端知道客户端还活着
2. 让服务端知道客户端消费到哪了
3. 让前端知道业务链路是否长时间没有响应

如果连续超过一定时间没有业务 pong,前端可以主动断开并重连。

这比一直等浏览器或底层 WebSocket 自己发现断线更可控。

为什么要用退避重连

简单重连的问题是:

text 复制代码
所有客户端断线后一起重连。

这会造成服务端瞬时压力。

退避重连的核心是:

text 复制代码
失败越多,重连越慢。

随机抖动的核心是:

text 复制代码
不要让所有客户端同一秒重连。

所以推荐:

js 复制代码
const delay = Math.min(1000 * 2 ** reconnectTimes, 30000);
const jitter = Math.floor(Math.random() * 1000);

最终等待:

js 复制代码
delay + jitter

这样网络恢复时,客户端会分散重连,服务端更稳。

为什么需要消息去重

断点续传通常会带来一个副作用:

text 复制代码
服务端为了保证不丢,可能会多补几条。

比如前端最后消费到:

text 复制代码
seq = 100

服务端可能从:

text 复制代码
seq = 98

开始补。

这时候重复收到 98, 99, 100 是正常的。

前端不能假设服务端永远不会重复推。

更稳的原则是:

text 复制代码
服务端至少送达;
前端保证幂等消费。

也就是:

text 复制代码
消息可以重复到达,但重复消息不能重复生效。

这就是 messageIdSet 的作用。

js 复制代码
if (!socketManager.rememberMessage(packet)) {
  return;
}

重复消息直接跳过。

和地图增量更新怎么结合

WebSocket 优化不是孤立的。

它应该服务于地图渲染优化。

之前地图已经从全量更新变成:

text 复制代码
维护 markerDataMap
  ↓
收到 patch
  ↓
合并 patch
  ↓
只更新变化的 marker

那 WebSocket 消息也应该设计成 patch。

比如后端推送无人机位置变化:

js 复制代码
{
  messageId: "uav_1001",
  seq: 1001,
  type: "uavMessage",
  action: "upsert",
  message: {
    id: "uav-001",
    longitude: 120.12,
    latitude: 30.25,
    height: 80,
    status: "online"
  }
}

前端转成:

js 复制代码
{
  upserts: [
    {
      id: "uav-001",
      type: "uav",
      longitude: 120.12,
      latitude: 30.25,
      height: 80,
      status: "online"
    }
  ]
}

如果是删除:

js 复制代码
{
  messageId: "uav_1002",
  seq: 1002,
  type: "uavMessage",
  action: "delete",
  message: {
    id: "uav-001"
  }
}

前端转成:

js 复制代码
{
  removeIds: ["uav-001"]
}

最后交给地图:

js 复制代码
this.$refs.cesiumMap.applyMarkerPatch(patch);

整体链路就是:

text 复制代码
后端推送 seq 消息
  ↓
前端判断是否重复
  ↓
更新 lastSeq
  ↓
转换成 marker patch
  ↓
合并 markerDataMap
  ↓
重新判断当前图层聚合/明细模式
  ↓
聚合层重算聚合
  ↓
明细层只更新变化点

这样 WebSocket 的稳定性优化,最后会直接体现在地图体验上。

后端需要配合什么

这套方案不是纯前端就能完整闭环的。

后端至少需要支持:

text 复制代码
1. 每条消息有全局递增 seq
2. 每条消息有唯一 messageId
3. 客户端 login 时可以带 lastSeq
4. 服务端能补发 lastSeq 之后的消息
5. 服务端保留一段时间的消息缓存
6. 服务端收到 ack 或心跳后记录客户端消费进度

一个简单的服务端逻辑可以是:

text 复制代码
客户端 login(lastSeq)
  ↓
服务端查询 seq > lastSeq 的消息
  ↓
按顺序补发
  ↓
再继续推实时消息

消息缓存可以保留:

text 复制代码
最近 5 分钟
最近 10 万条
按业务 topic 分区保存

具体保留多久,要看业务数据量和断线恢复要求。

ack 要不要做

如果要求更严格,可以增加 ack。

前端消费成功后:

js 复制代码
socketManager.emit("ack", {
  clientId: socketManager.clientId,
  seq: socketManager.lastSeq,
});

服务端收到 ack 后,记录这个客户端已经消费到哪个 seq。

有了 ack,服务端可以更准确地知道:

text 复制代码
客户端收到消息了
客户端处理成功了
客户端可以从哪个位置继续

但 ack 也会增加通信量。

如果消息频率很高,不建议每条都 ack,可以批量 ack:

text 复制代码
每 1 秒 ack 一次
每消费 100 条 ack 一次
心跳时顺带 ack lastSeq

大屏场景里,我更倾向于:

text 复制代码
心跳携带 lastSeq
重要业务消息单独 ack

这样既能降低网络压力,也能保证关键数据可靠性。

最终效果

优化前:

text 复制代码
WebSocket 断开
  ↓
立即重连
  ↓
重连成功后重新拉全量
  ↓
地图闪烁
  ↓
断线期间消息可能丢失
  ↓
补发消息可能重复消费

优化后:

text 复制代码
WebSocket 断开
  ↓
停止心跳
  ↓
指数退避重连
  ↓
重连 login 携带 lastSeq
  ↓
服务端补发缺失消息
  ↓
前端 messageId 去重
  ↓
消息转 patch
  ↓
地图增量更新
  ↓
页面不闪,数据追平

总结

WebSocket 在实时大屏里不是连上就完事。

真正难的是:

text 复制代码
连接断了怎么办?
重连时缺失的数据怎么办?
服务端补发导致重复怎么办?
页面怎么避免全量刷新闪烁?

这次优化的核心不是把 WebSocket 写得更复杂,而是把它从"连接工具"升级成"可靠实时数据通道"。

最终方案可以概括成:

text 复制代码
心跳检测:发现假连接和业务链路异常
退避重连:避免断网时疯狂打服务端
断点续传:重连后补齐缺失消息
消息去重:避免补发消息重复消费
增量 patch:避免重连后全量刷新地图

对于地图大屏来说,这套 WebSocket 优化和前面的点位增量更新是配套的。

前端不应该在重连后重新初始化整个地图,而应该让实时消息继续走:

text 复制代码
去重
  ↓
合并
  ↓
diff
  ↓
局部更新

这样才能同时保证:

text 复制代码
低延迟
稳定性
不丢消息
不重复消费
页面不闪烁
相关推荐
mldong9 小时前
你的 Vue3 项目也能有钉钉同款审批流设计器:npm 装包,10 分钟画出第一条审批流
前端·vue.js
2分钟速写快排9 小时前
什么是 RAG?如何用 RAG 实现一个用户记忆?
前端·后端·ai编程
passerby606110 小时前
如何自己造一个时间处理库
前端·javascript·github
走到天涯海角11 小时前
react里面的长列表渲染优化
前端·react.js·前端框架
小羊没烦恼!11 小时前
Hello Web API系列教程——Web API与国际化
java·服务器·前端·javascript·php
北岛贰11 小时前
迷茫焦虑期,我做了一个带支付带官网的 AI 聊天虚拟恋人 App
前端·人工智能·后端
mayaairi13 小时前
Vue2 组件通讯(三):全局事件总线、PubSub、插槽与组件实例属性
前端·javascript·vue.js
kyriewen13 小时前
面试官问我:AI 都能写代码了,前端凭什么还值 25K
前端·javascript·人工智能
风骏时光牛马14 小时前
AI源码分析:拆解模型底层实现逻辑
前端
IT_陈寒15 小时前
React子组件莫名其妙重渲染?你可能漏了这个Hook
前端·人工智能·后端