WebSocket消息去重需客户端主动拦截,因localStorage在多标签页、刷新后缓存过期、msgId非绝对唯一时会失效;应组合msgId与traceId生成指纹,用LRUMap缓存并配BroadcastChannel同步多标签页状态,且去重生命周期须独立于服务端ack。

WebSocket 本身不保证消息不重复,服务端重推、客户端重连、网络抖动都会导致同一条消息抵达两次甚至多次。单纯靠“前端不发两次”或“后端只处理一次”都不够——必须在**客户端侧主动拦截**,且拦截逻辑要能扛住页面刷新、多标签页、服务端重启等现实场景。
为什么 localStorage 去重会失效
很多团队第一反应是用 localStorage 存 msgId 或 traceId,但实际线上故障里,这招在三类情况下直接失灵:
- 用户开多个标签页(
localStorage不共享,去重缓存各自为政) - 页面强制刷新后未清空缓存,但服务端已过期该 ID 的幂等窗口(比如 30 秒),导致误判为“新消息”
-
msgId在抖音/飞书等平台中并非绝对唯一:有 0.7% 的概率出现msgId相同但traceId不同的情况,仅靠msgId会漏判
正确做法是组合字段生成指纹:`${msg.common.msgId}_${msg.traceId}`,并配合过期时间清理。但注意:不要用 Date.now() 做键,避免时钟漂移引发误删;建议用消息到达时间戳 + 固定 TTL 计算过期逻辑。
LRUMap 缓存比 Map 更适合前端去重
用原生 Map 存几千条指纹很快吃满内存,尤其在直播弹幕、实时协作等高频场景下,GC 压力明显。而基于 LRU 策略的缓存能自动淘汰旧项,兼顾性能与可控性:
立即学习“前端免费学习笔记(深入)”;
PigX UI Pro 前端开发指南 - Vue 3 + TypeScript + Element Plus。当用户提到 PigX UI、PigX 前端、lgb-mgui 项目、Vue 3 企业级后台开发、Element Plus 后台开发时使用此技能。
- 设置容量上限(如
1000),防止无限增长 - 每次访问都更新访问顺序,最久未用的自动被踢出
- 比轮询遍历
Map.keys()清理过期项更高效,无定时器开销
示例片段(非完整类):
const cache = new LRUMap(1000);
const fingerprint = `${msg.common.msgId}_${msg.traceId}`;
if (cache.has(fingerprint)) return true;
cache.set(fingerprint, Date.now());
多标签页场景必须用 BroadcastChannel 同步状态
单页应用在多个标签页打开时,每个页面的 localStorage / Map 是隔离的。一个标签页收到消息并标记为已处理,其他标签页完全不知情,仍会重复渲染或触发业务逻辑。
- 用
BroadcastChannel发送“已处理”事件,所有同源标签页监听并同步本地缓存 - 注意兼容性:
BroadcastChannel在 Safari 15.4+ 和 Chrome 64+ 支持良好,IE 全系不支持 - 不要依赖
localStorage的storage事件——它不触发当前页,只通知其他页,无法闭环
关键代码示意:
const bc = new BroadcastChannel('ws-dedup');
bc.addEventListener('message', e => {
if (e.data.type === 'mark-processed') {
cache.set(e.data.fingerprint, Date.now());
}
});
// 处理完消息后广播
bc.postMessage({ type: 'mark-processed', fingerprint });
服务端返回的 ack 不等于客户端去重完成
有些团队把去重责任全甩给服务端:只要收到 ack 就认为消息已安全落地。但问题在于,ack 只代表服务端收到了,不代表业务逻辑已执行完毕,也不代表前端 UI 已响应。例如:
- 服务端收到消息,写入 Kafka 成功,但下游消费失败 → 前端已渲染,用户看到“已到账”,实际没到账
- 前端收到
ack后立即清掉本地缓存,结果服务端因异常回滚,下一次重推过来又被当作新消息处理
所以,前端去重的生命周期必须独立于服务端 ack 流程:以消息到达时间为起点,按业务要求的幂等窗口(如 5 分钟)保留指纹,而不是收到 ack 就删除。真正的幂等边界,由业务语义决定,不是通信协议决定。

















