单节点Spring Boot+WebSocket达5000连接后必须拆节点,因长连接有状态而HTTP负载均衡无状态;Nginx默认轮询致Session不共享、消息丢失、断连重连失败;需用Redis存储userId→nodeId映射并结合Pub/Sub精准路由消息,禁用@ServerEndpoint改用STOMP+Netty提升并发能力。

单节点 Spring Boot + WebSocket 到达 5000 连接左右,CPU 和内存压力就明显上升,这不是配置调优能绕开的瓶颈——必须拆节点,但直接上 Nginx 负载均衡 + 多实例,session 不共享、消息发不出去、用户断连重连后收不到历史消息,这些问题会立刻暴露。核心矛盾只有一个:WebSocket 连接是长生命周期、有状态的,而 HTTP 负载均衡默认是无状态的。
为什么 Nginx 默认轮询会让 WebSocket 集群失效
当你用 upstream 配置多个 Spring Boot 实例,Nginx 默认按请求轮询分发,会导致同一个用户的多次连接被打到不同节点。而 Spring WebSocket 的 Session 只存本机 JVM 内存里,其他节点根本不知道这个用户连在哪。结果就是:
- 客户端发消息到 A 节点,A 节点查不到该用户在 B 节点上的
Session,无法广播 - 服务端想推消息给某用户,只在本节点遍历
ConcurrentHashMap<String, Session>,漏掉其他节点的连接 - 用户刷新页面重连,可能落到新节点,旧节点的连接还没超时释放,出现“双登录”或消息丢失
解决它不是换更高级的负载均衡器,而是让所有节点“知道彼此连了谁”。关键动作是:把连接归属关系抽出来,存到 Redis。
用 Redis 存会话映射 + Pub/Sub 转发消息(最简可行方案)
不需要引入 Kafka 或 RabbitMQ,Redis 自带的 PUB/SUB 就够用。每个节点启动时向 Redis 注册自己(比如 node:192.168.1.10:8080),同时监听全局频道 ws:broadcast;每次新连接建立,就把 userId → nodeId 写入 Redis Hash,如 HSET ws:user:nodes 1001 node:192.168.1.10:8080。
当 A 节点要给用户 1001 推送消息时:
- 先
HGET ws:user:nodes 1001查出他在哪个节点 - 如果查到是
node:192.168.1.20:8080,就往 Redis 频道ws:node:192.168.1.20:8080发一条 JSON 消息:{"userId":"1001","payload":"hello"} - B 节点一直订阅着自己的频道,收到后取出
Session并发送
注意:不要用 redisTemplate.convertAndSend("ws:all", ...) 全局广播,那会引发 N² 消息放大——10 个节点 × 1 万用户 = 每次推送 10 万条冗余消息。
@ServerEndpoint 注解在集群下必须禁用
Spring Boot 的 @ServerEndpoint 是基于 Tomcat/Jetty 原生 WebSocket 容器的,它不支持跨 JVM 的 Session 管理,且无法感知外部注册中心。你在配置类里写的 @Bean public ServerEndpointExporter() 必须删掉,否则多个实例会抢同一组端口或产生冲突日志。
WebSocket 8.18.2 是该协议规范的一个重要迭代版本,主要优化了连接稳定性与数据传输效率。它通过全双工通信机制,允许客户端与服务器在单一长连接上实时交换数据,大幅降低传统 HTTP 轮询的开销。该版本增强了心跳保活、自动重连及二进制帧传输能力,适用于即时通讯、在线游戏及金融行情推送等低延迟场景,为开发者提供更可靠的实时网络交互基础。
正确做法是:
- 关闭自动注册:
spring.websocket.enabled=false(application.yml) - 改用 Spring Messaging + STOMP:通过
@EnableWebSocketMessageBroker启动,它天然支持消息代理抽象,可插拔替换为 Redis-backed 的SimpleBroker或自定义StompBrokerRelay - 前端连接路径从
ws://host/ws改为ws://host/ws/stomp,走 STOMP 协议栈,便于统一做权限、心跳、重连控制
STOMP 不是银弹,但它把“连接管理”和“消息路由”剥离开,让你能在消息层做集群适配,而不是在 TCP 层硬扛。
Netty 替换内嵌容器是性能分水岭
Tomcat 的 WebSocket 实现用的是阻塞式 BIO 或受限的 NIO,每个连接至少占一个线程。实测中,4 核机器跑 8000 连接,Tomcat 线程数飙到 3000+,GC 频繁;换成 Netty 后,同样配置下轻松支撑 3 万连接,线程数稳定在 16 个(2 个 EventLoopGroup × 8 线程)。
切换要点:
- 排除
spring-boot-starter-websocket,引入netty-all和spring-boot-starter-web(仅保留 Web MVC) - 手写
ChannelInitializer,用WebSocketServerProtocolHandler处理协议升级,别依赖 Spring 的WebSocketHandler包装 - 连接对象从
javax.websocket.Session换成io.netty.channel.Channel,所有操作异步非阻塞,channel.writeAndFlush(...)即发即返
真正的难点不在编码,而在于:Netty 不帮你管用户身份绑定。你得在 handshakeComplete 阶段手动解析 URL 参数或 Cookie 中的 token,再查 Redis 把 channel.id() → userId 关系落库——这一步漏掉,集群消息路由就全瘫痪。
最后提醒一句:Redis 不是万能胶。当单节点 Redis 成为瓶颈(比如每秒 5 万次 HGET),别急着加 Redis 集群,先检查有没有在消息循环里反复查用户归属——应该把 userId → nodeId 映射缓存在本地 Caffeine 里,设置 30 秒 TTL,命中率通常能到 95% 以上。分布式系统里,最贵的永远是跨网络调用,不是代码逻辑。

















