☰
Spring Boot整合WebSocket实战:从握手到心跳及多节点扩展
2026/10/14 22:49:07 网站建设 项目流程

前阵子给一个内部管理系统加实时通知模块,需求本身不难,但踩了几个坑之后才发现,WebSocket在Spring Boot里跑通容易,跑稳才是真的考验。这篇分享我把整个实战过程完整复盘一遍,从握手到心跳、从广播到点对点、从单机到多节点扩展,都覆盖到。如果你是刚开始接触实时通信,或者已经被连接掉线、消息串线折磨过,这篇文章应该能帮你省不少时间。

1. 从实际需求聊起:为什么最终选择WebSocket做实时通信

1.1 需求场景还原

当时要解决的问题很简单:用户在前端发起一个耗时较长的任务,后端需要把执行进度、日志输出、最终结果推送给对应的客户端。传统的HTTP轮询方案也能实现,但问题在于——任务状态变化频繁,轮询间隔不好定。间隔短了,大量无意义的HTTP请求打到后端;间隔长了,用户体验就是“进度卡顿、结果要等半天”。

除了轮询,当时也考虑了SSE(Server-Sent Events),它是单向的,服务端往客户端推流很方便,但我们的场景里,客户端还需要往服务端发送指令,比如“取消任务”“调整参数”。SSE在这块支持不友好,还得另外再拉一套客户端到服务端的通道。这时候WebSocket就成了最自然的答案:一条TCP长连接,双向通信,服务端能主动推,客户端也能主动发,两端消息实时流通。

1.2 WebSocket方案的优势与适用边界

WebSocket的核心特点是:一次握手建立连接后,不再需要反复创建HTTP请求。连接建立后,数据帧在同一个TCP连接上直接传输,头部开销小,实时性高。对比HTTP轮询,省掉的是每次请求的握手、头部、连接释放等一堆额外损耗。

但WebSocket也不是万能胶水。它适合高频率、双向、低延迟的交互场景,比如:协作编辑、即时消息、在线客服、任务进度推送、多人在线对战。但如果你是低频推送,或者只需服务端单向推送,SSE或者简单轮询完全够用,没必要引入额外的长连接复杂度。长连接意味着服务端要维护连接状态,要考虑断线重连、心跳保活、并发连接管理,这些东西在Go、Netty里都很常见,Spring Boot封装后依然需要你自己掌握底层模型,否则出了问题无从下手。

2. 环境准备与基础工程搭建

2.1 依赖引入与会话端点配置

我使用的是Spring Boot 2.7版本,JDK 8,依赖引入非常简单,只需要一个starter:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency>

这个starter会把Spring WebSocket、WebSocket API(jakarta或javax版本取决于你的Boot版本)、以及内置的Tomcat WebSocket支持一起带进来,不用额外配置容器相关的东西。

接下来是最核心的配置类。继承WebSocketConfigurer,注册一个WebSocket处理器和对应的URL路径:

@Configuration @EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { @Resource private ChatWebSocketHandler chatWebSocketHandler; @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(chatWebSocketHandler, "/ws/chat") .addInterceptors(new AuthHandshakeInterceptor()) .setAllowedOriginPatterns("*"); } }

这里有两个细节很多人会漏。

第一个是setAllowedOriginPatterns,Spring Boot 2.7里的旧方法setAllowedOrigins对跨域限制比较严格,如果前端页面和后端服务不在同一个域,握手阶段会被浏览器拦截,报403。当初我就卡在这个地方,排查了半天日志,最后发现是CORS配置没放行。配置allowedOriginPatterns("*")后,跨域WebSocket握手就被允许了。

第二个是拦截器。addInterceptors是握手阶段执行拦截逻辑的地方,通常用来做身份认证、参数校验、把业务信息塞入会话属性。我后面专门讲了这块怎么设计。

2.2 核心Handler:连接、消息、异常处理

Handler是WebSocket服务端的核心组件,相当于Servlet里的Controller,但接口风格完全不同。我写了一个继承TextWebSocketHandler的类,覆盖四个关键方法:

