SpringBoot用Jetty实现WebSocket前后端通信实战 写这篇东西的起因挺简单手头一个项目需要在线提示和实时消息推送技术选型时没有用默认的Tomcat而是把SpringBoot的内置容器换成了Jetty。于是就有了“SpringBoot使用Jetty内置的WebSocket实现简单前后端通信”这个组合。折腾一圈下来发现Jetty的WebSocket支持比想象中靠谱但坑也确实比Tomcat多尤其是原生端点的注册机制和空闲超时行为网上的资料大多都是Tomcat视角直接照搬很容易翻车。这篇文章把我自己的实现路径、代码、排查过程都整理了一遍适合正在用SpringBootJetty、想快速加一个前后端长连接的读者参考。1. 整体设计与方案选型1.1 为什么用Jetty而不是默认TomcatSpringBoot默认内置的是Tomcat大多数教程也都是基于Tomcat写的。之所以换成Jetty主要还是看中它在嵌入式场景下的几个特点体积更小、启动更快、内存占用更低。尤其在做网关类、小规模微服务、边缘服务的时候Jetty的轻量优势很明显。WebSocket方面Jetty本身是JSR-356规范的很早期参与者对WebSocket协议的支持甚至比Tomcat更纯粹长连接场景下的表现并不差。从实际运维角度看SpringBoot更换容器成本极低本质上就是调一下依赖的事。但要注意一个容易被忽略的点SpringBoot 2.x和3.x对应的Jetty主版本不一样Servlet规范包名也不同。3.x之后统一走jakarta.servlet命名空间依赖版本如果和SpringBoot版本对不上会出现启动时类冲突或者ClassNotFoundException。建议直接用SpringBoot BOM管理Jetty版本不要手动写版本号。维度默认TomcatJetty嵌入式启动速度一般更快内存占用偏高较低WebSocket支持JSR-356完整JSR-356完整另有原生API版本命名空间SpringBoot3.x也是jakarta需要和SpringBoot版本匹配典型场景传统Web应用嵌入式网关、小服务、边缘节点1.2 实现WebSocket的两条技术路线在SpringBootJetty环境下实现WebSocket实际有两条路可以走我建议先用第一条。第一条是Spring封装的抽象层核心是WebSocketConfigurer加WebSocketHandler。它的好处是跟具体容器解耦换回Tomcat或者Undertow都不需要改业务代码而且握手拦截器、消息处理、会话管理都包装得比较好写起来很顺手和Spring生态的集成也最自然。第二条是Jetty原生WebSocket API。Jetty自己提供了一套注解和Servlet类比如WebSocket、JettyWebSocketServlet。这套方案的性能理论上更直接也能用Jetty特有的某些能力但侵入性更强代码绑定Jetty容器换容器就得重写维护成本高一些。我最终采用的方案是Spring抽象层加Jetty容器。理由很简单项目还要继续迭代不想被某个容器绑死。如果你就是铁了心只用Jetty原生API也不是不行但建议把WebSocket处理逻辑和容器API隔离开别到处散落Jetty类型。2. 环境准备与依赖配置2.1 引入Jetty依赖并剔除Tomcat用Maven做这一步非常容易。核心思路是在spring-boot-starter-web里排除Tomcat然后显式引入spring-boot-starter-jetty。同时还需要加一个spring-boot-starter-websocket注意这个依赖本身不绑定容器只提供JSR-356 API和Spring WebSocket的封装底层实际用哪个容器的WebSocket引擎取决于你内置的Servlet容器是谁。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId exclusions exclusion groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-tomcat/artifactId /exclusion /exclusions /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-jetty/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-websocket/artifactId /dependency这里有个细节值得说明如果项目里还有其他依赖间接引入了Tomcat只做上面的排除可能不够启动时容易同时出现Tomcat和Jetty的类甚至会因为SpingBoot的自动配置类扫描顺序问题导致端口冲突。稳妥的做法是加了Jetty依赖后再全局搜一遍依赖树用mvn dependency:tree -Dincludestomcat-embed-core确认没有Tomcat残留。2.2 关键配置项与版本注意切换Jetty后有几个配置需要确认。首先是application.properties里的端口和上下文路径这个和容器无关但上下文路径会直接影响WebSocket的握手URL后面联调时要格外注意。server.port8080 server.servlet.context-path/api # Jetty WebSocket 空闲超时默认300000ms这里改短一点方便观察心跳 server.jetty.websocket.idle-timeout120000第二个注意点是SpringBoot版本。以SpringBoot 3.x为例它用的Jetty 12在WebSocket实现上做了一次比较大的调整如果你在网上搜索资料会发现很多老文章还在用Jetty 9时代的org.eclipse.jetty.websocket.WebSocketServlet这些代码在SpringBoot 3.x下是编译不过的。搜资料碰到这类旧API最好直接跳过。第三个注意点是SpringBoot 3.1之后setAllowedOrigins已经标记为废弃推荐用setAllowedOriginPatterns。前者在配置通配符时有严格限制后者支持*和更有表达力的模式匹配。这个变化在本地前后端分离开发时非常关键因为前端源码运行在另一个端口浏览器携带的Origin和WebSocket服务端口不一致不放开跨源限制握手会被直接拒绝。3. 后端核心实现与原理拆解3.1 配置类注册Handler与握手拦截器Spring WebSocket的核心入口是实现了WebSocketConfigurer的配置类。我在这里注册了连接路径/ws/chat挂上自定义的握手拦截器并放开跨域来源限制。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 { private final ChatHandler chatHandler; private final AuthHandshakeInterceptor authHandshakeInterceptor; public WebSocketConfig(ChatHandler chatHandler, AuthHandshakeInterceptor authHandshakeInterceptor) { this.chatHandler chatHandler; this.authHandshakeInterceptor authHandshakeInterceptor; } Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(chatHandler, /ws/chat) .addInterceptors(authHandshakeInterceptor) .setAllowedOriginPatterns(*); } }注意这个配置类的写法构造器注入两个BeanSpringBoot自动完成装配不需要写Autowired。addInterceptors的作用是在握手阶段插入自定义逻辑比如校验token、提取用户ID。这里最核心的一个点在于WebSocket第一次连接时走的仍然是HTTP协议GET /ws/chat会带上一堆请求头只要服务器返回101 Switching Protocols后续才升级为WebSocket长连接。握手拦截器恰恰就工作在这个“即将升级但还没升级”的瞬间也是唯一能便捷获取HTTP请求参数和Header的阶段。关于setAllowedOriginPatterns(*)本地开发时确实省事但生产环境我不建议这么放飞。更合理的做法是配置成实际的前端域名比如https://admin.example.com防止其他站点利用用户浏览器主动连接你的WebSocket服务。3.2 文本消息处理器连接、收发、关闭消息处理器是整条链路的业务心脏。我用一个ConcurrentHashMap保存在线会话key为用户IDvalue为WebSocketSession。为什么用并发Map而不是普通HashMap因为WebSocket建连、断开和消息处理发生在不同线程普通Map在高并发下会出并发修改异常这是很基础的并发安全问题但确实在不少项目里看到有人忽略了。import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.springframework.web.socket.CloseStatus; import org.springframework.web.socket.TextMessage; import org.springframework.web.socket.WebSocketSession; import org.springframework.web.socket.handler.TextWebSocketHandler; import java.io.IOException; import java.util.HashMap; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; Slf4j Component public class ChatHandler extends TextWebSocketHandler { private static final MapString, WebSocketSession ONLINE_SESSIONS new ConcurrentHashMap(); private final ObjectMapper objectMapper new ObjectMapper(); Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { String userId (String) session.getAttributes().get(userId); ONLINE_SESSIONS.put(userId, session); MapString, Object onlineMsg new HashMap(); onlineMsg.put(type, online); onlineMsg.put(userId, userId); onlineMsg.put(total, ONLINE_SESSIONS.size()); broadcast(new TextMessage(objectMapper.writeValueAsString(onlineMsg)), null); log.info(用户 {} 建立连接当前在线 {}, userId, ONLINE_SESSIONS.size()); } Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { String payload message.getPayload(); JsonNode node objectMapper.readTree(payload); String type node.path(type).asText(unknown); if (ping.equals(type)) { session.sendMessage(new TextMessage({\type\:\pong\})); return; } String from (String) session.getAttributes().get(userId); String to node.has(to) ? node.get(to).asText() : null; String content node.path(content).asText(); MapString, Object resp new HashMap(); resp.put(type, type); resp.put(from, from); resp.put(to, to); resp.put(content, content); resp.put(timestamp, System.currentTimeMillis()); TextMessage respMsg new TextMessage(objectMapper.writeValueAsString(resp)); if (to ! null !to.isEmpty()) { WebSocketSession target ONLINE_SESSIONS.get(to); if (target ! null target.isOpen()) { sendWithLock(target, respMsg); } else { MapString, Object errorMsg new HashMap(); errorMsg.put(type, error); errorMsg.put(content, 用户 to 不在线); session.sendMessage(new TextMessage(objectMapper.writeValueAsString(errorMsg))); } } else { broadcast(respMsg, session); } } Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { String userId (String) session.getAttributes().get(userId); ONLINE_SESSIONS.remove(userId); MapString, Object offlineMsg new HashMap(); offlineMsg.put(type, offline); offlineMsg.put(userId, userId); offlineMsg.put(total, ONLINE_SESSIONS.size()); broadcast(new TextMessage(objectMapper.writeValueAsString(offlineMsg)), null); log.info(用户 {} 断开连接当前在线 {}, userId, ONLINE_SESSIONS.size()); } Override public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception { log.error(用户 {} 传输异常, session.getAttributes().get(userId), exception); if (session.isOpen()) { session.close(CloseStatus.SERVER_ERROR); } } private void broadcast(TextMessage message, WebSocketSession skipSession) { for (WebSocketSession session : ONLINE_SESSIONS.values()) { if (session skipSession) { continue; } sendWithLock(session, message); } } private void sendWithLock(WebSocketSession session, TextMessage message) { synchronized (session) { try { if (session.isOpen()) { session.sendMessage(message); } } catch (IOException e) { log.error(发送消息失败会话 {}, session.getId(), e); } } } }这段代码里有几个设计取舍值得说清楚。第一个是handleTextMessage中先处理ping直接返回pong。这是业务层心跳的约定后面前端部分会对应解释。协议层其实还有真正的Ping控制帧但浏览器JavaScript里的WebSocket对象没有暴露发送协议层Ping帧的API所以前端工程师普遍用业务层假心跳后端配合识别{type:ping}即可。第二个是点对点和广播的消息路由。to字段有值时走点对点投递没有值时当成广播消息。实际业务中广播场景非常多比如系统通知、公告、在线人数变动。在afterConnectionEstablished里我把上下线事件封装成消息广播出去前端就能实时显示“xxx上线了、当前在线N人”这类信息不需要轮询。第三个是sendWithLock。这里用synchronized(session)锁住目标会话原因是同一个WebSocketSession在并发写入时会抛异常这个问题我在后面排查章节还会单独讲。先用会话锁保证同一时刻只有一个线程在写某个连接能解决大部分并发推送问题。3.3 握手拦截器鉴权与参数透传握手拦截器实现了HandshakeInterceptor在WebSocket握手之前执行。在这里可以拿到原始的HttpServletRequest做参数校验、token校验、IP白名单等逻辑。我个人建议把“连接后所需的用户上下文”全部放进attributes它会被传递到WebSocketSession.getAttributes()里这样Handler里就不用再去解析请求参数了。import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; import org.springframework.http.server.ServerHttpRequest; import org.springframework.http.server.ServerHttpResponse; import org.springframework.http.server.ServletServerHttpRequest; import org.springframework.stereotype.Component; import org.springframework.web.socket.WebSocketHandler; import org.springframework.web.socket.server.HandshakeInterceptor; import java.util.Map; Slf4j Component public class AuthHandshakeInterceptor implements HandshakeInterceptor { Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, MapString, Object attributes) { if (request instanceof ServletServerHttpRequest servletRequest) { HttpServletRequest httpServletRequest servletRequest.getServletRequest(); String userId httpServletRequest.getParameter(userId); String token httpServletRequest.getParameter(token); // 实际项目里这里应该校验token推荐使用JWT或服务端缓存我这里只做非空校验 if (userId null || userId.isEmpty() || token null || token.isEmpty()) { log.warn(WebSocket握手失败缺少userId或token); return false; } attributes.put(userId, userId); return true; } return false; } Override public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) { if (exception ! null) { log.error(WebSocket握手异常, exception); } } }握手拦截器是很多人容易忽略的地方实际价值非常大。我在生产项目里用这个环节做过token有效期校验、从Header提取设备类型、根据用户ID做单点登录的顶号处理。有个经验是真正重量的校验逻辑不要放在beforeHandshake里同步去查数据库握手阶段阻塞太久会拖垮WebSocket服务的连接建立速度。正确做法是先做轻量校验比如JWT签名校验或Redis取一次token然后把用户信息放进attributes后续业务逻辑需要更多数据时再做异步加载。还有一个容易踩的坑参数编码。如果是中文或特殊字符浏览器URL上直接拼接参数可能有问题建议前端用encodeURIComponent包一下后端再解码。3.4 消息模型与JSON封装前后端通信现在基本都走JSON二进制消息的场景较少。这个项目里我定义了一个简单的消息协议本质是一个JSON对象包含type、from、to、content、timestamp五个字段。类型约定如下type方向说明chat双向点对点聊天消息broadcast服务端到客户端系统广播online服务端到客户端用户上线通知offline服务端到客户端用户下线通知ping客户端到服务端心跳请求pong服务端到客户端心跳响应error服务端到客户端错误提示在Java侧我用Jackson的ObjectMapper做序列化和反序列化。序列化这块有个小建议不要在Handler里直接拼JSON字符串容易漏转义、写错结构用Map或专门的消息DTO生成JSON既安全又易维护。服务端主动推送时把Map塞进ObjectMapper.writeValueAsString一行代码就能得到合法JSON。如果消息协议越来越复杂建议抽出独立的MessageData记录类字段上使用JsonProperty注解定义字段名方便前后端对齐字段变更。这个原则在多人协作时特别重要WebSocket的消息格式一旦上线前后端改动成本比普通HTTP接口高因为长连接断开和重连的补偿逻辑比较复杂。4. 前端连接与联调实战4.1 原生WebSocket封装与心跳保活前端不引入SockJS和STOMP直接用浏览器原生WebSocket。原因很简单我们走的是Jetty内置WebSocket不依赖STOMP协议层业务消息本身就是自定义JSON加一套STOMP反而多了维护成本。const wsProtocol location.protocol https: ? wss:// : ws://; let ws null; let heartbeatTimer null; let reconnectTimes 0; function connect() { const userId u1001; const token test-token; ws new WebSocket(${wsProtocol}${location.host}/api/ws/chat?userId${userId}token${token}); ws.onopen function () { console.log(WebSocket连接成功); reconnectTimes 0; heartbeatTimer setInterval(() { if (ws.readyState WebSocket.OPEN) { ws.send(JSON.stringify({type: ping})); } }, 15000); }; ws.onmessage function (event) { const msg JSON.parse(event.data); if (msg.type pong) { console.log(收到pong连接正常); return; } if (msg.type online || msg.type offline) { updateOnlineCount(msg.total); return; } if (msg.type chat) { renderMessage(msg); } }; ws.onclose function () { console.log(WebSocket连接关闭); clearInterval(heartbeatTimer); const delay Math.min(30000, 1000 * Math.pow(2, reconnectTimes)); setTimeout(connect, delay); reconnectTimes; }; ws.onerror function (error) { console.error(WebSocket错误, error); ws.close(); }; } function sendMessage(to, content) { if (ws ws.readyState WebSocket.OPEN) { ws.send(JSON.stringify({ type: chat, to: to, content: content })); } else { console.warn(连接未建立消息未发送); } } function closeConnection() { clearInterval(heartbeatTimer); if (ws) { ws.close(); } } connect();心跳间隔为什么设为15秒这是和后端配置的server.jetty.websocket.idle-timeout120000配合的。Jetty默认的空闲超时是300秒如果超过这个时间连接上没有数据交互框架会主动关闭连接。浏览器原生WebSocket没有协议层主动Ping的能力所以才需要业务层定时发{type:ping}保持活跃。间隔设置建议至少比空闲超时短一倍留出网络抖动缓冲。断线重连这里用了指数退避从1秒开始指数增长最大值封顶30秒。这个策略比固定间隔好很多服务端重启的瞬间大量客户端如果同时重连会形成“重连风暴”指数退避能把风暴摊开给服务端留出恢复时间。4.2 浏览器调试技巧与联调流程联调时最常用的调试入口是浏览器开发者工具。Chrome的Network面板里专门有一个WS标签页能看到连接列表、握手请求和实时Frame消息。打开WS标签后发一条消息下面会出现绿色和红色箭头标识的帧分别代表发送和接收。心跳消息会每隔一段时间出现从帧的间隔就能判断心跳链路是否正常。还有一个有用的细节Network面板最上方能看到WebSocket握手请求状态码必须是101。如果看到404先检查路径是否拼接了server.servlet.context-path如果看到403优先怀疑跨域拦截或握手拦截器返回了false如果看到405往往是前端用了HTTP普通请求去访问WebSocket地址或者路径注册和访问的HTTP方法不匹配。我建议把联调流程固定成四步效率会高很多。第一步看HTTP握手请求头确认URL和Origin是否合法第二步看握手响应状态码确认是否101第三步看WS标签里的Frame确认心跳和业务消息是否正常收发第四步看后端控制台日志确认Handler的afterConnectionEstablished方法有没有执行。按这个顺序排查大多数问题都能在几分钟内定位。5. 常见问题与排查技巧实录5.1 握手返回404长连接建立不起来这是最典型的容器切换适配问题。原因通常是WebSocket路径和实际注册路径不一致。如果项目设置了server.servlet.context-path/api那么前端连接地址必须是ws://host/api/ws/chat漏掉/api就会404。检查方法非常简单打开浏览器Network面板找到握手请求看Request URL是不是包含了上下文路径。同时也看一眼控制台有没有“WebSocket connection to ... failed”之类的报错信息报错里会带实际请求的完整URL。还有一个隐藏原因WebSocketHandlerRegistry注册时填的路径是/ws/chat但项目里如果用了其他Servlet过滤器或者路径映射可能把/ws/*拦截了。这类问题在网关类项目里尤其常见比如某些过滤器的urlPatterns写了/*并且对非JSON响应直接做了包装导致握手响应不是标准的101。排查时把自定义过滤器逐个禁用二分法定位。5.2 ServerEndpoint在嵌入式Jetty下不起作用网上大量SpringBoot WebSocket教程用的都是ServerEndpoint加ServerEndpointExporter这套组合在Tomcat下能跑通但换到嵌入式Jetty下会变得非常不可靠。原因是ServerEndpointExporter的机制是向容器注册JSR-356端点而SpringBoot嵌入式Jetty并不会像外置容器那样完整执行ServletContainerInitializer的扫描注册流程ServerEndpoint扫描不到或者注册时机不对。这个坑我实际踩过之后彻底放弃了这个方案。如果你正在从Tomcat迁移到Jetty发现ServerEndpoint注解没生效不用怀疑是自己代码写错换成Spring抽象层的WebSocketHandler实现即可。如果一定要用JSR-356和ServerEndpoint可以考虑放弃嵌入式模式把应用打包成war部署到外置Jetty但这样做的成本和收益完全不划算。5.3 并发推送时消息发送异常当线上有多个线程同时往同一个WebSocket连接写数据时会出现ConcurrentModificationException或者更隐蔽的“消息发送顺序错乱、部分消息丢失”。原因在于WebSocketSession.sendMessage并不是线程安全的多个线程同时调用同一个session的发送方法会互相干扰。我常用的解法有两种。第一种简单粗暴就是在发送方法上加synchronized按session粒度加锁就像上面的sendWithLock。这种方案在小规模场景足够稳定代码也容易理解。第二种是使用Spring提供的ConcurrentWebSocketSessionDecorator它内部对发送做了并发控制在构造时传入发送时间限制和缓冲大小适合对吞吐量要求更高的场景。private WebSocketSession decorate(WebSocketSession session) { return new ConcurrentWebSocketSessionDecorator(session, 5000, 1024 * 64); }这个包装器可以在afterConnectionEstablished里把原始session替换掉之后存储和发送都用包装后的session。它多了一个好处当某次发送阻塞超过阈值时会自动关闭底层连接避免一个慢客户端拖垮服务端线程。5.4 连接被服务端无故断开最常见的原因是空闲超时。Jetty对WebSocket连接有一个空闲检测机制连接在idleTimeout时间内没有任何数据读写就会被判定为空闲连接并主动关闭。浏览器端表现为onclose事件中的code1000或code1006后端日志一般能看到WebSocketSession closed或者空闲超时相关的提示。解法分两部分。一是调大或调小server.jetty.websocket.idle-timeout根据业务心跳频率来合理设置建议略大于心跳间隔的两倍二是确保前端心跳定时器真的在发送不要在页面切换tab时被浏览器节流掉。Chrome等浏览器在后台tab中会降低定时器执行频率极端情况可能暂停定时器导致心跳停止。另外一个隐藏因素反向代理层。如果生产环境前面有Nginx之类的代理它的proxy_read_timeout默认只有60秒左右如果心跳间隔大于这个值代理会先断开连接。后端都要检查一层WebSocket长连接是一个完整链路任何一环的空闲超时设置不合理都会表现为“服务端无故断开”。5.5 断线重连与消息补偿前端的断线重连必须做但只做重连还不够。长连接断开的瞬间用户可能正好发了一条消息这条消息会丢失。严格的生产级方案需要在客户端维护一个待确认消息队列发送消息时先把消息放进队列收到服务端ack后再移除连接断开重连成功后把队列里未确认的消息重新发送。这个机制我在项目中做成过一版并不复杂。发送消息时给消息生成一个唯一的msgId服务端处理成功后回一条{type:ack, msgId:xxx}。前端收到ack后把对应消息从待确认队列中删除。重连成功后遍历队列重新发送。这个方案虽然要求后端配合做幂等但对即时通信类系统非常有必要。6. 生产环境扩展建议6.1 从“点对点”升级到“事件驱动广播”上面这个Demo是两端直连的简单场景。真实项目里服务端往往需要主动推送业务消息比如订单状态变更、告警触发、用户被踢下线。这类需求最好用Spring的事件机制把业务逻辑和WebSocket逻辑解耦业务层发布一个PushEventWebSocket模块监听事件在事件处理方法里找出目标用户会话并推送。Component public class PushEventListener { EventListener public void onPushEvent(PushEvent event) { WebSocketSession session ChatHandler.getOnlineSession(event.getUserId()); if (session ! null) { // 构造消息并发送 } } }这样做的好处是任何业务模块只需要发布事件不需要感知WebSocket的细节。以后即使把推送改成接第三方推送服务业务层代码也基本不用动。6.2 多实例部署时的会话共享问题上面的ONLINE_SESSIONS是一个本地Map。一旦服务水平扩展成多个实例A实例上的用户和B实例上的用户无法互相发消息因为本地Map里找不到对方的会话。解决这类分布式会话共享常见方案是引入Redis的Pub/Sub或者RocketMQ等消息中间件做集群广播。服务端收到一条点对点消息后把消息发到Redis频道所有WebSocket实例订阅这个频道各自检查目标用户是否在自己本地在则推送。这套方案的代码量不算小但它保证了WebSocket服务的水平扩展能力。建议在做多实例改造前先把单实例的WebSocket模块做成无状态本地只保存“会话与实例”的临时关系真正的用户在线状态统一维护在Redis里。这样后续扩展只是时间问题。6.3 监控与容量评估WebSocket服务比普通HTTP服务更需要监控因为每个连接都是一个长期占用的资源。至少要监控三个指标当前连接数、每秒消息吞吐量、连接建立成功率。连接数可以做一个统计接口把ONLINE_SESSIONS.size()暴露出来给运维系统定时拉取。吞吐量可以在Handler里加计数逻辑或者接入监控SDK上报。容量评估上Jetty对WebSocket连接的支持能力很强单机几千个连接没有压力但要注意每个空闲连接也有内存开销。准备上线前用脚本模拟多客户端并发连接观察GC和内存在峰值连接下的表现。实测下来连接数从100升到2000的那个阶段内存上升会比较明显需要预留足够堆内存并且确认JVM对ByteBuf之类的堆外内存没有配置过小。最后再分享两个实操体会。第一个是Jetty模式下尽量给WebSocket路径单独规划前缀比如/ws/*方便在网关层统一做超时和转发配置也方便后续做路径级别的限流。第二个是前端页面卸载或者登录超时时记得主动关闭WebSocket别等浏览器自动回收否则服务端要等空闲超时才知道连接失效在线人数会虚高。这套SpringBoot加Jetty内置WebSocket的组合做一个中小规模实时通信功能完全够用把心跳、重连、鉴权这三件事做好稳定性基本就有了。