前阵子接了个需求:用户在一款上门服务类应用里下单后,服务端需要在瞬间把订单数据推送到对应的工作人员端,同时用户端还得实时看到接单、开始服务、完成服务这些状态变化。最开始我先考虑HTTP轮询,但仔细算了一下就放弃了——如果在线用户有几千人,每隔几秒打一次接口,服务器压力不小,而且数据延迟也没法真正控制在毫秒级。后来把方案定成了WebSocket,用Spring Boot做服务端,配合Nginx做代理,前前后后跑通了整套实时通信链路,也踩了不少坑。这篇文章就是把这次实战的完整过程拆开讲一遍:WebSocket到底解决了什么问题、Spring Boot里怎么落地、前端怎么对接、部署时有哪些隐藏的坑,以及最常见的报错该怎么排查。内容适合刚接触WebSocket的后端开发,也适合那些接口能跑通、但一上线就连接不稳、频繁断线的人参考。
1. 为什么实时通信场景绕不开WebSocket
1.1 HTTP轮询的痛:你的接口在“空转”
很多人第一反应就是轮询,毕竟HTTP协议大家都熟,前端定时调接口也能“假装实时”。但轮询的本质是客户端主动去问,服务端被动回答,这个模式下有几个痛点。
第一个痛点是浪费资源。假设一个订单状态机包含下单、接单、开始服务、完成、评价五个状态,如果最近一个状态变化要2秒内让用户看到,前端就得每2秒请求一次,哪怕服务端数据一点没变,请求照样要打过来。每一个轮询请求都意味着一次完整的HTTP握手、业务处理、响应返回,高峰期接口QPS会莫名其妙高出一大截,数据库和带宽都被无效请求白占。
第二个痛点是延迟不可控。轮询间隔设短了服务器扛不住,设长了用户感知就是“卡”。业务高峰期服务端响应变慢,轮询周期还会被拉长,实时性更差。而WebSocket不一样,它是客户端和服务端之间建立一条长连接,连接建立之后,服务端任何时刻都可以主动把数据推给客户端,延迟在毫秒级,而且没有数据的时候连接基本不产生额外流量,这是轮询做不到的。
第三个痛点是代码层面不好维护。为了模拟“服务端主动推送”,轮询方案里往往还要配合乐观锁、版本号、时间戳去判断数据有没有变化,逻辑越写越绕。WebSocket方案里服务端直接下发完整消息,客户端按类型处理就行,业务逻辑清晰得多。
1.2 WebSocket一次握手,双向跑数据
WebSocket本质上是一条建立在TCP之上的长连接,复用HTTP的80/443端口完成“握手”。握手阶段客户端发一个带Upgrade: websocket头的HTTP请求,服务端确认后返回101状态码,之后这条连接就升级成了WebSocket,双方可以互相发数据帧,不再受HTTP“一来一回”的限制。
这里可以拿打电话类比。HTTP请求就像寄快递,每次都要写地址、填单号、对方签收,一来一回都是独立事件;而WebSocket就像拨通电话后一直占着线,两边随时可以说话,不用每次开口都先通话。打电话当然要比寄快递多占一条线路,但如果你是在做实时沟通,这条线路带来的效率提升是质变的。
从协议层面看,WebSocket的数据帧非常轻量,一条消息只需要几个字节的控制头,相比HTTP请求里那一大堆Header,传输效率高很多。对于频繁推送的业务场景,这种差距在流量账单上看得很明显。另外WebSocket原生支持二进制帧,传文件、传图片流也很方便,不像HTTP轮询得考虑怎么封装二进制数据。
有个点是很多人容易忽略的:WebSocket虽然和HTTP共用握手端口,但它一旦升级成功,就不再受HTTP请求响应模型的限制,所以Nginx、网关这些中间件必须特殊配置才能正确转发。这也是很多后端写完代码本地一切正常、一到服务器就疯狂断连的直接原因,后面会详细讲。
1.3 Spring Boot集成WebSocket的三种路线,怎么选
Spring Boot集成WebSocket有几种常见方案,我实际用下来各有侧重,选不对后面会走弯路。
第一种是原生注解@ServerEndpoint,配合ServerEndpointExporter注册。这是最接近Java WebSocket规范(JSR-356)的方式,代码简洁、直白,一个类搞定连接管理和消息收发。对于大多数点对点推送、群发广播的场景,这个方案完全够用,而且不引入额外的协议层,调试也方便。我这次项目用的就是这种。
第二种是Spring框架封装的WebSocketHandler,实现TextWebSocketHandler接口,配合HandshakeInterceptor拦截握手。这种方式和Spring MVC结合更紧密,可以利用Spring的拦截器链做鉴权、参数解析,适合需要精细化控制握手过程的项目。但代码量会多一些,连接管理也要自己维护。
第三种是STOMP协议,基于WebSocket封装了一套类似消息队列的语义,支持订阅、点对点、广播这些概念,配合Spring的@MessageMapping注解,写起来像写Controller。这个方案适合业务逻辑比较复杂、消息类型很多、要按主题分发的大项目,但它对初学者来说协议层比较重,出了问题排查困难。
我的建议很直接:如果只是做“服务端推消息给客户端”这类常规实时通信,@ServerEndpoint是最快的路径,也是这篇文章后面要展开的重点。STOMP适合团队对消息模型有清晰规划、需要长期演进的大型系统,别为了“架构先进”一上来就上STOMP,简单场景复杂化会给自己找麻烦。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 服务端代码怎么写:从连接管理到主动推送
2.1 项目依赖拉起来只要十秒
Spring Boot里引入WebSocket非常简单,在pom.xml里加一个依赖就够了:
xml复制<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
我用的是Spring Boot 2.7.x版本,这个版本依赖里自带Tomcat 9.0.x,对WebSocket的支持已经很成熟。如果你的项目是Spring Boot 3.x,本质逻辑不变,只是底层的Jakarta命名空间不同,代码里基本不用改。加完依赖后还需要一个配置类来注册WebSocket端点,因为Spring Boot默认不会自动扫描@ServerEndpoint:
java复制@Configuration
public class WebSocketConfig {
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}
这段配置的作用是把所有带@ServerEndpoint的类注册到WebSocket容器里,让它们对外提供服务。有个细节要记住:如果你用的是Spring Boot内置Tomcat跑项目,这个Bean必须有;但如果你把项目打成war包部署到外部Tomcat,这个Bean反而会导致冲突,部署时要删掉或注释掉。这个问题后面部署章节还会重点说。
2.2 @ServerEndpoint核心代码拆解
下面是我们项目的核心类,我先贴完整代码,再逐段拆解:
java复制@Component
@Slf4j
@ServerEndpoint("/ws/{userId}")
public class WebSocketServer {
private static final Map<String, Session> SESSION_MAP = new ConcurrentHashMap<>();
@OnOpen
public void onOpen(Session session, @PathParam("userId") String userId) {
SESSION_MAP.put(userId, session);
log.info("用户连接成功: userId={}, 当前在线数: {}", userId, SESSION_MAP.size());
}
@OnClose
public void onClose(@PathParam("userId") String userId) {
SESSION_MAP.remove(userId);
log.info("用户断开连接: userId={}, 当前在线数: {}", userId, SESSION_MAP.size());
}
@OnMessage
public void onMessage(String message, @PathParam("userId") String userId) {
log.info("收到用户消息: userId={}, message={}", userId, message);
// 业务处理:解析消息、路由转发等
}
@OnError
public void onError(Session session, Throwable error) {
log.error("WebSocket连接异常: userId={}, error={}", session.getId(), error.getMessage());
}
public static void sendToUser(String userId, String message) {
Session session = SESSION_MAP.get(userId);
if (session != null && session.isOpen()) {
try {
session.getBasicRemote().sendText(message);
} catch (IOException e) {
log.error("消息发送失败: userId={}", userId, e);
}
}
}
public static void broadcast(String message) {
SESSION_MAP.values().forEach(session -> {
if (session.isOpen()) {
try {
session.getBasicRemote().sendText(message);
} catch (IOException e) {
log.error("广播消息发送失败, sessionId={}", session.getId(), e);
}
}
});
}
}
先看注解。@ServerEndpoint("/ws/{userId}")定义了WebSocket的访问路径,{userId}是路径参数,可以解析出连接属于哪个用户。@OnOpen、@OnClose、@OnMessage、@OnError四个注解对应连接的四个生命周期事件,这是Java WebSocket规范规定的回调方法,Spring Boot封装的容器会自动调用它们。
SESSION_MAP是整个类的核心,用ConcurrentHashMap保存每个在线用户的Session对象。Session就是一条WebSocket连接的抽象,持有它就能给对应客户端发消息。为什么用静态Map?因为@ServerEndpoint的实例生命周期和Spring的单例Bean不同,每个连接都会创建新的实例,如果Map是实例变量,各个连接之间就没法共享在线用户列表,必须用静态变量或者单独抽一个管理类。这里一定要深刻理解,我见过不少人在这踩坑。
2.3 用户维度连接管理:不能只有一个Map
上面的代码能跑,但放到真实业务里还有一个问题:一个用户可能同时在手机端、Web端、平板端登录,每个端会建立一条WebSocket连接,此时Map<String, Session>一个userId只能保存一个Session,后来的连接会把前面的顶掉,导致用户一边收不到消息。
解决思路有两种。如果你确认每个用户只需要一条在线连接,那普通Map就够;但更稳妥的做法是用Map<String, Set<Session>>,同一个userId维护一组Session,发消息时遍历整个集合:
java复制private static final Map<String, Set<Session>> SESSION_MAP = new ConcurrentHashMap<>();
public static void sendToUser(String userId, String message) {
Set<Session> sessions = SESSION_MAP.get(userId);
if (sessions != null) {
for (Session session : sessions) {
if (session.isOpen()) {
try {
session.getBasicRemote().sendText(message);
} catch (IOException e) {
log.error("消息发送失败: userId={}", userId, e);
}
}
}
}
}
连接移除时也要同步处理,当某个userId的Set为空了,记得把整个key从Map里删掉,否则会留下很多空集合,白白占内存。这个细节看起来小,但在用户量大、频繁上下线时能明显减少内存占用,日志里也不会有那种“明明显示在线却发不进去”的假数据。
另外一个隐藏问题是ConcurrentHashMap的复合操作,比如检查session.isOpen()然后再sendText(),这两步之间连接可能刚好断了,发送时会抛IOException。所以发送代码里必须有try-catch,同时把异常日志打全。别想着省事,消息发送失败的日志是后面排查线上问题的关键线索。
2.4 服务端主动推送的几种触发场景
WebSocket服务端最有价值的能力就是主动推送,代码里体现为sendToUser和broadcast两个静态方法。这两个方法设计为静态,是为了让业务层的任意地方都能直接调用,不必先注入WebSocketServer这个Bean。
实际业务中我总结了几种典型的触发场景:
第一种是业务事件触发。比如用户下单后,订单服务在事务提交后调用WebSocketServer.sendToUser(workerId, JSON.toJSONString(orderMessage)),把新订单消息推给工作人员端。这种推送一般放在业务代码里,和业务操作同步执行。需要注意的事务问题:如果WebSocket推送失败不能影响主业务,最好用try-catch包住,别让推送异常把订单流程搞挂了。
第二种是定时任务触发。比如系统每分钟推送一次在线统计、股票行情、预警提醒。这里可以用Spring的@Scheduled注解写定时任务,然后调用broadcast方法广播给所有在线用户:
java复制@Component
public class MetricsPushTask {
@Scheduled(fixedRate = 60000)
public void pushMetrics() {
String message = JSON.toJSONString(buildMetricsMessage());
WebSocketServer.broadcast(message);
}
}
注意@Scheduled默认是单线程执行,如果一个任务执行时间超过间隔,后续任务会排队。定时推送里如果还要查数据库、调接口,建议用@Async配合线程池,或者把@Scheduled的调度器线程池调大。
第三种是系统通知触发。比如运营后台发公告、版本更新提示,这类广播消息往往要推给所有在线用户。broadcast()方法在这里就很有用。如果项目里有MQ,也可以让消费者在收到消息后调用broadcast,实现“消息队列→WebSocket”的实时推送链路,这也是实时数据管道里最常见的组合。
还有一个要点是消息格式。线上线下联调时我发现,前端最怕收到格式不统一的消息。建议定义统一的消息包装类,至少包含type和data两个字段,type区分消息类型,data放具体业务数据。比如:
json复制{
"type": "NEW_ORDER",
"data": {
"orderId": "123456",
"amount": 99.00
}
}
前端拿到消息后先判断type,再走不同的处理逻辑,这样扩展新消息类型时不用改框架,只加一个枚举值就行。
3. 前端对接、Nginx代理与生产部署
3.1 前端JS建立连接:重连和心跳是绕不开的两件事
服务端写好了,前端不配合也白搭。WebSocket前端的核心逻辑就是:建立连接、监听消息、处理断开、重连、心跳保活。下面是一份我整理过的通用JS封装:
javascript复制class RealtimeClient {
constructor(userId, options = {}) {
this.userId = userId;
this.reconnectDelay = options.reconnectDelay || 3000;
this.heartbeatInterval = options.heartbeatInterval || 30000;
this.ws = null;
this.heartbeatTimer = null;
this.reconnectTimer = null;
this.connect();
}
connect() {
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
this.ws = new WebSocket(`${protocol}://${location.host}/ws/${this.userId}`);
this.ws.onopen = () => {
console.log('WebSocket连接建立');
this.startHeartbeat();
};
this.ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
this.handleMessage(msg);
};
this.ws.onclose = () => {
console.log('WebSocket连接断开,准备重连');
this.stopHeartbeat();
this.scheduleReconnect();
};
this.ws.onerror = (error) => {
console.error('WebSocket错误', error);
};
}
handleMessage(message) {
// 根据消息类型做不同处理
console.log('收到消息', message.type, message.data);
// 业务逻辑自行扩展...
}
startHeartbeat() {
this.heartbeatTimer = setInterval(() => {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: 'PING' }));
}
}, this.heartbeatInterval);
}
stopHeartbeat() {
clearInterval(this.heartbeatTimer);
}
scheduleReconnect() {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = setTimeout(() => this.connect(), this.reconnectDelay);
}
send(message) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message));
} else {
console.warn('WebSocket未连接,消息发送失败');
}
}
close() {
clearTimeout(this.reconnectTimer);
this.stopHeartbeat();
this.ws.close();
}
}
这里最核心的是重连和心跳两个机制。
重连是必须的。WebSocket连接会因为网络波动、代理超时、服务端重启而断开,如果不做自动重连,页面就得整体刷新才能恢复通信。断线重连的频率也很重要,我一般用3秒到5秒的延迟,别在断线后立刻疯狂重连,一方面浪费资源,另一方面服务端可能还没恢复,连了也白连。
心跳机制则是为了保活长连接。许多中间代理层(包括Nginx、云负载均衡)会主动断开长时间没有数据传输的连接。为了防止连接被中间层“没收”,客户端需要定时发送一个PING帧,服务端收到后可以回一个PONG,这样连接会保持活跃,代理层也不会误判为空闲连接。这里的间隔设置很关键,太短浪费资源,太长可能被代理提前断掉,30秒到60秒是实践下来比较稳的区间。
3.2 Nginx代理WebSocket的配置要点
本地开发连的是localhost:8080,一切正常。一旦部署到服务器,如果用户在Nginx后面访问,就很容易出现“页面能打开,WebSocket一直连不上”的问题。原因前面提到过:WebSocket握手依赖Upgrade头,而Nginx默认不转发这个头,必须显式声明。我用的Nginx配置如下:
nginx复制server {
listen 80;
server_name your-domain.com;
location /ws/ {
proxy_pass http://backend-server:8080/ws/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}
配置里每一行都有它的道理。proxy_http_version 1.1必须指定为1.1版本,因为HTTP/1.0不支持Upgrade机制;proxy_set_header Upgrade $http_upgrade把客户端发来的Upgrade头原样转发给后端;Connection "upgrade"固定写上upgrade标识。这三行缺一不可,少一行WebSocket握手就过不去。
proxy_read_timeout和proxy_send_timeout也是容易踩坑的点。Nginx默认的代理超时时间是60秒,如果连接建立后60秒内没有任何数据传输,Nginx会主动断开这条连接。对于实时通信应用来说,空闲一段时间就断线是不可接受的,所以要把超时时间调大,我一般调到3600秒。如果你前面做了心跳保活,理论上连接会一直有数据流动,超时时间即使小一点问题也不大,但为了保险还是调大更稳。
生产环境如果有HTTPS,Nginx配置里还要把SSL证书加上,然后前端连接地址要改成wss://。WebSocket的wss协议和HTTPS一样走TLS加密,端口同样是443,只要Nginx配好了SSL证书,location /ws/这块会自动支持wss,前端不用改端口,只用改协议前缀。
3.3 外部Tomcat部署与原理解析
如果你不是用Spring Boot内置Tomcat直接跑jar包,而是把项目打成war包丢到外部Tomcat的webapps目录下,那要特别注意ServerEndpointExporter这个Bean的问题。
按我上面的代码,Spring Boot项目默认用内置Tomcat时,ServerEndpointExporter会扫描@ServerEndpoint注解并注册WebSocket端点。但外部Tomcat有自己的WebSocket扫描机制,会自己扫描并注册一次,如果Spring Boot这边又注册一次,端点就被重复注册,启动时会报类似“无法对端点进行部署”的错误。解决办法很简单:在外置Tomcat部署时,把WebSocketConfig里那个@Bean方法注释掉或加一个条件判断。
另外外部Tomcat默认对WebSocket请求有路径限制,WebSocket端点的路径必须符合Servlet的映射规则。如果你部署在多应用环境下,还要注意上下文路径对/ws/{userId}的影响。前端在拼接WebSocket URL时要考虑到context-path,不然连接会404。这一点很多人容易忽略,我上线时就因为war包部署带了应用名,排查了半天才发现是路径少了一段。
4. 实战中踩过的坑:从报错到排查
4.1 连接刚建立就被断开(1006、1001、1002状态码)
WebSocket关闭时会有CloseCode,排查问题时先看这个码,能帮你快速定位方向。我在实战中见到最多的是下面几个:
1006是“异常关闭”,这是最让人头疼的码,因为它表示连接不是通过正常的Close帧关闭的,所以拿不到具体原因。绝大多数1006都出现在中间代理层,比如Nginx配置不对、负载均衡超时、防火墙掐断空闲连接。如果客户端报1006,先去查Nginx的Upgrade配置和超时时间。
1001是“Going Away”,服务端或代理要重启、下线时会发这个码,一般属于正常运维。如果频繁出现1001,看看服务端是不是有定时重启,或者内存不足导致进程被管理系统杀了。
1002是“协议错误”,说明握手或帧格式不对。前后端WebSocket版本不兼容、客户端发的不是合法的WebSocket请求时会遇到。我碰到过一次是前端代码里WebSocket地址拼错了,把ws://写成http://,结果连接被当成普通HTTP请求处理,服务端返回正常HTTP响应,前端自然解析不了。
排查思路可以遵循一个顺序:先看服务端日志有没有“连接建立”和“连接异常”记录;再看浏览器开发者工具的Network面板,WebSocket连接的状态和CloseCode都会显示;如果连接要经过Nginx,把Nginx的access log打开,看请求的Upgrade头是否正常转发。从日志到代码一步步缩小范围,比瞎猜快得多。
4.2 “stream disconnected before completion: websocket closed by server before res”这类报错是怎么回事
这个报错在浏览器控制台、Java客户端日志里都出现过,很多人第一次看到会很慌。字面意思是“流在完成前被断开,WebSocket被服务端在响应完成前关闭了”。
出现这种报错,最常见的场景是客户端发起WebSocket连接,服务端还没完成握手响应(101状态码),连接就被关闭了。原因通常是这几类:
服务端应用启动失败或端点注册异常,导致容器根本没有拉起WebSocket监听;Nginx或负载均衡配置不对,握手的Upgrade头被拦掉,后端收到的不是合法的升级请求;服务端@OnOpen回调里抛了异常,比如路径参数解析失败、业务初始化出错,连接建立就被中断。
我实际遇到的一次是@OnOpen里往Redis写缓存,Redis连接超时导致onOpen抛了NullPointerException,结果连接建立失败。这个问题的教训是:@OnOpen回调里千万别做容易失败的重操作,即使要做,也要用try-catch兜底,保证抛异常不会影响WebSocket本身的建立流程。连接先建立起来,消息再往后推,这才是稳妥的做法。
4.3 浏览器崩溃与内存泄漏排查
热词里有一个“websocket导致浏览器崩溃”,听起来很可怕,但拆开看,原因基本是三类。
第一类是页面里创建了大量WebSocket连接没有释放。单页应用路由切换时,组件被销毁但WebSocket对象没关闭,每切一次页面就新开一条连接,旧连接还在后台挂着,积累到几百条,浏览器内存必然暴涨。解决办法是在组件销毁钩子里调用close()方法,同时把全局的WebSocket引用置空。
第二类是服务端推送频率过高、数据量过大,前端onmessage回调里又做大量计算,导致主线程卡死。排查时看浏览器任务管理器的内存和CPU占用,如果一直飙升,就要检查服务端推送逻辑。推送可以做成批量合并:把极短时间内要推给同一个用户的多条消息合并成一条数组消息推过去,前端一次处理一批,能显著降低渲染压力。
第三类是消息里包含超大JSON,前端反复解析、渲染,内存回收跟不上。这种情况要给消息大小设上限,比如服务端限制单个消息不能超过1MB,超了就走下载或分块推送。
还有一个容易忽略的:页面在后台标签页时,浏览器会降级定时器和渲染频率,WebSocket重连逻辑和消息处理都会受影响。如果业务要求后台也要保持连接,可以监听visibilitychange事件,页面重新可见时主动检查连接状态并重连。
4.4 常见问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 连接状态一直是CONNECTING,然后失败 | Nginx未配置Upgrade转发 | 检查proxy_set_header Upgrade和Connection配置 |
| 连接能建立,但60秒左右自动断开 | Nginx代理超时时间太短 | 调大proxy_read_timeout和proxy_send_timeout |
| 报错1006 | 中间层异常关闭,无法获知具体原因 | 检查Nginx、防火墙、负载均衡配置,看服务端日志 |
| 报错1001 | 服务端主动关闭,可能是重启或下线 | 检查服务端进程、内存、发布日志 |
@OnOpen里注入的Spring Bean为null |
没有使用Spring的Bean管理API获取Bean | 用SpringContextUtils.getBean()或实现ApplicationContextAware |
| 外置Tomcat部署时端点注册两次 | ServerEndpointExporter与外置容器冲突 |
外置Tomcat部署时移除ServerEndpointExporter Bean |
| 浏览器标签页越来越多、内存暴涨 | 页面切换时WebSocket未关闭 | 组件销毁时调用close(),监听页面可见性 |
5. 进阶玩法:集群环境下的消息广播与鉴权
5.1 单机Session Map的局限
前面代码里的SESSION_MAP都是存储在JVM内存里的,这在单机部署下没有问题,但一旦应用扩展到多节点部署,马上就出问题。假设你有两个应用实例A和B,用户1连接在A上,用户2连接在B上,A节点要推送消息给用户2时,查A节点的SESSION_MAP根本找不到用户2的Session。还有会话状态不一致的问题:用户连接断开后,只有他所在的那个节点会删除Session,其他节点的Map里还留着旧数据,白白占内存。
解决集群问题有几个思路。如果项目规模不大,最简单的是用IP哈希负载均衡,让同一个用户的连接固定打到同一个实例上,这样单机Session Map依然有效。但这样做的容灾性很差,一个节点挂了,挂在它上面的所有连接全断,而且无法平滑扩容缩容。
更通用的方案是引入消息中间件,把“推送事件”广播到所有节点,每个节点检查自己的本机Session Map,如果目标用户恰好在自己这个节点上,就执行推送给客户端。这就是典型的“发布订阅”模式,也是我推荐的方式。
5.2 用Redis发布订阅打通多节点
Redis的发布订阅(Pub/Sub)是实现WebSocket节点间广播最轻量的一种方式。整体思路是:每个应用节点都订阅同一个频道,比如websocket:order;当某个节点收到业务推送请求时,不是直接查本机Map,而是往这个频道发一条消息;所有节点(包括自己)订阅到这个消息后,各自检查本机Session Map里有没有目标用户,有就推送。
Spring Boot里用StringRedisTemplate实现发布订阅非常简单。发布端:
java复制public class WebSocketPublishService {
private final StringRedisTemplate redisTemplate;
public WebSocketPublishService(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
public void publish(String channel, String message) {
redisTemplate.convertAndSend(channel, message);
}
}
订阅端用RedisMessageListenerContainer注册监听器:
java复制@Configuration
public class RedisMessageConfig {
@Bean
public RedisMessageListenerContainer container(RedisConnectionFactory factory,
MessageListenerAdapter adapter) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(factory);
container.addMessageListener(adapter, new ChannelTopic("websocket:order"));
return container;
}
@Bean
public MessageListenerAdapter listenerAdapter(RedisMessageReceiver receiver) {
return new MessageListenerAdapter(receiver, "onMessage");
}
}
RedisMessageReceiver里拿到消息后,从消息体中解析出目标userId,再调用WebSocketServer.sendToUser完成本地推送。这套方案的优点是不需要额外引入MQ组件,Redis本来就是很多项目的基础设施,加一个发布订阅零额外成本。
但要注意Redis Pub/Sub的一个限制:消息不会持久化,如果某个节点正在重启,它错过的消息就丢了。对实时通信来说,用户本来就是要实时收消息的,短暂重启期间的消息丢了影响其实不大,因为客户端重连后一般会主动拉取全量数据补齐。如果业务要求不丢消息,就得考虑用Redis Stream或者完整的MQ方案,并配合消息确认机制。
5.3 连接鉴权与业务解耦
WebSocket连接建立时的路径参数其实是不安全的。/ws/{userId}这种方式,任何人只要知道别人的userId就能伪造成对方建立连接,这在正式业务里是不可接受的。
标准的做法是使用Token鉴权。前端在连接时通过查询参数传一个短期有效的Token,比如/ws?token=xxx。服务端在握手阶段校验Token,校验通过才允许建立连接,同时从Token里解析出真正的userId,而不是直接信任路径参数。如果用的是@ServerEndpoint,握手阶段的鉴权可以通过ServerEndpointConfig.Configurator的modifyHandshake方法实现,或者直接在@OnOpen里校验Token,校验失败就调用session.close()关闭连接并返回错误信息。
另一个经验点是业务和WebSocket层要解耦。别在WebSocketServer里写太多业务逻辑,比如查数据库、调其他服务。WebSocketServer只负责连接的维护和消息收发,收到消息后把消息丢给业务层处理,业务层处理完再调用推送接口。这样做的好处很明显:消息的接收和处理可以异步化,不阻塞WebSocket线程;业务逻辑变化时不需要改动连接层代码;单元测试也更方便。
我实际的项目里会封装一层MessageRouter,根据消息的type字段路由到不同的Handler,每个Handler只处理一种消息类型。新加一个消息类型时,只需要新增一个Handler类,不需要动WebSocket核心代码。这个模式在消息种类多的系统里非常好用,强烈推荐。
最后再分享一个小技巧:WebSocket调试阶段,可以不用急着写前端页面,先用在线WebSocket测试工具连服务端验证消息收发,排除前端代码干扰,快速确认服务端逻辑正确。等后端接口稳定了再写前端逻辑,能省不少事。我在实际项目里一直保持这个习惯,排查问题的效率提升很明显。
