线上跑 websocket,一开始觉得就是换个协议的事儿。等连接数爬到几万,节点一扩容,消息乱飘、session 丢失、nginx 频繁断开、内存 oom 挨个教做人之后才明白,websocket 的难点从来不在握手,而在状态解耦和集群路由。
这篇不扯概念,直接拆解我们在生产环境里落地 spring boot websocket 集群踩过的坑、填平的路径,以及那些官方文档没写清楚的边界条件。
1. 协议选型:为什么轮询扛不住,websocket 又带来了什么新麻烦
早期做消息推送,团队里不少人习惯用短轮询或长轮询(comet)。短轮询看似简单,但客户端频繁发请求,服务端 tomcat 线程池很快被占满,cpu 全耗在上下文切换上;长轮询稍微好点,挂起请求等数据,但连接维持成本极高,稍微来点并发,服务器文件描述符和内存就被吃光。而且这两种方案头部开销大,每次请求带着几 kb 的 http header,带宽利用率低得离谱。
websocket 把问题解决了:一次握手升级协议,之后走 tcp 全双工,帧开销降到个位数,延迟直接压到个位数毫秒级。但天下没有白吃的午餐,协议一换,架构的痛点也转移了。以前 http 是无状态的,请求打完就扔;websocket 连接是长驻的,session 绑在 jvm 内存里。节点一多,用户连在 a 节点,业务逻辑跑在 b 节点,推消息直接找不到人。这时候光靠负载均衡的 ip hash 根本救不了场,扩缩容、故障转移全得重写。引入 websocket,本质上是用状态管理成本换实时性,后续的集群路由、心跳保活、断线补偿,一个都省不掉。
2. 握手、认证与心跳:别在底层协议上踩坑
2.1 握手拦截只做轻量校验
websocket 握手本质是个 http get,带 token 放 header 或 query 参数都行。生产环境千万别在 handshakeinterceptor 里查数据库或调远程服务,nio 线程一旦阻塞,握手队列瞬间打满。轻量验签、解析基础身份就足够,细粒度权限放到后续 stomp 订阅拦截器里做。
public class authhandshakeinterceptor implements handshakeinterceptor {
@override
public boolean beforehandshake(serverhttprequest request, serverhttpresponse response,
websockethandler wshandler, map<string, object> attributes) {
string token = request.getheaders().getfirst("authorization");
// 验签逻辑必须快,失败直接 return false 拒绝握手
if (!tokenvalid(token)) return false;
attributes.put("userid", extractuserid(token));
attributes.put("connecttime", system.currenttimemillis());
return true;
}
}
2.2 stomp 子协议不是银弹,但能省一半开发量
原生 websocket 只传纯文本/二进制,路由、订阅、消息确认全得自己写。spring 提供的 stomp over websocket 抽象层把这套逻辑标准化了。/app/** 收上行请求,/user/ 和 /topic/ 做下行广播/单播,订阅模型天然支持按需推送。除非你的业务协议极度特殊,否则直接上 stomp 是性价比最高的选择。
@configuration
@enablewebsocketmessagebroker
public class websocketconfig implements websocketmessagebrokerconfigurer {
@override
public void configuremessagebroker(messagebrokerregistry registry) {
registry.enablesimplebroker("/topic", "/user")
.setheartbeatvalue(new long[]{10000, 10000}) // 客户端/服务端心跳间隔 10s
.settaskscheduler(heartbeatscheduler);
registry.setapplicationdestinationprefixes("/app");
}
}
2.3 心跳间隔必须和网关超时对齐
长连接最怕“假死”。nat、防火墙、云厂商 lb 都有空闲连接回收策略,静默断掉后客户端根本不知道。stomp 原生带 heart-beat 头,spring 的 simplebrokermessagehandler 会按时发 ping 帧。这里有个死规定:stomp 心跳间隔必须小于 nginx/网关的 proxy_read_timeout。我们线上一般设 10s 心跳,nginx 超时配 300s,留足缓冲。客户端也要同步实现断线检测,收不到 pong 就主动重连。
3. 集群化部署:怎么把内存里的 session 变成可路由的状态
3.1 单机 session 的局限性
spring 默认的 simplebrokermessagehandler 是纯内存的。用户连上 node1,session 就在 node1 的 jvm 里。消息路由到 node2,node2 根本找不到这个用户,直接丢弃。早期很多团队用 sticky session(会话保持)硬扛,但节点宕机或扩缩容时,session 瞬间丢失,用户体验断崖式下跌。
3.2 轻量级分布式路由方案
生产上如果不想上重型 mq(rabbitmq/activemq),可以基于 redis pub/sub 搭一套轻量路由层。核心思路就三步:
- 上线注册:节点启动或新连接建立时,把
userid -> nodeid映射写入 redis hash。 - 本地优先,跨节点转发:消息落到任意节点,先查 redis。同节点直接走本地
simpmessagingtemplate;跨节点就发到对应节点的 redis channel。 - 离线清理:配合定时任务或连接断开事件,清理失效映射,防止脏数据堆积。
// 核心路由逻辑(生产环境需补全 null 判断与异常重试)
public void routemessage(string targetuserid, object payload) {
object targetnode = redistemplate.opsforhash().get("ws:routing:user", targetuserid);
if (targetnode == null) return; // 用户不在线
if (currentnodeid.equals(targetnode.tostring())) {
// 同节点,直接走 spring 本地 broker
messagingtemplate.convertandsendtouser(targetuserid, "/queue/notify", payload);
} else {
// 跨节点,走 redis pub/sub 转发
string channel = "ws:route:" + targetnode;
routemessage msg = new routemessage(targetuserid, payload);
redistemplate.convertandsend(channel, json.tojsonstring(msg));
}
}
实话实说:这套方案在万级连接、中等并发下完全够用,开发快、运维轻。但如果业务对消息顺序、持久化有强要求,或者节点数超过几十个,pub/sub 的广播特性和丢消息风险会放大,这时候老老实实接 kafka 或 rabbitmq 是更稳妥的选择。
4. 可靠性兜底:断线重连、离线补偿与消息去重
4.1 离线消息怎么补
客户端切后台、网络抖动断连太常见了。服务端在 afterconnectionclosed 里要记录用户最后一次成功 ack 的 seqid,没送达的消息暂存到 redis list 或 stream。客户端重连成功后,上报本地最大 last_seq_id,服务端拉取差量补发,补完等客户端回 ack 再清理。这套机制不能太复杂,否则客户端实现成本太高,反而影响稳定性。
4.2 重连风暴必须压住
客户端断线后如果立即死循环重连,服务端握手队列瞬间被打满。务必实现指数退避:delay = min(2^retry * base, 30s),加点随机抖动错开峰值。服务端也要配 max-concurrent-sessions 做硬限流,超出直接拒绝,保护核心线程池。
4.3 消息幂等是底线
网络重试+跨节点转发,重复投递是必然的。处理重复消息别指望业务层自己扛,架构层得兜底:
- 消息带全局唯一 id(snowflake 或 uuid),塞进 stomp header。
- 客户端本地用
set缓存最近几百条 id 去重。 - 服务端 redis 做
setnx ws:dedup:{msgid} 1 ex 300,窗口期内直接拦截。 - 核心写接口按
userid + msgid建唯一索引,数据库层面兜底幂等。
5. 压测与调优:内存泄漏排查与容器选型
5.1 别用 tomcat 跑高并发 ws
spring boot 默认的 tomcat 嵌入式容器,websocket 实现底层是阻塞/半异步模型,连接数上万时线程争用明显,p99 延迟抖动很厉害。压测跑过之后,切到 undertow 或 netty 是必须的。undertow 基于 xnio,非阻塞 io 处理长连接更平滑,gc 停顿也小很多。
# application.yml 切换 undertow
server:
undertow:
io-threads: 4 # nio 线程数,通常等于 cpu 核数
worker-threads: 64 # 业务处理线程池
buffer-size: 1024
direct-buffers: true压测别光看 qps,盯紧这三个指标:连接建立耗时 p95、帧投递延迟、堆外内存(direct memory)增长曲线。
5.2 内存泄漏怎么查
线上出过几次 oom,排查下来基本逃不出这几个点:
- 异常断开未清理:网络闪断没触发
afterconnectionclosed,session 残留。必须在异常捕获或拦截器里显式session.close()。 - 超大消息打爆堆:客户端乱发几 mb 的 base64 图片。配死
spring.websocket.max-text-message-size=64kb,超限直接抛异常断开。 - simplebroker 内存膨胀:默认实现会把未消费的消息全塞内存。压测时监控
simp.messagehandler队列深度,必要时切外部 broker 或加队列上限。 - jvm 参数别瞎配:
-xx:+useg1gc -xmx4g -xx:maxdirectmemorysize=1g足够。长连接场景老年代增长慢,但堆外内存容易漏,定期用jmap -histo:live或 arthas 看对象分布。
6. 安全与可观测性:线上问题怎么快速定位
6.1 基础安全加固
- 强制走
wss://,tls 1.2 起步,禁用 rc4/des 等弱套件。 - 握手阶段严格校验
origin和host,防 csrf 和恶意第三方嵌入。 - 限流别省:redis 滑动窗口控 ip/token 频率,异常高频直接封禁。恶意爬虫打 websocket 接口,比打 rest api 还狠,不防住网关早晚被拖垮。
6.2 监控指标怎么埋
没监控的 websocket 集群就是盲开。我们线上通常这么干:
- micrometer 暴露核心指标:
ws.connections.active(当前连接数)、ws.messages.in.rate/out.rate、ws.heartbeat.timeouts。接 prometheus + grafana,大盘一眼能看集群水位。 - traceid 透传:握手阶段生成
traceid放到 stomp 自定义 header 里,后续业务消息、异步处理全带上。结合 skywalking 或 jaeger,跨节点丢消息直接按链路查。 - 告警阈值:连接数 5 分钟跌 30%、redis pub/sub 延迟 > 200ms、gc 停顿 > 800ms,直接推钉钉/企微。别等用户投诉了才去翻日志。
7. 网关与高可用:nginx 配置、脑裂防御与优雅停机
7.1 nginx 反向代理避坑
nginx 默认不认 websocket 升级,配置错一个 header 就会导致握手失败或频繁断开。生产环境标准写法:
upstream ws_backend {
server 10.0.0.1:8080;
server 10.0.0.2:8080;
# websocket 升级后不走 upstream keepalive 连接池,这里配了也没用
# 重点靠后面的 timeout 和 worker_connections 兜底
}
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
location /ws {
proxy_pass http://ws_backend;
proxy_http_version 1.1;
proxy_set_header upgrade $http_upgrade;
proxy_set_header connection $connection_upgrade;
proxy_set_header x-real-ip $remote_addr;
# 关键:必须大于 stomp 心跳间隔,否则 nginx 会主动掐断空闲连接
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 10s;
# 禁用缓冲,避免大消息延迟或截断
proxy_buffering off;
}
}
注意:proxy_read_timeout 配太小是线上最常见的坑。nginx 认为连接空闲就会踢掉,客户端收到的是 1005 或 1006 异常码,排查半天发现是网关配置问题。
7.2 脑裂与路由冲突
网络分区时,两个节点可能同时认为某个用户在线,路由表冲突。防御手段其实就两条:
- 租约机制:节点注册路由表时带 ttl,定时续约。心跳超时自动清理,断网节点自然下线。
- 客户端单点约束:新连接建立前,客户端主动关旧连接;服务端收到
afterconnectionclosed立刻清理 redis 映射。不要搞复杂的分布式锁竞态,长连接场景下,简单比复杂更可靠。
7.3 优雅停机别用 thread.sleep
@predestroy 里 thread.sleep 是伪优雅。spring boot 2.3+ 原生支持 server.shutdown=graceful,开启后新请求拒绝,旧连接等待处理。websocket 侧配合定时广播 close 帧,给客户端 3~5 秒缓冲期重连即可。硬杀进程在容器化环境里越来越不可取,k8s 的 terminationgraceperiodseconds 也得对齐配置。
websocket 集群化不是加个负载均衡就能跑稳的。它逼着团队把状态管理从 jvm 内存抽离到中间件,把同步调用改成异步路由,把隐式的网络抖动变成显式的补偿机制。spring 的抽象层确实好用,但线上能不能扛住流量,取决于你对底层 io 模型、中间件边界、异常链路的敬畏程度。
这套架构我们线上跑了两年,经历过节点宕机、redis 抖动、客户端弱网切换,核心靠的就是状态外置、路由解耦、防御性兜底。协议底层怎么变,设计原则就这几句,落地时根据业务体量做加减法就行。
以上就是springboot集成websocket集群化与状态同步实战的详细内容,更多关于springboot集成websocket的资料请关注代码网其它相关文章!
发表评论