@Component public class ChatWebSocketHandler extends TextWebSocketHandler { private static final Map<String, WebSocketSession> SESSION_POOL = new ConcurrentHashMap<>(); // 连接建立后触发 @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { String userId = (String) session.getAttributes().get("userId"); SESSION_POOL.put(userId, session); System.out.println("用户连接成功:" + userId); } // 收到文本消息时触发 @Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { String payload = message.getPayload(); System.out.println("收到消息:" + payload); // 这里可以结合Spring容器调用业务Service } // 连接关闭后触发 @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { String userId = (String) session.getAttributes().get("userId"); SESSION_POOL.remove(userId); System.out.println("连接关闭:" + userId); } // 异常处理 @Override public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception { System.out.println("连接异常:" + exception.getMessage()); session.close(); } }

这四个方法覆盖了连接建立、消息处理、连接关闭、异常中断四类核心事件,业务代码主要写在这几个回调里。要注意的是,handleTextMessage里的message.getPayload()拿到的是原始字符串,如果你的业务需要传结构化数据,你还需要自己反序列化,这里就引出了消息协议设计的问题,下一节详细说。

3. 消息协议设计与双端联动实现

3.1 消息格式约定与序列化

刚开始做WebSocket的人容易犯的一个错误是:客户端和服务端各写一套消息解析逻辑,没有统一格式,结果就是维护成本爆炸,加一个字段要改两个端。我的建议是:所有消息都走JSON格式,且统一用“类型 + 数据体”结构。

我定义了一个通用的消息实体类:

public class SocketMessage { private String type; // 消息类型:CONNECT、PING、TEXT、TASK_PROGRESS、ERROR private String data; // 业务数据,JSON字符串 private Long timestamp; // 消息时间戳 // getter / setter 省略 }

客户端发来的消息,统一反序列化成SocketMessage,通过type字段路由到不同的业务处理方法。服务端推送的消息也遵循同样的结构。这样做的好处是:消息的类型判断、日志打印、异常拦截都能规范化。

反序列化推荐使用Jackson的ObjectMapper,Spring Boot默认自带,不用额外引依赖。实际开发中,我会在Handler里注入ObjectMapper,把TextMessage的payload转成SocketMessage。

3.2 服务端主动推送与前端实时渲染

消息协议定了,接下来就是怎么用。整个链路是这样的:

  1. 客户端建立连接时,握手拦截器把用户ID写入session attributes。
  2. 业务Service执行长任务时,通过SESSION_POOL拿到目标用户的WebSocketSession,把进度消息推出去。
  3. 前端onmessage监听收到消息,根据type字段决定渲染方式。

后端推送的核心代码:

public void sendToUser(String userId, SocketMessage message) throws IOException { WebSocketSession session = SESSION_POOL.get(userId); if (session != null && session.isOpen()) { session.sendMessage(new TextMessage(objectMapper.writeValueAsString(message))); } }

注意:session.sendMessage不是线程安全的,如果在多个线程同时往同一个session发送消息,需要加锁或用ConcurrentWebSocketSessionDecorator包装一下,不然会出现IOException: The remote endpoint was in state [TEXT_FULL_WRITING]这类并发错误。

前端部分,用一个简单的事件绑定就能搞定:

const socket = new WebSocket("ws://localhost:8080/ws/chat?userId=1001"); socket.onopen = () => { console.log("连接已建立"); }; socket.onmessage = (event) => { const msg = JSON.parse(event.data); if (msg.type === "TASK_PROGRESS") { updateProgressBar(JSON.parse(msg.data)); } else if (msg.type === "TASK_DONE") { showResult(JSON.parse(msg.data)); } }; socket.onerror = (err) => { console.error("WebSocket错误", err); }; socket.onclose = () => { console.log("连接关闭"); };

这里有一个我后来才意识到的问题:用户会在地址栏里直接通过?userId=1001来伪造身份。生产环境绝对不能这么干,必须用token之类的认证机制去换取用户ID。我当时的做法是握手拦截器里校验token,并从token中解析出用户ID,而不是直接信URL参数。

4. 连接管理与业务功能扩展

4.1 会话跟踪与在线用户管理

单机环境下,用ConcurrentHashMap存session引用就够了。但要注意几点。

第一,Map的key不要用sessionId,因为sessionId每次连接都会变,重连后关联关系就会断。建议用业务用户ID,一个用户同一时刻只保留一个连接。

第二,连接关闭时一定要从Map里移除,否则Map只增不减,时间一长就内存泄漏了。我之前见过一个项目,afterConnectionClosed里忘了移除,测试环境跑了一个月,堆内存涨了快一倍。

第三,处理“同用户重复连接”的问题。用户开了两个浏览器标签页,两个连接同时进来,后者会把前者的session覆盖掉。一般来说,最合理的行为是踢掉旧连接,保留新连接。实现方式也很简单,上线时先判断Map里有没有旧session,有的话把旧session关掉再put新的。

4.2 广播、点对点与心跳保活

实际业务里,消息不只发给一个人。比如系统公告要推给所有在线用户,可以遍历SESSION_POOL逐个发送。多节点部署时,直接在内存Map里遍历只能发到当前节点的连接,跨节点的广播就需要引入消息中间件来协调,这部分我在后面扩展内容里讲。

心跳保活是我这次实战里踩坑最多的一块。WebSocket本身有数据帧类型,但实际经过Nginx代理、负载均衡、云厂商的网关时,空闲连接很容易被中间设备掐断。这里的空闲,指的是连接上没有数据流动。代理设备通常有一个超时时间,比如Nginx默认proxy_read_timeout是60秒,超过这个时间没有数据流动,连接就会被切断。

解决方法是定期发送心跳包,保持连接活跃。心跳包可以分成两层:

