手头上刚好有几个项目在用 Spring Boot 做实时推送,从最早的咨询工单提醒,到后来给运营后台做数据看板,再到最近接的一个物联网设备状态上报,WebSocket 这条路算是踩过不少坑也攒下了一些经验。趁这次机会把 Spring Boot 集成 WebSocket 的做法从头到尾梳理一遍,从方案选型、依赖配置、核心代码,到心跳保活、集群会话、线上排障,一次性讲透。
这篇内容适合这么几类人看:刚接触实时通信、想在 Spring Boot 项目里接入 WebSocket 的后端开发;前端同事想搞明白后端 WebSocket 接口到底怎么设计、心跳该怎么做;还有那些项目已经在跑、但经常遇到连接掉线、消息推不到前端的同学。内容以 Spring Boot 2.x/3.x 为主,代码都是可以直接拿过去改的。
1. 实时通信方案选型:为什么最后选了 WebSocket
1.1 短轮询、长轮询、SSE 与 WebSocket 的取舍
先说结论:WebSocket 不是银弹,但服务端需要主动推送数据、并且对实时性要求高的场景,它确实是最稳妥的选择。
很多人项目一开始用短轮询,前端开一个setInterval每 3 秒调一次接口查有没有新数据。这种做法实现起来确实简单,但问题也很明显:第一是延迟不可控,3 秒的周期意味着用户最多要等 3 秒才能看到新消息;第二是浪费资源和流量,接口大部分时候返回的都是"没有新数据",可每次请求都要走完整的 HTTP 链路,包括 TCP 握手、HTTP 头部、鉴权校验。用户少的时候无感,一旦并发上来,数据库和网关的压力很快就顶不住。
长轮询是短轮询的改良版,客户端发请求后服务端先不返回,等有新数据了再响应,或者超过 30 秒超时后返回空结果、让客户端立刻重新发起请求。这个方案能把"主动推送"的效果模拟出来,但服务端为了 hold 住这些请求,会占用大量连接和线程资源。在高并发场景下,这些挂起的请求会变成服务器的隐形负担,而且代码写起来很容易出 bug,超时边界、乱序响应都要处理。
SSE(Server-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> <groupId>org.springframework.boot</groupId> <artifactId>spring-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 WebSocket(JSR 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("*")表示允许所有来源跨域连接,生产环境一定要收敛成具体的域名白名单,否则 CSWSH(Cross-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 Map<String, 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, Map<String, 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?token=xxxx'); 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?token=xxx,服务端在握手拦截器里取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。客户端发送的消息先到 broker,broker 再按订阅关系广播给所有实例,每个实例只处理自己持有的本地 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 | 正常关闭 | 对端主动调用 close |
| 1001 | 服务端即将下线 | 应用重启、发布 |
| 1006 | 异常关闭 | 网络中断、代理超时 |
| 1008 | 策略违规 | 鉴权失败、Origin 不允许 |
| 1009 | 消息过大 | 单条消息超过限制 |
碰到 1006 是最麻烦的,因为它是浏览器在底层 TCP 连接异常断开后给出的状态码,没有具体的错误原因。优先检查 Nginx、SLB、CLB 的空闲超时配置,把代理层超时时间调大。然后检查后端有没有OutOfMemoryError之类的异常,服务端 JVM 崩溃会导致所有连接异常断开。还有一种容易被忽略的情况是防火墙规则的连接跟踪表满了,旧的 NAT 映射被清掉,长连接就被切断了,这个在跨网络部署时尤其明显。
如果连接断开的时间点非常有规律,比如每隔 5 分钟一次,那多半就是某个中间层的超时设置在作祟,直接按超时时间去查配置。
6.3 消息乱序与并发发送问题
当服务端有多个线程向同一个 session 并发发消息时,消息顺序可能错乱。WebSocketSession不是线程安全的,同一时刻只能有一个线程调用sendMessage,否则内部缓冲区会出问题,甚至抛ConcurrentModificationException。
我的处理方案是给每个 session 配一个发送锁,或者统一通过一个单线程的发送队列。Spring 的WebSocketSession底层会做同步,但业务消息有先后依赖关系时,建议在推送逻辑里串行化。比如:
private final Map<String, 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 里通过监听ApplicationListener<ContextClosedEvent>或者@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 握手的响应状态码,比盲猜高效得多。希望这篇内容能帮你在实时通信这条路上少踩几个坑。