WebSocket Session不可序列化,故不能存入Redis;应采用Redis Pub/Sub广播用户状态变更事件,各节点仅更新本地缓存并按需路由消息。

WebSocket 集群中无法靠共享 Session 实现用户状态同步——因为 javax.websocket.Session(或 jakarta.websocket.Session)不可序列化,硬塞进 Redis 会直接抛 NotSerializableException。必须绕过 Session 对象本身,只同步「状态意图」。
为什么不能把 WebSocket Session 存到 Redis
很多人第一反应是“用 Spring Session + Redis 复制 HttpSession 那套”,但这是错的。HttpSession 被设计为可序列化,而 WebSocket 的 Session 是运行时连接句柄,内部持有着 SocketChannel、ByteBuffer、线程上下文等原生资源,根本没法跨 JVM 搬运。
-
Session实例只在建立连接的那个节点内存中有效,其他节点拿到它的序列化字节也没法还原出可用连接 - 强行反射修改或自定义序列化器,会导致连接中断、内存泄漏或
IllegalStateException: Session is closed - 哪怕你用 Netty 或 Undertow 自行封装,只要底层依赖 NIO Channel,就逃不开这个限制
用 Redis Pub/Sub 同步用户在线状态变更
真正可行的做法是:把「用户上线/下线/角色变更」这类事件当作消息广播,各节点收到后更新本地缓存(如 ConcurrentHashMap<string userstatus></string>),不碰 Session 本身。
- 用户登录成功后,服务端往 Redis 频道
user:status:online发布 JSON 消息:{"userId":"u1001","status":"ONLINE","nodeId":"app-01"} - 所有集群节点都订阅该频道,收到后仅更新本地
userStatusMap.put("u1001", status),不尝试反查或重建 Session - 前端发消息给某用户时,先查本地
userStatusMap.get("u1001");如果存在且nodeId匹配当前节点,才调用session.getBasicRemote().sendText() - 若不匹配,说明目标用户连接在别的节点——此时不做转发,而是让网关层(如 Spring Cloud Gateway)根据一致性哈希路由到对应实例,或由客户端重连
分布式锁只用于临界状态写入,不是用来保护 Session
有人试图用 Redis 分布式锁包裹 session.getBasicRemote().sendText(),这是过度设计。锁的粒度应落在业务状态上,比如「用户正在编辑文档」,而不是「我正往这个 Session 写字节」。
- 对单个
Session加锁毫无意义:它本就不跨节点,本地线程安全靠synchronized(session)就够了 - 真正需要锁的是共享资源,例如:
redis.lock("doc:123:edit_lock", "u1001"),防止多人同时提交冲突版本 - 锁超时必须设合理(建议 30–60 秒),且要用原子 Lua 脚本释放,避免死锁;别用
tryLock(timeout)简单包一层就完事
前端必须配合做连接保活与重试降级
后端方案再稳,前端没处理好重连逻辑,用户照样掉线。这不是可选优化,是必填项。
- WebSocket 连接建立后,立即启动心跳:
socket.send("PING"),服务端响应PONG;超时 3 次未响应则主动 close 并触发重连 - 重连时带上上次连接的
nodeId(从首次握手响应头或初始 message 中获取),让网关优先路由回原节点 - 如果重连失败,退化为短轮询拉取离线消息(
/api/v1/messages?since=1748155680000),直到长连接恢复 - 不要依赖浏览器自动重连——
socket.onclose触发后,必须手动 new WebSocket(),否则多数浏览器不会重试
最易被忽略的一点:状态同步不是「让所有节点都有同一份 Session」,而是「让所有节点知道用户此刻在哪、能做什么」。一旦盯着 Session 对象不放,路就走歪了。











