Java WebSocket 实战指南:从原生 API 到集群心跳与避坑

发布时间:2026/10/6 19:00:58
Java WebSocket 实战指南:从原生 API 到集群心跳与避坑
你手上如果有一份实时推送、客服聊天、服务端主动通知的需求多半躲不开 WebSocket。市面上讲 WebSocket 的文章不少但要么堆概念、要么贴一段碎片代码真正能拿来改一改就用的反而难找。这篇我直接汇总这些年我用 Java 写 WebSocket 的各类场景原生 API、Spring 封装、聊天室、心跳保活、集群消息广播、常见连环坑全部带可运行的示例和避坑说明。不管你是刚接触 WebSocket 的新手还是被线上连接抖动折腾过的老手这份清单都能让你少走几趟弯路。1. 内容整体设计与思路拆解1.1 为什么 Java 后端选 WebSocket 而不是轮询或 SSEWebSocket 能火起来本质是因为 HTTP 协议“一来一回”的模式在实时场景下太憋屈。举个最简单的例子服务端要通知前端“订单状态变了”用传统轮询前端得每隔几秒发一次请求大多数请求其实没有新数据白费带宽和 CPU用 SSEServer-Sent Events服务端能单向推送给客户端但客户端想给服务端发消息还得走单独的 HTTP 请求双向通信仍然别扭。WebSocket 的独特价值在于一条 TCP 连接上实现了全双工通信。握手阶段通过 HTTP Upgrade 协议完成之后客户端和服务端都能随时主动发数据消息头开销只有 2 到 14 字节比 HTTP 动不动几百字节的头部省得多。我在实际项目里用下来最直观的感受是“推送延迟从秒级降到毫秒级”而且连接一旦建立就不需要反复握手服务端压力反而更可控。需要特别强调的是WebSocket 适合的是“双向、频繁、低延迟”的场景比如在线聊天、协同编辑、行情推送、游戏对战。如果你的场景只是服务端单向通知、且频率不高SSE 反而更省事如果客户端根本不要求实时性那就别为了炫技引入 WebSocket徒增连接和运维复杂度。1.2 一个标准 WebSocket 交互流程是什么样的理解 WebSocket 前脑子里得有这张图先 HTTP 升级再双向自由通信最后一方断开。具体展开是这样的浏览器或客户端发起一个带Upgrade: websocket头的 HTTP 请求服务端收到后校验Sec-WebSocket-Key返回101 Switching Protocols响应连接协议就从 HTTP 切换成 WebSocket。从这一刻起客户端和服务端地位对等谁都可以随时扔消息给对方。这里有个容易忽略的细节HTTP 握手本身是短暂的但升级后的 WebSocket 连接是长连接背后占着一条 TCP 套接字。服务端需要维护这些连接而 TCP 连接在长时间空闲时可能被中间网络设备路由器、防火墙静默掐断所以外面都要做心跳保活——这也就是网上关于“WebSocket 心跳机制实现”搜得特别多的原因。Java 里实现 WebSocket 服务端主流路径有三条纯 Java EE 标准javax.websocket或者新包名jakarta.websocketTomcat、Jetty 等容器内置支持注解风格写起来很清爽。Spring 框架封装spring-websocket模块配合WebSocketHandler或ServerEndpoint使用和 Spring Boot 集成最顺。Netty 实现性能天花板高但代码复杂度也高适合深度定制协议或高并发场景。这篇文章后面给出的示例以 Java EE 标准和 Spring Boot 两条路径为主因为覆盖面最广拿来即用。2. 基础示例原生 Java WebSocket 服务端与客户端2.1 用 ServerEndpoint 三步搭建一个入门服务端先看代码这是用 Java 原生 WebSocket API 写的最简服务端我在 Tomcat 9 和 Spring Boot 内嵌 Tomcat 里都跑过。import javax.websocket.*; import javax.websocket.server.ServerEndpoint; import java.io.IOException; import java.util.concurrent.CopyOnWriteArraySet; ServerEndpoint(/chat) public class ChatEndpoint { // 存放所有在线会话线程安全集合 private static final CopyOnWriteArraySetSession sessions new CopyOnWriteArraySet(); OnOpen public void onOpen(Session session) { sessions.add(session); System.out.println(连接建立 session.getId() 当前在线数 sessions.size()); } OnMessage public void onMessage(String message, Session session) throws IOException { System.out.println(收到消息 message 来自 session.getId()); // 广播给所有在线客户端 for (Session s : sessions) { if (s.isOpen()) { s.getBasicRemote().sendText(message); } } } OnClose public void onClose(Session session) { sessions.remove(session); System.out.println(连接关闭 session.getId() 当前在线数 sessions.size()); } OnError public void onError(Session session, Throwable error) { error.printStackTrace(); sessions.remove(session); } }这段代码解决的是“最基础的服务端收发和广播”问题。三个注解OnOpen、OnMessage、OnClose对应连接生命周期的三个关键节点CopyOnWriteArraySet用来存放会话是因为它在“读多写少”的并发场景下特别稳遍历广播时不会抛ConcurrentModificationException。光有服务端类还不够还需要把它注册到容器里。如果你用的是 Spring Boot只需在配置类加一个ServerEndpointExporterimport org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.socket.server.standard.ServerEndpointExporter; Configuration public class WebSocketConfig { Bean public ServerEndpointExporter serverEndpointExporter() { return new ServerEndpointExporter(); } }这里有个坑必须提醒ServerEndpointExporter这个 Bean 在 Spring Boot 内嵌容器场景下是必需的但你如果最终是部署到外部 Tomcat打 war 包而不是 jar 包这个 Bean 反而会冲突要注释掉。我一开始没注意在外部 Tomcat 上直接 404排查了半天才发现是这个 Exporter 在捣鬼。2.2 浏览器端与 Java 客户端如何连上上面的服务端浏览器端用原生 WebSocket API 就能连// 浏览器端 WebSocket 客户端 const socket new WebSocket(ws://localhost:8080/chat); socket.onopen function () { console.log(连接已建立); socket.send(大家好我上线了); }; socket.onmessage function (event) { console.log(收到服务端消息, event.data); }; socket.onclose function () { console.log(连接已关闭); }; socket.onerror function (error) { console.error(连接出错, error); };Java 客户端则有两种选择。如果你用的也是标准 API可以直接用javax.websocket的ContainerProvider创建连接import javax.websocket.*; import java.net.URI; public class WebSocketClient { public static void main(String[] args) throws Exception { WebSocketContainer container ContainerProvider.getWebSocketContainer(); Session session container.connectToServer(new Endpoint() { Override public void onOpen(Session session, EndpointConfig config) { session.addMessageHandler(new MessageHandler.WholeString() { Override public void onMessage(String message) { System.out.println(收到服务端消息 message); } }); try { session.getBasicRemote().sendText(我是 Java 客户端); } catch (IOException e) { e.printStackTrace(); } } }, URI.create(ws://localhost:8080/chat)); Thread.sleep(5000); session.close(); } }这个客户端的写法比较绕因为原生 API 的设计风格是“回调套回调”不如浏览器端直观。如果你只是想测试服务端通不通我建议直接装一个在线 WebSocket 测试工具比如 Postman 或者浏览器控制台写段 JS比写 Java 客户端快得多。2.3 围绕基础示例必须掌握的三个配置细节第一个细节路径参数与查询参数。生产环境里服务端往往需要知道消息是发给哪个用户、哪个房间的路径和参数设计直接影响后续的业务代码。ServerEndpoint的路径支持模板ServerEndpoint(/chat/{roomId}/{userId}) public class RoomEndpoint { OnOpen public void onOpen(Session session, PathParam(roomId) String roomId, PathParam(userId) String userId) { System.out.println(用户 userId 进入房间 roomId); session.getUserProperties().put(roomId, roomId); session.getUserProperties().put(userId, userId); } }PathParam可以直接把 URL 模板变量绑定到方法参数上。拿到之后放进session.getUserProperties()里缓存起来后面收发消息时就能随时取用。这个做法非常关键因为OnMessage方法里默认拿不到OnOpen的局部变量跨方法共享状态就得靠Session.getUserProperties()。第二个细节消息类型不只是文本。很多新手以为 WebSocket 只能传字符串其实Session.getBasicRemote()还有一系列重载方法可以发送二进制数据// 发送二进制消息比如小图片、文件切片 session.getBasicRemote().sendBinary(ByteBuffer.wrap(bytes)); // 发送完整的 Pong 消息用于响应心跳 session.getBasicRemote().sendPong(ByteBuffer.wrap(pong.getBytes()));前端的event.data类型会自动变成Blob或ArrayBuffer和发送时的类型对应。文本、JSON 字符串、二进制这几种类型在实战里都会遇到不要只盯着sendText一种方法。第三个细节阻塞式发送与异步发送的区别。getBasicRemote().sendText()是阻塞发送消息发完才返回如果某一方处理速度跟不上可能拖慢整体节奏。getAsyncRemote().sendText()是异步发送立刻返回是真正的高并发推荐方式。我在做群发广播时会尽量用异步发送配合回调记录失败情况session.getAsyncRemote().sendText(message, new SendHandler() { Override public void onResult(SendResult result) { if (!result.isOK()) { System.err.println(发送失败 result.getException()); } } });不过也要注意异步发送虽然不阻塞当前线程但底层连接还是那个连接大量异步任务同时堆积也可能把内存打爆后面在心跳和集群章节会再展开讲。3. Spring Boot 集成 WebSocket 的两种主流姿势3.1 基于原生注解的 Spring Boot 集成方式前面第 2 节的ServerEndpoint例子其实就是基于原生注解的 Spring Boot 集成方式因为javax.websocket标准本身不依赖 Spring但通过ServerEndpointExporter让 Spring Boot 识别并注册它。这种方式的优点是与 Java EE 标准一致、代码直观特别适合小团队快速开发聊天室、通知推送功能。缺点和限制也很明显ServerEndpoint默认是每个连接一个实例不是 Spring 管理的单例。如果要在OnMessage里注入UserService、OrderService等 Spring Bean直接Autowired是不行的需要用静态工具类或者SpringContextHolder来获取。对 Spring Security、Spring Interceptor 的集成不够原生做鉴权还得靠Session里的参数手动校验。解决 Spring Bean 注入问题打个比方ServerEndpoint实例就像临时工不属于 Spring 这个“正式编制”所以不能享受依赖注入待遇。想要注入服务常见做法是用一个静态的工具类持有 Spring 上下文import org.springframework.beans.BeansException; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; import org.springframework.stereotype.Component; Component public class SpringContextHolder implements ApplicationContextAware { private static ApplicationContext context; Override public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { context applicationContext; } public static T T getBean(ClassT clazz) { return context.getBean(clazz); } }然后在OnMessage里UserService userService SpringContextHolder.getBean(UserService.class);这种写法适合中小项目临时应急可以。但如果你要在 Spring 生态里长期维护代码我更推荐下一种写法。3.2 基于 WebSocketHandler 的 Spring 官方集成方式Spring 官方主推的方式是通过实现WebSocketHandler接口配合WebSocketConfigurer注册。它和ServerEndpoint模式最大的区别是Handler 本身是 Spring 管理的单例 Bean天然可以Autowired注入其他服务。直接看代码。先实现WebSocketHandlerimport org.springframework.web.socket.*; import org.springframework.web.socket.handler.TextWebSocketHandler; public class ChatWebSocketHandler extends TextWebSocketHandler { private final ChatService chatService; public ChatWebSocketHandler(ChatService chatService) { this.chatService chatService; } Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { // 连接建立后触发类似 OnOpen System.out.println(连接建立 session.getId()); } Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { // 收到文本消息时触发 String payload message.getPayload(); System.out.println(收到消息 payload); chatService.doSomething(payload); session.sendMessage(new TextMessage(服务端已收到 payload)); } Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { System.out.println(连接关闭 session.getId()); } Override public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception { System.err.println(传输错误 exception.getMessage()); } }这里我先通过构造器传入ChatService因为这种 Handler 是单例Spring 可以直接把依赖塞进来不会出现前面那种注入失效的问题。然后注册路由实现WebSocketConfigurerimport 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 ChatService chatService; public WebSocketConfig(ChatService chatService) { this.chatService chatService; } Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(new ChatWebSocketHandler(chatService), /ws/chat) .setAllowedOrigins(*); } }这段代码里有个特别值得注意的参数setAllowedOrigins(*)。这是跨域配置开发环境可以放开但生产环境如果不限制来源就意味着任何网站都可以往你的 WebSocket 服务发消息这是个严重的安全隐患。正确做法是把线上域名写进去registry.addHandler(chatWebSocketHandler, /ws/chat) .setAllowedOrigins(https://yourdomain.com, https://admin.yourdomain.com);3.3 两种姿势对比什么时候选注解什么时候选 Handler我给你的选择建议非常简单如果是学习 Demo、小工具、内部系统用ServerEndpoint代码量最少心智负担最低。如果项目要长期迭代、要做权限控制、要大量复用 Spring 服务优先用WebSocketHandler因为单例和依赖注入让你不用整天用静态上下文“捞” Bean。如果团队里有人熟悉 Netty并且你们的并发量真的到了“万级连接”以上再考虑用 Netty 完全替代 Spring WebSocket 抽象。表格对比一下两者差异对比维度ServerEndpoint 注解方式WebSocketHandler 方式实例管理每个连接一个实例Spring 单例Spring Bean 注入需要静态上下文辅助直接构造器注入鉴权/拦截器需要自行在握手阶段做可配置 HandshakeInterceptor代码风格注解驱动直观接口回调结构清晰适合场景快速开发、小规模应用中大型项目、需深度集成 Spring4. 核心进阶示例群聊、心跳机制与消息广播4.1 群聊功能按房间维度管理会话实际开发中很少做“全站一个聊天室”基本都是按房间分组广播。实现思路很朴素用MapString, SetSession把每个房间的会话存起来发消息时只遍历目标房间下的 Session。import javax.websocket.*; import javax.websocket.server.PathParam; import javax.websocket.server.ServerEndpoint; import java.io.IOException; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArraySet; ServerEndpoint(/chat/room/{roomId}) public class RoomChatEndpoint { // 房间 - 会话集合 private static final MapString, SetSession roomSessions new ConcurrentHashMap(); OnOpen public void onOpen(Session session, PathParam(roomId) String roomId) { session.getUserProperties().put(roomId, roomId); SetSession sessions roomSessions.computeIfAbsent(roomId, k - new CopyOnWriteArraySet()); sessions.add(session); broadcast(roomId, 新成员加入当前房间人数 sessions.size()); } OnMessage public void onMessage(String message, Session session) { String roomId (String) session.getUserProperties().get(roomId); broadcast(roomId, message); } OnClose public void onClose(Session session) { String roomId (String) session.getUserProperties().get(roomId); SetSession sessions roomSessions.get(roomId); if (sessions ! null) { sessions.remove(session); broadcast(roomId, 成员离开当前房间人数 sessions.size()); } } private void broadcast(String roomId, String message) { SetSession sessions roomSessions.get(roomId); if (sessions ! null) { for (Session s : sessions) { if (s.isOpen()) { synchronized (s) { try { s.getBasicRemote().sendText(message); } catch (IOException e) { e.printStackTrace(); } } } } } } }房间里代码不难但有几点我想强调ConcurrentHashMap保证了多线程同时操作房间容器不崩溃。用computeIfAbsent代替putIfAbsent代码更优雅还能省一次二次查询。广播时我对每个 Session 加了synchronized(s)这是因为一个连接同时被多个线程发送消息时底层可能收到交错的数据帧粘包乱包。这个细节在“多线程推送同一会话”的场景下很重要。4.2 心跳机制为什么必须要做以及两种实现方案WebSocket 连接空闲久了会被网络中的 NAT 网关、负载均衡器悄悄干掉。对方 TCP 连接已经断了但你服务端还没收到 FIN 报文于是这个连接就成了“僵尸连接”占着资源不干活。做心跳的目的就是用周期的探测消息维护连接活性尽早发现已经死掉的连接并清理。网上关于“WebSocket 心跳机制实现”的搜法非常多我在这里把主流方案整理成两种方案一服务端定时 Ping客户端回 Pong推荐。这是协议层面最优雅的姿势。 WebSocket 协议定义了Ping和Pong两种控制帧Ping发出去之后符合规范的客户端必须自动回Pong。服务端只需定时扫描所有连接发现超过阈值没回 Pong 的直接关闭。关键代码思路如下import javax.websocket.*; import java.nio.ByteBuffer; import java.util.concurrent.*; ServerEndpoint(/heartbeat) public class HeartbeatEndpoint { private static final ScheduledExecutorService scheduler Executors.newScheduledThreadPool(4); private static final long HEARTBEAT_INTERVAL 20; // 每20秒发一次Ping private static final long TIMEOUT_THRESHOLD 60; // 60秒没Pong就判定死亡 OnOpen public void onOpen(Session session) { // 每个连接建立一个心跳任务 ScheduledFuture? future scheduler.scheduleAtFixedRate(() - { try { if (session.isOpen()) { session.getBasicRemote().sendPing(ByteBuffer.wrap(new byte[]{1})); } } catch (Exception e) { try { session.close(new CloseReason(CloseReason.CloseCodes.VIOLATED_POLICY, 心跳超时)); } catch (IOException ioException) { ioException.printStackTrace(); } } }, HEARTBEAT_INTERVAL, HEARTBEAT_INTERVAL, TimeUnit.SECONDS); session.getUserProperties().put(heartbeatTask, future); } // 注意Java WebSocket 标准 API 没有直接的 OnPong 注解 // 需要借助 MessageHandler 或者从底层容器获取 Pong 事件。 OnClose public void onClose(Session session) { ScheduledFuture? future (ScheduledFuture?) session.getUserProperties().get(heartbeatTask); if (future ! null) { future.cancel(true); } } }这里有个需要坦诚说明的细节Java 标准javax.websocket对 Pong 帧的接收处理支持比较弱不同容器表现不一致。如果你想做到“收到 Pong 更新在线状态”这种精细控制要么依赖 Tomcat 特有的 API要么直接用 Netty 自己控制帧类型。这也是我在纯注解方案里只给出“服务端定时 Ping 客户端自动 Pong”的原因——大多数场景下只要 Ping 能发出去客户端协议栈会自动回 Pong连接就不会被 NAT 断开。方案二应用层心跳 JSON。如果你的前后端都是自己人用 JSON 字符串做心跳最可控。约定一个特殊消息体比如{type:ping}和{type:pong}前端收到 ping 就回 pong服务端通过消息处理器更新该连接的最后活跃时间然后开启一个定时任务扫描“最后活跃时间超过阈值”的连接并关闭。这个方案的好处是不依赖协议帧、跨容器兼容性好缺点是多了一些业务消息开销。我在中小项目里用得最多的是方案一和二的结合服务端用 Ping 帧保活同时业务层再发{type:ping}消息做业务状态同步双保险。4.3 面向多实例部署的消息广播Redis Pub/Sub 方案如果你只是单机部署前面用静态Map存会话的实现没问题。但一旦搞集群两台服务器各有各的本地MapA 机器上某个连接发的消息B 机器上的连接根本收不到。这时候就需要一个“跨实例转发层”。我在生产环境常用的方案是 Redis Pub/Sub。思路非常直白每台服务启动时都订阅固定的 Redis 频道比如ws_broadcast。某台服务的某个连接收到消息除了发给本机在线会话外还把这个消息publish到 Redis 频道。所有订阅了频道的服务实例都会收到这条消息然后各自发给本机匹配的会话。核心代码思路如下用 Spring Data Redis 的RedisTemplate封装import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Component; Component public class RedisMessagePublisher { private final RedisTemplateString, String redisTemplate; public RedisMessagePublisher(RedisTemplateString, String redisTemplate) { this.redisTemplate redisTemplate; } public void publish(String channel, String message) { redisTemplate.convertAndSend(channel, message); } }订阅端实现MessageListenerimport org.springframework.data.redis.connection.Message; import org.springframework.data.redis.connection.MessageListener; public class WebSocketRedisListener implements MessageListener { private final WebSocketSessionManager sessionManager; public WebSocketRedisListener(WebSocketSessionManager sessionManager) { this.sessionManager sessionManager; } Override public void onMessage(Message message, byte[] pattern) { String payload new String(message.getBody()); // 解析出目标用户或房间通过本地 sessionManager 转发 sessionManager.sendToLocalClients(payload); } }用 Redis Pub/Sub 做广播是我认为性价比最高的集群方案。比直接引入 RocketMQ、Kafka 轻量又比手动维护分布式 Session 列表省心。但注意Redis Pub/Sub 的消息不带持久化服务重启会丢消息如果你对可靠性有硬要求再考虑把消息丢进 MQ 或使用 Redis Stream。5. 生产环境的几个高频问题与排查经验5.1 连接数一多服务端内存暴涨很多第一次做 WebSocket 上线的人都会遇到开发环境好好的并发上来后内存飙升最后 OOM。原因一般有两个。第一每个连接自带缓冲区Tomcat 默认的maxBufferSize可能按 1MB 甚至更多计算几万个连接光缓冲区就吃掉几十 GB。需要对连接数和内存做规划尤其是容器参数里maxTextMessageBufferSize不建议设置得过大比如后端业务消息最长只有 64KB那缓冲区设成 128KB 就够了session.setMaxTextMessageBufferSize(128 * 1024);第二业务层往 Map 里塞了太多对象没清理。最常见的是session.getUserProperties()里存了用户对象、权限对象、历史消息列表连接关闭时只 remove 了 session但那些属性对象如果没有被主动清理还是会驻留在内存里。我在写OnClose时会把getUserProperties().clear()执行一遍确保临时数据能回收。5.2 连接被服务端或中间层中断客户端不自知这是个很典型的问题。客户端开着页面上午还正常下午发现消息收不到你查服务端发现连接已经关了客户端却毫无感知。这是因为 TCP 连接断开没有数据传输时客户端根本不知道链路已经断了。解决思路就是第 4 节的心跳机制。客户端也要做兜底每隔一段时间发一个ping如果连续几次没收到pong或服务端响应就主动socket.close()然后重连。前端示例let socket null; let heartbeatTimer null; let reconnectTimer null; let retryCount 0; function connect() { socket new WebSocket(ws://localhost:8080/chat); socket.onopen function () { console.log(连接成功开始心跳); clearInterval(heartbeatTimer); heartbeatTimer setInterval(() { if (socket.readyState WebSocket.OPEN) { socket.send(JSON.stringify({ type: ping })); } }, 20000); }; socket.onmessage function (event) { const data JSON.parse(event.data); if (data.type pong) { // 收到 pong说明服务端还活着 retryCount 0; } }; socket.onclose function () { clearInterval(heartbeatTimer); reconnect(); }; socket.onerror function () { socket.close(); }; } function reconnect() { clearTimeout(reconnectTimer); const delay Math.min(1000 * Math.pow(2, retryCount), 30000); console.log(重连中延迟, delay, ms); reconnectTimer setTimeout(() { retryCount; connect(); }, delay); } connect();这段前端代码里我用的是指数退避重连第 1 次失败等 1 秒第 2 次等 2 秒第 4 次等 8 秒最多等到 30 秒。这个策略能有效避免服务端重启时所有客户端同时撞上来重建连接把服务端冲垮。5.3 握手失败背后常见的三类原因WebSocket 握手阶段最容易出的问题我总结为三类跨域不过、路径不对、代理不支持。跨域不过浏览器从https://a.com访问wss://b.com/ws服务端没有配置setAllowedOrigins(https://a.com)握手直接 403。排查方法很简单打开浏览器开发者工具的 Network 标签找到 WebSocket 请求看响应状态码。路径不对尤其注意反向代理后的路径差异。比如 Nginx 配置了/ws/前缀转发但服务端实际注册的是/ws/chat真实路径就变成了/ws/ws/chat。这种问题我遇到不止一次后来统一约定代理层路径和服务端路径完全分离比如客户端访问/gateway/chat代理转发到后端/chat。代理不支持Nginx 反向代理 WebSocket 需要显式设置 Upgrade 头。下面是常用配置片段如果你用 Nginx直接复制这段再调整为你的端口和路径location /chat { proxy_pass http://backend-server:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_set_header Host $host; proxy_read_timeout 3600s; }这里的proxy_read_timeout 3600s也是在为长连接做保底防止 Nginx 自己挥手断连。5.4 WebSocket 和 HTTP 鉴权如何共用一个登录态这个问题的解法本质上是“握手阶段偷看 Cookie 或 Header”。WebSocket 的升级请求本身就是一个 HTTP GET 请求所以你可以拿到Cookie、Authorization等头部信息。如果是 Spring 的WebSocketHandler你可以通过HandshakeInterceptor在握手前后拦截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 AuthHandshakeInterceptor implements HandshakeInterceptor { Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, MapString, Object attributes) { // 从请求头中获取 token String token request.getHeaders().getFirst(Authorization); if (token null || !valid-token.equals(token)) { return false; // 握手失败 } // 把用户信息放入 attributes之后可以在 WebSocketSession 中取到 attributes.put(userId, 10001); return true; } Override public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) { // 握手成功后的遗留逻辑 } }然后在配置类里把拦截器挂上registry.addHandler(chatWebSocketHandler, /ws/chat) .addInterceptors(new AuthHandshakeInterceptor()) .setAllowedOrigins(*);握手成功后attributes里的内容会被塞进WebSocketSession.getAttributes()业务 Handler 里直接取值就行String userId (String) session.getAttributes().get(userId);这个思路比我见过的一些“先连上 WebSocket 再发送登录消息”的做法要安全得多因为握手不通过连接根本建不起来也省掉了“未登录连接”的资源开销。5.5 服务端主动关闭连接和异常状态码的选择服务端不只有被动等客户端断开遇到长时间不活跃、数据格式错误、权限变更等情况需要主动断连。Java 标准 API 关闭连接有两种姿势// 正常关闭传业务状态码和原因 session.close(new CloseReason(CloseReason.CloseCodes.NORMAL_CLOSURE, bye)); // 策略违规关闭适合身份过期、消息格式错误等场景 session.close(new CloseReason(CloseReason.CloseCodes.VIOLATED_POLICY, auth expired)); // 协议错误关闭 session.close(new CloseReason(CloseReason.CloseCodes.PROTOCOL_ERROR, bad frame));我建议你在项目里自定义一套关闭码枚举至少区分正常关闭、鉴权失败、超时踢出、服务端重启。前端拿到event.code之后可以根据不同的关闭码决定是否自动重连、是否提示用户重新登录。这比一刀切地“看到关闭就重连”要健壮得多因为如果是鉴权失败你重试一万次也没有用。socket.onclose function (event) { if (event.code 4001) { // 鉴权失败跳转登录页 window.location.href /login; } else if (event.code 4002) { // 踢出提示不自动重连 alert(账号在其他设备登录); } else { reconnect(); } };6. 从实战视角补充的选型建议WebSocket 的代码写法在网上一搜一大把但我发现很少有人把“和业务怎么结合”讲透。这里我用自己几个项目的体会给出一份按需求场景划分的选型建议。如果你的业务是消息通知中心服务端推送多、客户端很少主动发消息那么推荐使用 Spring 原生的WebSocketHandler加一个HandshakeInterceptor做统一鉴权再配合 Redis Pub/Sub 做多机广播。这套组合能覆盖绝大多数内部系统通知需求。如果你的业务是客服系统、在线聊天那就需要房间管理、离线消息补拉、消息已读回执。 WebSocket 更适合做“实时读写通道”像消息记录这类数据不应该全部用 WebSocket 传输而是用 WebSocket 推送一个“有新消息”的事件然后客户端再通过 HTTP 接口拉取消息列表。这样能避免全量消息塞在 WebSocket 里内存和带宽都更可控。如果你的业务是实时大屏、行情看板这类场景读多写少、数据变化快建议在 WebSocket 之上做一层“订阅推送模型”。客户端连接后发送{cmd:subscribe,topic:stock_300750}服务端维护一个 topic 到 session 的映射行情数据产生时按 topic 定向推送。不建议把每个连接都做一个独立的定时推送任务那会儿导致大量重复计算。如果你的业务是协同编辑、白板、游戏延迟和消息顺序是最核心的痛点。这种情况下 Java 标准 API 的抽象层级可能不太够用最好直接上 Netty自己控制消息解析、帧调度甚至自定义二进制协议。这不是说 Java WebSocket API 不能用而是 Netty 给了你更大的控制面。另一个要注意的维度是组织复用。如果你所在团队已经熟练使用 Spring Boot Redis那就别在 WebSocket 技术上引入额外复杂度。就算 Netty 性能更极致但团队维护成本高、排障经验少很容易把你自己的高效功能搞成线上事故。技术选型从来不是“哪个绝对更强”而是“哪个在你们团队当前阶段更稳”。7. 常见问题速查表与避坑清单问题现象可能原因排查与解决连接握手返回 404路径注册不对或代理层路径重写错误检查控制台、注册路径和 Nginx 路径握手返回 403跨域限制或鉴权拦截器拦截检查setAllowedOrigins和拦截器逻辑连接建立后频繁断开网络设备空闲超时或者心跳没做加服务端 Ping 和客户端心跳重连广播时客户端大部分收不到跑多个实例但没做集群广播引入 Redis Pub/Sub 或 MQ群聊消息偶发乱序多线程同时向同一连接发消息对单个Session的发送动作加锁服务端内存持续增长会话未清理、缓冲区过大、属性对象堆积检查OnClose清理逻辑设置合理缓冲区Spring 注解端点里 Bean 注入为 nullServerEndpoint非 Spring 管理使用静态上下文获取 Bean 或改用WebSocketHandler最后再分享一个小技巧。线上排查 WebSocket 问题时不要只盯着业务代码先看容器层面的连接数和线程栈。观察 Tomcat 的 WebSocket 连接数是否和预期一致配合抓包工具看握手是否成功、心跳帧是否正常发出往往比在 Java 代码里打半天日志更高效。WebSocket 调试最忌讳“直接打开浏览器看 console”因为浏览器把很多底层错误吞掉了你看到的往往只是一个泛泛的断开提示。把服务端日志、代理层日志、客户端事件三者串起来看问题定位速度能翻一倍。