前阵子给一个内部管理系统加实时通知模块,需求本身不难,但踩了几个坑之后才发现,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 服务端主动推送与前端实时渲染
消息协议定了,接下来就是怎么用。整个链路是这样的:
- 客户端建立连接时,握手拦截器把用户ID写入session attributes。
- 业务Service执行长任务时,通过
SESSION_POOL拿到目标用户的WebSocketSession,把进度消息推出去。 - 前端
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,建议在动手前先定好三件事:
- 消息协议长什么样,一个
type字段怎么约定,服务端和客户端各自认哪些值。 - 连接生命周期怎么管,登录态怎么校验,超时标准是多少,重连策略怎么定。
- 未来会不会多节点部署,如果会,Session共享方案提前预留,别等架构升级了再回头改。
最后再分享一个小技巧:开发调试阶段,除了浏览器的DevTools,还可以装一个独立的WebSocket测试工具,直接连地址、发心跳、看返回。比起频繁刷新页面,这种方式调试消息格式快得多。
这套基础架构搭好后,后续可以继续扩展的方向还有:离线消息补推、消息持久化到数据库、基于STOMP协议的订阅-发布模式、对接更上层的消息推送SDK。每一条路都有不少细节,但地基就是这篇文章里的这些核心点。先把地基打稳,后面怎么加功能都不会慌。