实时推送这个需求做后端的人早晚都会碰上。下单要通知、后台改了配置前端要立刻生效、监控大屏要刷数据、客服系统要聊天只要你碰上服务器主动找客户端的场景HTTP那一套轮询方案就捉襟见肘了。我在好几个企业项目里都折腾过WebSocket从最初照着文档写个Demo到后来处理集群广播、认证鉴权、心跳保活这些生产环境躲不开的问题踩了不少坑。这篇就围绕Spring Boot下的WebSocket实践把那些不会写在官方文档里的细节一次说透希望能让后来的人少走弯路。开始之前先交代清楚这篇内容的适用范围它面向的是企业级后端应用默认你用的是Spring Boot 2.7或Spring Boot 3.x构建工具选Maven或Gradle都行前端部分我会给纯JavaScript示例不管你是React、Vue还是原生页面逻辑是通用的。你如果只是写个Demo跑通连接那网上教程随便抄抄就行但只要你想让WebSocket真正扛住生产流量这篇文章值得读完。1. 先想清楚你的场景真的需要WebSocket吗1.1 实时推送的三种主流方案老规矩动手写代码之前先聊选型。现在做服务器推数据这件事主流的方案有三种短轮询、SSEServer-Sent Events、WebSocket。很多新人上来就选WebSocket理由是它最先进但先进不一定适合你选错方案后面运维会很难受。先说短轮询。前端每隔几秒用Ajax拉一次数据这个方案实现成本最低后端就是一个普通HTTP接口对前端也没有任何要求。缺点是请求里绝大多数是无效请求服务器压力大数据实时性也差——你设了3秒轮询那用户看到的数据理论延迟就是0到3秒。它最适合的场景是低频、可容忍延迟、实现成本敏感的内部系统比如后台管理页面里那种任务执行状态刷新。再说SSE。它是HTML5引入的能力客户端用EventSource对象建立一个单向的HTTP长连接服务器可以持续往这个连接里写数据。它比轮询省资源而且自动带断线重试机制浏览器原生支持协议开销小。但注意SSE是单向的服务器能推给浏览器浏览器往服务器发数据还是得走普通HTTP请求。它适合看行情、看日志、推通知这类纯推送场景。最后是WebSocket。它通过一次HTTP握手升级为长连接之后就是全双工的TCP通信服务器和客户端可以互相随时发数据。它延迟最低、双向通信、省请求头开销适合实时聊天、协同编辑、在线游戏这类强交互场景。代价是协议相对复杂断线重连、心跳保活、鉴权这些都要自己兜底集群场景还要额外设计消息分发方案。1.2 企业级项目最终怎么选我的建议很简单能上SSE就别上WebSocket除非你有双向通信的需求。我在一个物联网大屏项目里就吃过亏当时觉得反正要实时直接用WebSocket一步到位结果前后端联调多花了一倍时间就为了处理鉴权、心跳、异常断开这几个破事。后来另一个项目做股票行情推送我直接用SSE后端就是一个SseEmitter往队列里塞数据前端EventSource监听代码量少了一半稳定性和实时性完全够用。反过来如果你做的是IM聊天、多人协作白板、多人在线编辑这类产品不用犹豫直接WebSocket。还有一个判断标准客户端是否需要持续给服务器发高频消息。是就选WebSocket不是SSE更香。技术方案通信方向实现成本实时性断线重连适用场景短轮询单向客户端主动极低中无天然低频状态刷新SSE服务端单向推送低高浏览器原生支持行情、通知、日志WebSocket全双工高极高需自己实现IM、协同、在线游戏2. Spring Boot集成WebSocket从配置到第一个连接2.1 依赖引入与基础配置假设你已经想清楚了非WebSocket不可那下面开始正经实操。我用的是Spring Boot官方封装的spring-boot-starter-websocket它底层封装了Java标准WebSocket APIJSR-356也支持Spring自己的WebSocketHandler抽象。Maven添加依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-websocket/artifactId /dependency如果你的项目用了Spring Security还要显式引入安全相关的依赖后面鉴权章节我会单独说。这里先提一句WebSocket的握手请求会带着Origin、Cookie等头信息拦截器的写法跟普通Spring MVC的拦截器完全是两码事别搞混。然后是核心配置类。用Configuration实现WebSocketConfigurer接口注册handler和握手拦截器Configuration EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { Resource private ChatWebSocketHandler chatWebSocketHandler; Resource private AuthHandshakeInterceptor authHandshakeInterceptor; Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(chatWebSocketHandler, /ws/chat) .addInterceptors(authHandshakeInterceptor) .setAllowedOrigins(*); } }这里几个细节挨个说。addHandler里的路径是WebSocket握手端点前端连的就是这个地址。addInterceptors注册握手拦截器它会在HTTP协议升级成WebSocket之前执行是做鉴权、参数校验的绝佳位置。setAllowedOrigins控制跨域生产环境别像我示例里那样写成*如果前后端域名不一致把前端域名明确列出来更安全。有个坑必须提醒WebSocket的握手走的是HTTP GET请求所以这个端点不会进你的Spring MVC拦截器。我在项目里遇到过团队新人把鉴权逻辑写在MVC拦截器里结果WebSocket连接畅通无阻地打进来权限完全没起作用。记住WebSocket的鉴权在HandshakeInterceptor里做不是在MVC拦截器里做。2.2 用WebSocketHandler处理消息收发Handler是整个WebSocket服务的核心所有连接生命周期事件、消息收发都在这一个类里完成。继承TextWebSocketHandler处理文本消息如果要处理二进制数据比如文件传输继承BinaryWebSocketHandler。我写一个最基础的聊天室HandlerSlf4j Component public class ChatWebSocketHandler extends TextWebSocketHandler { // 保存所有在线Sessionkey为sessionId private static final MapString, WebSocketSession ONLINE_SESSIONS new ConcurrentHashMap(); Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { ONLINE_SESSIONS.put(session.getId(), session); log.info(连接建立: {}, session.getId()); session.sendMessage(new TextMessage(欢迎加入聊天室当前在线人数: ONLINE_SESSIONS.size())); } Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { String payload message.getPayload(); log.info(收到消息: {}来自Session: {}, payload, session.getId()); // 群发 for (WebSocketSession ws : ONLINE_SESSIONS.values()) { if (ws.isOpen()) { ws.sendMessage(new TextMessage(session.getId() 说: payload)); } } } Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { ONLINE_SESSIONS.remove(session.getId()); log.info(连接关闭: {}状态: {}, session.getId(), status); } }ConcurrentHashMap保存在线Session是我用的老办法它线程安全高并发下不会出并发修改异常。但这里提前说一句单体项目用这个没问题一旦部署多实例A机器上的Session在B机器上找不到消息就发丢了。集群方案我在第5章讲这里先别改结构。2.3 前端建立连接与调试前端就一行关键代码但有不少细节。以聊天室为例const token localStorage.getItem(token); // 注意ws的地址是相对路径浏览器会自动拼上当前域名 const socket new WebSocket(ws://${location.host}/ws/chat?token${token}); socket.onopen function () { console.log(连接已建立); socket.send(hello server); }; socket.onmessage function (event) { console.log(收到消息:, event.data); }; socket.onclose function (event) { console.log(连接关闭:, event.code, event.reason); }; socket.onerror function (error) { console.error(连接出错:, error); };这里有个大坑要讲清楚WebSocket的URL不能用http://必须用ws://或wss://加密连接。生产环境强烈建议用wss://因为很多企业内部的防火墙、代理服务器会拦截非加密的WebSocket流量我在一个银行项目里就吃过这个亏内网环境ws://怎么都连不上换成wss://之后秒通。如果你在Nginx后面做反向代理还需要额外配置Upgrade和Connection头这个放到第6章排查部分细说。联调时最有价值的工具是浏览器DevTools的Network面板切到WS标签页能看到所有WebSocket连接的帧数据发送、接收、关闭的原因都一目了然。我调试WebSocket问题时90%的时间都花在这个面板上。3. 认证鉴权别让随便谁都能连上你的WebSocket3.1 握手阶段做Token校验企业级系统里WebSocket和REST API用的是一套用户体系。REST API你可能用的是JWT或者Spring Security的Session机制那WebSocket同样要校验身份。问题是WebSocket握手只支持HTTP GET你不能自定义Header让浏览器带上浏览器原生WebSocket API就是没法加自定义Header除非你用一个支持额外Header的WebSocket客户端库。所以实战中最常见的方案是Token放在URL查询参数里new WebSocket(wss://host/ws?tokenxxx)。当然你可能担心Token漏进日志或者被浏览器历史缓存所以JWT本身有过期时间就算被翻出来也很快失效。更稳妥的做法是配合短期有效的临时Token——登录后让后端签发一个5分钟内有效的WebSocket专用Token用完即弃。服务端在握手拦截器里校验Token。先定义一个拦截器Component public class AuthHandshakeInterceptor implements HandshakeInterceptor { Resource private JwtService jwtService; Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, MapString, Object attributes) throws Exception { // 1. 从查询参数里取token String token ((ServletServerHttpRequest) request).getServletRequest().getParameter(token); // 2. 校验token if (token null || !jwtService.validateToken(token)) { // 握手失败 return false; } // 3. 把userId解析出来放attributes后续通过session.getAttributes()取用 String userId jwtService.parseUserId(token); attributes.put(userId, userId); return true; } Override public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) { // 握手结束后的回调一般用不上 } }关键点beforeHandshake返回false握手就被拒绝前端会触发onerror和onclose事件。而attributes这个Map会在握手成功后自动合并到WebSocketSession的attributes里所以你在Handler里就能通过session.getAttributes().get(userId)拿到当前用户身份。这个数据通道是官方设计的比你用静态Map去存用户和Session的对应关系可靠得多。3.2 身份绑定与Session管理光校验通过还不够企业级系统一定得知道这条连接是谁的不然怎么给指定用户推送消息我在Handler里维护了两个表一个按Session ID管理连接一个按UserId管理连接public class ChatWebSocketHandler extends TextWebSocketHandler { // sessionId - WebSocketSession private static final MapString, WebSocketSession SESSION_MAP new ConcurrentHashMap(); // userId - 该用户的所有sessionId集合同一用户多端登录 private static final MapString, SetString USER_SESSIONS new ConcurrentHashMap(); Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { String userId String.valueOf(session.getAttributes().get(userId)); String sessionId session.getId(); SESSION_MAP.put(sessionId, session); USER_SESSIONS.computeIfAbsent(userId, k - ConcurrentHashMap.newKeySet()).add(sessionId); } Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { String userId String.valueOf(session.getAttributes().get(userId)); String sessionId session.getId(); SESSION_MAP.remove(sessionId); SetString sessions USER_SESSIONS.get(userId); if (sessions ! null) { sessions.remove(sessionId); if (sessions.isEmpty()) { USER_SESSIONS.remove(userId); } } } /** * 给指定用户推送消息他可能有多端在线 */ public void sendToUser(String userId, String content) throws IOException { SetString sessionIds USER_SESSIONS.get(userId); if (sessionIds null || sessionIds.isEmpty()) { return; } for (String sessionId : sessionIds) { WebSocketSession session SESSION_MAP.get(sessionId); if (session ! null session.isOpen()) { session.sendMessage(new TextMessage(content)); } } } }两层的Map设计原因很简单用户可能手机、电脑两个地方同时登录你要给他同一个消息推到两个端。另外一件事多端重复登录怎么处理从产品角度你们要定策略从技术上来看如果你只想保留最新一个连接那在新连接建立的时候把旧Session关掉就行。我做过一个客服系统产品要求一个用户同时只能有一个在线连接所以我在afterConnectionEstablished里把同userId的旧连接都踢下线// 同一用户在别处登录强制之前连接下线 SetString oldSessions USER_SESSIONS.get(userId); if (oldSessions ! null) { for (String oldSessionId : oldSessions) { WebSocketSession oldSession SESSION_MAP.get(oldSessionId); if (oldSession ! null oldSession.isOpen()) { oldSession.close(CloseStatus.NORMAL.withReason(forced offline)); } } }这个逻辑要放在新连接加入Map之后、注册进USER_SESSIONS之前顺序错了会把自己也踢了。这种细节就是生产环境和Demo的区别写的时候要小心。3.3 和Spring Security集成如果你的项目已经上了Spring Security那么WebSocket的握手请求默认会被安全过滤链拦下来。好消息是握手本质上是一个HTTP GET请求它会走Spring Security的过滤链一旦升级成WebSocket后续的消息帧就不再走Spring Security的过滤链了。所以你的安全策略可以这样设计Configuration public class SecurityConfig { Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf - csrf.ignoringRequestMatchers(/ws/**)) .authorizeHttpRequests(auth - auth .requestMatchers(/ws/**).permitAll() // 鉴权交给HandshakeInterceptor .anyRequest().authenticated() ); return http; } }注意我这里把/ws/**的HTTP层校验放行了因为真正的鉴权逻辑在HandshakeInterceptor里面。如果你在HTTP层拦截会导致那些携带token走自定义逻辑的客户端无法完成握手如果你不做握手拦截器、纯粹靠Spring Security放行那WebSocket就裸奔了。更讲究的做法是用ChannelInterceptor在STOMP协议层做鉴权但那是STOMP方案的玩法我后面会讲。另外要特别提醒CSRF的问题。Spring Security默认对POST等写操作开启了CSRF防护而WebSocket握手是GET一般不会触发CSRF拦截。但有些团队会配置csrfProtectionMatcher导致/ws/**也被扫到结果前端怎么连都连不上。如果你遇到握手一直返回403的情况第一件事就是查CSRF配置。4. 心跳机制与连接保活看似简单坑却不少4.1 为什么必须要心跳WebSocket长连接长啥样就是一条TCP连接挂在那里一动不动。这带来一个实际问题网络路径上的设备和操作系统都会清理空闲连接。比如家里的NAT路由器可能60秒没有数据流动就把映射关系删了公司的防火墙策略可能更激进300秒没流量就给你掐了。连接被掐掉后客户端和服务端并不会立刻感知——因为只有真正发数据时才会发现发不出去或者收不回来。所以业界通行的做法就是心跳机制客户端定期发一个轻量的ping消息服务器收到后回复pong证明连接还活着。这个机制同时解决两个问题一是“保活”让中间设备认为连接还在使用不回收二是“探活”一旦发现连续几次心跳都没回应立刻关闭这条半死的连接重新建立新的。4.2 Spring WebSocket的心跳实现方案WebSocket协议原生是支持Ping/Pong帧的。Java标准API里WebSocketSession提供了pingMessage(ByteBuffer)方法服务端可以主动发送ping帧客户端收到ping帧时协议层会自动回pong帧多数现代浏览器也是自动回的。Spring的WebSocketHandler里可以覆写handlePongMessage方法来处理pong帧。我实际项目中用过两种方案各有利弊方案一依赖协议原生Ping帧服务端定时检查Scheduled(fixedRate 30000) // 每30秒 public void heartbeatCheck() { long now System.currentTimeMillis(); for (Map.EntryString, WebSocketSession entry : SESSION_MAP.entrySet()) { WebSocketSession session entry.getValue(); if (session.isOpen()) { long lastActiveTime session.getLastActiveTime(); // Spring 5.3支持 if (now - lastActiveTime 120_000) { try { session.close(CloseStatus.SESSION_NOT_RELIABLE); } catch (IOException e) { log.error(关闭空闲连接失败: {}, session.getId(), e); } } } } }getLastActiveTime()是Spring 5.3之后WebSocketSession接口新增的方法它记录的是最近一次收发的活跃时间不用你自己维护。这个方案服务端主动干活客户端可以不发心跳对前端要求低但缺点是你得额外维护一个定时任务而且如果客户端只是被动的“活着”你其实无法证明它还有能力接收消息。方案二客户端主动发应用层心跳服务端只负责响应和超时检查这个方案更通用。客户端每隔30秒发一个约定的文本消息内容是PING服务端收到后立即回PONG。同时服务端维护一个上次收到客户端心跳的时间如果超过90秒都没收到就认为这条连接死了主动关闭// 在WebSocketSession的attributes里存上次心跳时间 Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { String payload message.getPayload(); if (PING.equals(payload)) { // 刷新心跳时间 session.getAttributes().put(lastHeartbeatTime, System.currentTimeMillis()); session.sendMessage(new TextMessage(PONG)); return; } // 处理其他业务消息... }定时清理线程Scheduled(fixedRate 30000) public void cleanExpiredConnections() { long threshold System.currentTimeMillis() - 90_000; for (WebSocketSession session : SESSION_MAP.values()) { Object val session.getAttributes().get(lastHeartbeatTime); if (val null) { continue; } long lastHeartbeat (long) val; if (lastHeartbeat threshold session.isOpen()) { try { session.close(CloseStatus.SESSION_NOT_RELIABLE); log.info(心跳超时关闭连接: {}, session.getId()); } catch (IOException e) { log.error(关闭心跳超时连接异常, e); } } } }我强烈推荐方案二。它把主动权交给了客户端服务端只需要做超时兜底而且应用层的心跳消息在DevTools里面能直接看到排查问题非常直观。生产环境必选方案二。4.3 前端心跳与断线重连前端的心跳逻辑要让后端配合好通常这样写function startHeartbeat(socket) { // 每30秒发一次PING const timer setInterval(() { if (socket.readyState WebSocket.OPEN) { socket.send(PING); } }, 30000); // 把timer存起来方便断线重连时重置 socket._heartbeatTimer timer; } function stopHeartbeat(socket) { if (socket._heartbeatTimer) { clearInterval(socket._heartbeatTimer); } }再加上断线重连机制这里要注意指数退避避免服务器抖了一下之后所有客户端同时重连造成惊群function connectWithRetry() { let retryCount 0; function connect() { const socket new WebSocket(wss://${location.host}/ws/chat?token${token}); socket.onopen function () { retryCount 0; // 连接成功重置重试次数 startHeartbeat(socket); }; socket.onclose function () { stopHeartbeat(socket); // 指数退避1s, 2s, 4s, 8s, ...最大30s const delay Math.min(1000 * 2 ** retryCount, 30000); retryCount; setTimeout(connect, delay); }; socket.onerror function () { // 别在onerror里重连onclose一定会触发 }; } connect(); }这里有两个容易被忽略的点第一心跳定时器要在onopen里启动、onclose里清除否则可能开着多个定时器页面卡死第二断线重连别写在onerror里写因为onerror之后必定跟随onclose你在两处都重连会建立两条连接。这两条都是我在实际项目里给前端同事review代码时看到过的典型错误。另外心跳间隔和超时阈值要留足余量。如果你客户端30秒发一次心跳服务端90秒才判定超时相当于允许丢两次心跳。太激进比如10秒心跳、15秒超时会让正常的网络抖动导致大量误判断线用户会投诉“老是掉线”太宽松比如60秒心跳、5分钟超时又起不到及时发现死连接的作用。我一般用30秒/90秒这个比例生产环境跑下来比较稳。5. 消息推送实战单聊、群发、广播5.1 基于Session的直接推送先把基础推送达成就地解决。单体应用下你要给某个用户推消息其实就是在自己的Session Map里找到他的WebSocketSession然后调用sendMessage。这个我在第3章的sendToUser方法里写过了原理很朴素。但这里面有两个细节会影响稳定性必须单独说第一个是线程安全问题。Spring WebSocket发送消息时同一个WebSocketSession的多个并发sendMessage调用可能导致数据错乱甚至抛出异常。尤其是你的推送来自多个线程——比如业务线程在推订单消息、心跳线程在发PONG、定时任务在推公告——如果不做同步轻则消息交错、重则底层Channel写冲突。解决方式很简单发送前做同步public void sendMessage(WebSocketSession session, TextMessage message) throws IOException { synchronized (session) { if (session.isOpen()) { session.sendMessage(message); } } }用session对象本身当锁不会影响到其他Session的收发代价最小。第二个是发送超时。sendMessage是阻塞的如果客户端网络状况差TCP窗口满了发不出去这个调用会卡住所在线程严重时会把Tomcat或Netty的线程池拖垮。所以推送方法的签名要带上throws IOException由调用方决定是忽略还是记录或者用Async异步推送别让实时推送阻塞业务主链路。5.2 集群场景怎么办Redis发布订阅如果你们的应用已经上了多实例部署——哪怕只是两台机器Nginx负载均衡——单体Session Map的方案直接报废。因为客户端A连的是实例1客户端B连的是实例2实例1给B发消息时B的Session根本不在实例1的内存里。集群方案本质上是消息分发。常见的做法有三种用Redis发布订阅做广播、用RabbitMQ/Kafka等消息中间件做路由、用Spring集成STOMP协议配合Broker。我重点讲最常用也最容易上手的Redis发布订阅方案因为大部分企业的微服务架构里Redis已经是标配。整体思路每个实例启动一个Redis订阅者关注频道比如ws:message:topic当某个实例要给用户发消息时它把消息发到Redis频道所有实例的订阅者都收到这条消息然后各自检查目标用户是否连在自己身上是就推给客户端。Component public class RedisMessagePublisher { Resource private StringRedisTemplate stringRedisTemplate; /** * 发布消息到WebSocket频道 */ public void publish(String userId, String content) { stringRedisTemplate.convertAndSend(ws:message:push, JSON.toJSONString(Map.of(userId, userId, content, content))); } }订阅端配置Configuration public class RedisPubSubConfig { /** * 创建监听适配器收到消息后转交WebSocket推送处理器 */ Bean public MessageListenerAdapter wsMessageListener(RedisMessageListener listener) { return new MessageListenerAdapter(listener, onMessage); } Bean public RedisMessageListenerContainer container(RedisConnectionFactory factory, MessageListenerAdapter adapter) { RedisMessageListenerContainer container new RedisMessageListenerContainer(); container.setConnectionFactory(factory); container.addMessageListener(adapter, new PatternTopic(ws:message:push)); return container; } }收到消息后的实际推送逻辑需要拿着userId去查当前实例的SESSION_MAPComponent public class RedisMessageListener { Resource private ChatWebSocketHandler chatWebSocketHandler; public void onMessage(Message message, byte[] pattern) { String body new String(message.getBody(), StandardCharsets.UTF_8); JSONObject obj JSON.parseObject(body); String userId obj.getString(userId); String content obj.getString(content); // 这个方法内部会查LOCAL_SESSION_MAP只推给连在本实例上的session chatWebSocketHandler.sendToUser(userId, content); } }这个方案的优点是已经有的sendToUser代码不用大改对业务代码侵入小Redis本身也扛得住高并发消息。缺点是广播语义太粗——所有实例都会收到消息只有目标实例做了真正的推送浪费一点点带宽。如果消息量大到广播有压力就得升级到更精确的路由方案让实例A知道目标用户在实例B上直接把消息发到实例B的某个内部HTTP接口。更复杂的方案就超出本文范围了我自己的项目目前Redis广播的QPS完全扛得住。另外补充一个关键组件如果多实例共用同一个RedisRedis发布订阅天然就是跨实例的不需要额外配置。但如果你的服务是跨机房部署、Redis也是分片的那就得换MQRabbitMQ、Kafka来做跨机房路由原理类似只是把Redis换成了MQ的topic/queue。5.3 推送失败处理与离线消息企业级系统里必须考虑用户不在线连接断了怎么办消息就这么丢掉显然不行尤其是IM、订单通知这类业务。我的处理套路是三层兜底第一层实时通道推送。用户在线直接通过WebSocket推。第二层实时通道失败降级为站内信/App推送。如果sendToUser发现Session不存在或发送异常调用策略是写入离线消息表等用户下次上线时再拉取。第三层历史消息补拉。客户端建立连接后主动调用一次REST接口GET /api/messages/offline?userIdxxx把离线期间错过的消息一次性拉回来。这也是为什么我强烈建议WebSocket只做实时通知历史数据一律走REST接口补拉——WebSocket天然是易失的它不适合做可靠的消息投递。与其费劲保证消息不丢不如接受可能丢消息的事实用离线表兜底简单可靠。离线消息表的设计最简单就三列用户ID、消息内容、创建时间加个是否已读的字段。用户上线时查未读列表推过去之后标记已读。注意如果你要做的系统对消息可靠性要求极高比如金融交易对账单推送建议直接上MQ消息表ACK确认机制不要把WebSocket当成万能的。这是架构层面的取舍项目一开始就要定下来中途换会非常痛苦。6. 常见问题排查实录与性能调优6.1 连接建立但收不到消息最常见的问题这是我被问得最多的问题现象是onopen触发了连接建立了但服务器后端的消息一直推不过来。排查步骤我建议按顺序走第一步看服务端有没有成功保存Session。很多新人把Session存在局部变量或者普通Map里Spring容器一扫描就丢对象Session自然找不回来。检查你的Session存储是否是静态的或者Spring容器管理的单例Bean持有。第二步看推送代码是不是在同一条线程里执行的。我最常见的一个错误是业务Service里直接session.sendMessage()但Service的某次事务提交后、连接已经被关闭了甚至发送的时候session早已不是握手成功时的那个session了。在handleTextMessage里发的消息收到的连接一定在SESSION_MAP里但如果是别的Service通知Handler发送一定要检查Handler是不是被两次实例化了——把Handler直接new出来的后果就是你往一个没人用的Handler实例里发消息当然等于什么都没发生。解决方式是Handler必须标注Component所有地方都注入同一个Bean不要自己new。第三步看是不是异步发送没有flush。这个在Netty场景下比较突出某些底层实现里sendMessage后需要flush才能把数据写出去。Spring Boot默认的Tomcat实现不存在这个问题但如果换了UnderTow或Netty就要留意底层代码。第四步看消息格式是不是JSON前端解析炸了。前端onmessage里做JSON.parse(event.data)时抛异常可能只是控制台报错但页面无感知会产生没收到消息的错觉。让前端先在onmessage里直接打印原始数据确认服务端到底推没推。6.2 前端连接不稳定、频繁断开这个问题的元凶通常是心跳或Nginx。先说Nginx。生产环境用Nginx做反向代理必须显式配置WebSocket升级的Header否则Nginx的默认HTTP处理会在代理空闲超时后关闭连接导致每天固定时间掉线。Nginx配置里这样写location /ws/ { proxy_pass http://backend-server/; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; # 空闲超时时间设长一些默认60秒太短 proxy_read_timeout 300s; proxy_send_timeout 300s; }这个proxy_read_timeout 300s很关键。Nginx默认的read timeout是60秒也就是说60秒内如果没有数据从后端传到前端Nginx就帮你把连接断了。如果你的客户端心跳周期是30秒服务端也在回PONG那一般没问题但如果某个时间段服务端因为GC或者慢SQL导致PONG延迟超过60秒Nginx就会先动手。生产环境我把超时直接改成10分钟让上层不干预长连接的生死由应用层心跳来管。再说心跳参数。如果服务端Scheduled清理线程的时间阈值设置得太激进加上Nginx的timeout叠加正常用户都会频繁掉线。我给的推荐值自己再用一遍客户端心跳30秒、服务端超时90秒、Nginx空闲超时200秒以上。这样能容忍两次心跳丢失但不会让死连接长期占用资源。6.3 高并发下的参数调优WebSocket是长连接跟HTTP短连接最大的不同是并发数等于在线连接数。每个连接都会占用一个文件描述符和一小块内存。所以高并发场景下要先算清楚机器的承载量。Spring Boot内置Tomcat的默认最大线程数是200这个参数对WebSocket来说参考意义不大因为WebSocket连接并不一直占线程IO线程在等待时会被释放。真正要关注的是操作系统级别的文件描述符上限默认Linux是1024一个在线用户占一个FD超过就报Too many open files。生产服务器必须改成65535或更高ulimit -n 65535同时Tomcat有个会坑到WebSocket的配置最大连接数server.tomcat.max-connections默认8192。如果你们业务量预测在线用户会超过这个数记得在application.yml里调大server: tomcat: max-connections: 20000另一个重要配置是WebSocket缓冲大小。高并发推送大消息时如果超过缓冲区上限会触发CloseStatus。Spring产品的默认缓冲区是8KB如果要推比较大的业务包比如整块报表JSON要调大spring: websocket: container: max-text-message-buffer-size: 65536 max-binary-message-buffer-size: 65536另外WebSocket的Session内存占用也要心里有数。一个连接在Java堆上大约要占1~2KBSession对象、缓冲区、消息封装一万个在线连接大概多占20MB堆这倒不是主要问题真正要注意的是如果有大量消息堆积比如某个慢客户端拖住了发送线程消息对象的堆积才会造成内存爆炸。所以要在handleTextMessage里对消息大小做限制超过指定大小直接拒绝接收。6.4 监控与告警生产环境的WebSocket服务不能做“盲盒”。至少要监控三层连接数监控在线连接数的曲线能直观反映服务健康度突然断崖式下跌说明服务重启或者网络故障了。Spring Boot Actuator可以自定义一个WebSocketMetrics每30秒把SESSION_MAP的大小暴露到Prometheus。消息积压监控如果你用Redis发布订阅Redis的发布队列堆积情况要盯住如果你用Async异步推送线程池的活跃度和队列大小也要监控。心跳成功率监控服务端统计过去5分钟内“心跳超时关闭的连接数”和“总连接数”的比值超过阈值就告警。这个指标能提前暴露网络链路劣化的问题。我自己的做法是定义一组自定义Metric暴露给PrometheusGrafanaComponent public class WebSocketMetrics { private final AtomicInteger onlineCount new AtomicInteger(0); public void increment() { onlineCount.incrementAndGet(); } public void decrement() { onlineCount.decrementAndGet(); } Bean public MeterRegistryCustomizerMeterRegistry webSocketMetrics() { return registry - Gauge.builder(ws.online.count, onlineCount, AtomicInteger::get) .description(在线连接数) .register(registry); } }然后在Handler的连接建立、关闭时调用increment/decrement。这套东西加起来不到100行代码但生产环境里能省掉无数排查时间。还有一个实战小技巧WebSocket连接数要跟用户在线数区分开。一个用户可以开多个连接多端登录连接数会大于用户数。告警阈值按连接数设置同时记录一下每用户平均连接数如果这个值异常升高说明前端有连接泄漏——某个页面只开不关时间长了连接数会膨胀到把服务器拖死。我在项目里确实因为某个前端页面在Tabs切换时没关旧WebSocket连接数一路涨到爆掉后来加了连接泄漏检测才稳住。7. 一些额外的考量STOMP还是原生WebSocket前面所有示例用的都是原生WebSocketHandler这是最底层的抽象。如果你在处理复杂消息路由像订阅指定频道、点对点私信、群组通知Spring还提供了更高层的STOMP协议支持——它基于WebSocket但加了一层消息代理比如内置SimpleBroker或对接RabbitMQ可以声明订阅关系消息自动路由到订阅者。STOMP的好处是自带消息路由和订阅语义。比如前端可以这样订阅一个专属队列stompClient.subscribe(/user/queue/message, function (frame) { console.log(收到消息:, frame.body); }); stompClient.send(/app/send, {}, JSON.stringify({ to: u123, content: hello }));后端的Controller就非常直观Controller public class MessageController { MessageMapping(/send) public void handleMessage(Payload ChatMessage message, Principal principal) { // 自动把消息转发到 /user/queue/message messagingTemplate.convertAndSendToUser(message.getTo(), /queue/message, message); } }STOMP把很多编码工作变成了声明式配置但它也引入了一层复杂度前端必须引入stomp.js和sockjs.js后端要理解消息代理的概念出问题时排查链路更长。我个人的经验是简单的消息推送需求点对点、群发用原生WebSocket就够需求复杂到需要“频道订阅”语义或者要换RabbitMQ这种专业Broker做消息路由的再考虑STOMP。别为了一个“酷”字上复杂方案你的同事会感谢你的。回到最开始聊的那个问题。企业级项目里实时推送没有银弹选型要克制实现要细腻。WebSocket作为全双工通信的基石一旦选定后续的心跳保活、鉴权拦截、集群广播、离线兜底每一环都得考虑周全。我在多个项目里验证下来上面这套方案是性价比最高、坑也最少的组合原生WebSocketHandler握手拦截器鉴权应用层心跳Redis发布订阅广播离线消息兜底。如果你正在设计新的实时推送服务可以照这个骨架去落地如果你已经在跑着老方案也可以对照着排查隐患尤其盯一盯心跳参数和Nginx超时这两个最容易出问题的点。