你手上如果有一份实时推送、客服聊天、服务端主动通知的需求,多半躲不开 WebSocket。市面上讲 WebSocket 的文章不少,但要么堆概念、要么贴一段碎片代码,真正能拿来改一改就用的反而难找。这篇我直接汇总这些年我用 Java 写 WebSocket 的各类场景:原生 API、Spring 封装、聊天室、心跳保活、集群消息广播、常见连环坑,全部带可运行的示例和避坑说明。不管你是刚接触 WebSocket 的新手,还是被线上连接抖动折腾过的老手,这份清单都能让你少走几趟弯路。
1. 内容整体设计与思路拆解
1.1 为什么 Java 后端选 WebSocket 而不是轮询或 SSE
WebSocket 能火起来,本质是因为 HTTP 协议“一来一回”的模式在实时场景下太憋屈。举个最简单的例子:服务端要通知前端“订单状态变了”,用传统轮询,前端得每隔几秒发一次请求,大多数请求其实没有新数据,白费带宽和 CPU;用 SSE(Server-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.websocket),Tomcat、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 CopyOnWriteArraySet<Session> 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,只需在配置类加一个ServerEndpointExporter:
import 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.Whole<String>() { @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(Class<T> 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注入其他服务。
直接看代码。先实现WebSocketHandler:
import 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 可以直接把依赖塞进来,不会出现前面那种注入失效的问题。
然后注册路由,实现WebSocketConfigurer:
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 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 |
| 代码风格 | 注解驱动,直观 | 接口回调,结构清晰 |
| 适合场景 | 快速开发、小规模应用 | 中大型项目、需深度集成 Spring |
4. 核心进阶示例:群聊、心跳机制与消息广播
4.1 群聊功能:按房间维度管理会话
实际开发中很少做“全站一个聊天室”,基本都是按房间分组广播。实现思路很朴素:用Map<String, Set<Session>>把每个房间的会话存起来,发消息时只遍历目标房间下的 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 Map<String, Set<Session>> roomSessions = new ConcurrentHashMap<>(); @OnOpen public void onOpen(Session session, @PathParam("roomId") String roomId) { session.getUserProperties().put("roomId", roomId); Set<Session> 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"); Set<Session> sessions = roomSessions.get(roomId); if (sessions != null) { sessions.remove(session); broadcast(roomId, "成员离开,当前房间人数:" + sessions.size()); } } private void broadcast(String roomId, String message) { Set<Session> 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存会话的实现没问题。但一旦搞集群,两台服务器各有各的本地Map,A 机器上某个连接发的消息,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 RedisTemplate<String, String> redisTemplate; public RedisMessagePublisher(RedisTemplate<String, String> redisTemplate) { this.redisTemplate = redisTemplate; } public void publish(String channel, String message) { redisTemplate.convertAndSend(channel, message); } }订阅端实现MessageListener:
import 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, Map<String, 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 注入为 null | @ServerEndpoint非 Spring 管理 | 使用静态上下文获取 Bean 或改用WebSocketHandler |
最后再分享一个小技巧。线上排查 WebSocket 问题时,不要只盯着业务代码,先看容器层面的连接数和线程栈。观察 Tomcat 的 WebSocket 连接数是否和预期一致,配合抓包工具看握手是否成功、心跳帧是否正常发出,往往比在 Java 代码里打半天日志更高效。WebSocket 调试最忌讳“直接打开浏览器看 console”,因为浏览器把很多底层错误吞掉了,你看到的往往只是一个泛泛的断开提示。把服务端日志、代理层日志、客户端事件三者串起来看,问题定位速度能翻一倍。