Spring Boot 集成 WebSocket 全指南:从实时通信到心跳集群与排障
发布时间:2026/10/2 18:21:01来源:尧图网络
手头上刚好有几个项目在用 Spring Boot 做实时推送从最早的咨询工单提醒到后来给运营后台做数据看板再到最近接的一个物联网设备状态上报WebSocket 这条路算是踩过不少坑也攒下了一些经验。趁这次机会把 Spring Boot 集成 WebSocket 的做法从头到尾梳理一遍从方案选型、依赖配置、核心代码到心跳保活、集群会话、线上排障一次性讲透。这篇内容适合这么几类人看刚接触实时通信、想在 Spring Boot 项目里接入 WebSocket 的后端开发前端同事想搞明白后端 WebSocket 接口到底怎么设计、心跳该怎么做还有那些项目已经在跑、但经常遇到连接掉线、消息推不到前端的同学。内容以 Spring Boot 2.x/3.x 为主代码都是可以直接拿过去改的。1. 实时通信方案选型为什么最后选了 WebSocket1.1 短轮询、长轮询、SSE 与 WebSocket 的取舍先说结论WebSocket 不是银弹但服务端需要主动推送数据、并且对实时性要求高的场景它确实是最稳妥的选择。很多人项目一开始用短轮询前端开一个setInterval每 3 秒调一次接口查有没有新数据。这种做法实现起来确实简单但问题也很明显第一是延迟不可控3 秒的周期意味着用户最多要等 3 秒才能看到新消息第二是浪费资源和流量接口大部分时候返回的都是没有新数据可每次请求都要走完整的 HTTP 链路包括 TCP 握手、HTTP 头部、鉴权校验。用户少的时候无感一旦并发上来数据库和网关的压力很快就顶不住。长轮询是短轮询的改良版客户端发请求后服务端先不返回等有新数据了再响应或者超过 30 秒超时后返回空结果、让客户端立刻重新发起请求。这个方案能把主动推送的效果模拟出来但服务端为了 hold 住这些请求会占用大量连接和线程资源。在高并发场景下这些挂起的请求会变成服务器的隐形负担而且代码写起来很容易出 bug超时边界、乱序响应都要处理。SSEServer-Sent Events是单向的。服务端可以通过 HTTP 长连接源源不断地往客户端推数据但客户端不能通过这条连接往服务端发消息。如果你的业务只是后台有更新往页面上推SSE 确实够用而且天然支持断线重连、字段也简单。但需要双向通信的业务比如聊天、协同操作、在线状态互推SSE 就无能为力了。WebSocket 和前面几种方案的本质区别在于它通过一次 HTTP 握手之后把连接升级为全双工的 TCP 长连接客户端和服务端随时都能往对方发数据。不需要每次都带 HTTP 头消息帧也很轻量实时性可以做到毫秒级。这么对比下来就很清晰了方案通信方向实时性实现成本典型场景短轮询客户端请求、服务端响应取决于轮询间隔极低低频非实时数据刷新长轮询基本单向模拟推送较好但有空窗中低频推送、老系统兼容SSE服务端单向推送好低通知、数据流订阅WebSocket双向全双工毫秒级中IM、协同编辑、行情推送1.2 什么场景适合上 WebSocket结合我实际接过的需求以下几个场景是最典型的消息类站内信、工单提醒、订单状态变更通知。这类需求核心是服务端有事件发生要第一时间告诉用户而且用户可能还需要回复操作双向通信让客户端动作和服务端推送可以在同一条链路上完成。实时数据看板运营后台的 PV/UV 统计、设备上报状态、任务执行进度。以前用轮询每 5 秒拉一次接口数据量大之后每次全量查询都很重。换成 WebSocket 后服务端在数据变更时主动推送增量前端只负责渲染数据库的压力直线下降。协同场景在线文档编辑、多人白板、客服工作台。这类场景对消息顺序和双向通信要求都很高WebSocket 是唯一能保证低延迟双向交互的通用方案。一个经验是如果业务是低频的比如每天只推送几条通知没必要上 WebSocket短轮询或者 SSE 完全够用如果推送频率高、又要双向交互就别犹豫。还有个隐蔽的考量WebSocket 连接是长连接会持续占用服务端资源如果峰值在线用户量特别大需要考虑连接上限、消息吞吐和集群会话共享后面我会展开讲。2. Spring Boot 接入 WebSocket 前的准备工作2.1 引入依赖与版本匹配Spring Boot 对 WebSocket 的支持非常成熟Spring Framework 从很早就提供了 WebSocket 模块。你不需要引入乱七八糟的第三方包官方spring-boot-starter-websocket就够了。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-websocket/artifactId /dependency如果你用的是 Spring Boot 3.x要注意底层已经从javax.servlet迁移到了jakarta.servlet代码里直接依赖javax.websocket的旧写法要同步调整。另外Spring Boot 3 是基于 Spring Framework 6 的内置的 WebSocket API 和 2.x 基本兼容WebSocketConfigurer、TextWebSocketHandler这些核心类的包路径没有变化只是如果你是直接使用 Jakarta WebSocketJSR 356注解方式开发的话ServerEndpoint的包名要改成jakarta.websocket。这里我多说一句Spring Boot 内置的 WebSocket 有两种开发模式。一种是直接用 Spring 的抽象也就是WebSocketHandler加WebSocketConfigurer代码完全由 Spring 管理和 Spring MVC 是一套生态另一种是 JSR 356 标准的ServerEndpoint注解。我个人强烈建议用 Spring 的抽象虽然多写几个类但你能拿到 Spring 的依赖注入能力和拦截器、安全框架的整合也顺畅。用ServerEndpoint时因为它的实例由 WebSocket 容器管理Spring 的Autowired经常失效很多人卡在这个地方。2.2 yml 配置里的关键项Spring Boot 的 WebSocket 本身没有特别多的配置项很多参数其实是由内嵌的 Tomcat 容器决定的。我在实际项目中主要在application.yml里配置过这些东西server: port: 8080 servlet: context-path: /demo tomcat: max-connections: 10000 max-threads: 200 min-spare-threads: 50 connection-timeout: 20000 accept-count: 200max-connections是 Tomcat 接受的最大连接数包括普通 HTTP 和 WebSocket 长连接。如果你的系统同时服务 HTTP 请求和 WebSocket 连接这个值要合理评估。之前有个项目上线后数据库连接池没爆、接口响应也正常但用户就是连不上 WebSocket查了半天发现是 Tomcatmax-connections设置得太小HTTP 请求把连接都占满了。还有一个需要注意的参数是空闲超时。WebSocket 连接建立后如果长时间没有数据传输部分中间件或操作系统会主动断开空闲连接。你可以在后端为每个连接定期发心跳包来维持活跃状态这个后面单独讲。Tomcat 层的connection-timeout是连接建立阶段的超时不是长连接的空闲超时别搞混了。真正影响长连接的是 WebSocket 会话的超时时间Spring 里可以在配置类中设置Configuration EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(realTimeMessageHandler(), /ws/notification) .setAllowedOrigins(*) .addInterceptors(authHandshakeInterceptor()); } }addHandler的第二个参数是 WebSocket 端点路径这个路径就是前端连接用的 URL。setAllowedOrigins(*)表示允许所有来源跨域连接生产环境一定要收敛成具体的域名白名单否则 CSWSHCross-Site WebSocket Hijacking攻击会找上门这一点我在安全相关部分详细说。3. 核心代码实现从握手到消息推送3.1 配置类与 Handler 注册Spring Boot 集成 WebSocket 的入口是WebSocketConfigurer。下面的配置类把 Handler 注册到指定路径同时挂上握手拦截器Configuration EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { private final AuthHandshakeInterceptor authHandshakeInterceptor; public WebSocketConfig(AuthHandshakeInterceptor authHandshakeInterceptor) { this.authHandshakeInterceptor authHandshakeInterceptor; } Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(realTimeMessageHandler(), /ws/notification) .setAllowedOrigins(*) .addInterceptors(authHandshakeInterceptor); } Bean public WebSocketHandler realTimeMessageHandler() { return new RealTimeMessageHandler(); } }EnableWebSocket是启动 WebSocket 支持的开关。很多初学着会漏掉这个注解结果 handler 注册了却一直报 404。再说路径设计。/ws/notification这个路径要放到服务端渲染的页面或者纯静态页面都能访问的位置。如果项目设置了context-path: /demo那么前端连接地址就要带上上下文路径ws://localhost:8080/demo/ws/notification。很多前后端联调时连接失败就是忘了这个前缀。我的习惯是统一用/ws/**作为所有 WebSocket 端点的前缀然后在拦截器里做路径判断结构清晰也好做权限控制。3.2 Handler 编写session 管理与消息路由Handler 是 WebSocket 消息处理的核心。这里用我项目里一个简化版的实时通知 Handler 来演示public class RealTimeMessageHandler extends TextWebSocketHandler { private static final MapString, WebSocketSession SESSION_MAP new ConcurrentHashMap(); Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { String userId (String) session.getAttributes().get(userId); SESSION_MAP.put(userId, session); session.sendMessage(new TextMessage(连接成功userId userId)); } Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { String payload message.getPayload(); // 解析消息内容比如 {targetUserId:1001,content:你好} // 根据 targetUserId 从 SESSION_MAP 中找到目标会话并推送 TextMessage reply new TextMessage(服务端已收到消息 payload); session.sendMessage(reply); } Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { String userId (String) session.getAttributes().get(userId); SESSION_MAP.remove(userId); // 移除会话的同时可以记录日志或者触发离线消息补推逻辑 } Override public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception { // 异常断开时的兜底处理这里一定要释放资源 session.close(CloseStatus.SERVER_ERROR); String userId (String) session.getAttributes().get(userId); SESSION_MAP.remove(userId); } public void sendToUser(String userId, String content) throws IOException { WebSocketSession session SESSION_MAP.get(userId); if (session ! null session.isOpen()) { session.sendMessage(new TextMessage(content)); } } }关于SESSION_MAP我有几点经验要强调。第一必须用ConcurrentHashMap。WebSocket 的每个连接都会在线程池里的某个线程上回调多个连接可能同时触发afterConnectionEstablished和handleTextMessage普通HashMap在高并发下可能因为扩容导致 CPU 100% 甚至死循环这是个很隐蔽的线上事故。第二Map 的 key 建议用业务用户 ID 而不是 sessionId。用 sessionId 做 key 的话服务端要主动给某个用户推送消息时还得额外维护一个用户ID到sessionId的映射关系。直接以用户ID为 key一个用户多端在线的情况用CopyOnWriteArraySet存 session 集合。我要给某个人推消息时遍历他的所有 session 依次发送。第三handleTransportError往往被忽略。很多时候连接异常断开不会走afterConnectionClosed直接走handleTransportError。如果这里不做清理SESSION_MAP 里会残留大量失效的 session越积越多最终导致内存泄漏和消息推送失败。我在所有项目的规范里都要求异常处理和正常关闭必须同时清理 session一个都不能少。sendToUser是服务端主动推送的入口。比如订单模块在支付成功后调用sendToUser(1001, 你的订单已支付成功)前端就能实时收到通知。这里要注意session.isOpen()判断否则往一个已关闭的 session 发消息会抛异常。3.3 握手拦截器连接前的认证与参数传递WebSocket 的握手阶段其实就是一次 HTTP GET 请求所以你可以在握手之前做鉴权也可以利用这个时机把业务参数传递到 WebSocket 会话中。public class AuthHandshakeInterceptor implements HandshakeInterceptor { Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, MapString, Object attributes) throws Exception { // 1. 从请求中获取 token String token null; if (request instanceof ServletServerHttpRequest) { ServletServerHttpRequest servletRequest (ServletServerHttpRequest) request; token servletRequest.getServletRequest().getParameter(token); // 也可以从 Header 中获取 if (token null) { token servletRequest.getServletRequest().getHeader(Authorization); } } // 2. 校验 token这里调用你项目的登录态校验逻辑 UserInfo userInfo authService.checkToken(token); if (userInfo null) { response.setStatusCode(HttpStatus.UNAUTHORIZED); return false; } // 3. 把业务参数放进 attributes后续在 Handler 中通过 session.getAttributes() 获取 attributes.put(userId, userInfo.getUserId()); attributes.put(nickname, userInfo.getNickname()); return true; } Override public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) { // 握手完成后的回调一般用不上 } }这里的关键是attributes.put(userId, ...)和 Handler 里session.getAttributes().get(userId)是对应起来的。通过握手拦截器你可以在不修改 Handler 的情况下把用户身份放进会话里。这个方案比在 Handler 里再次解析 token 要干净得多也方便统一管理鉴权逻辑。有一个坑是握手拦截器中获取到的ServerHttpRequest具体类型是ServletServerHttpRequest所以要用instanceof判断一下再强转。不判断直接强转在单元测试或者特别定制的环境里有可能会抛ClassCastException。3.4 服务端主动推送一个完整示例把上面的代码串起来服务端主动推送的调用链是这样的Service public class OrderService { private final RealTimeMessageHandler messageHandler; public OrderService(RealTimeMessageHandler messageHandler) { this.messageHandler messageHandler; } Transactional public void paySuccess(String orderId, String userId) { // 业务处理更新订单状态、记录支付日志等 // ... // 实时通知用户 String content String.format({\type\:\ORDER\,\orderId\:\%s\,\status\:\PAID\}, orderId); try { messageHandler.sendToUser(userId, content); } catch (IOException e) { // 推送失败记录日志走离线消息逻辑 } } }我提一句生产者消费者模式的变体如果你的项目消息类型很多、推送逻辑复杂不要在业务代码里直接调 Handler。可以引入一个消息推送服务内部用一个BlockingQueue或者交给线程池去发避免业务事务还没提交就发消息导致用户收到了推送、查明细却查不到数据。这是一个非常经典的事务一致性问题。4. 客户端接入与连接稳定性4.1 浏览器原生 WebSocket 客户端前端这一侧浏览器原生就支持 WebSocket不需要引入额外的库。基础用法非常简单const socket new WebSocket(ws://localhost:8080/demo/ws/notification?tokenxxxx); socket.onopen function () { console.log(WebSocket 连接已建立); // 连接建立后可以发送消息 socket.send(JSON.stringify({ type: PING })); }; socket.onmessage function (event) { const data JSON.parse(event.data); console.log(收到服务端消息, data); // 根据 data.type 分发给不同的业务处理函数 }; socket.onerror function (error) { console.error(WebSocket 发生错误, error); }; socket.onclose function (event) { console.log(连接关闭, event.code, event.reason); // 这里触发重连逻辑 };一个容易被忽略的细节前端在onopen里不要急着发业务消息。有些后端服务需要在握手成功后做一些初始化工作比如查用户未读消息、注册 session此时你立刻发消息过去后端可能还没准备好。我的经验是消息推送统一由服务端发起首条连接确认消息客户端收到确认后再开始业务交互。4.2 心跳机制连接不掉的秘密WebSocket 连接看起来是长连接但实际网络中会经过各种代理、负载均衡器、运营商 NAT 设备。这些中间设备通常会清理空闲连接。如果客户端和服务端长时间不互发数据连接可能就被设备默默地断了而且客户端感知不到直到下一次发消息才发现连接已经死了。解决这个问题的手段是心跳机制。心跳的本质是定时发送一个探测消息维持连接活跃同时能快速发现死连接。后端实现心跳有两种思路。一种是利用 WebSocket 协议自带的 Ping/Pong 控制帧。Spring 的WebSocketSession提供了pingMessage方法// 服务端定时发送 Ping Scheduled(fixedRate 30000) public void heartbeat() { SESSION_MAP.values().forEach(session - { if (session.isOpen()) { try { session.sendMessage(new PingMessage()); } catch (IOException e) { // 发送失败说明连接可能已断开触发清理 } } }); }浏览器端的 WebSocket API 没有直接暴露 Ping/Pong 帧的发送方法但浏览器会在协议层自动响应服务端的 Ping 帧。所以如果是后端主动发 Ping前端不用做额外的响应代码连接就能保持活跃。另一种更通用的方案是业务层心跳客户端每隔 30 秒发一条{type:PING}消息服务端收到后回复{type:PONG}。这个方案的优点是不依赖协议细节后端可以基于消息内容做超时判断比如超过 60 秒没收到任何业务消息就主动关闭会话。我实际项目里更推荐业务层心跳因为日志可查、超时逻辑可控排查问题时能直接从消息记录里看到心跳链路。给一个简单的客户端心跳实现function startHeartbeat(socket) { // 每 30 秒发送一次心跳消息 const heartbeatTimer setInterval(() { if (socket.readyState WebSocket.OPEN) { socket.send(JSON.stringify({ type: PING })); } }, 30000); // 在页面上关闭时要清理定时器 socket.addEventListener(close, () { clearInterval(heartbeatTimer); }); }4.3 断线重连策略设计WebSocket 连接不稳定是常态断线重连是必须做的。最简单粗暴的做法是onclose里setTimeout重连但有个细节要处理好不要在页面存活期间无限重连要加最大重试次数和退避策略否则用户切后台一小时再回来页面可能已经堆积了几百个重连定时器。一个实用的重连策略let reconnectAttempts 0; const MAX_RECONNECT_ATTEMPTS 10; function connect() { const socket new WebSocket(url); socket.onclose function (event) { // 1000 是正常关闭不需要重连其他状态码才需要重连 if (event.code ! 1000 reconnectAttempts MAX_RECONNECT_ATTEMPTS) { const delay Math.min(30000, 1000 * Math.pow(2, reconnectAttempts)); reconnectAttempts; setTimeout(connect, delay); } }; socket.onopen function () { reconnectAttempts 0; }; }退避策略里指数补偿的价值在于如果服务端正在重启所有客户端同时用 1 秒间隔重连会形成重连风暴。从 1 秒、2 秒、4 秒、8 秒这样递增上限 30 秒能有效降低服务端重启窗口期的压力。我经历过一次半夜发布上线客户端全部瞬间重连把刚起来的服务直接打垮从那以后所有重连逻辑都加了退避。另外断线期间业务消息会丢失这是 WebSocket 方案绕不开的痛点。我的做法是前端在onmessage里把最近的消息缓存起来比如存到localStorage或者内存数组重连成功后服务端根据客户端上传的最后收到消息序号补推缺失的消息。服务端也要实现一个消息时序号的概念否则前端无法判断有没有漏消息。5. 进阶认证、集群会话与监控5.1 WebSocket 连接怎么鉴权WebSocket 握手是一次 HTTP GET 请求但浏览器发起 WebSocket 请求时无法自定义Authorization头浏览器底层限制了WebSocketAPI 的自定义 Header。所以常见的鉴权方式有这么几种一是 token 放在 URL 查询参数里ws://localhost:8080/ws?tokenxxx服务端在握手拦截器里取token校验。优点是实现简单缺点是 token 会出现在访问日志、代理日志里有一定泄露风险。为了降低风险可以给 token 设一个很短的过期时间比如 5 分钟WebSocket 长连接建立后靠心跳维持会话。二是token 放在子协议Sec-WebSocket-Protocol里。浏览器允许new WebSocket(url, protocols)传入协议名服务端可以从握手请求的Sec-WebSocket-Protocol头里取 token。这个方法能避免 URL 暴露 token但设置子协议后服务端必须在响应中返回一个协议名否则浏览器会判定连接失败细节容易出错。三是先通过普通 HTTP 接口换取一个短期票据再用票据去建立 WebSocket。这个流程最安全适合对安全要求高的系统。我在项目里通常用第一种方案加短期 token配合握手拦截器做校验。只要记住一个原则beforeHandshake返回false就会拒绝连接所以鉴权逻辑必须放在这里。另外要提一下 CSWSH 防护。setAllowedOrigins(*)在开发环境很省事但线上必须设置白名单registry.addHandler(realTimeMessageHandler(), /ws/notification) .setAllowedOrigins(https://admin.example.com, https://app.example.com)不限制 Origin 的话恶意网站可以通过用户浏览器向你的 WebSocket 端点发起跨站连接如果后端还信任 Cookie 自动登录后果很严重。有些项目因为 WebSocket 不 stored in session疏忽了 Origin 校验这个坑一定要避开。5.2 多实例部署时的会话共享单机部署的 WebSocket 非常简单SESSION_MAP 在内存里谁连接了就记谁。但线上服务为了高可用都会部署多个实例这时问题来了用户A连接的是实例1用户B连接的是实例2实例1要把消息推给 B但 B 的 session 不在实例1上推送失败。解决这个问题的思路是把会话注册信息从本地内存提到共享存储。我用过的方案有这么几种第一种是 Redis 存储 session 映射关系。每个实例在连接建立时把userId - 实例ID sessionId写进 Redis需要推送时查 Redis 找到目标实例再通过实例间通信转发。这个方案需要自己实现实例间通信通常配合 Redis Pub/Sub 或者直接走消息队列。第二种是用 STOMP over WebSocket 加消息代理。Spring 对 STOMP 的支持很完善可以配置使用 RabbitMQ 或 ActiveMQ 作为 broker。客户端发送的消息先到 brokerbroker 再按订阅关系广播给所有实例每个实例只处理自己持有的本地 session天然解决了多实例问题。代价是架构复杂度上升协议从原生 WebSocket 变成了 STOMP。第三种最简单但不一定适合所有项目把 WebSocket 服务单独拆出来做成一个独立的推送服务节点。这个节点不依赖业务服务集群的会话状态只在它内部维护所有连接。业务服务需要推送时通过内部 HTTP 接口或消息队列调用它。这种连接层和服务层分离的做法在公司项目里很常见也便于做连接数的大规模扩展。我个人的建议如果项目刚开始、在线连接数不大优先用本地 SESSION_MAP Redis 记录连接分配关系在推送时通过 Redis Pub/Sub 广播消息各实例只推送自己本地持有的 session。这个方案代码量不大又解决了跨实例推送问题。等连接量到了十万级别再把推送服务独立拆分。5.3 用 Actuator 监控 WebSocket 连接线上排查问题时连接数是一个关键指标。Spring Boot Actuator 可以暴露应用的健康和指标信息但默认不包含 WebSocket 连接数。我这里给一个简单的做法在 Handler 里维护一个AtomicInteger统计当前活跃连接数并暴露成 Actuator 的自定义指标。Component public class WebSocketMetrics { private final AtomicInteger activeConnections new AtomicInteger(0); public void increment() { activeConnections.incrementAndGet(); } public void decrement() { activeConnections.decrementAndGet(); } public int getActiveConnections() { return activeConnections.get(); } }然后在 Handler 里调用增减方法配合 Actuator 端点或者 Micrometer 上报给 Prometheus就能在监控大盘上看到实时连接数变化。这个数字能帮你快速定位很多问题比如正常运行连接数应该在 3000 上下某天突然掉到 500说明大量客户端异常断线大概率是网络波动或者服务端有报错。5.4 Nginx 代理配置要点绝大多数线上环境都会在应用前面加一层 Nginx。WebSocket 走的是Upgrade协议头Nginx 默认配置不会自动转发这些头需要显式配置location /ws/ { proxy_pass http://backend-server; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_read_timeout 3600s; proxy_send_timeout 3600s; }proxy_read_timeout和proxy_send_timeout这两个参数很关键。Nginx 默认的 60 秒超时会导致 WebSocket 连接空闲 60 秒后就被断开即使你后端应用做了心跳只要两跳之间有一层代理超时连接照样保不住。我建议把超时时间调到和心跳周期匹配比如心跳 30 秒Nginx 超时给 3600 秒确保空闲窗口不会超过代理层的容忍范围。如果前面还有阿里云 SLB、腾讯云 CLB 这类负载均衡也要检查它们的 TCP 监听空闲超时配置很多云厂商默认是 300 秒左右同样需要调大。这类中间层断开的问题往往很难排查因为客户端和服务端都以为连接是好的实际上中间已经被掐断了唯一的排查办法就是在两端记录最后一条消息的时间戳对比找出断点。6. 常见问题与排查技巧实录6.1 连接建立成功但收不到消息这是碰壁率最高的问题。现象是 WebSocket 连接状态是 OPEN前端没报任何错但服务端推过来的消息前端收不到或者前端发的消息服务端没反应。第一步先确认消息到底有没有从服务端发出来。在 Handler 的sendToUser里打日志记录目标 userId、session 是否为空、是否 open。如果 session 为空说明用户 ID 对不上检查握手拦截器里attributes.put(userId, ...)的 key 和 Handler 里get(userId)的 key 是否拼写一致。这种低级错误我犯过不止一次。如果 session 存在且 open但前端收不到就把网络抓包打开看看。浏览器开发者工具里切换到 Network 面板、筛选 WS 标签能看到 WebSocket 帧的收发情况。如果连接正常但消息帧缺失检查是不是走了一层没有配置proxy_set_header Upgrade的网关或 Nginx。还有一种常见情况服务端推送时前端页面已经切到别的路由去了页面里的组件被销毁了但 WebSocket 对象没有主动 close连接还挂着。这时候前端收不到消息不是因为网络问题而是事件监听器早就被移除或者整个组件被卸载了。我的建议是前端在路由切换、组件销毁时统一调用socket.close()并清理定时器。6.2 连接频繁断开连接频繁断开先区分是客户端主动断的还是被动断的。看onclose的event.code关闭码含义常见原因1000正常关闭对端主动调用 close1001服务端即将下线应用重启、发布1006异常关闭网络中断、代理超时1008策略违规鉴权失败、Origin 不允许1009消息过大单条消息超过限制碰到 1006 是最麻烦的因为它是浏览器在底层 TCP 连接异常断开后给出的状态码没有具体的错误原因。优先检查 Nginx、SLB、CLB 的空闲超时配置把代理层超时时间调大。然后检查后端有没有OutOfMemoryError之类的异常服务端 JVM 崩溃会导致所有连接异常断开。还有一种容易被忽略的情况是防火墙规则的连接跟踪表满了旧的 NAT 映射被清掉长连接就被切断了这个在跨网络部署时尤其明显。如果连接断开的时间点非常有规律比如每隔 5 分钟一次那多半就是某个中间层的超时设置在作祟直接按超时时间去查配置。6.3 消息乱序与并发发送问题当服务端有多个线程向同一个 session 并发发消息时消息顺序可能错乱。WebSocketSession不是线程安全的同一时刻只能有一个线程调用sendMessage否则内部缓冲区会出问题甚至抛ConcurrentModificationException。我的处理方案是给每个 session 配一个发送锁或者统一通过一个单线程的发送队列。Spring 的WebSocketSession底层会做同步但业务消息有先后依赖关系时建议在推送逻辑里串行化。比如private final MapString, Object sessionLocks new ConcurrentHashMap(); public void sendToUser(String userId, String content) throws IOException { WebSocketSession session SESSION_MAP.get(userId); if (session ! null session.isOpen()) { Object lock sessionLocks.computeIfAbsent(userId, k - new Object()); synchronized (lock) { session.sendMessage(new TextMessage(content)); } } }如果你的推送并发量很大更合适的做法是引入 Disruptor 或者消息队列做削峰把发送操作从业务线程里摘出来。实时性要求稍微降一点没关系换来的是系统稳定性和消息顺序性。6.4 发布重启时的优雅下线每次发版重启应用所有 WebSocket 连接都会断客户端重连还有退避机制用户体验会受到影响。为了做到优雅下线我一般的做法是在应用关闭前向前端广播一条特殊消息告知客户端服务端即将重启请主动重连然后等待几秒再真正关闭连接。这样客户端收到消息后可以立即主动重连而不是等连接断开后靠心跳超时才感知到。Spring Boot 里通过监听ApplicationListenerContextClosedEvent或者PreDestroy方法实现。关键是给客户端留出足够的时间去感知并处理这个时间一般在 3 到 5 秒左右太短客户端来不及处理太长影响发布效率。7. 实操中容易踩的逻辑坑7.1 消息大小与服务端瓶颈WebSocket 没有明显的消息大小上限但 Tomcat 默认对 WebSocket 的缓冲区大小有限制。如果推送大消息比如单个 JSON 超过 8K某些容器配置下会直接断开连接或者报OutOfMemoryError。通过配置调大缓冲区server: tomcat: max-swallow-size: -1不过我的建议是设计消息协议时主动控制单条消息大小超过一定阈值就走先通知、再拉取的模式。比如推送消息里只包含 ID 和变更类型前端收到后再用 HTTP 接口拉详情。这样 WebSocket 只负责通知大流量载荷都走 HTTP两端压力都小。7.2 与 Spring MVC 拦截器的关系很多人会误以为 Spring MVC 的HandlerInterceptor也能拦截 WebSocket 握手请求。实际上握手走的是WebSocketHandler注册表里的路径虽然底层也是通过 DispatcherServlet 分发但常规 MVC 拦截器不一定能作用到 WebSocket 握手流程。如果你需要对握手做拦截一定要用HandshakeInterceptor这个类才是专门为 WebSocket 握手设计的。7.3 心跳线程的释放用了Scheduled做心跳推送之后要小心定时任务线程的积累。Spring 的Scheduled默认单线程执行如果心跳处理逻辑里有阻塞操作比如往一个 TCP 不通的 session 发消息卡住后续所有心跳任务都会被阻塞。建议给定时任务配置线程池或者把心跳任务拆细。另外连接关闭后在心跳任务里访问这个 session 要加isOpen()判断否则日志会被刷屏。我在实际项目中还遇到过一个诡异的现象心跳定时器还在跑但 SESSION_MAP 里的 session 早就失效了导致心跳循环一直发消息、一直抛IOException、一直刷日志。后来我在心跳任务里加了连续失败 N 次就清理 session的逻辑问题才彻底解决。结尾写到这里Spring Boot 集成 WebSocket 从选型到落地的关键环节基本都过了一遍。我个人在实际操作中的体会是WebSocket 最大的难点不在协议本身而在于连接的生命周期管理——谁负责断开、断开后怎么清理、消息丢了怎么补、多实例之间怎么路由。把这些边界条件想清楚了代码写起来会顺利很多。最后再分享一个小技巧线上排查 WebSocket 问题时别急着翻代码先在浏览器控制台里执行new WebSocket(ws://你的地址/ws?token测试token)直接把问题条件复现出来。能连上说明链路通连不上就抓 HTTP 握手的响应状态码比盲猜高效得多。希望这篇内容能帮你在实时通信这条路上少踩几个坑。
网站建设高端定制企业官网