Java WebSocket实战:从协议原理到SpringBoot整合与集群排坑
前一阵子在给一个订单系统做消息实时推送甲方只提了一个要求订单状态一变前端页面要立刻看到不要等用户刷新。这需求听起来简单但把HTTP那套请求-响应模型拉过来直接用几乎没法做。于是又一次回到了Java里高频出现的那个东西——WebSocket。如果你正准备用Java做聊天、弹幕、通知、设备数据上行或者正被面试官的八股文问得头皮发麻这篇内容应该能帮你把思路理顺从最底层协议原理到原生Java API再到SpringBoot整合与生产环境排坑按零基础能上手、有经验能查漏补缺的方式写。无论你是刚学会Spring Boot的应届生还是写了几年Java但还没真正碰过WebSocket的同事都可以按顺序往下看如果你只是想找排查手册直接跳到最后一章速查表就行。1. 先搞清楚基础WebSocket到底解决了什么问题1.1 HTTP那套一问一答的模式为什么不够用HTTP协议发展到今天底层交互模型其实一直没变客户端发请求服务端给响应。这个模型处理“获取数据”很擅长但一碰到“服务端主动通知客户端”就特别尴尬。举个例子。聊天室里有人在群聊服务端必须把消息推给房间里其他所有人后端一个异步任务跑完了要第一时间告诉前端展示结果运维监控面板每隔几秒要刷新一次CPU和内存曲线。这些都是服务端有数据变化、客户端却不知道什么时候变化只能被动等着的场景。在没有WebSocket的年代最朴素的解决办法是轮询。前端用setInterval每5秒发一次HTTP请求问“服务端有新鲜事吗”服务端不管有没有数据都返回一个响应客户端拿到后更新页面。轮询的缺点一眼就能看穿没有新消息时请求照样在发白白占了带宽和服务器连接想实时一点轮询间隔就得缩到1秒甚至更短代价成倍上涨想省资源间隔拉长到30秒用户看到消息的时间就被拖到半分钟之后。而且HTTP每个请求都要带请求头、Cookie、各种标识信息很多场景里光请求头可能都比真正传输的数据还大。后来出现长轮询思路是客户端发一个请求后服务端先不着急返回一直挂到有数据或者超时再返回客户端收到响应后立刻再发起下一个请求。这种做法把“定时扫一遍”改成了“蹲在门口等”实时性好了不少但代价也大每个长轮询都要长期占着一个服务端线程或连接在线用户量大时连接数会非常夸张而且一次结果返回后客户端要重新建立请求中间一样存在空窗期网络抖动时还会出现消息延迟和错乱。一句话概括HTTP这套模型天生是“你问我答”服务端没有机会主动开口。轮询和长轮询都是绕着弯子模拟“主动推送”本质上还是在让客户端反复发起请求。这种绕弯需要在WebSocket出现后被彻底终结。1.2 WebSocket的核心原理一次握手双向长连接WebSocket是一个基于TCP、有完整标准RFC 6455的协议。它的巧妙之处在于没有另起炉灶搞一套全新的端口和握手方式而是复用了HTTP的Upgrade机制从一条普通HTTP连接“升级”而来。握手过程大概是这样的客户端先发一个普通HTTP GET请求请求头里带着三个关键字段Connection: UpgradeUpgrade: websocketSec-WebSocket-Key客户端生成的随机Base64值本质是16字节随机数编码后的结果Sec-WebSocket-Version: 13服务端收到后校验版本和Key如果同意升级就返回101 Switching Protocols。这个101响应就是握手的完成标志从这一刻开始连接双方可以随时向对方发送数据没有请求-响应顺序的约束也没有“必须先叫服务端才轮到服务端回话”的限制。这里有个细节很多人没在意Sec-WebSocket-Key并不是明文丢给服务端就完了服务端要根据它计算出Sec-WebSocket-Accept返回给客户端。计算规则是拿Key拼接一个固定GUID字符串258EAFA5-E914-47DA-95CA-C5AB0DC85B11然后做SHA-1哈希再Base64编码。这个流程的核心作用不是加密而是让客户端确认“对面真的是一个WebSocket服务端”避免普通HTTP服务器被意外卷进来。握手完成后的数据传输走的是WebSocket Frame帧帧头很小通常只有2到14字节比HTTP请求头那几十上百字节的“搬运费”低太多。帧里带着操作码文本、二进制、关闭、Ping/Pong等、数据长度、掩码位等信息。协议还规定客户端发给服务端的数据必须做掩码处理服务端发给客户端的数据不用掩码。这项设计是历史原因——防止早期网络代理把WebSocket消息误当成HTTP缓存起来导致数据污染。用生活里的话说HTTP像发微信消息你发一条对方回一条一来一回靠缘分WebSocket像打电话拨号成功后线路一直保持两边随时都能说话不需要重新拨号。这也是为什么WebSocket特别适合聊天、股票行情、实时协同、游戏同步这类对实时性要求高、交互频率又随机的场景。2. 原生Java WebSocket快速上手注解版聊天室2.1 环境准备一个Maven项目加两个依赖Java在WebSocket上的标准API叫做JSR 356也就是javax.websocket包下那一堆接口和注解。JDK 8之后这套API就进入了标准生态但注意API规范本身只是接口具体实现要靠容器比如Tomcat、Jetty或者专门的实现库如Tyrus。如果你写的不是一个SpringBoot项目而是一个普通Servlet项目在pom里加两个依赖就能开跑dependencies dependency groupIdjavax.websocket/groupId artifactIdjavax.websocket-api/artifactId version1.1/version scopeprovided/scope /dependency dependency groupIdorg.apache.tomcat.embed/groupId artifactIdtomcat-embed-websocket/artifactId version9.0.75/version scopeprovided/scope /dependency /dependency第一个依赖是标准API第二个是Tomcat对WebSocket的实现。scope用provided是因为部署进Tomcat等容器后容器本身自带这些类本地依赖只负责编译避免打包时带出一堆重复类库冲突。如果你用的是Servlet 3.0容器其实第二个依赖在部分场景下都可以省但加上它能让本地IDE里跑测试客户端更顺手。还有一点如果你用的是Spring Boot 3.x或更现代的Jakarta EE环境包名已经从javax.websocket变成了jakarta.websocketMaven坐标也会变成jakarta.websocket:jakarta.websocket-api。在旧项目里见过太多人拷了旧依赖直接报ClassNotFoundException这个问题提前知道能省不少事。2.2 写一个服务端端点ServerEndpoint原生Java API最友好的地方是它提供了几个注解声明式地就把WebSocket端点写出来了。我拿一个最简单的群聊房间举例代码不长但足以覆盖90%的语法点import javax.websocket.*; import javax.websocket.server.PathParam; import javax.websocket.server.ServerEndpoint; import java.util.Collections; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; ServerEndpoint(/chat/{room}) public class ChatRoomEndpoint { private static final MapString, SetSession ROOM_SESSIONS new ConcurrentHashMap(); private Session session; private String room; OnOpen public void onOpen(Session session, PathParam(room) String room) { this.session session; this.room room; ROOM_SESSIONS .computeIfAbsent(room, k - ConcurrentHashMap.newKeySet()) .add(session); broadcast(room, 用户 session.getId() 加入房间 room); } OnMessage public void onMessage(String message, Session session) { broadcast(room, session.getId() 说: message); } OnClose public void onClose(Session session) { SetSession sessions ROOM_SESSIONS.get(room); if (sessions ! null) { sessions.remove(session); if (sessions.isEmpty()) { ROOM_SESSIONS.remove(room); } } broadcast(room, 用户 session.getId() 离开房间); } OnError public void onError(Session session, Throwable error) { error.printStackTrace(); try { session.close(new CloseReason(CloseReason.CloseCodes.UNEXPECTED_CONDITION, 服务端异常)); } catch (Exception e) { e.printStackTrace(); } } private void broadcast(String room, String message) { SetSession sessions ROOM_SESSIONS.getOrDefault(room, Collections.emptySet()); for (Session s : sessions) { if (s.isOpen()) { synchronized (s) { s.getBasicRemote().sendText(message); } } } } }逐个说注解。ServerEndpoint(/chat/{room})把当前类标记成一个WebSocket端点花括号里的room是路径参数客户端连接的URL类似ws://localhost:8080/你的应用名/chat/room001。OnOpen在握手成功时触发通常在这里把Session存起来OnMessage在收到客户端消息时触发参数类型可以是String、byte[]或ByteBuffer对应文本帧、二进制帧OnClose在连接关闭时触发这里必须把Session从集合里移除否则后面还给一个已经断开的连接发消息就会出问题OnError在异常发生时触发这个回调千万不能留空实现生产里很多连接泄露都是因为这里空着连接断了服务端却不知道。连接池用的是ConcurrentHashMapkey是房间名value是对应当前房间的Session集合集合用ConcurrentHashMap.newKeySet()创建的线程安全Set。为什么非要线程安全因为WebSocket服务端天然多线程Tomcat处理不同连接的线程会同时操作这个Map普通HashMap在并发扩容时甚至可能造成死循环线程安全结构是底线。群发逻辑里做了两步保护s.isOpen()判断连接还开着synchronized(s)防止多个线程同时通过BasicRemote往同一个Session写数据。getBasicRemote()是同步发送多个线程并发往里面塞消息会抛TEXT_FULL_WRITING异常对应getAsyncRemote()是异步发送但异步模式下要自己处理回调初学者先用同步加锁最稳。客户端测试代码也非常简单浏览器F12控制台直接就能连const ws new WebSocket(ws://localhost:8080/context/chat/room001); ws.onopen () { console.log(已连接); ws.send(大家好); }; ws.onmessage (event) { console.log(收到服务端消息, event.data); }; ws.onclose (event) { console.log(连接关闭, event.code, event.reason); };ws协议对应明文wss对应TLS加密。生产环境必须用wss这是明文传输的安全底线。2.3 Spring环境下的依赖注入坑照抄必踩很多人在SpringBoot项目里按上面的方式写ServerEndpoint类然后在类里直接Autowired一个业务Service结果运行时发现service是null怎么注入都不进来。原因很直接ServerEndpoint标注的类在应用启动时由WebSocket容器扫描并管理每来一个连接就new一个Endpoint实例这个创建过程完全绕过了Spring容器。Spring的依赖注入只对他自己管理的Bean生效Endpoint实例不在Spring容器里自然也没人给它装配字段。常见解决办法是写一个静态的Spring容器持有者在Endpoint里手动拿BeanComponent public class SpringContextHolder implements ApplicationContextAware { private static ApplicationContext applicationContext; Override public void setApplicationContext(ApplicationContext applicationContext) { SpringContextHolder.applicationContext applicationContext; } public static T T getBean(ClassT type) { return applicationContext.getBean(type); } }然后在Endpoint里调SpringContextHolder.getBean(OrderService.class)来执行业务逻辑。这招能在注解版方案里解决问题但不够优雅所以我更直接的建议是在Spring项目中尽量别用裸ServerEndpoint改成第三章里的Spring原生WebSocket Handler那本来就在Spring Bean体系里Autowired随便用。还有一个隐藏坑你迟早会碰到如果你给ServerEndpoint类上面同时加了ComponentSpring会把端点类当成单例Bean实例化一次但WebSocket容器每来一个连接又new一个全新实例出来。如果端点类里写了private Session session这类实例状态字段单例里这个字段压根没人用每次连接使用的是新实例但如果你用Spring注入了一些Bean注入的又是那个单例。这种“一半容器管、一半Spring管”的状态最容易让人懵解决方案是给端点类补一个Scope(prototype)确保每次使用都是新实例。不过说真的到了这一步还不如直接转用Spring原生方案。3. SpringBoot整合WebSocket企业项目的主流姿势3.1 基于TextWebSocketHandler的标准实现SpringBoot项目里整合WebSocket首选是Spring WebSocket模块提供的TextWebSocketHandler而不是裸写ServerEndpoint。原因前面提过Handler本身就是Spring容器里的Bean可以直接注入Service、Mapper、RedisTemplate业务代码写起来干净太多。先加依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-websocket/artifactId /dependency写一个Handler处理器核心方法是四个Component public class MyWebSocketHandler extends TextWebSocketHandler { Autowired private WebSocketSessionManager sessionManager; Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { String userId (String) session.getAttributes().get(userId); sessionManager.add(userId, session); session.sendMessage(new TextMessage(连接成功)); } Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { // 客户端消息按需解析 } Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { String userId (String) session.getAttributes().get(userId); sessionManager.remove(userId); } Override public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception { // 记录日志关闭连接 session.close(CloseStatus.SERVER_ERROR); } }接着写一个配置类注册这个Handler到某个路径上Configuration EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { Autowired private MyWebSocketHandler myWebSocketHandler; Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(myWebSocketHandler, /ws) .addInterceptors(new IdentityHandshakeInterceptor()) .setAllowedOrigins(*); } }这里有个细节很容易写错registry.addHandler里传的Handler对象必须是从Spring容器里拿出来的Bean而不是new MyWebSocketHandler()。如果你图省事直接newHandler内部注入的sessionManager会变成null这是新手最常见的翻车点。addInterceptors是Spring提供的一个扩展点作用是在握手阶段把一些信息塞进WebSocketSession的attributes里。比如把登录用户的ID、昵称放进去方便Handler里直接拿到。自定义一个拦截器也很简单public class IdentityHandshakeInterceptor implements HandshakeInterceptor { Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, MapString, Object attributes) { // 从request查询参数、header或HttpSession里解析出userId attributes.put(userId, 10086); return true; } Override public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) { } }返回false可以拒绝握手这里天然能做简单的身份校验和黑名单过滤。setAllowedOrigins(*)表示允许任意来源跨域连接开发阶段方便生产环境建议改成明确的前端域名白名单降低被跨站伪造连接的风险。3.2 实时推送服务让业务系统主动把数据塞给前端Handler只是负责接收连接、接收消息真正“把数据推给前端”的能力需要一个连接管理器。这个管理器本质上就是一个Session的注册表业务代码拿着用户ID就能找到对应的WebSocketSession往里丢消息。我一般这样写Component public class WebSocketSessionManager { private static final ConcurrentHashMapString, WebSocketSession SESSIONS new ConcurrentHashMap(); public void add(String userId, WebSocketSession session) { SESSIONS.put(userId, session); } public void remove(String userId) { SESSIONS.remove(userId); } public void sendToUser(String userId, String message) { WebSocketSession session SESSIONS.get(userId); if (session ! null session.isOpen()) { synchronized (session) { try { session.sendMessage(new TextMessage(message)); } catch (Exception e) { // 记录异常并清理失效session SESSIONS.remove(userId); } } } } public void sendToAll(String message) { for (WebSocketSession session : SESSIONS.values()) { if (session.isOpen()) { synchronized (session) { try { session.sendMessage(new TextMessage(message)); } catch (Exception e) { e.printStackTrace(); } } } } } }在afterConnectionEstablished里注册Session在afterConnectionClosed里移除Session保证Map里存的全是活跃连接。然后业务代码就随意了。比如订单状态变更时orderService.updateStatus(orderId, newStatus); webSocketSessionManager.sendToUser(order.getUserId(), {\orderId\:\ order.getId() \,\status\:\ newStatus \});又比如做一个定时推送把服务器指标推给所有在线用户Scheduled(fixedRate 60_000) public void pushMetrics() { String metrics getCpuUsageAndMemory(); webSocketSessionManager.sendToAll(metrics); }这样设计最大的好处是推送逻辑和连接逻辑解耦。订单Service不需要知道WebSocket怎么建立、怎么管理它只需要干一件事调sendToUser。Handler也只需要干自己的事维护连接。业务消息体建议统一用JSON字符串复杂系统可以封装一个MessageT模板用Jackson序列化别在代码里手拼字符串后面维护起来会想砸键盘。3.3 集群部署Session分散在多台机器上怎么办单机版跑得好好的一旦部署到多节点问题立刻现形用户A连接在node1上业务请求被负载均衡分到了node2node2调用sendToUser时在自己的内存Map里查不到用户A推送就丢了。所有WebSocket集群方案归根结底回答一个问题推送动作怎么落到目标用户对应的节点上。最通用也最简单的思路是广播。把“某用户需要收到某条消息”这个事件发布到所有节点每个节点收到事件后在自己本地的Session Map里找目标用户找到就推找不到就忽略。实践中我见过两种主流实现第一种是Redis Pub/Sub。业务代码在任意节点执行完向指定频道convertAndSend一条消息所有节点都订阅了这个频道收到消息后在本地Manager里查目标用户并推送。好处是轻量Redis本就是大多数项目的标配不需要额外引入消息中间件。第二种是用消息队列比如RabbitMQ的fanout交换机或Kafka的广播topic。效果和Redis Pub/Sub类似但消息队列能承载更大吞吐和更可靠的重试机制适合推送量大的场景。广播方案的缺点是无效消息多一万个节点只有一个是目标节点但所有节点都要收到消息。节点多了之后可以优化成精确路由用一个Redis表维护userId - nodeId业务节点先查这个路由表只往目标节点发送。这样消息量从广播降为单播但需要额外维护路由表的一致性连接建立、断开、节点宕机都要更新。这个优化建议等节点规模确实需要时再做前期用广播最简单估算一下你的推送频率和节点数通常广播完全够用。4. 调试工具与常见连接姿势WebSocket Test Client实操4.1 本地服务怎么快速测试三种顺手的方式服务端写完第一件事是验证能不能连上这时候手里得有靠谱的测试工具。我常用的有三种各有适用场景。浏览器F12控制台最快捷适合随手验证一个端点通不通const ws new WebSocket(ws://localhost:8080/ws); ws.onopen function() { console.log(open); ws.send({action:PING}); }; ws.onmessage function(e) { console.log(收到: e.data); }; ws.onerror function(e) { console.error(e); };不用装任何东西打开任意网页按F12把这段塞进Console就行。缺点是它只能测试浏览器客户端并且受页面域名和CORS影响。新版IDEA自带WebSocket测试客户端。在IntelliJ IDEA中可以通过Tools菜单或.http文件里直接创建WebSocket连接填好URL就可以连接、发送消息、查看返回。如果你和我一样经常在IDE和浏览器之间切这个功能能省不少事。最推荐的是自己写一个Java测试客户端适合把它放到集成测试里反复跑。核心代码几十行public class WsClient { public static void main(String[] args) throws Exception { WebSocketContainer container ContainerProvider.getWebSocketContainer(); container.connectToServer(new Endpoint() { Override public void onOpen(Session session, EndpointConfig config) { System.out.println(连接已打开); session.addMessageHandler(new MessageHandler.WholeString() { Override public void onMessage(String message) { System.out.println(收到: message); } }); try { session.getBasicRemote().sendText({\action\:\PING\}); } catch (Exception e) { e.printStackTrace(); } } }, URI.create(ws://localhost:8080/ws)); Thread.sleep(10_000); } }这段代码需要tomcat-embed-websocket依赖在运行期可用。如果是和之前pom里同样的依赖注意scope如果是provided运行客户端时要调整或者单独建一个测试模块把依赖设为test。4.2 想通过WebSocket发送POST请求怎么办WebSocket协议本身没有Method这个概念什么POST、GET、PUT、DELETE统统不存在。但业务上确实经常出现“希望通过WebSocket发起一个创建/更新操作”的需求比如想通过长连接提交一条订单、发送一条指令。行业里的习惯做法是在消息体里定义action字段表示业务动作用payload字段携带具体数据再配一个requestId用于关联响应。{ action: CREATE_ORDER, requestId: uuid-001, payload: { skuId: 1001, count: 2 } }服务端收到后按action分发到对应的处理方法OnMessage public void onMessage(String json, Session session) throws Exception { JsonNode node new ObjectMapper().readTree(json); String action node.get(action).asText(); switch (action) { case CREATE_ORDER - orderService.create(node.get(payload)); case CANCEL_ORDER - orderService.cancel(node.get(payload)); default - session.getBasicRemote().sendText({\error\:\unsupported action\}); } }这其实就是一种简易的JSON-RPC约定。团队之间可以自己约定一套消息协议模板只要把action、requestId、payload、code、message这些字段标准化前后端联调时就不会各猜各的。还有些场景里你并不是真的要从浏览器“发POST”而是外部HTTP系统触发了某个业务事件需要把结果推给WebSocket上的前端。比如微信支付回调成功后支付回调接口是一个HTTP接口它收到通知后在服务端调用webSocketSessionManager.sendToUser(userId, ...)把支付成功事件推给网页。这种“外部HTTP触发推送”在实际项目里非常常见相当于服务端先接收一个POST再通过长连接把结果转推给前端。4.3 调试中遇到的数据格式和大小陷阱调试WebSocket时最让人困惑的往往不是连接而是“连上了但没反应”。最常见的两个原因第一个是消息类型不匹配。客户端用ws.send(hello)发字符串服务端OnMessage方法参数如果写的是ByteBuffer或byte[]容器默认只处理二进制帧你发的文本帧直接被忽略。反过来你发一个Blob或ArrayBuffer服务端参数写成String也什么都收不到。调试时先确认事件里event.data的类型再对照服务端参数类型。第二个是消息长度超限。Tomcat默认的文本消息缓冲区大小一般只有8192字节8KB超过这个上限的消息会被拒绝表现是连接被强制关闭或者抛TextMessageTooBigException。本地小数据测不出问题一旦传大JSON就断线排查起来特别隐蔽。SpringBoot下可以通过一个Bean调大限制Bean public ServletServerContainerFactoryBean createWebSocketContainer() { ServletServerContainerFactoryBean container new ServletServerContainerFactoryBean(); container.setMaxTextMessageBufferSize(2 * 1024 * 1024); container.setMaxBinaryMessageBufferSize(2 * 1024 * 1024); container.setMaxSessionIdleTimeout(60_000); return container; }setMaxSessionIdleTimeout也要注意它控制一个WebSocket连接如果一直没有消息多久会被容器主动关闭。默认值在不同版本里差异很大有的版本是60秒如果业务需要长期保持静默连接必须把这个值调大同时用应用层心跳维持活性。5. 高频问题排查与经验总结5.1 WebSocket面试八股文自查表聊WebSocket的面试题绕不开几个基础但高频的点自己先把这张表吃透对比项HTTPWebSocket通信模式请求-响应一问一答全双工双方随时可发连接形态默认短连接可复用长连接握手后保持数据帧开销请求头动辄几十上百字节帧头一般2-14字节服务端主动推送不支持需轮询或长轮询原生支持实现复杂度简单需要处理连接管理、心跳、断线重连适用场景普通CRUD、页面浏览实时聊天、行情、协同编辑、游戏常被追问的几个点必须能接住为什么WebSocket基于TCP而不是UDP因为WebSocket和HTTP的根基是可靠有序传输消息不能丢、不能乱序TCP天然保证这两点UDP需要应用层再实现可靠机制成本高得多。WebSocket和SSEServer-Sent Events怎么选SSE是单向的服务端到客户端基于HTTP长连接浏览器API非常简单还自带断线重连。如果你的核心诉求只是“后台有数据往前端推”不需要频繁从客户端上传数据SSE可能比WebSocket更轻量。但WebSocket是双向的能同时处理客户端上行消息生态也更丰富团队认知度更高。选择的关键不是谁好谁坏而是推送方向和数据交互频率。心跳机制怎么做WebSocket协议层有Ping/Pong帧Java里可以调session.getBasicRemote().sendPing(ByteBuffer)发送容器一般会自动回复Pong。但生产项目中我更推荐应用层心跳客户端每30~60秒发一条{action:PING}服务端收到后更新lastActiveTime同时回一条pong服务端另起一个定时任务扫描所有连接超过90秒没活动就主动关闭。应用层心跳的好处是能同时验证“连接通不通”和“业务链路通不通”协议层的Ping只能证明TCP链路没断不能证明服务端业务处理器还正常工作。WebSocket会发生TCP粘包吗WebSocket消息帧自带长度字段消息边界由协议层处理好了应用层不需要像处理裸TCP粘包那样自己拼长度前缀。因此业务代码里读到的每条消息都是完整的一条省心很多。但要注意一个消息特别大时底层分帧对应用透明不需要特殊处理。5.2 生产环境最容易踩的四个坑第一坑连接状态清理不及时。OnClose里忘了移除SessionOnError里又没关连接时间一长Manager里的Map全是死连接。排查手段很简单打印连接总数和活跃数对比。对策是封装统一的清理方法在afterConnectionClosed、handleTransportError和OnError三条路径里都调用别小看这个细节线上内存泄漏一半是它。第二坑并发往同一个Session发消息。多线程同时调session.sendMessage或getBasicRemote().sendText会报TEXT_FULL_WRITING或者消息交错现象是客户端收到两段消息拼在一起。对策是我在代码里反复用的synchronized (session)或者改用AsyncRemote并把发送操作收口到一个线程池。简单粗暴的锁虽然不性感但在WebSocket群发场景里是真的管用。第三坑给已经关闭的Session发消息。客户端刷新页面、断网、服务端超时关闭Session状态都变成closed代码里如果没有isOpen()判断调sendMessage直接抛IllegalStateException一次推送就能搞炸一个线程。正确姿势是发送前判断、发送时捕获异常、捕获后把失效Session清理掉。第四坑连接建立了但很快断了。多半不是应用代码问题而是代理层没配置好。如果前面挂着NginxNginx默认没开Upgrade头WebSocket握手就过不去如果Nginx配了Upgrade但proxy_read_timeout设得太短服务端60秒不推消息连接就被Nginx静默断开。表现是前端onclose事件触发code通常是1006很难直接联想到是代理干的。下面这张速查表我每次排查WebSocket线上的问题都会先对着扫一遍现象可能原因处理方式握手一直失败前端onerror路径不对、Nginx没配Upgrade头核对URL检查Nginx配置连接通但发消息没收到消息类型不匹配、Handler没注册检查文本/二进制帧类型收到TEXT_FULL_WRITING多线程同时sendText对Session加锁或收敛到单线程发送报IllegalStateExceptionSession已关闭send前判isOpen()并捕获异常连接偶发1006关闭代理超时、容器空闲超时调大readTimeout配合心跳本地能连线上连不上跨域Origin限制、WSS证书问题检查allowedOrigins和TLS配置5.3 Nginx与跨域配置长连接能不能长一半看它不管是什么技术栈只要线上前面挂着NginxWebSocket想稳定工作Nginx至少要长成下面这样location /ws/ { proxy_pass http://backend_servers; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_set_header Host $host; proxy_read_timeout 60s; proxy_buffering off; }这里每个配置都有讲究。proxy_http_version 1.1是为了让Upgrade机制可用HTTP/1.0没有升级WebSocket的能力proxy_set_header Upgrade $http_upgrade和proxy_set_header Connection upgrade是核心Nginx默认会重建和后端之间的Connection头不显式设置这个头会被吞掉后端根本不知道你想升级协议proxy_read_timeout是Nginx对上游响应的读超时如果这个值比心跳间隔还短连接会被代理层提前杀掉常见的坑是把proxy_read_timeout保留默认60秒心跳周期却设成90秒结果连接每60秒断一次非常经典proxy_buffering off防止代理缓冲对实时性造成干扰。跨域方面Spring的setAllowedOrigins(*)开发模式随便用生产环境建议改成明确域名列表。WebSocket协议本身没有跨域限制这么一说但浏览器发连接时会带Origin头服务端可以通过校验Origin拒绝非法来源的握手请求防止恶意网页在用户登录状态下偷偷往你的服务端建立WebSocket连接。这个风险叫CSWSHCross-Site WebSocket Hijacking类似CSRF但危害面更大因为它能直接拿到推送通道。最简单的防御就是握手拦截器里检查Origin白名单同时鉴权信息不要只依赖Cookie尽量通过token在握手时校验。集群部署时还注意负载均衡策略。如果负载均衡用的是随机轮询用户每次断线重连可能落到不同节点节点间的Session不共享消息就找不到人。生产环境配合IP Hash或一致性哈希能保证同一用户尽量落在同一节点提升连接稳定性。写到这里把这几年的WebSocket实战经验基本都掏干净了。我个人最大的体会是WebSocket真正难的从来不是把那几个回调方法写出来而是把它放进真实系统后要面对连接管理、并发控制、代理超时、集群广播这些看似琐碎的细节。上面这些坑几乎都在联调或压测时真实踩过。如果你正准备在项目里引入WebSocket我的建议是从一个小房间聊天室跑通然后再逐步加心跳、鉴权、离线消息和集群广播每加一层就对照一遍文里的细节能少走很多弯路。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →