Java WebSocket实战:从API到Spring Boot心跳与重连

发布时间:2026/10/8 15:06:28
Java WebSocket实战:从API到Spring Boot心跳与重连 刚接触Java WebSocket那会儿我其实是被一个在线客服项目逼的。用户在前端点“开始咨询”后端必须立刻把消息推过去延迟稍微高点页面的加载状态就尴尬得没边。我一开始图省事用HTTP轮询撑了两周数据库被白白查了几十万次服务端CPU天天飘红。后来整个模块重构成WebSocket实时性上来了服务器压力反而降下去了。这篇文章就是把Java WebSocket常用的使用示例做一个系统汇总从原生的javax.websocketAPI写到Spring Boot集成再重点讲心跳机制和断线重连这些生产环境绕不开的细节。正在做IM、在线客服、实时报表推送、协作类功能的Java开发者拿这篇文章当参考目录是最合适的。1. 先搞懂WebSocket到底解决了什么问题1.1 HTTP为什么做不到真正的实时推送HTTP是经典的请求-响应模型也就是说每一次数据交换都必须由客户端主动发起请求服务端只能被动响应。这个设计在浏览器场景下非常有用但它天然决定了服务端没法把消息主动推到客户端手里。想实现“服务端主动推”传统做法只有轮询前端每隔两三秒发一次HTTP请求问服务端“有没有新消息”。这个方案在低频率场景下是能用的但一旦消息量上来问题就非常明显。首先是查询浪费多半请求都是空响应等于白白占用网络带宽和数据库连接其次是延迟不可控两次轮询之间的窗口期如果来了新消息用户只能等下一次轮询才能看到体验上永远是“慢半拍”最后是连接开销大高并发下每个用户都在高频发起请求Tomcat的连接数很快就会被占满。长轮询Long Polling稍微好一点客户端发一次请求后服务端先挂起连接有新消息才返回然后客户端立刻再发一次。但这种方式对服务端线程的占用更恐怖而且连接的目的地不明确消息到达时如果正好赶上连接重建很容易丢消息。它能缓解延迟问题但治标不治本。1.2 WebSocket的握手过程和三个关键概念WebSocket和HTTP的关系并不是替代关系而是借用了HTTP的握手能力。客户端发起一次带有Upgrade: websocket头的HTTP请求服务端如果同意就返回101 Switching Protocols之后双方就“升级”到WebSocket协议连接也就从HTTP连接切换成了一条TCP长连接。这个过程如果打个比方HTTP是互相发邮件你发一封、对方回一封WebSocket更像是先通过邮件约好时间然后两个人拨通电话之后随时都能说话。电话一旦挂断互相之间的实时通话也就断了。真正理解Java WebSocket需要先掌握三个核心概念Endpoint端点服务端和客户端各自对应的处理逻辑单元相当于WebSocket世界的接口实现。Session会话一条WebSocket连接对应的生命周期对象服务端通过它给特定的客户端发送消息。Frame帧WebSocket消息的最小单位分文本帧、二进制帧、Ping帧、Pong帧和关闭帧等类型底层传输就是按帧进行的。还有一个容易被忽略的点WebSocket连接虽然是在TCP上建立的长连接但它不保证消息在应用层的“有序可靠”完全等同于TCP对字节流的有序可靠。TCP保证的是字节流不重不漏但WebSocket把字节流切成帧以后如果多个帧同时发送接收方的处理顺序在实际高并发场景下是需要应用层小心处理的。这一点在后面的并发章节我会展开。2. 原生Java WebSocket API写一个能跑的服务端2.1 依赖和环境准备标准的Java WebSocket APIJSR-356从Java EE 7开始进入规范包名是javax.websocket。如果项目是普通Maven工程只需要引入API依赖具体实现由容器Tomcat、Jetty等提供dependency groupIdjavax.websocket/groupId artifactIdjavax.websocket-api/artifactId version1.1/version scopeprovided/scope /dependency注意这里的scope是provided因为运行时WebSocket的实现类在Tomcat安装目录下的websocket-api.jar里。如果你用的是Spring Boot内嵌Tomcat也一样容器会自动加载实现。如果写的是Java 9的模块化工程javax.websocket模块还需要在module-info.java里显式引入。2.2 ServerEndpoint服务端完整代码原生API最直观的写法是用注解ServerEndpoint声明一个WebSocket服务端端点。下面这段代码是一个最简单的聊天室服务端连接路径为/ws/chatimport javax.websocket.*; import javax.websocket.server.PathParam; import javax.websocket.server.ServerEndpoint; import java.io.IOException; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArraySet; ServerEndpoint(/ws/chat/{roomId}) public class ChatEndpoint { // 保存所有连接的会话key是roomIdvalue是当前房间内的session集合 private static final ConcurrentHashMapString, CopyOnWriteArraySetSession ROOM_SESSIONS new ConcurrentHashMap(); OnOpen public void onOpen(Session session, PathParam(roomId) String roomId) { CopyOnWriteArraySetSession sessions ROOM_SESSIONS.computeIfAbsent( roomId, k - new CopyOnWriteArraySet()); sessions.add(session); sendToRoom(roomId, 系统消息有用户加入房间当前在线人数 sessions.size()); } OnMessage public void onMessage(String message, Session session, PathParam(roomId) String roomId) { // 业务上可以先解析消息这里直接广播 String payload 用户[ session.getId() ]说 message; sendToRoom(roomId, payload); } OnClose public void onClose(Session session, PathParam(roomId) String roomId) { CopyOnWriteArraySetSession sessions ROOM_SESSIONS.get(roomId); if (sessions ! null) { sessions.remove(session); sendToRoom(roomId, 系统消息有用户离开当前在线人数 sessions.size()); } } OnError public void onError(Session session, Throwable error) { // 日志里一定要记录完整堆栈不要只记一句话 System.err.println(WebSocket连接发生错误sessionId session.getId()); error.printStackTrace(); } private void sendToRoom(String roomId, String message) { CopyOnWriteArraySetSession sessions ROOM_SESSIONS.get(roomId); if (sessions null || sessions.isEmpty()) { return; } for (Session s : sessions) { if (s.isOpen()) { try { s.getBasicRemote().sendText(message); } catch (IOException e) { // 这里不要直接吞掉异常建议记录日志后关闭连接 try { s.close(); } catch (IOException closeException) { closeException.printStackTrace(); } } } } } }这段代码有几个细节值得展开讲一下。CopyOnWriteArraySet是专门为“读多写少”场景设计的并发Set遍历发送消息时不会抛ConcurrentModificationException在有成员离线要移除的场景下也很安全。它唯一的缺点是写操作开销大但对WebSocket房间成员这种“连接建立后一般不会频繁变化”的场景非常合适。session.getBasicRemote()返回的是同步发送接口一次只能发一个消息而getAsyncRemote()返回的是异步接口不需要阻塞当前线程。对于消息量很大的推送场景我会优先考虑异步发送但要注意网络情况不好时异步发送的堆积问题后续章节再展开。2.3 Java客户端连接与本地联调原生API同样提供了客户端端点注解ClientEndpoint。写一个Java客户端来测试上面的服务端非常方便不用打开浏览器就能验证逻辑import javax.websocket.*; import java.net.URI; ClientEndpoint public class ChatClient { OnOpen public void onOpen(Session session) { System.out.println(连接建立成功sessionId session.getId()); } OnMessage public void onMessage(String message) { System.out.println(收到服务端消息 message); } OnClose public void onClose(Session session, CloseReason reason) { System.out.println(连接关闭原因 reason.getReasonPhrase()); } public static void main(String[] args) throws Exception { WebSocketContainer container ContainerProvider.getWebSocketContainer(); String url ws://localhost:8080/ws/chat/room001; Session session container.connectToServer(ChatClient.class, URI.create(url)); session.getBasicRemote().sendText(大家好我是Java客户端); // 保持连接一段时间方便观察 Thread.sleep(10_000); session.close(); } }用Java客户端做联调是原生API的一大优势。尤其是服务端逻辑还没完全稳定时可以直接用JUnit测试类去连接WebSocket端点不需要打开浏览器、不需要手动输入消息这样才能把连接生命周期、消息收发逻辑作为可重复的自动化测试跑起来。我在实际项目里还见过不少人踩过ContainerProvider.getWebSocketContainer()返回null的坑。这个一般发生在没依赖容器实现的环境里比如在纯JSE环境下跑了客户端。解决方式是把容器实现依赖比如Tomcat的tomcat-embed-websocket或tyrus-standalone-client作为compile范围引入。3. Spring Boot集成生产项目更常用的方案3.1 为什么从原生转到Spring Boot原生ServerEndpoint的写法确实简单但只要项目里已经有Spring Boot我几乎不会直接在Controller层面混用这种端点。原因有三个。第一Bean生命周期管理。ServerEndpoint的实例生命周期由WebSocket容器管理默认是每个连接一个实例没法直接AutowiredSpring的Service。虽然可以通过静态工具类绕过去但写起来会很别扭。Spring封装的WebSocketHandler则是在Spring容器里管理的BeanAutowired随手就能用。第二拦截器机制。Spring家族提供了HandshakeInterceptor可以在握手阶段加载HTTP Session里的用户信息、校验Token这比在OnOpen里自己解析请求头要优雅很多。第三Spring生态整合。如果你想用STOMP子协议做消息路由、配合Spring Security做鉴权、或者用SendTo注解做消息转发原生API不具备这些能力。3.2 配置类与Handler完整示例Spring Boot集成WebSocket官方推荐的思路是一个配置类负责注册端点一个Handler类负责业务逻辑。下面是最常用的纯文本消息方案import org.springframework.context.annotation.Configuration; import org.springframework.web.socket.config.annotation.EnableWebSocket; import org.springframework.web.socket.config.annotation.WebSocketConfigurer; import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; Configuration EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(new ChatWebSocketHandler(), /ws/chat) .addInterceptors(new ChatHandshakeInterceptor()) .setAllowedOrigins(*); } }这里setAllowedOrigins(*)是允许所有来源跨域连接。生产环境千万不要这么写死改成你自己的前端域名列表。Handler类的实现import org.springframework.web.socket.*; import org.springframework.web.socket.handler.TextWebSocketHandler; import java.util.concurrent.ConcurrentHashMap; public class ChatWebSocketHandler extends TextWebSocketHandler { // 全局在线会话表键为用户标识 private static final ConcurrentHashMapString, WebSocketSession ONLINE_SESSIONS new ConcurrentHashMap(); Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { String userId (String) session.getAttributes().get(userId); ONLINE_SESSIONS.put(userId, session); session.sendMessage(new TextMessage({\type\:\sys\,\content\:\连接成功\})); } Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { String userId (String) session.getAttributes().get(userId); // 这里可以解析JSON做业务分发下面只是简单回显 String payload message.getPayload(); String response {\type\:\echo\,\from\:\ userId \,\content\:\ payload \}; session.sendMessage(new TextMessage(response)); } Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { String userId (String) session.getAttributes().get(userId); ONLINE_SESSIONS.remove(userId); // 记得通知其他相关用户该用户已离线 } Override public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception { // 传输层异常一般选择记录日志后关闭会话 exception.printStackTrace(); if (session.isOpen()) { session.close(CloseStatus.SERVER_ERROR); } } }这里的session.getAttributes()是Spring WebSocketSession提供的属性Map在握手阶段由拦截器往里塞数据。拦截器的关键代码import org.springframework.http.server.ServerHttpRequest; import org.springframework.http.server.ServerHttpResponse; import org.springframework.web.socket.WebSocketHandler; import org.springframework.web.socket.server.HandshakeInterceptor; import java.util.Map; public class ChatHandshakeInterceptor implements HandshakeInterceptor { Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, MapString, Object attributes) throws Exception { // 从HTTP Session或请求参数中取用户标识 // 因为握手本质是一次HTTP请求所以这里能拿到query参数 String userId request.getURI().getQuery() ! null ? request.getURI().getQuery() : anonymous; // 更规范的做法是解析token后从缓存中获取用户ID attributes.put(userId, userId); return true; } Override public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) { // 握手完成后的回调一般用不到 } }beforeHandshake返回false会直接终止握手这是做鉴权的关键入口。比如解析到Token无效返回false客户端会收到HTTP 403WebSocket连接根本建立不了。3.3 群发、点对点和前端联调示例实际项目里很少只做“收到消息回显给发送方”这种玩具逻辑更需要的是群发广播和点对点发送两种能力。无论是哪种核心都在于维护好ONLINE_SESSIONS这张会话表。点对点发送的套路是先根据接收方用户ID从表中拿到目标Session再判断连接是否依然有效后发送public void sendToUser(String targetUserId, String message) { WebSocketSession targetSession ONLINE_SESSIONS.get(targetUserId); if (targetSession ! null targetSession.isOpen()) { try { targetSession.sendMessage(new TextMessage(message)); } catch (IOException e) { // 发送失败说明连接已经不可用建议移除并触发重连策略 ONLINE_SESSIONS.remove(targetUserId); } } else { // 目标离线可以写入离线消息表 } }群发广播就简单多了遍历会话表逐台发送。注意这里我用的是ConcurrentHashMap遍历时不建议直接对Map做集合的remove而是标记后再统一处理避免迭代期间修改造成ConcurrentModificationException。前端联调的代码也很固定我贴一段在实际项目中反复用到的骨架const ws new WebSocket(ws://localhost:8080/ws/chat?userId1001); ws.onopen function () { console.log(WebSocket连接已建立); // 连接建立后立即启动心跳 startHeartbeat(); }; ws.onmessage function (event) { const data JSON.parse(event.data); console.log(收到服务端消息, data); // 根据data.type字段做业务分发 }; ws.onclose function (event) { console.log(连接关闭code event.code); }; ws.onerror function (error) { console.error(WebSocket连接出错, error); };到这里你会发现Spring Boot方案和原生方案在“会话管理”上的思路是完全一致的都需要一个线程安全的Map来维护在线会话。区别只在于Spring帮你处理了生命周期、拦截器、序列化这些外围能力业务代码可以更专注于消息本身。4. 心跳机制把“看起来连着”变成“真连着”4.1 连接为什么会假死我在前面已经提过WebSocket是TCP长连接。但“长连接”三个字很容易让人产生一个错觉连上了就能一直用。真实生产环境里连接假死的情况远比你想的常见。假死的典型场景有三个第一中间网络设备超时清理。很多负载均衡器、Nginx反向代理、云服务商的网关都会对空闲连接设置一个超时时间。默认配置往往是60秒或120秒。一旦一条WebSocket连接在这段时间内没有任何数据帧通过网关就默默地把这条连接关了。客户端和服务端在操作系统层面可能还没感知到但数据已经没法双向流通了。第二客户端断网但没有正常关闭连接。比如用户把笔记本合上或者手机切到飞行模式再切回来客户端的TCP栈没有主动发送FIN包服务端上的Socket就还保持着“ESTABLISHED”状态。这种情况下服务端内存里的Session对象不会触发onClose会话表里就长期残留着无效条目。第三服务端GC停顿或线程阻塞。如果在极端情况下服务端长时间无法处理消息客户端已经因为等待超时主动关闭连接但服务端内部还没有走到关闭回调这种现象也会让连接状态失真。所以结论是WebSocket本身不含心跳机制和TCP的Keep-Alive两回事必须由应用层自己做。这基本上也是Java WebSocket面试题里必考的一问。4.2 服务端空闲超时检测与客户端Ping心跳机制的实现业界通行的做法是双向配合客户端定时发Ping服务端记录最近收到消息的时间超过阈值关闭连接。先看服务端怎么检测空闲超时。标准Java WebSocket容器本身提供了一个简洁的原生手段session.setMaxIdleTimeout(long)。这个时间单位是毫秒设置为0表示永不超时。但仅依赖这个配置不够灵活因为空闲超时对服务端来说是个“黑盒”我们没法在超时前做补发或告警。更可控的方式是自己维护一个空闲时间戳。在handleTextMessage里记录心跳包的到达时间再用一个ScheduledExecutorService定时扫描当前所有会话对比当前时间和上次心跳时间// 服务端心跳检测调度器 ScheduledExecutorService heartbeatScheduler Executors.newSingleThreadScheduledExecutor(); // 在应用启动时调度 heartbeatScheduler.scheduleAtFixedRate(() - { long now System.currentTimeMillis(); ONLINE_SESSIONS.forEach((userId, session) - { Long lastBeat LAST_HEARTBEAT.get(userId); if (lastBeat ! null now - lastBeat 45_000) { // 超过45秒没收到任何消息强制关闭 try { session.close(CloseStatus.SESSION_NOT_RELIABLE); } catch (IOException e) { e.printStackTrace(); } ONLINE_SESSIONS.remove(userId); LAST_HEARTBEAT.remove(userId); } }); }, 10, 10, TimeUnit.SECONDS);服务端只负责超时检测而发送Ping的主动方在客户端。因为客户端是最清楚自己网络状态的一方服务端如果主动Ping万一网络已经断开Ping包发出去也收不到确认最后还是得靠超时兜底。客户端的实现就是开一个定时器每隔30秒发送一条应用层消息let heartbeatTimer null; function startHeartbeat() { if (heartbeatTimer) { clearInterval(heartbeatTimer); } // 每个30秒发送一条存活消息 heartbeatTimer setInterval(() { if (ws.readyState WebSocket.OPEN) { ws.send(JSON.stringify({type: ping, timestamp: Date.now()})); } }, 30000); }服务端收到type ping的消息时更新该会话的最后存活时间然后可以回一条pong让客户端确认链路是双向通畅的。这样客户端如果连续两三次发Ping都没收到Pong就可以判断连接已经不通主动发起重连。这里有个细节scheduleAtFixedRate在心跳检测这个场景下如果上一次检测因为线程阻塞卡住了下一次不会并行执行而是会等待上一次结束后尽快补跑。这就保证了即使服务端瞬间繁忙心跳检测任务也不会造成线程堆积。但要注意这个定时器本身是单线程如果检测逻辑里包含耗时的网络IO操作建议先做容量评估。4.3 断线重连与指数退避心跳机制负责“发现问题”断线重连负责“解决问题”。前端WebSocket在连接关闭后不会自动重连必须自己写重连逻辑。最简单粗暴的重连是固定间隔3秒重试一次但这种方案在服务端故障恢复期间会造成大量连接请求同时涌进来。更合理的方案是指数退避第一次等待1秒第二次2秒第三次4秒最多等待30秒同时加上随机抖动避免所有客户端在同一时刻发起重连let reconnectAttempts 0; function connect() { ws new WebSocket(ws://localhost:8080/ws/chat?userId1001); ws.onclose function (event) { let delay Math.min(1000 * Math.pow(2, reconnectAttempts), 30000); delay Math.random() * 1000; // 随机抖动 reconnectAttempts; setTimeout(connect, delay); }; }指数退避的“最大上限”很重要。没有上限的话连续断线重试几次后等待时间会呈指数爆炸用户可能等几分钟都连不上。我一般把上限设在30~60秒之间。重连成功之后还有一个消息补偿的问题。客户端离线期间服务端推送的消息会丢失。最简单可靠的做法是客户端在重连握手时把lastMessageId或lastSyncTime带给服务端服务端从历史消息表里把这段时间内的消息补发回来。这个逻辑理论上很成熟但实现时要注意控制补发的消息数量不能把用户在断线期间的全部聊天记录一次性全推过来那样页面会卡死。我通常的做法是只补发最近100条并让前端滚动加载更早的历史消息。5. 实战踩坑与排查链路5.1 握手失败HTTP 400/404的排查路径很多人第一次跑通WebSocket遇到的第一个报错就是握手失败。服务端日志可能只显示一句“Handshake failed due to invalid upgrade header”看着很抽象其实原因就那么几类。先看路径对不对。你注册的Handler路径是/ws/chat前端连接的URL就必须是完整匹配的/ws/chat不能带多余的后缀也不能漏掉前缀。原生ServerEndpoint里的路径如果带PathParam比如/ws/chat/{roomId}那前端传参的方式是路径参数而不是Query参数/ws/chat?roomIdroom001这种写法是匹配不上端点的。再看跨域设置。Spring Boot早期的版本默认是允许本地跨域的但如果你显式设置了setAllowedOrigins就必须把前端实际域名加进去。很多开发环境用http://localhost:8081调试但配置里白名单写的是http://localhost:8080结果线上没问题、本地死活连不上。最后看拦截器。我之前说过beforeHandshake里如果抛出异常或者返回false客户端看到的不是WebSocket的失败码而是HTTP层面的非101响应。这时候浏览器的onerror回调只会给你一个很隐晦的提示正确排查方式是用Network面板直接看那条握手请求的HTTP状态码如果是403基本就是鉴权或跨域的问题如果是404大概率是路径不匹配。5.2 Nginx反向代理后的WebSocket配置生产环境几乎很少直接用Tomcat暴露WebSocket端口给浏览器通常前面会有一层Nginx做反向代理。WebSocket在Nginx上有一个很容易忽略的坑默认配置下Nginx不会转发Upgrade头。虽然HTTP协议规定Connection头的值可以是upgrade但Nginx默认的HTTP处理逻辑会把Connection头清掉导致后端根本不知道前端要升级协议。解决方法是在location块里显式配置location /ws/ { proxy_pass http://backend_server; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_set_header Host $host; # 心跳超时时间建议设置得比客户端心跳间隔大一些 proxy_read_timeout 300s; }这里proxy_read_timeout 300s是关键。如果客户端心跳间隔是30秒Nginx默认的60秒proxy_read_timeout其实也够用但如果你把心跳间隔拉长到5分钟Nginx那边的默认超时就会提前把连接掐断。所以心跳间隔和反向代理超时时间要配套设置两端不匹配掉线是必然的。排查Nginx层的问题时最直接的手段是在后端服务日志里看有没有连接建立成功的记录。如果后端完全没收到握手请求问题大概率出在Nginx的转发层如果后端收到了但连接很快断了再看是不是超时配置问题。5.3 并发发送消息导致的Socket写入冲突这个坑我印象非常深。有一次做在线人数统计的实时推送用户量上来之后偶尔会报出IOException: The remote endpoint was in state [TEXT_FULL_WRITING]。查了半天才发现同一个WebSocketSession被两个不同的业务线程同时调用了sendMessage。标准WebSocket规范没有要求同一Session的并发写入一定是线程安全的。Tomcat默认对同一连接的管理方式是当一个线程正在写大消息的时候另一个线程再写就会抛出上面的状态冲突。解决方法有三种第一种也是最简单的对每个Session的发送操作加锁。用synchronized(session)或者更细粒度的锁对象把所有对该Session的写操作串行化。第二种统一走异步发送。在异步模式下虽然不用等上一个消息写完才能发下一个但底层还是有一个等待队列适合消息量不大但多个线程都会触发发送的场景。第三种引入一个发送线程池让所有消息都通过一个专用执行器发送。这个方案适合消息量很大的平台型应用发送任务在线程池中排队不会因为业务线程的并发问题导致状态冲突。我个人的习惯是中小项目用第一种第二种的组合大并发应用用第三种。不要为了追求异步的高吞吐一开始就上线程池因为线程池如果设置不当反而会带来消息延迟和内存堆积的连锁问题。5.4 集群环境下会话管理为什么不能只靠本地Map最后是集群问题。我上面所有方案里维护的ONLINE_SESSIONS都是一个JVM内部的本地Map这在单机部署时完全够用。但一旦服务做了负载均衡用户A连到了实例1用户B连到了实例2实例1上的消息想推给用户B走本地Map是找不到B的Session的。集群WebSocket的经典做法是引入广播中间件让各个实例共享一份“在线状态”和“消息通道”。最常听见的方案是Redis的Pub/Sub发布订阅天然就是广播模型实例1把要推送的消息发布到某个频道所有订阅该频道的实例都能收到然后各实例在自己的本地Map里查目标Session如果目标Session恰好落在自己实例上就发送出去。// 伪代码发布消息到Redis频道 stringRedisTemplate.convertAndSend(ws:message:room roomId, jsonMessage); // 订阅方伪代码 public void onMessage(String message) { // 拿到目标userId查本地在线表 WebSocketSession targetSession ONLINE_SESSIONS.get(targetUserId); if (targetSession ! null targetSession.isOpen()) { targetSession.sendMessage(new TextMessage(message)); } }这里还有一个负载均衡策略的问题。为了减少跨实例推送的消耗最好让负载均衡器配置会话粘滞sticky session让同一个客户端的连接尽量落在同一台实例上。否则连一次性握手请求都可能在两台机器之间跳虽然也能工作但会显著增加跨实例消息的比例。6. 从面试到落地WebSocket还常考的几件事写到这里核心的示例和排错链路都已经过了一遍。最后补充几个面试里高频出现、落地时也常常踩到的问题。6.1 面试常问的三板斧问题一WebSocket和HTTP有什么区别回答时要从三个维度去说协议形式上WebSocket握手借用HTTP但建立连接后数据帧走的是WebSocket协议不再有HTTP的请求头通信模式上HTTP是半双工请求-响应WebSocket是全双工双方随时可以发连接寿命上HTTP通常一条连接只服务一次请求WebSocket连接是长寿命的直到某一方主动关闭如果断线。问题二WebSocket会丢消息吗这是最容易答片面的一问。底层TCP保证了传输层的可靠不会丢字节但应用层如果服务端在发送时进程崩溃、或者客户端在断线期间错过了推送消息就“丢”了。所以严格地说WebSocket具备可靠传输的能力但应用层如果不做消息补偿机制业务上依然可能丢消息。问题三服务端怎么知道哪个用户连了哪个Session本质上就是我在前面反复用到的思路握手拦截器里读取用户身份存在Session的attributes里再以userId作为key注册到ConcurrentHashMap。这个问题的延伸变体还有“怎么踢一个用户下线”“怎么统计在线人数”“广播接口怎么做”其实都是在问你那张会话表的管理粒度。6.2 性能和调优的实际参数调优这个话题我给出来的数值都是基于常规项目的经验值不是“标准答案”但可以作为起点参考。首先是Session的空闲超时。你用setMaxIdleTimeout设置的超时时间一定要大于心跳间隔。如果心跳30秒超时时间至少给60秒以上否则网络抖动一下连接就断了。我见过不少人把空闲超时设成30秒、心跳也是30秒结果刚好在心跳发送前被判定超时整条链路就一直处于“刚连上就断开”的循环里。其次是消息大小限制。默认的WebSocket消息最大限制通常够用但如果你的业务会传大JSON或Base64图片一定要在容器层面调整maxTextMessageBufferSize和maxBinaryMessageBufferSize。Spring Boot的话可以在WebSocketHandler注册时设置容器的缓冲区属性。不调整的后果是消息体超过默认值时会被直接截断而不是报错排查起来非常迷惑。最后是线程模型。WebSocket是长连接应用后端处理线程的模型和普通HTTP请求不同。普通HTTP请求是请求完线程就释放长连接则意味着一个线程可能被一条连接长期占用。所以并发连接数很大时不要指望默认线程池能扛住要考虑异步非阻塞方案甚至转向Netty一类的纯NIO框架。6.3 我的个人习惯做WebSocket服务端几年下来我最后养成了一个固定习惯不管项目工期多紧上线之前一定会把心跳、断线重连、消息补偿这三件事先做完而不是等线上出问题再补。因为WebSocket与HTTP最大的不同是HTTP的失败是显式的返回一个错误码客户端立刻就能感知而WebSocket的失败往往是“静默”的连接状态显示正常消息却发不出去。用户不会理解这种网络层面的差异他只知道自己收不到消息。所以把连接的每一个状态变化都变成可观测、可恢复的比任何业务优化都更能提升线上稳定性。