不能只用Redis Pub/Sub做多端同步,因其消息“发后即焚”、无历史可溯、广播无寻址能力,会导致断线重连无法补推、多设备重复接收、无法精准投递;必须结合Redis Stream(带ID/时序/消费者组)与本地session映射协同实现。

WebSocket 本身不保存状态,也不跨实例共享连接,单靠它无法实现「用户在手机、PC、平板同时登录,发一条消息,三端都收到」——必须靠 Redis 做中枢协调。
为什么不能只用 Redis Pub/Sub 做多端同步
Pub/Sub 看似简单:服务 A 发,所有订阅 topic:user:1001 的网关实例都能收到。但问题很现实:
- 消息一发即焚,客户端断网重连后,
PUB/SUB里没有“历史”可拉取 - 多个网关实例同时订阅同一频道,会重复推送给同一个用户的多个设备(比如用户 A 的两个浏览器都收两遍)
- 无法区分「这条消息该推给谁」——Pub/Sub 是广播,不是寻址
所以,纯 Pub/Sub 只适合做「事件通知」(如「有新订单」),不适合做「用户级消息投递」。
Redis Stream 是多端同步的真正基础
Stream 天然带 ID、时间序、消费者组,正好补上 WebSocket 的三大短板:无状态、无历史、无路由能力。
关键设计点:
- Stream key 按会话粒度建,例如
stream:dm:1001:2002(用户1001 ↔ 用户2002),别用全局stream:all - 写入时机必须是「消息已确认送达」或「目标用户离线」时才
XADD,不是一收到就存(否则可能存了又撤回) - 务必用
XADD stream:dm:1001:2002 * ...让 Redis 自动生成 ID;手动生成 ID 容易冲突,且失去毫秒级时序保证 - 每个网关实例启动时,用
XREADGROUP绑定独立消费者名(如consumer:gateway-1),避免多实例争抢同一条消息
客户端连接后怎么拉取未读 + 同步在线设备
WebSocket 连接建立后的第一件事,不是等推送,而是主动问:「我上次看到哪条了?」
前端发初始化请求,比如:
{"type":"fetch_history","stream_key":"stream:dm:1001:2002","last_id":"1716471382000-0","count":30}
后端执行:
XREVRANGE stream:dm:1001:2002 1716471382000-0 - COUNT 30
注意三点:
- 用
XREVRANGE而非XRANGE,确保最新消息排在前面,前端prepend更自然 -
last_id是上一条消息的完整 ID(如1716471382000-0),不是时间戳;ID 才能精确去重和续读 - 首次登录时传
+(最大 ID),再加COUNT限制,避免全量扫描拖垮 Redis
多设备在线时,如何避免重复推送
核心不在 Redis,而在服务端内存 + Redis 联合寻址:
- 每个网关实例维护一个
Map<string set>></string>,key 是userId,value 是该用户当前所有活跃的sessionId(来自 WebSocket 握手参数) - 当需要推送给用户 1001 时,先查这个 Map,拿到全部 session ID,再逐个调用
session.getBasicRemote().sendText(...) - 用户登出或心跳超时,必须从 Map 中清理对应 session;同时用 Redis 的
EXPIRE给该映射加 5 分钟过期,防内存泄漏 - 如果网关是多实例,这个 Map 不能只存在本地——要改用 Redis Hash,key 为
online:sessions:{userId},field 是sessionId,value 可为空或时间戳
真正的难点从来不是「怎么发出去」,而是「怎么确认发给了所有该发的设备,且只发一次」——ID 对齐、消费者组隔离、session 映射时效性,三者缺一不可。