  • 应用层心跳:客户端每隔30秒发送一个{"type":"PING"}消息,服务端收到后回一个PONG。前面定义的SocketMessage里已经有type字段,这个逻辑跟业务完全解耦。
  • 底层WebSocket Ping/Pong帧:Spring的WebSocketSession支持发送底层Ping帧,但浏览器端的WebSocketAPI不支持主动发底层Ping帧,所以实际以应用层心跳为主。

服务端还要有“超时未收到心跳就关闭连接”的机制。我当时的做法是:在Handler里记录每个session的最近活跃时间,用一个ScheduledExecutorService定时扫描,超过90秒没活跃就关闭连接,并触发重连逻辑。这是保证服务端资源不被僵尸连接占满的有效手段。

5. 常见问题排查与避坑实录

5.1 握手失败与403问题

这是新手遇到最多的问题。表现是浏览器控制台报WebSocket connection to 'ws://...' failed,服务端日志没有异常。

排查步骤从三个方向入手:

  • 看地址是不是ws://或wss://,HTTPS页面下如果用了ws://会被浏览器拦截。
  • 看跨域配置,setAllowedOriginPatterns没有设置或设置不对,握手会被Spring的CORS校验拦截。
  • 看Nginx配置,反向代理时proxy_set_header Upgrade $http_upgrade和proxy_set_header Connection "upgrade"必须显式配置,否则握手时Nginx不知道要升级成WebSocket协议,直接把连接断了。

另外提醒一个容易被忽略的点:Nginx的proxy_read_timeout默认60秒,如果客户端没有心跳机制,这个值一定调大,常规做法是设置成3600s,并在应用层用心跳来保持活跃。

5.2 消息串线与并发安全

消息串线是个非常隐蔽的问题,我当时排查了很久才发现。现象是:A用户发了一条消息,B用户也收到了。排查下来发现,问题出在SESSION_POOL的key用了session.getId(),而Nginx反复重连时,sessionId会被复用,导致新连接把旧连接的session顶掉,消息发到了同sessionId但不同用户的那条连接上。

另外,往不同session同时发消息时,如果直接并发操作ConcurrentHashMap里的session,也会有隐患。一个稳妥的方案是使用ConcurrentWebSocketSessionDecorator包装session,它内部实现了发送锁,能防止并发写的问题:

WebSocketSession session = new ConcurrentWebSocketSessionDecorator(rawSession, 5000, 1024 * 1024);

5.3 连接假死与内存泄漏

“假死”是长连接场景的老大难问题。客户端看着连接还在,但实际服务端早就挂了,或者中间设备把连接断了。这就要靠心跳保活和超时检测来自动兜底。客户端也要监听onclose事件实现自动重连,重连时带上递增的重试退避时间,别断线就高频暴击服务端。

内存泄漏方面,SESSION_POOL的remove遗漏是最常见的。此外还有个隐藏点:如果在Handler里注册了事件监听器,比如实现了ApplicationListener或者持有了其他Bean的引用,连接关闭时没反注册,也会导致对象无法回收。多注意这类隐式引用,别光盯着显式集合。

为了让你排查时有点对照,我把这次实战中遇到过的问题整理成了下面这张表:

现象可能原因解决思路
握手403跨域未放行;wss/ws协议不匹配配置allowedOriginPatterns;确认页面协议
连接建立后被秒断Nginx未配置Upgrade头配置proxy_set_header Upgrade和Connection
空闲连接频繁断开代理设备超时回收心跳保活 + Nginx超时时间调大(3600s)
消息发给了错误的用户SESSION_POOL的key设计有问题用业务用户ID做key,关掉旧连接
高并发推送报异常session并发写冲突用ConcurrentWebSocketSessionDecorator包装
服务端内存持续增长连接关闭未移除session引用在afterConnectionClosed里及时清理

5.4 多节点部署时的会话共享问题

这是一个值得单独说的扩展场景。系统一旦升级到多节点部署,内存版的SESSION_POOL就失效了,因为用户A连的是节点1,用户B连的是节点2,节点1想给B推送消息,根本找不到B的session。

我给的方案是引入Redis保存在线状态,用发布订阅做消息广播。连接建立时,把用户ID和节点信息写入Redis;推送时,先把消息发到Redis频道,每个节点订阅这个频道,收到消息后只处理属于本节点的连接。伪代码大概是这个思路:

// 连接建立时 stringRedisTemplate.opsForValue().set("ws:online:" + userId, nodeId); // 推送消息时,先发到Redis频道 stringRedisTemplate.convertAndSend("ws:message", JSON.toJsonString(message)); // 每个节点用MessageListener订阅频道 // 收到后检查Redis里该用户的nodeId是否等于本节点,是则走SESSION_POOL推送

这套方案的优点是简单,不需要额外引入消息中间件,可靠性对大多数业务场景足够了。如果需求升级到需要离线消息补发、消息确认、已读回执这类强一致性场景,那就需要换更重的方案,比如引入带消息队列的推送框架,但那就是另一个量级的复杂度了,这里不展开。

6. 个人实操总结与后续可扩展方向

WebSocket在Spring Boot里的实现,表面上就是“一个配置类 + 一个Handler”的事,但真正把它用稳,涉及握手拦截、协议设计、心跳保活、并发控制、连接清理一整条链路。我这次做完下来最大的体会是:长连接比短连接更考验全局设计能力,因为连接的生命周期跨越了多次请求,状态管理、资源释放、异常恢复全都要考虑到。

如果你准备在项目里正式用WebSocket,建议在动手前先定好三件事:

  1. 消息协议长什么样,一个type字段怎么约定,服务端和客户端各自认哪些值。
  2. 连接生命周期怎么管,登录态怎么校验,超时标准是多少,重连策略怎么定。
  3. 未来会不会多节点部署,如果会,Session共享方案提前预留,别等架构升级了再回头改。

最后再分享一个小技巧:开发调试阶段,除了浏览器的DevTools,还可以装一个独立的WebSocket测试工具,直接连地址、发心跳、看返回。比起频繁刷新页面,这种方式调试消息格式快得多。

这套基础架构搭好后,后续可以继续扩展的方向还有:离线消息补推、消息持久化到数据库、基于STOMP协议的订阅-发布模式、对接更上层的消息推送SDK。每一条路都有不少细节,但地基就是这篇文章里的这些核心点。先把地基打稳,后面怎么加功能都不会慌。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询