结论:单一层级去重不可靠,必须客户端缓存+服务端内存过滤+持久化幂等表三层协同。因traceId和msgId在旧SDK、弱网重连、双击送礼等场景下均可能重复或不唯一,单独使用会导致漏判或误判,实测引发礼物统计虚高17%、重启后5分钟内重复率飙升至12%等严重问题。

直接说结论:靠单一层级的去重(比如只校验 msgId)基本不可靠,必须分层做——客户端缓存 + 服务端内存过滤 + 持久化幂等表,三者缺一不可。
为什么只用 traceId 或 msgId 去重会翻车
抖音、飞书等主流平台的 WebSocket 消息里,traceId 看似是“业务链路唯一ID”,但实测发现:旧版 SDK、弱网重连、客户端双击送礼等场景下,traceId 可能重复生成;而 msgId 在部分协议版本中仅保证“单次推送唯一”,跨连接不保证全局唯一。更麻烦的是,0.7% 的消息存在 msgId 相同但 traceId 不同的情况——这意味着你用任一字段单独判断,都会漏判或误判。
常见错误现象包括:
- 直播间礼物统计虚高 17%,源于连击消息被当成重复丢弃
- 用户在 Chrome 和小程序同时在线,收到 4 条相同订单通知
- 服务端重启后,内存缓存清空,5 分钟内重复率飙升至 12%
内存级去重怎么写才扛得住高并发
用 ConcurrentHashMap 存 traceId 是入门做法,但压测时容易 GC 崩盘。真正可用的方案得兼顾吞吐、过期和内存水位:
WebSocket 8.18.2 是该协议规范的一个重要迭代版本,主要优化了连接稳定性与数据传输效率。它通过全双工通信机制,允许客户端与服务器在单一长连接上实时交换数据,大幅降低传统 HTTP 轮询的开销。该版本增强了心跳保活、自动重连及二进制帧传输能力,适用于即时通讯、在线游戏及金融行情推送等低延迟场景,为开发者提供更可靠的实时网络交互基础。
- 选
Caffeine而非Guava Cache:QPS 高出 2.6 倍,且支持异步清理,避免主线程阻塞 - key 必须加前缀,例如
"ws_dedup:" + traceId,防止和其他缓存 key 冲突 - 过期时间设为 5 分钟,但实际要配合客户端行为——抖音客户端默认重发窗口是 5 秒,所以服务端至少保留 30 秒以上
- 不做全量消息体缓存,只存
traceId字符串,否则大消息体(如带 base64 图片的弹幕)会快速吃光堆内存
持久化幂等表必须包含哪些字段
当集群节点多、服务可能滚动重启时,纯内存方案必然失效。此时必须落地一张幂等表,但字段设计很关键:
-
id(主键):自增或雪花 ID,不用 UUID,减少索引碎片 -
dedup_key:拼接值,推荐traceId + method或msgId + roomId,长度控制在 128 字符内 -
created_at:精确到毫秒,用于清理过期记录(建议保留 7 天) -
status:枚举值'processed'/'failed',方便排查卡住的消息
别省事建单字段索引——必须给 dedup_key 加唯一索引,靠数据库层面拒绝插入,比应用层查再判快一个数量级。实测 MySQL 唯一约束冲突的写入耗时稳定在 0.8ms 以内,而先 SELECT 再 INSERT 平均要 3.2ms 且有竞态风险。
客户端也要做幂等,不是可选项
服务端再稳,也拦不住用户在多个标签页或 App + H5 同时登录。这时候前端必须自己挡一层:
- 用
localStorage缓存最近 30 秒内的msgId,格式为`${msgId}_${Date.now()}`,避免时间戳碰撞 - 监听
beforeunload清理缓存,否则用户关页再开,缓存残留导致误判 - 对「连击类」消息(如
combo: true或repeatCount > 1),前端不能直接丢弃,要透传给服务端,由业务逻辑决定是否合并而非去重
最容易被忽略的一点:WebSocket 断线重连时,客户端 SDK 默认会重发未 ACK 的消息,但很多前端没实现 ACK 回调,导致服务端反复收到同一包——这个环节一旦漏掉,后面所有服务端去重都白搭。

















