Redis Pub/Sub仅负责消息广播,不解决寻址与投递成功问题;必须结合本地Session缓存+Redis路由映射+消息过滤,否则会出现重复消费或消息丢失。

Redis Pub/Sub 能解决分布式 WebSocket 的跨节点消息投递,但不能替代 Session 管理或状态同步 —— 它只负责“广播”,不负责“寻址”和“投递成功”。
为什么直接用 Redis.publish() 发送消息到客户端会失败
常见错误现象是:服务节点 A 收到推送请求,调用 redisTemplate.convertAndSend("ws:channel", msg),但客户端始终收不到;或者只有部分节点上的客户端能收到。
根本原因不是 Redis 没发出去,而是所有节点都监听了同一个频道,导致每条消息被所有节点重复消费,而只有持有目标 WebSocketSession 的那个节点才该处理它 —— 其余节点应忽略。
- 必须在消息体中携带明确的路由标识,例如
userId、deviceId或sessionId - 每个节点只能处理「本机内存中存在对应 Session」的消息,其余一律丢弃(不报错、不重试)
- 监听频道不能是泛用的
"ws:all",推荐按业务维度拆分,比如"ws:user"、"ws:device",避免干扰
如何让每个节点只处理自己负责的连接
关键不在订阅逻辑,而在本地缓存 + 消息过滤的组合。节点启动时注册自身 ID,并在连接建立时把 userId → sessionId → WebSocketSession 三层关系写入本地 ConcurrentHashMap;同时将 userId → serverId 映射写入 Redis,格式如 SET ws:user:1001 "srv-a7f2"。
当收到推送请求时:
- 先查 Redis 得到目标
userId所在的serverId - 如果等于本机
serverId,直接从本地缓存取WebSocketSession推送 - 如果不等,才走 Pub/Sub:构造消息体
{"userId":"1001","content":"hello"},发往"ws:user"频道 - 所有节点监听
"ws:user",但仅当本地缓存含该userId时才执行发送
这样既避免空转,又不依赖 Redis 的原子性保证 —— 即使某次 publish 失败,业务层可补发,不阻塞主流程。
StringRedisTemplate 和 RedisTemplate 在消息序列化上的坑
用错模板会导致监听端收到 byte[] 而不是 JSON 字符串,反序列化失败,日志里只看到 Cannot construct instance of ... 这类 Jackson 错误。
- 发布端必须用
StringRedisTemplate,确保convertAndSend()输出的是 UTF-8 字符串 - 监听端的
MessageListenerAdapter必须绑定StringRedisTemplate的序列化器,不能混用RedisTemplate<string object></string> - 若坚持用
RedisTemplate,需显式设置setValueSerializer(new StringRedisSerializer()),否则默认是 JDK 序列化,客户端无法解析
一个快速验证方法:用 redis-cli 手动 PUBLISH ws:user '{"userId":"1001"}',看监听服务能否正常打印日志 —— 如果不能,基本就是序列化不匹配。
连接断开后本地缓存没清理,导致消息“发给了空气”
这是最隐蔽也最常被忽略的问题:客户端断连时,afterConnectionClosed() 触发了,但本地 ConcurrentHashMap 里对应的 sessionId 没删,Redis 里的 userId → serverId 映射也没删。后续推送仍会命中这个“幽灵 Session”,session.isOpen() 返回 false,sendText() 抛 IllegalStateException,但若没 catch 就静默失败。
务必做到三点同步清理:
- 在
afterConnectionClosed()中,从本地缓存移除sessionId,并检查该userId是否还有其他 session,没有则删 Redis 的ws:user:{userId} - 加个定时任务(比如每 5 分钟),扫描本地缓存中超过 2 分钟未活动的 session,主动 close 并清理
- Redis 的
ws:user:{userId}建议设 TTL(如 30 分钟),靠过期自动兜底
否则上线跑两天,你会发现推送成功率从 99% 掉到 70%,排查日志全是 “session is invalid”,但没人告诉你它为什么还留在 map 里。


















