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 复制代码
低延迟
稳定性
不丢消息
不重复消费
页面不闪烁
相关推荐
柚yuzumi1 小时前
CSS 定位布局:让元素各就各位
前端·css
光影少年1 小时前
react navite图片加载优化、大图卡顿、缓存策略
前端·react native·react.js
小林ixn1 小时前
用 Next.js 和 Redis 撸一个 Markdown 笔记系统:RSC 实战与组件化拆解
前端·redis·next.js
渣波1 小时前
重构旅行体验:基于 React 的 AI 旅游助手对话系统实战解析
前端·javascript
今日无bug1 小时前
列表转树:一道题搞懂 HashMap 在算法里的价值
前端·数据结构
用户921080262861 小时前
5. 数据大屏实时通信第一步:为什么选择 WebSocket,以及如何接入 Socket.IO
前端
岁月留痕1681 小时前
17 实战项目二:实现“日记”项目多页面管理
前端
李顿波1 小时前
Chrome 插件弹窗一直停留在初始的小尺寸 —— 你看到的小方块
前端·javascript·chrome
悟空瞎说1 小时前
Cesium 与 Three.js 融合实战:在数字地球上渲染自定义 3D 场景
前端