必须用 redis pub/sub 中转 websocket 消息,因 session 无法跨节点共享;集群下 simpmessagingtemplate 因目标节点无用户 session 而静默失败,需采用“广播+本地匹配”模式,结合 redis stream 提升可靠性。

多节点下 WebSocket 消息发不出去,不是代码写错了,是 session 无法跨节点共享 —— 必须用 Redis Pub/Sub 中转,否则不管怎么 reload 或 retry 都没用。
为什么直接调用 SimpMessagingTemplate 在集群里会丢消息
单机时 SimpMessagingTemplate.convertAndSendToUser() 能立刻找到本进程里的 WebSocketSession;但集群下,用户 A 连的是 node1,而触发推送的请求被 Nginx 转到了 node2,node2 根本没有 A 的 session 对象,调用就静默失败,连日志都不打。
常见错误现象:
- 前端收不到消息,后端日志里却显示“已发送”
- 同一用户反复刷新页面后偶尔能收到,说明只在连对节点时才生效
- 用
@SendToUser注解完全失效,Principal对象在跨节点时丢失上下文
根本原因:WebSocket session 是内存态对象,不能序列化进 Redis,也不能远程代理。所以必须放弃“找 session 再 send”的思路,改用“广播 + 本地匹配”模式。
RedisMessageListenerContainer 的配置要点和坑
Spring Boot 默认不自动注册监听器,必须显式声明 RedisMessageListenerContainer Bean,并绑定监听逻辑。漏掉这一步,Redis 订阅就形同虚设。
关键实操建议:
- 监听器 Bean 名必须用
@Bean("redisMessageListenerContainer")显式命名,否则 Spring Messaging 可能创建另一个默认实例,导致监听失效 - 订阅频道要用
PatternTopic(如new PatternTopic("ws:topic:*")),而不是ChannelTopic,否则无法按用户 ID 动态路由 - 序列化必须统一:
StringRedisTemplate用于发消息,监听器收到的Message.getBody()才是可读字符串;混用RedisTemplate<object object></object>会导致字节数组乱码 - 不要在监听器里直接调用
simpMessagingTemplate广播全量,而是先从本地ConcurrentHashMap<string websocketsession></string>查 userId 对应的 session,查到才推
消息体结构设计决定能不能精准投递
Redis Pub/Sub 是无状态广播,所有节点都收到同一份原始消息。如果消息体只含纯业务数据(比如 {"orderId":"123","status":"success"}),那每个节点都得自己解析、再查一遍自己存的 session 映射表 —— 效率低还容易错。
将 Spring Boot 2.7 项目升级到 Spring Boot 3.5 的实战流程,覆盖版本基线、依赖坐标替换、Jakarta 迁移、配置兼容、异步上下文传递改造与验证门禁。用于企业多模块 Maven 项目升级与排障。
推荐结构(JSON 字符串):
{"targetUserId":"u_789","destination":"/user/queue/notify","payload":{"type":"order_update","data":{"id":"123"}}}
这样做的好处:
-
targetUserId让各节点快速判断是否该自己处理(查本地 Map) -
destination保持与 STOMP 路由一致,避免手动拼接路径出错 - payload 不做业务解析,原样透传,降低监听器耦合度
注意:StringRedisTemplate.convertAndSend() 发送前必须 JSON.toJSONString(),不能直接传 Map —— 否则序列化成 JDK 字节流,其他节点收不到可读内容。
连接管理别依赖全局静态 Set,用 Spring 的 ConcurrentWebSocketSessionDecorator
很多示例用 static CopyOnWriteArraySet<websocket></websocket> 存 session,这在单机可行,但集群下会误导你——它只反映本节点连接,且无法感知超时、异常断连。
更健壮的做法:
- 用
ConcurrentHashMap<string websocketsession></string>存 userId → session 映射,key 从HandshakeInterceptor中的Principal.getName()提取 - 配合
@EventListener监听SessionConnectedEvent和SessionDisconnectEvent自动增删 - 加一层
ConcurrentWebSocketSessionDecorator包装原始 session,可设超时踢出、心跳计数等逻辑 - 绝不把 session 对象存 Redis —— 它不是 Serializable,强行序列化会报
NotSerializableException,且反序列化后 socket channel 已失效
真正容易被忽略的点:Redis Pub/Sub 本身不保证消息不丢,生产环境务必换成 Redis Stream + consumer group,否则网络抖动或节点重启时,未消费的消息就永久消失了。










