1. 为什么实时消息互通值得单独拿出来做
做过管理后台或者物联网项目的人大概率都碰过这个需求:前端要实时看到服务端的推送,服务端也要随时接收前端上报的状态。用轮询吧,浪费连接、延迟还高;用长连接吧,自己从零写一套心跳和重连又容易踩坑。SpringBoot 集成 WebSocket就是在这个场景下最省事的一条路——它把连接握手、会话管理、消息编解码这些脏活都封装好了,你只需要关心"谁发消息、发给谁、发什么"。
这个案例要解决的核心问题很明确:服务端和客户端之间建立一条双向通道,双方都能主动发消息,且消息能准确送达对方。听起来简单,但真正落地时会遇到一堆细节:连接怎么保持、断线怎么发现、多客户端怎么区分、消息格式怎么统一、并发发送会不会串。这些才是决定这套东西能不能上生产的关键。
适合谁来参考?如果你已经会写 SpringBoot 的 Controller,能看懂基本的注解,那这篇内容对你就是"照着做就能跑通"的级别。如果你是完全没接触过 WebSocket 的新手,也没关系,我会把每个配置项为什么这么写讲清楚,你照着抄一遍,再回头看原理,基本就通了。整套方案不依赖任何特殊环境,本地起一个 SpringBoot 服务加一个 HTML 页面就能验证,非常适合拿来当项目里的实时通信底座。
我下面会按"整体设计思路 → 核心细节 → 完整实操 → 问题排查"这条线走,中间穿插我自己踩过的坑和参数选择的依据。你如果时间紧,可以直接跳到第 3 节的实操部分抄代码,但我建议至少把第 2 节看完,因为很多坑都是配置层面埋下的。
2. 整体设计与技术选型拆解
2.1 为什么选 WebSocket 而不是轮询或 SSE
先把选型逻辑讲透,不然你后面调优会没有方向。实时通信常见三条路:短轮询、SSE(Server-Sent Events)、WebSocket。
短轮询就是前端定时发请求问"有没有新消息"。它的优点是实现简单、兼容性无敌,缺点是延迟取决于轮询间隔,间隔短了服务端压力大,间隔长了消息不及时。假设 1000 个客户端每 2 秒轮询一次,服务端每秒就要扛 500 个请求,其中绝大多数是空响应,纯浪费。
SSE 是服务端单向推送给客户端,基于 HTTP 长连接。它适合"服务端广播、客户端只收"的场景,比如消息通知、日志流。但它天生是单向的,客户端要发消息还得另开一个普通请求,双向互通做起来别扭。
WebSocket 则是真正的全双工:一次握手升级协议之后,这条 TCP 连接上双方都能随时发数据,没有 HTTP 的头部开销,延迟可以做到毫秒级。对于"服务端与客户端消息互通"这个需求,它就是最贴合的。代价是它需要服务端维持连接状态,对连接数有上限要求,但这个上限通常远高于轮询能承受的并发。
提示:如果你的场景只是服务端单向推送,SSE 其实更轻量,别为了用 WebSocket 而用 WebSocket。双向互通才是选它的核心理由。
2.2 SpringBoot 里两种集成方式的取舍
SpringBoot 集成 WebSocket 有两条主流路线,很多人一开始会选错。
第一条是原生 JSR-356 的@ServerEndpoint方式,配合ServerEndpointExporter注册。它的写法接近原生 WebSocket API,一个类上标@ServerEndpoint("/ws"),方法上用@OnOpen、@OnMessage、@OnClose注解。优点是直观、代码量少;缺点是它管理的端点实例不是 Spring 容器里的单例,依赖注入会失效,你得用静态变量或者ApplicationContext手动取 Bean,写起来很别扭。
第二条是Spring 自己的WebSocketHandler方式,通过WebSocketConfigurer注册处理器。它的写法更"Spring 味",Handler 是标准的 Spring Bean,@Autowired随便用,会话管理也更清晰。缺点是配置代码稍多。
这个案例我选第二条,原因是它更适合真实项目:你需要注入 Service、需要做鉴权、需要统一异常处理,这些在WebSocketHandler体系里都是自然的。@ServerEndpoint那套在 Demo 里很爽,一进项目就开始难受。
2.3 消息格式与线程安全的前置设计
在动手写代码之前,有两个设计决策必须先定下来,否则后面返工成本很高。
消息格式统一用 JSON 文本帧。WebSocket 支持文本帧和二进制帧,文本帧传 JSON 是最通用的做法,前后端都好解析,调试时肉眼也能看懂。二进制帧留给文件传输、音视频这类场景,本案例用不上。定一个统一的消息体结构,比如{type, from, content, timestamp},type 用来区分业务类型,这样一条通道能承载多种消息,不用为每种业务开一个连接。
会话管理必须考虑线程安全。服务端要维护"当前有哪些连接",通常用一个ConcurrentHashMap存 sessionId 到 session 的映射。为什么强调 ConcurrentHashMap?因为 WebSocket 的消息回调是在不同线程里触发的,多个客户端同时连接、断开时,普通 HashMap 会出现并发修改异常,甚至丢数据。这个点新手特别容易忽略,本地单客户端测试永远发现不了,一上并发就炸。
把这两点定下来,整个架构就清晰了:客户端连上来 → 服务端把 session 存进并发 Map → 双方通过 JSON 文本帧通信 → 断开时从 Map 移除。下面进入具体实现。
3. 核心细节与实操要点解析
3.1 依赖引入与版本匹配
第一步是加依赖。用 Maven 的话,在pom.xml里加:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency>这个 starter 已经把spring-websocket和spring-messaging都带进来了,不需要额外引别的。这里有个版本坑要提醒:starter 的版本跟着你的 SpringBoot 父版本走,不要手动指定版本号。我见过有人手动写了个5.x的版本,结果和 SpringBoot 主版本冲突,启动直接报NoSuchMethodError。让父 POM 统一管理版本是最稳的。
如果你用的是 Gradle,对应写implementation 'org.springframework.boot:spring-boot-starter-websocket'即可,同样不写版本。
3.2 配置类:注册 Handler 与放行握手
配置类的职责有两个:把自定义的 Handler 注册到某个路径上,以及设置允许跨域(开发阶段前端页面和服务端不同端口时必需)。
@Configuration @EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { @Autowired private MyWebSocketHandler handler; @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(handler, "/ws") .setAllowedOrigins("*"); } }@EnableWebSocket这个注解不能漏,它才会触发 WebSocket 相关的基础设施装配。setAllowedOrigins("*")在开发阶段很方便,但生产环境一定要改成具体域名,否则任何网站都能连你的 WebSocket,存在被滥用的风险。这是安全底线,别图省事。
注意:
setAllowedOrigins("*")和携带 Cookie 的跨域请求是互斥的,如果你后面要做基于 Cookie 的鉴权,这里必须写具体来源,不能写星号。
3.3 Handler 的四个核心回调
WebSocketHandler接口有四个必须实现的方法,理解它们各自的触发时机是关键:
afterConnectionEstablished:连接建立后触发,适合做鉴权、把 session 存进 Map、给客户端发欢迎消息。handleMessage:收到客户端消息时触发,是业务处理的主入口。handleTransportError:传输层出错时触发,比如连接异常中断。afterConnectionClosed:连接关闭后触发,必须在这里把 session 从 Map 移除,否则内存泄漏。
一个常见的误区是把 session 存在 Handler 的成员变量里。Handler 是单例 Bean,成员变量会被所有连接共享,直接存就串了。正确做法是存进ConcurrentHashMap,key 用 sessionId。
private static final Map<String, WebSocketSession> SESSIONS = new ConcurrentHashMap<>();用static是为了方便在别的类里做广播,如果你不想用静态,也可以把它抽成一个独立的SessionManagerBean 注入进来,那样更优雅。Demo 阶段用静态 Map 足够。
3.4 消息收发的编解码处理
handleMessage拿到的WebSocketMessage是个抽象类型,实际可能是TextMessage、BinaryMessage或PongMessage。处理前要先判断类型:
@Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { String payload = message.getPayload(); // 解析 JSON,处理业务 // 回复消息 session.sendMessage(new TextMessage("收到:" + payload)); }继承TextWebSocketHandler比直接实现WebSocketHandler更省事,因为它帮你把文本帧的处理单独抽出来了,二进制帧走默认逻辑。绝大多数业务场景继承它就够。
发送消息时要注意:session.sendMessage不是线程安全的。如果多个线程同时对同一个 session 调 sendMessage,会抛IllegalStateException: The remote endpoint was in state [TEXT_FULL_WRITING]。解决办法是给每个 session 的发送操作加锁,或者用ConcurrentWebSocketSessionDecorator包装一下。这个坑在单线程测试时完全看不出来,一旦做广播就必现。
4. 完整实操过程与关键环节实现
4.1 服务端 Handler 完整实现
下面这份代码可以直接抄,我加了详细注释说明每个决策点:
@Component public class MyWebSocketHandler extends TextWebSocketHandler { private static final Logger log = LoggerFactory.getLogger(MyWebSocketHandler.class); // 线程安全的会话容器 private static final Map<String, WebSocketSession> SESSIONS = new ConcurrentHashMap<>(); @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { SESSIONS.put(session.getId(), session); log.info("新连接建立,sessionId={},当前在线数={}", session.getId(), SESSIONS.size()); // 主动给客户端发一条欢迎消息,验证双向通道 session.sendMessage(new TextMessage("{\"type\":\"welcome\",\"content\":\"连接成功\"}")); } @Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { String payload = message.getPayload(); log.info("收到来自 {} 的消息:{}", session.getId(), payload); // 简单回显,实际项目里这里换成业务分发 String reply = "{\"type\":\"echo\",\"content\":\"" + payload + "\"}"; session.sendMessage(new TextMessage(reply)); } @Override public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception { log.error("传输异常,sessionId={}", session.getId(), exception); if (session.isOpen()) { session.close(); } SESSIONS.remove(session.getId()); } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { SESSIONS.remove(session.getId()); log.info("连接关闭,sessionId={},原因={},当前在线数={}", session.getId(), status, SESSIONS.size()); } // 供外部调用的广播方法 public static void broadcast(String text) { TextMessage msg = new TextMessage(text); SESSIONS.forEach((id, session) -> { if (session.isOpen()) { try { session.sendMessage(msg); } catch (IOException e) { log.error("广播失败,sessionId={}", id, e); } } }); } }几个关键点解释一下。afterConnectionEstablished里主动发欢迎消息,是为了让客户端一连接就能验证通道是通的,不用等用户手动发消息。handleTransportError里先关 session 再移除,避免残留半开连接。afterConnectionClosed里移除 session 是必须的,这是防止内存泄漏的最后一道关。
广播方法用static是为了方便在 Controller 里直接调,比如你有个 REST 接口触发推送,直接MyWebSocketHandler.broadcast(...)就行。但要注意,session.sendMessage的线程安全问题在这里依然存在,如果广播频率很高,建议用ConcurrentWebSocketSessionDecorator包装 session 再存进 Map。
4.2 客户端页面实现
客户端用一个纯 HTML 页面就能验证,不需要任何框架:
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>WebSocket 测试</title> </head> <body> <div> <button onclick="connect()">连接</button> <button onclick="sendMsg()">发送</button> <button onclick="closeConn()">断开</button> </div> <input id="msgInput" placeholder="输入消息" /> <div id="log" style="margin-top:20px;border:1px solid #ccc;height:300px;overflow:auto;"></div> <script> let ws = null; function log(text) { const div = document.getElementById('log'); div.innerHTML += '<p>' + new Date().toLocaleTimeString() + ' - ' + text + '</p>'; div.scrollTop = div.scrollHeight; } function connect() { ws = new WebSocket('ws://localhost:8080/ws'); ws.onopen = () => log('连接已建立'); ws.onmessage = (e) => log('收到:' + e.data); ws.onclose = () => log('连接已关闭'); ws.onerror = () => log('发生错误'); } function sendMsg() { const input = document.getElementById('msgInput'); if (ws && ws.readyState === WebSocket.OPEN) { ws.send(input.value); log('发送:' + input.value); input.value = ''; } else { log('连接未就绪'); } } function closeConn() { if (ws) ws.close(); } </script> </body> </html>这里有个细节值得说:ws.readyState === WebSocket.OPEN这个判断不能省。用户手速快的时候,点了连接立刻点发送,此时连接可能还在握手阶段,直接 send 会报错。加上状态判断就稳了。
4.3 参数选择与验证过程
启动 SpringBoot 应用,默认端口 8080。用浏览器打开上面的 HTML 文件,点"连接",控制台会看到服务端日志打印"新连接建立",页面日志显示"连接已建立"和"收到:连接成功"。再输入一段文字点发送,页面会显示服务端回显的内容,服务端日志也会打印收到的消息。
验证双向互通的关键是两个方向都要测:客户端发、服务端收(看服务端日志),服务端发、客户端收(看页面日志)。只测一个方向不算跑通。
关于连接超时,SpringBoot 默认没有对 WebSocket 空闲连接做超时限制,但很多反向代理层(比如常见的 Nginx)默认 60 秒没数据就断连。如果你部署在代理后面,要么调大代理的超时时间,要么在应用层加心跳。心跳的做法是客户端定时发一个ping类型的消息,服务端收到后回pong,这样连接就不会因为空闲被掐断。心跳间隔建议 30 秒左右,太频繁浪费资源,太稀疏起不到保活作用。
5. 常见问题与排查技巧实录
5.1 连接建立失败的三类原因
连接建不起来是最常见的问题,按下面这个顺序排查基本能定位:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 浏览器报 404 | 路径不匹配 | 检查registerWebSocketHandlers里的路径和前端new WebSocket的路径是否一致 |
| 报 403 | 跨域被拦 | 检查setAllowedOrigins是否放行了前端来源 |
| 一直 pending 后失败 | 端口或代理问题 | 确认服务端口、确认代理是否支持协议升级 |
路径问题特别隐蔽的一点是:如果你在application.yml里配了server.servlet.context-path,那 WebSocket 的完整路径要带上这个前缀。比如 context-path 是/api,那前端要连ws://localhost:8080/api/ws,而不是/ws。这个坑我踩过,排查了半小时才发现。
5.2 消息发送时的并发异常
前面提到的TEXT_FULL_WRITING异常,本质是同一个 session 被并发写。解决方案有两种:
第一种是给发送操作加同步锁,简单粗暴:
synchronized (session) { session.sendMessage(new TextMessage(text)); }第二种是用 Spring 提供的装饰器,它内部维护了发送队列,更适合高并发:
WebSocketSession safeSession = new ConcurrentWebSocketSessionDecorator(session, 5000, 512 * 1024);两个参数分别是发送超时时间(毫秒)和缓冲区大小(字节)。超过缓冲区会触发背压,防止慢客户端拖垮服务端。生产环境建议用第二种。
5.3 断线检测与自动重连
WebSocket 有个特性:网络异常断开时,onclose不一定立即触发,可能过很久才感知到。所以不能只依赖 onclose 做断线处理,必须配合心跳。客户端定时发心跳,如果连续几次没收到服务端响应,就主动关闭并重连。
客户端重连逻辑要加退避,别一断开就疯狂重连,那样会把服务端打挂:
let retryDelay = 1000; function reconnect() { setTimeout(() => { connect(); retryDelay = Math.min(retryDelay * 2, 30000); // 指数退避,最大30秒 }, retryDelay); }连接成功后记得把retryDelay重置回 1000。这个指数退避策略在网络抖动时特别有用,能避免大量客户端同时重连造成的雪崩。
5.4 会话管理与内存泄漏
SESSIONS这个 Map 如果只加不减,跑几天内存就满了。除了在afterConnectionClosed里移除,还要考虑一种极端情况:服务端进程被 kill,所有连接都没走正常关闭流程,Map 里的数据自然也没了,这个没问题。但如果某个 session 因为异常变成"僵尸连接"(TCP 层已断但应用层没感知),它就会一直占着 Map 的位置。
解决办法是加一个定时任务,定期扫描所有 session,对!session.isOpen()的做清理。或者更彻底一点,记录每个 session 的最后活跃时间,超过阈值没活跃的就主动关闭。这个清理任务用@Scheduled注解就能实现,几分钟跑一次即可。
提示:清理任务里遍历
ConcurrentHashMap时,用entrySet().removeIf(...)比先收集再删除更安全,避免并发修改。
5.5 消息体过大导致的截断
WebSocket 单帧默认有大小限制,SpringBoot 里可以通过配置调整。如果你要传较大的 JSON,可能会遇到消息被截断或者直接报错。可以在配置里设置:
registry.addHandler(handler, "/ws") .setAllowedOrigins("*") .addInterceptors(new HttpSessionHandshakeInterceptor());更直接的是在容器层面调大缓冲区,比如用 Tomcat 时设置maxTextMessageBufferSize。不过我的建议是:别用 WebSocket 传大文件,超过几十 KB 的内容走 HTTP 接口更合适,WebSocket 就专注做轻量的实时消息。这是架构层面的取舍,不是技术限制。
6. 我在实际项目里的一些体会
这套东西我从 Demo 做到生产,最大的感受是:跑通很容易,跑稳很难。Demo 阶段你只关心消息能不能发出去,生产阶段你要关心连接会不会掉、掉了能不能自动恢复、并发高了会不会串消息、内存会不会涨。这些问题的答案都不在官方文档里,而是踩出来的。
如果让我给一个最实用的建议,那就是先把心跳和重连做扎实,再谈业务。我见过太多项目,业务逻辑写得花里胡哨,结果网络一抖连接就断了,用户那边毫无感知,消息全丢。心跳和重连是这套方案的地基,地基不稳,上面盖什么都是白搭。
另外一个小技巧:调试 WebSocket 时,浏览器开发者工具的 Network 面板里,WebSocket 连接会单独显示,点进去能看到收发的每一帧消息,比在代码里打日志直观得多。排查消息格式问题时,先看这里,能省很多时间。