Spring Boot WebSocket实战:原生@ServerEndpoint与WebSocketHandler对比详解
1. 从HTTP到WebSocket:为什么我们需要它?
如果你做过实时聊天、股票行情推送或者在线游戏,肯定遇到过一个问题:HTTP协议太“慢”了。这里的慢,不是指数据传输速度,而是指它的“一问一答”模式。客户端发个请求,服务器回个响应,然后连接就断了。下次客户端想知道服务器有没有新消息,得再发起一次请求。这种模式,我们称之为“轮询”(Polling),效率低下且浪费资源。
想象一下,你和朋友用对讲机聊天,每说一句话都要按一下通话键,说完松开,等对方也按一下键才能回复。而WebSocket就像把对讲机换成了电话,一旦接通,双方可以随时说话,线路一直保持畅通。这就是全双工通信。在Spring Boot项目中集成WebSocket,就是为了建立这种“电话线路”,让服务器能主动、实时地把数据“推”给客户端,而不是等客户端来“拉”。
Spring Boot为WebSocket提供了两种主流的集成方式,这也是很多开发者初次接触时容易混淆的地方。一种是基于JSR-356标准(Java API for WebSocket)的原生@ServerEndpoint注解,另一种是Spring框架自己封装的、更“Spring风格”的WebSocketHandler方式。前者更接近底层标准,配置灵活但需要自己管理一些Spring上下文;后者与Spring MVC无缝集成,能自动享受依赖注入等便利,但抽象层次更高。
我经历过从原生注解切换到Spring封装的完整过程,也踩过不少两者混用或配置冲突的坑。这篇文章,我就结合实战,把这两种方式的核心原理、具体实现、适用场景以及那些官方文档不会告诉你的细节,一次性讲透。
2. 环境准备与项目初始化
在开始敲代码之前,我们需要先把舞台搭好。这里我假设你已经有基本的Spring Boot开发经验。
2.1 依赖引入:选对起步依赖
创建一个标准的Spring Boot项目。关键就在于pom.xml中的依赖。对于WebSocket,Spring Boot提供了一个非常方便的起步依赖。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency>这个spring-boot-starter-websocket依赖,内部已经包含了我们需要的核心库:spring-websocket和spring-messaging。它不包含Tomcat等服务器自带的WebSocket实现(javax.websocket-api),因为Spring Boot的Web Starter里自带的Tomcat已经提供了。如果你使用的是Undertow或Jetty,它们也有自己的WebSocket实现,Spring Boot会自动适配。
这里有个小细节:如果你查看这个starter的依赖树,会发现它引入了spring-boot-starter-web。这意味着,一旦你引入了WebSocket starter,你的应用默认就是一个Web应用。如果你只想做一个纯WebSocket服务(没有REST API),理论上可以排除web starter,但实践中很少这么做,因为管理起来更麻烦。
2.2 核心配置类:启用WebSocket支持
依赖加好之后,我们需要一个配置类来启用WebSocket功能。这是使用Spring封装方式必须的一步。
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 // 关键注解:启用WebSocket支持 public class WebSocketConfig implements WebSocketConfigurer { @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { // 在这里注册你的WebSocket处理器(WebSocketHandler) // 并指定访问的端点路径,比如 /ws // registry.addHandler(myHandler(), "/ws").setAllowedOrigins("*"); } }这个@EnableWebSocket注解的作用是,让Spring去查找实现了WebSocketConfigurer接口的Bean(比如我们这个配置类),然后调用其registerWebSocketHandlers方法来注册处理器。如果你用原生@ServerEndpoint方式,这个配置类不是必须的,但通常我们还是会用它来做一些全局配置,比如后面会讲到的拦截器。
注意:很多初学者会在这里卡住,写好了
@ServerEndpoint的类,但客户端连不上,往往就是因为缺少了@EnableWebSocket这个注解(对于Spring封装方式)或者没有进行正确的ServerEndpointExporter配置(对于原生方式)。我们接着往下看。
3. 方式一:使用原生@ServerEndpoint注解
JSR-356是Java EE 7引入的WebSocket标准API。Spring Boot可以很好地兼容它。这种方式更接近WebSocket协议本身,对于从其他Java EE容器迁移过来的项目,或者希望代码更“标准”的团队,可能更熟悉。
3.1 创建端点(Endpoint)类
首先,我们创建一个普通的Java类,并用@ServerEndpoint注解来标记它是一个WebSocket端点。
import javax.websocket.*; import javax.websocket.server.ServerEndpoint; import java.io.IOException; import java.util.concurrent.ConcurrentHashMap; @ServerEndpoint("/native/chat") // 定义WebSocket的访问路径 @Component // 必须声明为Spring的Bean! public class NativeWebSocketEndpoint { // 用来保存每个客户端会话(Session)的对象,键可以自定义,比如用户ID private static ConcurrentHashMap<String, Session> sessionMap = new ConcurrentHashMap<>(); /** * 连接建立成功时调用的方法 * @param session 当前客户端的会话对象,是与客户端通信的核心通道 */ @OnOpen public void onOpen(Session session) { String sessionId = session.getId(); sessionMap.put(sessionId, session); System.out.println("客户端连接建立,会话ID: " + sessionId + ",当前在线数: " + sessionMap.size()); sendMessage(session, "服务器:连接成功,你的会话ID是 " + sessionId); } /** * 收到客户端消息时调用的方法 * @param message 客户端发送的文本消息 * @param session 发送消息的客户端会话 */ @OnMessage public void onMessage(String message, Session session) { System.out.println("收到来自[" + session.getId() + "]的消息: " + message); // 这里可以实现业务逻辑,比如广播、私聊等 broadcast("用户[" + session.getId() + "]说: " + message); } /** * 连接关闭时调用的方法 */ @OnClose public void onClose(Session session) { String sessionId = session.getId(); sessionMap.remove(sessionId); System.out.println("客户端连接关闭,会话ID: " + sessionId + ",当前在线数: " + sessionMap.size()); } /** * 发生错误时调用的方法 */ @OnError public void onError(Session session, Throwable error) { System.out.println("发生错误,会话ID: " + session.getId()); error.printStackTrace(); } // 辅助方法:向指定会话发送消息 private void sendMessage(Session session, String message) { try { // getBasicRemote()是同步发送,getAsyncRemote()是异步发送 session.getBasicRemote().sendText(message); } catch (IOException e) { e.printStackTrace(); } } // 辅助方法:广播消息给所有连接的客户端 private void broadcast(String message) { sessionMap.forEach((id, session) -> { if (session.isOpen()) { sendMessage(session, message); } }); } }代码看起来挺直观的,有几个关键点需要强调:
@Component注解必不可少:这是最容易踩的坑!@ServerEndpoint是JSR-356的标准注解,Spring容器默认不会管理它。如果不加@Component(或@Service等),这个类不会被实例化为Spring Bean。后果就是,@Autowired注入会全部失败,并且这个端点根本不会被注册。Session对象:这是服务器与单个客户端通信的通道。每个连接都有一个独立的Session实例。通过它可以发送消息、关闭连接、获取连接属性等。- 会话管理:我们用一个静态的
ConcurrentHashMap来管理所有在线的Session。这是最简单的实现,但在分布式环境下会失效,因为HashMap是内存级的,不同服务器实例间不共享。生产环境需要考虑用Redis等中间件来存储会话映射关系。 @OnOpen,@OnMessage,@OnClose,@OnError:这四个注解分别对应WebSocket生命周期的四个事件:连接建立、收到消息、连接关闭、发生错误。方法签名(参数)是固定的,不能随意更改。
3.2 关键配置:ServerEndpointExporter
仅有上面的端点类还不够。我们需要显式地配置一个ServerEndpointExporterBean,它的作用是将所有带有@ServerEndpoint注解的类注册到WebSocket服务器中。
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会自动注册使用了@ServerEndpoint注解声明的WebSocket endpoint。 * 如果使用独立的Servlet容器(而不是直接使用Spring Boot的内置容器), * 需要提供自己的ServerEndpointExporter。 */ @Bean public ServerEndpointExporter serverEndpointExporter() { return new ServerEndpointExporter(); } }为什么需要这个Bean?因为Spring Boot默认的WebSocket支持是基于Spring自己的抽象层(WebSocketHandler)的。ServerEndpointExporter是一个适配器,它负责扫描Spring容器中的@ServerEndpoint注解类,并将它们“桥接”到底层服务器(Tomcat/Jetty/Undertow)的WebSocket实现上去。
重要提示:如果你使用的是Spring Boot的内置容器(默认),这个Bean是必须的。但如果你将应用打包成WAR包,部署到外部的、完整的Servlet容器(如独立的Tomcat),那么容器本身会负责扫描和注册
@ServerEndpoint,此时再提供这个Bean可能会导致重复注册错误。通常,在Spring Boot项目中,我们总是显式地声明它。
3.3 原生方式的优缺点与注入难题
优点:
- 标准:基于JSR-356,代码可移植性相对较好,知识可以迁移到其他Java EE容器。
- 直观:注解清晰,直接对应WebSocket协议事件,易于理解。
- 灵活:对
Session的控制更直接,可以方便地获取底层连接信息。
缺点与坑点:
- 依赖注入问题:这是最大的坑。在
@ServerEndpoint类中,每个客户端连接都会创建一个新的端点实例,而不是复用单例Bean。这意味着,如果你在类里用@Autowired注入了一个Service,这个注入只会发生一次(在Spring创建Bean时)。但是,当@OnOpen等方法被调用时,你操作的实际上是另一个新创建的端点实例,它的Service成员是null。- 解决方案:需要通过
SpringContextUtil这类静态工具类,在方法内部手动从Spring容器中获取Bean。
你需要事先创建一个工具类,实现@OnMessage public void onMessage(String message, Session session) { // 错误方式:userService 为 null // userService.doSomething(); // 正确方式:通过工具类获取 UserService userService = SpringContextUtil.getBean(UserService.class); userService.doSomething(); }ApplicationContextAware接口来保存Spring上下文。 - 解决方案:需要通过
- 不支持复杂的消息传递模式:原生API对STOMP这种订阅-发布模式的支持较弱,需要自己实现分发逻辑,对于复杂的消息路由(如根据目的地发送)不够方便。
- 与Spring生态集成较弱:比如想用Spring Security来鉴权WebSocket连接,在原生的
@ServerEndpoint上配置会比较麻烦。
4. 方式二:使用Spring封装的WebSocketHandler
这是Spring官方更推荐的方式,它提供了一层抽象,与Spring框架集成得更好,尤其是当你需要用到STOMP协议时(常用于消息代理)。我们先从基础的、类似原生功能的WebSocketHandler开始。
4.1 实现WebSocketHandler接口
WebSocketHandler是一个接口,我们需要实现它来处理WebSocket事件。
import org.springframework.web.socket.*; import org.springframework.web.socket.handler.TextWebSocketHandler; import java.io.IOException; import java.util.concurrent.ConcurrentHashMap; @Component // 声明为Spring Bean public class MyWebSocketHandler extends TextWebSocketHandler { // 继承TextWebSocketHandler更方便 private static final ConcurrentHashMap<String, WebSocketSession> sessionMap = new ConcurrentHashMap<>(); /** * 连接建立成功后调用 */ @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { String sessionId = session.getId(); sessionMap.put(sessionId, session); System.out.println("Spring WS 连接建立,会话ID: " + sessionId); session.sendMessage(new TextMessage("服务器:Spring WS连接成功!")); } /** * 处理收到的文本消息 */ @Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { String payload = message.getPayload(); // 获取消息内容 System.out.println("Spring WS 收到消息[" + session.getId() + "]: " + payload); // 广播 broadcast("Spring WS 广播: " + payload); } /** * 连接关闭后调用 */ @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { String sessionId = session.getId(); sessionMap.remove(sessionId); System.out.println("Spring WS 连接关闭,会话ID: " + sessionId + ",原因: " + status); } /** * 传输错误时调用 */ @Override public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception { System.out.println("Spring WS 传输错误,会话ID: " + session.getId()); exception.printStackTrace(); } private void broadcast(String message) { TextMessage textMessage = new TextMessage(message); sessionMap.forEach((id, sess) -> { try { if (sess.isOpen()) { sess.sendMessage(textMessage); } } catch (IOException e) { e.printStackTrace(); } }); } }TextWebSocketHandler是WebSocketHandler的一个抽象实现,它已经帮我们区分了文本消息和二进制消息。我们只需要覆盖处理文本消息的方法即可。如果还要处理二进制消息,可以实现BinaryWebSocketHandler或直接实现WebSocketHandler接口。
4.2 注册Handler与配置拦截器
现在,我们需要回到之前创建的WebSocketConfig配置类,在registerWebSocketHandlers方法中注册这个Handler。
@Configuration @EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { @Autowired private MyWebSocketHandler myWebSocketHandler; @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(myWebSocketHandler, "/spring/chat") // 指定处理器和路径 .setAllowedOrigins("*") // 设置允许的跨域来源,生产环境应严格限制 .addInterceptors(new MyHandshakeInterceptor()); // 添加握手拦截器 } }这里引入了两个新概念:
setAllowedOrigins("*"):解决跨域问题。WebSocket握手阶段会受到同源策略限制。在开发测试时可以用*,但上线后务必替换为具体的、可信的域名列表,如.setAllowedOrigins("https://trusted-domain.com"),这是重要的安全措施。addInterceptors:添加握手拦截器。这是Spring WebSocket非常强大的一个功能,允许我们在握手前后进行拦截,比如进行身份认证、记录日志、设置Session属性等。
4.3 实现握手拦截器(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.support.HttpSessionHandshakeInterceptor; import java.util.Map; public class MyHandshakeInterceptor extends HttpSessionHandshakeInterceptor { /** * 握手之前调用,可以在这里进行身份验证、参数提取等。 * 返回true则继续握手,返回false则终止连接。 */ @Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception { // 例如,从请求参数中获取token进行验证 String token = request.getURI().getQuery(); // 简单演示,实际应从query或header解析 System.out.println("握手前,Token: " + token); // 可以将一些属性存入attributes,后续在WebSocketHandler中可以通过WebSocketSession.getAttributes()获取 attributes.put("clientInfo", "来自拦截器的信息"); // 调用父类方法,以便将HTTP Session中的属性复制到WebSocket Session中(如果需要) return super.beforeHandshake(request, response, wsHandler, attributes); } /** * 握手之后调用 */ @Override public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception ex) { System.out.println("握手完成"); super.afterHandshake(request, response, wsHandler, ex); } }在WebSocketHandler中,你可以这样获取拦截器设置的属性:
@Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { Map<String, Object> attributes = session.getAttributes(); String clientInfo = (String) attributes.get("clientInfo"); System.out.println("获取到拦截器设置的属性: " + clientInfo); // ... }4.4 Spring封装方式的优缺点
优点:
- 完美的Spring集成:
WebSocketHandler本身是单例Bean,可以轻松使用@Autowired进行依赖注入,没有原生方式的那个注入坑。 - 功能强大:天然支持拦截器,方便进行统一的认证、鉴权、日志等操作。
- 易于扩展:是通往更高级特性(如STOMP over WebSocket)的基石。Spring的STOMP支持就是构建在
WebSocketHandler之上的。 - 更好的抽象:提供了
WebSocketSession对象,它是对底层Session的封装,接口更友好。
缺点:
- 抽象层次高:离原始的WebSocket API稍远,如果想做一些非常底层的控制,可能需要多绕一层。
- 学习曲线:对于只熟悉JSR-356的开发者,需要学习一套新的API。
5. 客户端连接测试与常见问题
理论讲完了,我们得实际连一下看看。这里以最常用的浏览器JavaScript客户端为例。
5.1 编写HTML测试页面
创建一个简单的test.html文件,放在Spring Boot的static目录下,或者通过控制器返回这个页面。
<!DOCTYPE html> <html> <head> <title>WebSocket 测试</title> </head> <body> <h2>原生端点测试 (/native/chat)</h2> <button onclick="connectNative()">连接原生端点</button> <button onclick="sendNative()">发送消息(原生)</button> <input type="text" id="nativeMsg" placeholder="输入消息"> <div id="nativeOutput"></div> <hr> <h2>Spring端点测试 (/spring/chat)</h2> <button onclick="connectSpring()">连接Spring端点</button> <button onclick="sendSpring()">发送消息(Spring)</button> <input type="text" id="springMsg" placeholder="输入消息"> <div id="springOutput"></div> <script> let nativeSocket; let springSocket; function connectNative() { // 注意协议是 ws 或 wss (加密) nativeSocket = new WebSocket('ws://localhost:8080/native/chat'); setupSocketEvents(nativeSocket, 'nativeOutput'); } function connectSpring() { springSocket = new WebSocket('ws://localhost:8080/spring/chat'); setupSocketEvents(springSocket, 'springOutput'); } function setupSocketEvents(socket, outputDivId) { socket.onopen = function(event) { log(outputDivId, '连接已打开'); }; socket.onmessage = function(event) { log(outputDivId, '收到消息: ' + event.data); }; socket.onclose = function(event) { log(outputDivId, '连接关闭,代码: ' + event.code + ', 原因: ' + event.reason); }; socket.onerror = function(error) { log(outputDivId, '发生错误: ' + error); }; } function sendNative() { let msg = document.getElementById('nativeMsg').value; if (nativeSocket && nativeSocket.readyState === WebSocket.OPEN) { nativeSocket.send(msg); log('nativeOutput', '已发送(原生): ' + msg); } else { alert('原生连接未建立!'); } } function sendSpring() { let msg = document.getElementById('springMsg').value; if (springSocket && springSocket.readyState === WebSocket.OPEN) { springSocket.send(msg); log('springOutput', '已发送(Spring): ' + msg); } else { alert('Spring连接未建立!'); } } function log(divId, message) { let div = document.getElementById(divId); div.innerHTML += '<p>' + new Date().toLocaleTimeString() + ' - ' + message + '</p>'; } </script> </body> </html>5.2 连接失败问题排查指南
如果点击连接按钮没反应,或者控制台报错,可以按照以下步骤排查:
- 检查服务器是否启动:确保Spring Boot应用已经成功启动,并且没有端口冲突。
- 检查端点路径:
- 确认客户端连接的URL(如
ws://localhost:8080/native/chat)与服务器端@ServerEndpoint或registry.addHandler中定义的路径完全一致。 - 注意上下文路径(Context Path):如果你的应用设置了
server.servlet.context-path=/myapp,那么WebSocket的完整路径应该是ws://localhost:8080/myapp/native/chat。
- 确认客户端连接的URL(如
- 检查依赖和配置:
- 对于原生
@ServerEndpoint:确认类上有@Component注解,并且配置了ServerEndpointExporterBean。 - 对于Spring
WebSocketHandler:确认配置类有@EnableWebSocket注解,并且Handler已正确注册。
- 对于原生
- 查看服务器日志:连接握手失败通常会在服务器控制台打印异常信息,比如
404(路径不对)、403(拦截器拒绝)等。 - 浏览器开发者工具:打开浏览器的Network(网络)选项卡,查看WebSocket连接(类型是
ws)的状态。如果是101 Switching Protocols,说明握手成功。如果是其他状态码(如404, 500),可以查看响应详情。 - 跨域问题:如果前端页面地址(如
http://localhost:63342)与后端地址(ws://localhost:8080)不同源,浏览器会阻止握手。确保在服务器端通过.setAllowedOrigins("*")或指定具体源来允许跨域。再次强调,生产环境不要用*。 - 防火墙/代理问题:确保本地或服务器防火墙没有屏蔽WebSocket使用的端口(通常是80/ws或443/wss)。
6. 进阶话题:心跳、断线重连与分布式会话
一个健壮的WebSocket服务,不能只满足于连通。在实际生产环境中,网络不稳定、服务器重启是常态。下面聊聊如何让WebSocket连接更可靠。
6.1 心跳机制(Heartbeat)
WebSocket连接可能因为网络空闲而被中间设备(如Nginx、防火墙)断开。心跳机制就是客户端和服务器定期发送一个小数据包(Ping/Pong),告诉对方“我还活着”。
服务器端发送心跳:在Spring的WebSocketHandler中,可以在连接建立后启动一个定时任务。
@Component public class MyWebSocketHandler extends TextWebSocketHandler { @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { // ... 其他逻辑 startHeartbeat(session); } private void startHeartbeat(WebSocketSession session) { ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); // 每隔30秒发送一次Ping消息 scheduler.scheduleAtFixedRate(() -> { try { if (session.isOpen()) { // Spring提供了sendPingMessage方法,底层会发送Ping帧 session.sendMessage(new PingMessage()); // 或者发送一个特定的文本消息作为心跳 // session.sendMessage(new TextMessage("ping")); } else { scheduler.shutdown(); // 连接关闭,停止心跳任务 } } catch (IOException e) { e.printStackTrace(); scheduler.shutdown(); } }, 30, 30, TimeUnit.SECONDS); // 需要将会话和scheduler关联起来,在连接关闭时清理资源,这里简化了 } }客户端处理心跳与超时:浏览器WebSocket API可以监听onmessage事件来处理服务器发来的Ping(或特定文本),并定时向服务器发送Pong(或特定文本)响应。
let heartbeatInterval; socket.onopen = function() { log('连接打开'); // 启动心跳,每20秒发送一个ping heartbeatInterval = setInterval(() => { if (socket.readyState === WebSocket.OPEN) { socket.send('ping'); // 或发送二进制的ping帧 } }, 20000); }; socket.onmessage = function(event) { if (event.data === 'ping') { // 收到服务器ping,回复pong socket.send('pong'); return; } // ... 处理其他业务消息 }; socket.onclose = function() { clearInterval(heartbeatInterval); // 清理定时器 log('连接关闭'); };6.2 客户端断线重连
网络波动导致连接断开是不可避免的。一个良好的客户端应该具备自动重连的能力。
let socket; let reconnectAttempts = 0; const maxReconnectAttempts = 5; const reconnectDelay = 2000; // 2秒 function connect() { socket = new WebSocket('ws://localhost:8080/spring/chat'); socket.onopen = function() { log('连接成功'); reconnectAttempts = 0; // 重置重连计数 }; socket.onclose = function(event) { log('连接断开,尝试重连...'); if (reconnectAttempts < maxReconnectAttempts) { reconnectAttempts++; setTimeout(connect, reconnectDelay * reconnectAttempts); // 退避重连 } else { log('重连次数已达上限,请检查网络或联系管理员。'); } }; socket.onerror = function(error) { log('连接错误: ' + error); socket.close(); // 触发onclose进行重连 }; // ... 其他事件监听 }6.3 分布式会话管理
之前我们用内存ConcurrentHashMap保存会话。这在单机环境下没问题,但一旦部署多台服务器,用户可能连接到A服务器,而他的好友连接到B服务器。当A服务器的用户发送消息时,无法直接找到在B服务器上的好友会话进行推送。
解决方案:引入消息中间件常见的做法是使用Redis的Pub/Sub功能或专业的消息队列(如RabbitMQ, Kafka)来解耦。
- 会话标识与存储:用户连接WebSocket时,生成一个全局唯一的会话ID(或使用用户ID),并将
服务器节点ID + 会话ID作为键,存入Redis。同时,订阅一个公共的广播频道。 - 消息发送:当A服务器的某个会话需要发消息时,它不直接遍历本地Map,而是将消息和目标标识(用户ID、群组ID或广播)发布到Redis的特定频道。
- 消息接收与转发:所有服务器节点都订阅了Redis的广播频道。当B服务器从Redis收到一条消息时,它会检查目标会话是否在自己本地。如果在,就通过本地的
WebSocketSession将消息发送出去。
// 伪代码示例:使用RedisTemplate @Component public class RedisMessageListener { @Autowired private RedisTemplate<String, Object> redisTemplate; @Autowired private MyWebSocketHandler webSocketHandler; // 需要能获取到本地会话Map @PostConstruct public void init() { // 订阅频道 redisTemplate.getConnectionFactory().getConnection().subscribe((message, pattern) -> { String channel = new String(message.getChannel()); String body = new String(message.getBody()); // 解析消息体,获取目标用户ID String targetUserId = parseTargetUserId(body); // 查找本地会话并发送 WebSocketSession session = webSocketHandler.getSession(targetUserId); if (session != null && session.isOpen()) { session.sendMessage(new TextMessage(body)); } }, "websocket.broadcast".getBytes()); } // 发送消息到Redis频道的方法 public void sendMessageToRedis(String channel, String message) { redisTemplate.convertAndSend(channel, message); } }这样,无论用户连接到哪台服务器,消息都能通过Redis这个“中转站”准确送达。这是构建大规模、高可用WebSocket应用的关键一步。
7. 性能调优与生产环境注意事项
当你的WebSocket服务用户量上来之后,一些在开发阶段不明显的问题就会暴露出来。这里分享几个关键的调优点和注意事项。
7.1 服务器参数调优
以最常用的Tomcat为例,在application.yml中可以对WebSocket连接进行一些优化:
server: tomcat: max-connections: 10000 # 最大连接数 max-threads: 200 # 最大工作线程数 min-spare-threads: 10 # 最小空闲线程数 # WebSocket相关缓冲区设置,在Spring Boot 2.x+中,部分配置需要通过自定义Bean实现对于WebSocket,更需要关注的是连接超时和缓冲区大小。WebSocket连接是长连接,默认不会超时。但你可以通过自定义ServletWebSocketContainer来配置:
@Bean public ServletWebSocketContainerFactory createWebSocketContainer() { return new ServletWebSocketContainerFactory() { @Override protected void customizeTomcat(ConfigurableTomcatWebSocketContainerFactory container) { // 设置异步发送超时时间(毫秒) container.setAsyncSendTimeout(60000L); // 设置最大会话空闲超时时间(毫秒),超过后服务器会主动关闭连接 container.setMaxSessionIdleTimeout(15 * 60 * 1000L); // 15分钟 // 设置消息缓冲区大小(字节) container.setMaxTextMessageBufferSize(8192); // 8KB container.setMaxBinaryMessageBufferSize(8192); } }; }7.2 使用Nginx反向代理
在生产环境,我们通常不会让客户端直接连接应用服务器,而是前面挂一个Nginx做反向代理和负载均衡。Nginx从1.3版本开始就支持WebSocket代理,配置很简单但很关键:
http { map $http_upgrade $connection_upgrade { default upgrade; '' close; } upstream websocket_backend { server app1:8080; server app2:8080; # 可以配置负载均衡策略,如ip_hash保证同一客户端连接到同一后端 ip_hash; } server { listen 80; server_name your-domain.com; location /ws/ { # 你的WebSocket路径前缀 proxy_pass http://websocket_backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; # 以下两行很重要,防止代理超时断开连接 proxy_read_timeout 3600s; proxy_send_timeout 3600s; } } }核心就是那三个proxy_set_header:Upgrade和Connection头用于告知Nginx这是WebSocket连接,需要“升级”协议。proxy_read_timeout和proxy_send_timeout需要设置得足够长,因为WebSocket是长连接。
7.3 监控与日志
线上服务离不开监控。你需要关注几个核心指标:
- 活跃连接数:反映当前负载。
- 消息吞吐率:每秒收发消息数。
- 连接建立/关闭速率:异常升高可能意味着有问题。
- 错误率:各种错误(握手失败、消息解析失败、发送超时)的数量。
对于Spring封装的WebSocket,你可以实现WebSocketHandlerDecoratorFactory来装饰所有的Handler,从而统一收集 metrics。
@Configuration public class WebSocketMetricsConfig implements WebSocketConfigurer { // ... 其他配置 @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(myWebSocketHandler, "/path") .addInterceptors(new MyHandshakeInterceptor()) .setAllowedOrigins("*") .withSockJS(); // 如果需要SockJS支持可以加这个 } @Bean public WebSocketHandlerDecoratorFactory loggingDecoratorFactory() { return new WebSocketHandlerDecoratorFactory() { @Override public WebSocketHandler decorate(WebSocketHandler handler) { return new WebSocketHandlerDecorator(handler) { @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { // 连接建立,记录日志或增加计数器 log.info("WebSocket连接建立: {}", session.getId()); Metrics.counter("websocket.connections").increment(); super.afterConnectionEstablished(session); } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) throws Exception { // 连接关闭 log.info("WebSocket连接关闭: {}, status: {}", session.getId(), closeStatus); Metrics.counter("websocket.connections").decrement(); super.afterConnectionClosed(session, closeStatus); } }; } }; } }结合Micrometer等监控库,可以将这些指标暴露给Prometheus,再通过Grafana展示,这样你就能对WebSocket服务的健康状态一目了然了。
从最基础的连接建立,到心跳重连保活,再到分布式架构下的会话共享,最后到生产环境的部署调优,这基本上就是一个WebSocket服务从零到一、从一到一百需要经历的关键路径。选择原生注解还是Spring封装,没有绝对的好坏,只有适合与否。对于大多数Spring Boot项目,从与Spring生态无缝集成的角度出发,我更倾向于使用Spring封装的WebSocketHandler方式起步,它的拦截器、STOMP扩展等特性会让后续的开发更顺畅。而如果你需要极致的控制,或者项目本身就是从其他标准容器迁移而来,那么原生的@ServerEndpoint也是一个可靠的选择。关键在于理解其背后的原理,这样无论用哪种方式,当问题出现时,你都能知道该从哪里入手排查。