ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java WebSocket API JSR-356详解:从握手到心跳的实战指南

Java WebSocket API JSR-356详解:从握手到心跳的实战指南 做Java后端的同学只要项目里冒出过“实时推送”“在线聊天”“行情订阅”“协同编辑”这类需求最后基本都会绕到JSR-356和Java WebSocket API这两组词上。JSR-356是Java社区统一WebSocket服务端接口的标准早在Java EE 7时代就纳入标准体系Tomcat、Jetty、Undertow、WildFly等主流容器全都内置了实现。最大的好处就是你写的ServerEndpoint代码搬到任何容器里都能跑不用绑定某家厂商的特殊API。这篇博客我按实战链路把JSR-356拆开讲依赖坐标、hello端点、Session和消息收发、编解码器、握手鉴权、Spring Boot集成最后是我在真实项目里踩过的断连和并发写坑。适合刚接触WebSocket服务端的同学也适合已经在用但遇到诡异问题的朋友。看到“Java WebSocket APIJSR-356详解”这个标题我先说结论这套API本身不复杂真正的坑全在容器版本、线程模型和连接生命周期上。把这三条主线理清剩下的都是体力活。1. 项目到底要解决什么从轮询到长连接的本质变化1.1 HTTP轮询和长轮询为什么让人头疼WebSocket出现之前服务端要主动给网页推消息用的基本都是轮询。每次前端setInterval发请求问“有没有新消息”服务端不管有没有都要回一个完整HTTP响应。一次没数据还好几十上百个客户端同时轮询QPS直接被无关请求翻倍HTTP头那几百字节的固定开销也跟着放大服务器压力一大半花在“问候”本身。长轮询稍微聪明点让请求挂在服务端等到有数据再返回但挂住的请求在Servlet 3.0以前的线程模型里会占住一个线程连接多了线程池很容易被打满。而且链路只要有一层超时长轮询就会断掉重新来客户端还要处理重新挂载的时序问题。WebSocket的本质区别是先通过HTTP完成一次101 Upgrade握手之后双方在同一个TCP连接上双向收发帧不用再重复请求-响应。这就好比寄信和打电话的区别。轮询是每次问一句收一句WebSocket是接通之后直接对话服务端能主动开口客户端也能随时打断。1.2 JSR-356规范到底规定了哪些东西JSR-356的全称是Java API for WebSocket是JCP制定的标准。它规定的核心组件包括ServerEndpoint注解定义服务端端点、Endpoint抽象类对应编程式开发、Session代表一条连接会话、MessageHandler处理消息、RemoteEndpoint负责发送消息、Encoder/Decoder负责消息体和Java对象互转以及ServerEndpointConfig来承载端点配置和握手细节。规范把开发方式分成注解式和编程式两种。注解式适合大多数业务场景一个类加上ServerEndpoint写好OnOpen、OnMessage、OnClose、OnError四个生命周期方法端点就完成了。编程式适合需要大量复用、动态注册、或者要在代码里灵活拼装端点的场景。容器层面Tomcat 7之后陆续完整支持JSR-356Jetty 9、Undertow 1.0同样内置Spring Boot嵌入式容器也能直接用。这里有个版本坑必须提前说老规范的坐标是javax.websocket-api 1.1对应Java EE 7/8、Tomcat 9、Spring Boot 2Jakarta EE 9之后包名全部从javax变成jakartaTomcat 10对应jakarta.websocket-api 2.x。如果你在Tomcat 10上用javax.websocket的注解容器压根不认编译期不会报错运行期没有任何效果。1.3 什么项目才适合引入WebSocket页面聊天、实时通知、协同编辑、实时看板、行情推送、棋牌对战、移动端消息推送这些场景都值得用WebSocket。判断标准就两条数据更新频率高不高服务端需不需要主动发起通信。如果只是每分钟拉一次这种低频场景用HTTP轮询成本反而更低没必要引入长连接管理、心跳、断线重连这些额外复杂度。另一个要提前想清楚的点是影响范围WebSocket长连接会占用服务端资源更久连接数上限、内存占用、网关超时、用户断线重连都要在架构设计时就考虑进去。单机连接数、消息吞吐、发布扩容时老连接的平滑迁移这些都是“能用”和“好用”之间的鸿沟。下面前几节先解决“能用”最后一节再聊资源边界。2. 工程准备依赖坐标、容器选型与第一个能跑的端点2.1 依赖坐标与容器版本对照如果你是用普通war包部署到已有Tomcat/Jetty环境Maven里只需要引入API规范包并且scope用provided让容器提供实现类。Java EE 8时代的标准写法是dependency groupIdjavax.websocket/groupId artifactIdjavax.websocket-api/artifactId version1.1/version scopeprovided/scope /dependency如果是Spring Boot 3.x或Tomcat 10的环境要用Jakarta坐标dependency groupIdjakarta.websocket/groupId artifactIdjakarta.websocket-api/artifactId version2.1/version scopeprovided/scope /dependency为什么scope用provided因为实现类比如Tomcat的WsWebSocketContainer由容器自己提供如果硬塞一个vendor实现进去很可能和容器自带版本冲突运行时出现ClassCastException或者MethodNotFound。这个报错非常阴间查半天发现是依赖打架。Spring Boot场景更省事直接引starterdependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-websocket/artifactId /dependency这个starter会把内嵌容器的WebSocket实现和spring-websocket模块都带进来API坐标它自己管理不需要你再手动加javax或jakarta的api包。表格整理一下使用场景部署方式推荐依赖包名典型版本外部Tomcat 9javax.websocket-api 1.1javax.websocketJava EE 8外部Tomcat 10jakarta.websocket-api 2.xjakarta.websocketJakarta EE 9Spring Boot 2.xspring-boot-starter-websocketjavax.websocketTomcat 9Spring Boot 3.xspring-boot-starter-websocketjakarta.websocketTomcat 10/112.2 写第一个能够跑通的WebSocket服务端不搞复杂例子先写一个Echo端点客户端发什么回什么。这个示例能把JSR-356四个核心注解全部覆盖到ServerEndpoint(/echo) public class EchoEndpoint { OnOpen public void onOpen(Session session) { System.out.println(连接建立sessionId session.getId()); } OnMessage public String onMessage(String message) { return echo: message; } OnClose public void onClose(Session session, CloseReason reason) { System.out.println(连接关闭 reason.getReasonPhrase()); } OnError public void onError(Session session, Throwable t) { t.printStackTrace(); } }这里要理解一个JSR-356的关键设计默认情况下容器为每个WebSocket连接创建一个新的Endpoint实例。这和服务端单例bean的思路完全不同所以一个类的字段不能用来存跨连接共享的数据后面讲Spring集成时这个坑还会放大。部署到Spring Boot内嵌容器时不能直接跑因为JSR-356的ServerEndpoint注解默认不会被Spring Boot注册需要显式声明一个ServerEndpointExporter的Bean。后面Spring Boot章节会专门说。如果是传统war包扔到外部Tomcat把类放好就能被容器扫描到。2.3 用浏览器和JMeter快速验证最直接的验证方式就是浏览器控制台。开F12切到Console执行const ws new WebSocket(ws://localhost:8080/echo); ws.onopen () console.log(open); ws.onmessage (e) console.log(recv:, e.data); ws.onclose (e) console.log(close:, e.code, e.reason);然后执行ws.send(hello)如果能看到服务端返回“echo: hello”说明端点通了。这个验证方法不依赖任何工具前后端对不上时第一步就用它能立刻区分是服务端问题还是客户端问题。如果需要做简单的并发压测JMeter要装WebSocket Sampler插件。打开JMeter的Plugins Manager搜索“WebSocket Samplers by Peter Doornbosch”装上重启就能在Sampler里看到WebSocket Open Connection、WebSocket Request Response这些取样器。老项目里常见的jpgc插件也能用但新版本建议直接用Peter Doornbosch维护的版本协议支持更完整。注意JMeter发ws请求时地址一定要写完整格式ws://host:port/path路径和ServerEndpoint里的值严格一致少个斜杠都会握手失败。3. Session与消息收发整个API的命脉3.1 Session到底该怎么管Session是JSR-356里最重要的运行时对象它代表一条已经握手的WebSocket连接。每个连接都有唯一的Session实例通过getId可以拿到唯一标识用isOpen判断连接是否还活着。Session还挂着两个发送入口getBasicRemote返回同步发送对象getAsyncRemote返回异步发送对象。这里有几个容易踩的点Session不是绝对线程安全的尤其是RemoteEndpoint的并发写不加控制会出现状态机冲突报错信息是TEXT_FULL_WRITING之类。getOpenSessions方法虽然能拿到同端点所有连接但不同容器的行为有差异有的容器只返回当前JVM且同路径下的Session跨集群根本不可靠。做在线用户列表一定要自己维护连接集合。Session的setMaxIdleTimeout要显式设置不同容器默认值不一样有的默认60秒空闲就断有的默认不超时这直接决定连接稳定性和服务器资源占用。服务端保存会话集合的标准姿势是使用CopyOnWriteArraySet因为WebSocket场景是“连接建立/断开的写操作少消息广播的读操作多”刚好匹配它的适用场景public class SessionRegistry { private static final CopyOnWriteArraySetSession SESSIONS new CopyOnWriteArraySet(); public static void add(Session session) { SESSIONS.add(session); } public static void remove(Session session) { SESSIONS.remove(session); } public static void broadcast(String message) { for (Session session : SESSIONS) { if (session.isOpen()) { session.getBasicRemote().sendText(message); } } } }3.2 OnMessage的参数形态和消息分发OnMessage注解承载了JSR-356最主要的入站消息逻辑方法参数决定了消息类型。可以接收的类型包括String、byte[]、ByteBuffer、Reader、InputStream、PongMessage以及配置了Decoder的自定义Java对象。服务端不从方法签名判断“文本还是二进制”而是看参数类型String和Reader是文本消息byte[]和InputStream是二进制消息。有一点容易被忽略OnMessage方法不一定只能处理一个参数。它还可以加上PathParam、QueryParam这些注解参数用多个参数接收路径变量和其他信息。如果方法返回值不是void那么返回值会自动作为响应发回给客户端返回String、byte[]、ByteBuffer这些基础类型容器会直接发送。实际项目里我建议不要过度依赖“方法返回即响应”这个简洁写法。原因有二一是业务里经常需要“收到消息后先落库、再回复、再给第三方发通知”返回值的时机太早二是如果返回了一个非法状态异常处理会变成黑盒。把回复逻辑明确写在方法体里用session.getBasicRemote()或getAsyncRemote()自己控制发送时机排查问题会清晰很多。3.3 编解码器直接在方法里接收领域对象JSR-356最讨喜的一个功能就是Encoder/Decoder。服务端方法可以写成public void onMessage(ChatMessage msg)容器自动把JSON字符串反序列化成ChatMessage对象回复时也能直接返回对象由Encoder序列化后再发出去。一个典型的Decoder实现长这样public class ChatMessageDecoder implements Decoder.TextChatMessage { Override public ChatMessage decode(String s) { // 这里做反序列化Jackson、Fastjson2都行 return JSON.parseObject(s, ChatMessage.class); } Override public boolean willDecode(String s) { // 返回true表示愿意处理这条消息 return s ! null s.startsWith({); } Override public void init(EndpointConfig config) {} Override public void destroy() {} }对应Encoderpublic class ChatMessageEncoder implements Encoder.TextChatMessage { Override public String encode(ChatMessage message) { return JSON.toJSONString(message); } }然后在端点注解上声明ServerEndpoint( value /chat, encoders ChatMessageEncoder.class, decoders ChatMessageDecoder.class ) public class ChatEndpoint { }注意每个WebSocket连接建立时容器都会为OnMessage方法创建新的Decoder实例每次发送对象时容器也会创建新的Encoder实例。规范明确不允许也不推荐在Encoder/Decoder里保存成员状态因为实例生命周期完全由容器控制你没法保证复用。JSON序列化工具选择上用Fastjson2或Jackson都可以但强烈建议只用一种别在Decoder里引一个序列化框架、在业务代码里又引另一个排查字符集问题时会疯。4. 握手阶段和编程式端点从入门到进阶4.1 用Configurator在握手时完成鉴权WebSocket的握手本质是一次HTTP请求升级所以很多需要“先验证再建立连接”的业务逻辑最适合放在握手阶段处理。JSR-356提供了ServerEndpointConfig.Configurator这个扩展点核心方法就是modifyHandshake。在modifyHandshake里可以拿到HandshakeRequest里面包含HTTP头、查询参数、Cookie等原始信息。常见的token校验就这么做public class MyConfigurator extends ServerEndpointConfig.Configurator { Override public void modifyHandshake(ServerEndpointConfig config, HandshakeRequest request, HandshakeResponse response) { // 从查询参数里拿token实际项目里也常见放在Authorization头 String token request.getParameterMap() .getOrDefault(token, List.of()) .get(0); if (!isValidToken(token)) { response.setStatus(403); return; } // 认证通过后把用户信息放进配置属性后续onOpen里能取到 config.getUserProperties().put(userId, getUserId(token)); } }然后在ServerEndpoint里指定configuratorServerEndpoint(value /ws, configurator MyConfigurator.class)为什么这个设计合理因为握手失败根本就不会建立连接客户端拿不到101响应后续onOpen、onMessage都不会执行。比“建立连接后在onOpen里判断一下再close”要干净得多也省了一次无效连接的资源开销。还有一个细节modifyHandshake里设置的HTTP响应状态码客户端只能看到握手失败看不到具体错误体HTTP协议在这个阶段没有给业务自定义错误响应体的余地。所以前端要做友好的错误提示一般是通过特定状态码客户端映射来区分别指望像REST接口那样返回一段JSON错误信息。4.2 路径参数与多房间路由的实战写法实际业务很少只有一个聊天室JSR-356支持在ServerEndpoint里写路径模板用{param}占位然后在方法参数上用PathParam取ServerEndpoint(/chat/{roomId}) public class RoomEndpoint { OnOpen public void onOpen(Session session, PathParam(roomId) String roomId) { // 用roomId把同一个房间的Session都存到一个Map里 } OnMessage public void onMessage(String message, PathParam(roomId) String roomId, Session session) { // 只给当前房间广播而不是全局广播 } }这里有个隐藏约束路径参数默认不能包含斜杠。如果你把roomId设计成“a/b”这种层级需要容器配置允许未转义的斜杠很多容器默认不支持。更稳妥的做法是路径参数只用简单ID复杂参数全部放查询字符串或消息体里。多房间路由的核心数据结构建议用ConcurrentHashMapString, CopyOnWriteArraySetSessionkey是roomIdvalue是该房间的连接集合。注意OnMessage方法里直接操作Session集合进行遍历时不要边遍历边删除连接要么用CopyOnWriteArraySet自带的弱一致性迭代器要么在广播前先把当前房间的连接拷贝一份再遍历。4.3 编程式Endpoint更灵活的兜底方案注解式端点写起来很舒服但有些场景它不够灵活比如运行时才决定注册哪些路径、需要复用同一个类处理不同路径、或者要在代码里动态组装消息处理器。这时候就要用JSR-356的编程式API。编程式端点是继承javax.websocket.Endpoint或jakarta.websocket.Endpoint抽象类实现onOpen、onClose、onError方法消息处理通过Session.addMessageHandler注册public class MyEndpoint extends Endpoint { Override public void onOpen(Session session, EndpointConfig config) { session.addMessageHandler(new MessageHandler.WholeString() { Override public void onMessage(String message) { System.out.println(收到消息 message); } }); } }注册端点也不再依赖注解扫描而是在ServletContextListener里拿到ServerContainer手动addEndpointWebListener public class WsInitializer implements ServletContextListener { Override public void contextInitialized(ServletContextEvent sce) { ServerContainer container (ServerContainer) sce.getServletContext() .getAttribute(ServerContainer.class.getName()); container.addEndpoint( ServerEndpointConfig.Builder .create(MyEndpoint.class, /dynamic) .build()); } }这个写法的好处是路径可以在代码里拼装比如根据数据库配置动态生成一批端点路径。代价是要自己管理Session、自己注册MessageHandler样板代码比注解式多。我的建议是能用注解式就别用编程式但是必须知道编程式存在因为你很难保证不遇到需要动态注册端点的需求哪怕几年才用上一次关键时刻它是最好的兜底方案。5. Spring Boot集成和消息群发把端点纳入Spring体系5.1 引入starter并把端点交给Spring托管Spring Boot项目里用JSR-356要做两件事第一步引入前面说的spring-boot-starter-websocket第二步显式声明ServerEndpointExporter这个Bean否则ServerEndpoint不会注册。配置类很简单Configuration public class WebSocketConfig { Bean public ServerEndpointExporter serverEndpointExporter() { return new ServerEndpointExporter(); } }这个Bean的作用是扫描Spring容器里的ServerEndpoint注解类调用ServerContainer的addEndpoint注册到内嵌容器。如果你是把war扔到外部Tomcat部署一般不需要这个Bean因为外部容器自己会做注解扫描。到这里还没完。更坑的是依赖注入。JSR-356端点类默认由容器通过无参构造创建不走Spring的Bean生命周期所以你在端点类里写的Autowired直接是null。最常见的解法是Spring提供的SpringConfigurator把端点的实例创建权交给SpringServerEndpoint(value /chat, configurator SpringConfigurator.class) public class ChatEndpoint { private final ChatService chatService; Autowired public ChatEndpoint(ChatService chatService) { this.chatService chatService; } }SpringConfigurator每次连接都会从ApplicationContext里拿Bean依赖注入自然就生效了。这个方案比“静态字段Autowired setter”那种老写法干净太多强烈建议新项目直接用SpringConfigurator。5.2 群发实现与并发容器的选择聊天室、广播通知的核心就是群发。JSR-356没有内置广播API大家自己维护Session集合。前面给过CopyOnWriteArraySet的写法这里再说几个实战细节在onOpen里add在onClose里remove一定成对出现。如果忘记remove连接关闭后集合里还留着无效Session广播时isOpen判断为false但集合越来越大内存泄漏就是这么来的。广播遍历时单条消息发送失败不能影响其他人要逐条try-catch把失败的连接收集起来最后统一remove。如果需要按用户维度定向推送用ConcurrentHashMapString, Session维护userId到Session的映射断线重连时要处理同一个userId新Session覆盖旧Session的问题旧连接要么主动close要么标记失效。群发性能方面同步发送getBasicRemote在连接多了以后性能一般因为每条消息都要等TCP写缓冲区排空。建议吞吐优先的场景用getAsyncRemote()发送方法不阻塞调用线程配合SendHandler在发送完成后做回调session.getAsyncRemote().sendText(message, new SendHandler() { Override public void onResult(SendResult result) { if (!result.isOK()) { // 处理发送失败比如记录日志、移除连接 } } });5.3 空闲超时与心跳方案的落地长连接最怕两件事一是网关或NAT把空闲连接默默回收了二是服务器端认为连接还活着但客户端网络其实已经死了。这两件事靠一个心跳机制就能基本解决。JSR-356的Session提供了setMaxIdleTimeout方法单位是毫秒设置连接最大空闲时间。比如OnOpen public void onOpen(Session session) { session.setMaxIdleTimeout(120_000); // 2分钟没有消息就自动关闭 }注意这个值不能设得太小否则业务消息稍微慢一点就被容器主动掐断。稳妥做法是30秒到2分钟之间结合具体业务选择。更主动的方案是服务端定期发Ping控制帧。客户端收到Ping后WebSocket协议要求自动回Pong不需要业务代码处理。服务端可以用一个定时任务给所有Session发PingScheduledExecutorService scheduler Executors.newSingleThreadScheduledExecutor(); scheduler.scheduleAtFixedRate(() - { for (Session session : SessionRegistry.getAll()) { if (session.isOpen()) { try { session.getBasicRemote().sendPing(ByteBuffer.wrap(new byte[]{1})); } catch (IOException e) { // 发送失败说明连接已经断了交给onClose清理 } } } }, 30, 30, TimeUnit.SECONDS);如果不想用控制帧也可以走“业务心跳”的方案客户端每隔30秒主动发一条{type:ping}服务端收到后回{type:pong}然后利用收到消息的时间戳刷新Session的最近活跃时间。业务心跳的额外好处是能顺带验证消息编解码链路是否正常所以我个人偏向业务心跳为主、服务端Ping为辅的组合拳。6. 线上踩坑记录断连、并发写与App连不上的真相6.1 “Stream disconnected before completion”到底是什么问题这个报错在热词里出现了我单独说一下。它的字面意思是“流在完成之前就被断开了”典型场景是客户端用非阻塞IO或异步客户端发起WebSocket请求服务端或中间链路在握手或消息传输没完成时把TCP连接给关了。服务端这边最常见的原因有三个。第一个是空闲超时。如果你的网络链路里有一个比服务端更早“放弃”的节点比如SLB、nginx、云防火墙它们的连接空闲阈值比服务端Session的MaxIdleTimeout更短数据一段时间不流动连路就被最外层设备悄悄回收了。排查方法是把两边的超时配置拉出来对一遍服务端设120秒空闲网关默认60秒那结果一定是网关先断。第二个是服务端线程阻塞。OnMessage里的业务处理如果长时间不返回容器处理WebSocket的线程池被占满后续消息没法及时读取连接会慢慢堆积直到被判定异常。这个问题在压测时特别明显解决办法是耗时的IO操作丢到独立线程池别占着容器的WebSocket处理线程。第三个是SSL握手阶段的问题。wss连接握手时证书链不完整或TLS版本不匹配客户端底层可能在读TLS握手响应时发现连接被重置抛出这个异常。排查时先关掉TLS直接用ws://试能连上就说明问题在证书或TLS配置上。排查“谁断了连接”最直接的方法是抓包。服务端用tcpdump客户端用Wireshark看FIN包是从哪个方向发出的。谁发FIN谁就是主动方这个定位思路比瞎猜配置高效得多。6.2 并发发送时的TEXT_FULL_WRITING问题真实场景里经常出现这种报错The remote endpoint was in state [TEXT_FULL_WRITING]还有可能看到NEXT_PENDING_SEND_NOT_ALLOWED。原因是同一个Session同时被多个线程调用sendTextWebSocket底层发送状态机不允许同时有两个写操作在进行第二个写直接抛异常。这个问题最容易出现的场景是“用户同时在多个终端登录每条消息都要广播给所有连接”或者“一个Session既被业务线程用来发消息又被心跳定时器拿来发心跳”两边抢同一个RemoteEndpoint谁都没加锁。解决思路有三个。最简单的方案是统一使用getAsyncRemote()异步发送发送时机由容器内部管理能避免大部分并发写异常。但异步发送也有顺序问题如果业务要求消息严格按照发送顺序达到客户端异步模式下要靠SendHandler或Future时序来控制比较麻烦。更可控的方案是给每个Session配一个发送锁把同一连接的所有发送操作串行化private final ConcurrentHashMapString, ReentrantLock sendLocks new ConcurrentHashMap(); private void safeSend(Session session, String message) { ReentrantLock lock sendLocks.computeIfAbsent( session.getId(), k - new ReentrantLock()); lock.lock(); try { if (session.isOpen()) { session.getBasicRemote().sendText(message); } } finally { lock.unlock(); } }这个方案性能损失很小但彻底解决了状态冲突问题。我项目里的经验是对外广播用异步订单状态这类强顺序消息用加锁同步发送。两个模式都保留按消息场景切换比单一方案更容易调出理想效果。6.3 H5能连、App连不上的几个常见根因“websocket运行到H5可以连接打包为App连接不了”这个现象我在多个项目里都遇到过。浏览器能连说明服务端本身、网络通路、端口都是通的问题基本出在App端的环境差异上。第一个根因是Android 9及以上默认禁止明文流量。如果你的连接地址是ws://开头的明文WebSocketApp默认会直接拒绝而浏览器因为是明文的兼容环境可能放行。解决办法要么改用wss://加密连接要么在AndroidManifest里配置networkSecurityConfig允许特定域名使用明文流量。第二个根因是证书信任问题。App如果用了自签名证书或私有CA签发的wss证书原生网络栈会严格校验失败后表现成“连接不了”但浏览器内核因为已经安装了信任证书所以能连。排查方式是用一个正式证书先试如果正式证书能通基本就是证书链问题。第三个根因经常被忽略AndroidManifest里没加INTERNET权限。开发环境的H5跑在电脑浏览器中当然没问题打包成App后没权限访问网络任何连接都是失败的。这个错误非常基础但越基础的错误越容易在忙乱中被漏掉。第四个根因是WebView场景下的混合内容限制。如果App里是WebView加载的H5页面页面通过https加载但WebSocket用了ws://或者http资源浏览器引擎会按混合内容策略拦截H5在外部浏览器能连在WebView内部反而不行。这时候要在WebSettings里配置混合内容模式或者统一走wss。排查App连接问题我有个固定顺序先看日志里是DNS解析失败、TCP连接失败还是TLS握手失败然后逐层解决。这个顺序能帮你快速定位是权限问题、网络问题还是证书问题而不是拿着代码猜。7. 问题排查速查表和资源边界预估7.1 常见报错与现象对照表把实际工作中碰到的问题整理成一个表格方便大家遇到类似情况直接定位现象 / 报错直接原因优先排查项连接建立后很快被断开空闲超时配置太短检查Session.setMaxIdleTimeout和SLB/nginx超时时间Stream disconnected before completion: failed to send websocket requestTCP在请求完成前被断开抓包定位是谁先发FIN/RST逐层调整超时The remote endpoint was in state [TEXT_FULL_WRITING]同一Session并发发送给Session加锁或统一用异步发送Connection closed: 1006 Abnormal Closure连接异常关闭无正常Close帧查服务端onError、容器线程池、进程是否重启ServerEndpoint里Autowired为null端点实例不是Spring创建的换成SpringConfigurator前端ws连接返回404端点路径没对上或没注册检查路径和ServerEndpointExporter是否配置App连不上但H5能连权限/明文流量/证书问题先加INTERNET权限再查ws还是wss最后查证书链7.2 连接数上限、缓冲区与线程池预估长连接服务上线前最好先估算容量。一条WebSocket连接在服务端的内存占用大致是Session对象本身、TCP内核收发缓冲区、以及你自己的会话集合引用。Tomcat默认情况下每条连接的内存开销在几KB到几十KB之间单机撑几万到十几万连接是常见水平但前提是消息频率不高。连接数上限往往不是内存先爆而是文件描述符先爆。Linux上limits.conf里的nofile、系统级file-max、以及容器内ulimit都要调。默认1024的进程fd上限开超过一千个连接就直接报Too many open files。这个坑每个上线长连接服务的人基本都会踩一次提前调整可以省掉很多半夜日志轰炸。消息大小方面Session.setMaxTextMessageBufferSize控制单个文本消息的最大字节数默认值各容器不同有8KB也有其他值。聊天业务够用但要传大JSON或base64图片就要调大超过限制的帧会被容器直接判定为异常并关闭连接。线程池方面要理解WebSocket的处理模型握手完成后连接不占用Servlet请求线程但OnMessage里的代码会占用容器为WebSocket连接分配的处理线程。如果消息处理里有数据库查询、调用外部接口这类耗时操作建议丢到独立线程池避免拖慢同一个容器里其他连接的读写。最后关于这个话题想多说一句JSR-356本身就是一个“够用但不完全体”的规范它把基础协议封装得很简洁但分布式、多租户、断线续传这些高级话题它不管。很多项目最终还是要引入更上层的框架不过底层原理搞清楚之后你会发现那些框架做的事情本质上还是Session管理、消息路由、心跳保活、并发控制这几件事。把这几件事吃透你的WebSocket代码不管叠在哪一层框架上都不会太被动。就我个人而言凡是新上线的WebSocket端点我都强制自己先做三件事显式设置最大空闲时间、消息缓冲区大小把耗时的业务逻辑从OnMessage里拆出去然后补上一套业务心跳。做完这三件事再谈联调和优化线上故障率能降一大截。
返回列表