Set仅能实现单会话内存级去重,无法解决分布式、跨进程或断连残留等逻辑重复问题;真正可靠的排重需结合客户端防误操作、会话层校验及Redis全局订阅中心三层机制协同。
直接用 Set 实现“自动排重”的 WebSocket 订阅池,本质是利用其原生去重能力管理用户订阅关系,但需注意:Set 只能防内存级重复,无法解决分布式、跨进程或断连残留导致的逻辑重复。真正可靠的排重必须结合服务端状态一致性机制。
为什么单靠 Set 不够用
前端或单节点后端用 JavaScript 的 Set 存储用户订阅的 channel 或 topic,确实能避免同一连接重复 subscribe 同一主题。比如:
const subscriptions = new Set();
subscriptions.add("ticker:AAPL");
subscriptions.add("ticker:AAPL"); // 无效,Set 自动忽略
但这只在当前 WebSocket Session 内有效。一旦用户刷新页面、重连、或部署多实例,Set 就失效了——新连接会重新加入,旧连接可能还挂着未清理的订阅。更严重的是,后端若没做全局去重,多个节点可能同时向同一用户推送重复消息。
真正起作用的三层排重设计
要让“自动排重”落地,得把 Set 放对位置,并补上其他环节:
-
客户端层(轻量防误操作):用 Set 缓存已订阅列表,点击“订阅”前先 check,避免高频重复点击触发多次 send;配合防抖和 loading 状态,提升体验但不依赖它保一致性
-
连接会话层(关键守门员):每个 WebSocket 连接初始化时生成唯一 sessionID,后端用 Map 或 Redis Hash 存 { sessionId: { userId, subscribedTopics: ["a","b"] } };收到新订阅请求时,先查该 session 是否已含该 topic,再决定是否下发或更新
-
全局订阅注册中心(最终防线):用 Redis Set 存 { userId: ["topic1", "topic2"] },每次 subscribe 先 SADD,UNSUBSCRIBE 用 SREM;消息分发前,从该 Set 读取用户实际关注列表——这才是跨节点、抗重启、可审计的排重依据
一个典型流程示例
用户 A 登录后订阅 “news:tech”:
- 前端 Set 记录 "news:tech",禁用重复按钮
- WebSocket 发送 { cmd: "sub", topic: "news:tech" }
- 后端校验该 session 是否已订阅 → 否,继续
- 执行 Redis 原子操作:
SADD user:1001:subs "news:tech"
- 成功返回后,更新本地 session 缓存中的 subscribedTopics 数组
- 后续发消息时,不再遍历所有连接,而是
SMEMBERS user:1001:subs 获取去重后的主题列表,再按主题路由到对应频道
别踩的坑
常见错误不是不用 Set,而是错用 Set:
- 把 Set 当数据库用,重启就丢数据 → 必须持久化到 Redis
- 仅在内存里 Set 去重,却没同步清理 Redis 中的记录 → 断连后残留订阅,造成消息漏推或误推
- 用 Set 存整个 message 对象做去重 → 毫无意义,消息本就不该靠对象引用去重,而应靠业务 ID + 幂等键(如 msgId)
- 订阅/取消没配对处理 → 必须保证 SADD 和 SREM 成对出现,建议封装成 service 方法,加 try/finally 清理