这段时间一直在折腾若依框架,权限、菜单、用户体系这套基础能力确实省心,但真到做具体业务模块的时候,有个东西基本每次都要自己补:服务端主动给浏览器推数据。站内信要实时出红点、运维监控要往大屏上推告警、客服要跟页面访客开在线会话,这些场景靠前端定时轮询接口撑得了一时,撑不住用户量和实时性的双重压力。把 WebSocket 集成进若依,成了很多做后台项目的开发者迟早要面对的一关。
不少同学第一次搞这块,直接搜个 demo 就往上抄,结果不是握手一直 404,就是连接管理漏了 session 清理,再不然被项目的登录鉴权拦在半路上。这篇文章我把若依里集成 WebSocket 的完整过程、选型理由和实际踩过的坑从头到尾捋一遍,项目里需要实时推送的可以照着改,不需要重复造轮子。
1. 先说结论:为什么若依项目里需要 WebSocket
1.1 哪些业务场景非它不可
先说几个我在实际项目里遇到过的场景。第一个就是站内通知,后台系统里经常有"提交审批、审批通过、有人回复了你"这一类消息,以前的实现方式很原始:前端每隔十秒或者三十秒拉一次未读消息接口,看到有新的就弹提示、刷新红点。用户量小的时候这方案能用,但接口被轮询打得太难看,而且消息到达的体验是延迟的,用户可能提交完在页面等半天才有反馈。
第二个场景是数据大屏和监控面板。大屏上要展示实时成交金额、设备状态、在线人数,这些数据如果靠页面自己在定时器里刷新,整个屏幕会频繁整块重绘,动画不连贯不说,后端接口压力也大。用 WebSocket 之后,服务端一旦发现数据变化就主动推给大屏页面,整个页面可以做到秒级甚至毫秒级更新,大屏体验完全不一样。
第三个场景是客服聊天和一键双工交互。用户在 H5 页面找客服,客服在后台接待,两个人之间需要一条双向通道:用户发一句,客服立刻收到;客服回复,用户那边马上显示。HTTP 轮询在这种场景下体验是断断续续的,哪怕做到每秒拉一次也像在用对讲机。WebSocket 建立后就是一条长连接,消息随发随达。
这些场景说到底都是同一个诉求:服务端要能主动找客户端说话。传统 HTTP 请求-响应模型里,客户端不发请求服务端就没机会开口,WebSocket 就是专门解决这个问题的。
1.2 轮询、长轮询、SSE 和 WebSocket 怎么选
很多人在方案讨论阶段会纠结:到底用轮询还是上 WebSocket,能不能用 SSE(服务端推送事件)凑合。我整理过一个简单的对比,放在团队里讨论方案时直接用。
| 方案 | 连接方式 | 实时性 | 服务端压力 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|---|
| 短轮询 | 反复请求 | 由轮询间隔决定 | 高,请求量巨大 | 最低 | 低频非实时,临时功能 |
| 长轮询 | 请求挂住等响应 | 较好 | 中等,连接占用多 | 中 | 旧系统改造,兼容要求高 |
| SSE | 单向长连接 | 好 | 较低,服务端推 | 较低 | 仅需要服务端单向推送 |
| WebSocket | 双向长连接 | 最好 | 低,一条连接双工 | 中高 | 双向实时交互、在线状态等 |
我的结论很直接:服务端要推的是一对多的广播通知、或者纯展示类数据,SSE 是可以考虑的;但只要涉及双向交互、在线状态维护、用户和用户之间点对点通信,就直接上 WebSocket。虽然编码量比 SSE 多一个拦截器和 session 管理的活儿,但后面扩展聊天、在线客服、多人协作这类功能时,WebSocket 底座是现成的,不用再推倒重来。
另外提一句性能上的体感。以前用短轮询做站内通知,一千个在线用户每 30 秒轮询一次,相当于每分钟多出 2000 个请求,后端接口和数据库压力是实打实的;换成 WebSocket 之后,同样一千个在线用户,只有一千条长连接挂在服务端,每秒心跳和业务消息加起来不过几十条,压力几乎可以忽略。长连接占的是连接数和内存,但对普通后台系统来说远没有轮询的 QPS 冲击来得猛。
2. 集成方案选型与整体设计思路
2.1 为什么用 Spring WebSocket 而不是 STOMP 或 Netty
若依基于 Spring Boot,所以选择上天然有三条路:直接用 Spring 自带的 WebSocket、用 STOMP 协议子协议栈、或者引 Netty 自己搭一套链路。
我的建议是直接用 Spring 的org.springframework.web.socket这套原生 API,不建议一上来就搞 STOMP。原因有三:一是原生 API 足够简单,配置一个 handler、一个拦截器就能跑起来,没有多余的概念;二是 STOMP 虽然提供了订阅、广播这些高级语义,但调试复杂度也上去了,前端要引 STOMP 客户端库,后端要配消息代理,对若依这种单机部署也能跑的后台系统来说属于杀鸡用牛刀;三是项目的登录鉴权基于 Spring Security 或 Shiro,原生的 WebSocket 握手过程可以自己写拦截器,把用户身份从 token 里解析出来,整个过程自己能完全控制,STOMP 多了协议转换的一层,反而更容易出认知偏差。
Netty 就更不用考虑了。Netty 适合做高并发网关、IM 服务这种独立的长连接服务,而若依是典型的 Spring MVC 后台管理系统。在 Spring Boot 项目里再引入一套 Netty 长连接服务,等于同时维护两套应用服务,端口、部署、监控都要单独处理,对绝大多数项目是负收益。
2.2 代码放在哪个模块最合适
用过若依的都清楚,它典型的分模块结构一般长这样:ruoyi-admin是启动入口和 Controller 层,ruoyi-framework放框架配置,ruoyi-common放通用工具,ruoyi-system放系统业务模块。WebSocket 代码我建议新建一个子包放在ruoyi-framework下,比如com.ruoyi.framework.websocket。
这样放的逻辑是:WebSocket 的握手过程要依赖登录鉴权相关的类(比如若依分离版的TokenService),而TokenService本来就属于框架层;同时ruoyi-system里的业务 Service 在消息发生时要调用推送能力,依赖方向是业务层往下依赖框架层,这是若依原本就有的依赖关系。如果项目已经改成单模块结构了,直接建一个com.company.project.websocket包也行,核心是让连接管理类(Session 池)和业务推送方法能被任意业务模块调用,路径上不要彼此绕弯路。
有一点需要留意:WebSocket 配置类上加了@Configuration之后,Spring 扫描要能扫到它所在的包。若依启动类扫描的根包通常是com.ruoyi,只要你不把包建到根包外面,一般没问题。但如果你在若依的某些衍生版本里改了启动类扫描路径,这个小细节很容易让人排查半天。
3. 若依集成 WebSocket 核心代码实战
按我现在的做法,整个过程分六步走:加依赖、写配置类、写 Handler、写握手拦截器、维护 Session 池、打通登录放行。下面按顺序过一遍,都基于若依比较常见的分离版(Spring Security + JWT)来写,Shiro 版本的差异我放在后面提醒。
3.1 添加 Maven 依赖
第一步是在pom.xml里引入 Spring Boot 的 WebSocket starter。若依项目的父工程已经统一管理了 Spring Boot 版本,所以这个依赖一般不需要写<version>。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency>这里有个不少人犯过的迷糊:以为 WebSocket 是 Spring Web 的一部分,项目里已经引了spring-boot-starter-web就不用管了。实际不是,spring-boot-starter-web只包含 Spring MVC 那一套,WebSocket 需要额外这个 starter。不加依赖直接写@EnableWebSocket,应用启动时不会立即报错,但容器初始化到 WebSocket 注册的 bean 时会报NoClassDefFoundError,这类错误出现在启动后半段,比编译期报错难受得多。
3.2 编写 WebSocket 配置类
加完依赖后,新建一个配置类,实现WebSocketConfigurer接口,把 Handler 和拦截器注册到指定路径上。
@Configuration @EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { private final WebSocketHandler webSocketHandler; private final WebSocketHandshakeInterceptor handshakeInterceptor; public WebSocketConfig(WebSocketHandler webSocketHandler, WebSocketHandshakeInterceptor handshakeInterceptor) { this.webSocketHandler = webSocketHandler; this.handshakeInterceptor = handshakeInterceptor; } @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(webSocketHandler, "/ws") .addInterceptors(handshakeInterceptor) .setAllowedOriginPatterns("*"); } }路径/ws是前端连接 WebSocket 时用的端点,后面 Security 或 Shiro 的放行路径也得跟着它写。addInterceptors把握手拦截器挂上去,这样每次前端连接 WebSocket 时都会先经过拦截器做身份校验。
setAllowedOriginPatterns("*")这个配置在新版本 Spring 里很关键,旧版本用的是setAllowedOrigins("*"),但那套对跨域来源的处理比较死板,一旦带Origin头就容易出问题,新版本更推荐setAllowedOriginPatterns。在前端页面和后端不在同一域名的时候,没有这个配置会直接握手失败。
3.3 实现消息处理器 Handler
Handler 是 WebSocket 的核心处理类,负责连接建立、收发消息、连接关闭这些生命周期事件。我写的是继承TextWebSocketHandler,专门处理文本消息。
@Component public class WebSocketHandler extends TextWebSocketHandler { @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { Long userId = (Long) session.getAttributes().get("userId"); if (userId != null) { WebSocketSessionPool.addSession(userId, session); // 这里可以给用户推一条欢迎消息,或者标记在线 } } @Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { String payload = message.getPayload(); // 和前端约定好 ping/pong 心跳 if ("ping".equals(payload)) { session.sendMessage(new TextMessage("pong")); } } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { Long userId = (Long) session.getAttributes().get("userId"); if (userId != null) { WebSocketSessionPool.removeSession(userId); } } @Override public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception { Long userId = (Long) session.getAttributes().get("userId"); if (userId != null) { WebSocketSessionPool.removeSession(userId); } session.close(CloseStatus.SERVER_ERROR); } }连接建立后,我从session.getAttributes()里把userId取出来,这个值是在握手拦截器里放进去的。这里要强调一个新手特别容易搞混的点:WebSocketSession是 Spring 封装的会话接口,不是 Java 自带的javax.websocket.Session。第一次写的时候很容易把javax.websocket.Session引进来,然后发现它根本没有getAttributes()方法,翻半天 API 找不到人。只要 import 的是org.springframework.web.socket.*,这些方法才存在。
handleTransportError我习惯在里面做一次清理并关闭连接。网络异常、服务端主动断开、客户端异常拔网线,这些情况都会走到这个方法,不在里面清理的话,Session 池里会遗留大量已失效连接,推消息时永远报MessageDeliveryException。
3.4 实现握手拦截器,绑定登录用户
握手拦截器的核心作用是在 WebSocket 连接建立之前,拿到当前用户信息,并把它放进 WebSocket 会话的属性里。这样后面的 Handler 才能知道"这个连接是谁"。
@Component public class WebSocketHandshakeInterceptor implements HandshakeInterceptor { @Resource private TokenService tokenService; @Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception { if (request instanceof ServletServerHttpRequest servletRequest) { String token = servletRequest.getServletRequest().getParameter("token"); LoginUser loginUser = tokenService.getLoginUser(token); if (loginUser == null) { response.setStatusCode(HttpStatus.UNAUTHORIZED); return false; } attributes.put("userId", loginUser.getUserId()); attributes.put("username", loginUser.getUsername()); return true; } return false; } @Override public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) { } }这段代码里的TokenService是若依框架里现成的登录用户服务,getLoginUser方法会根据前端传的 token 解析出登录用户。如果 token 无效,直接返回false,握手失败,连接不会建立。
在若依这种带登录鉴权的系统里,前端在连接 WebSocket 时把 token 放在 URL 查询参数上是最简单的做法。也有人会把 token 放在请求头里再写 JS 拦截器去处理,但 WebSocket 握手的头信息在很多前端环境里不好自定义,直接用 URL 参数最简单直接。需要注意一句:URL 参数方式意味着 token 会出现在日志里,如果对安全敏感,可以改成先把 token 换成一个短时有效的随机 code 再拼到 URL 里,后端用 code 换用户信息,这里我不展开细讲。
Shiro 版本的若依没有TokenService,思路可以换成:把/ws加入匿名访问后,在握手拦截器里获取前端传入的业务凭证,再用项目的自定义认证逻辑解析出用户 ID。原理一样,都是"握手必须确定连接归属"。
3.5 管理在线连接的 Session 池
Session 池是 WebSocket 集成里的关键组件。因为项目里要给指定用户推送,就必须把用户 ID 和 WebSocket 会话的对应关系存起来。我直接用一个静态的ConcurrentHashMap来存。
public class WebSocketSessionPool { private static final Map<Long, WebSocketSession> SESSIONS = new ConcurrentHashMap<>(); private WebSocketSessionPool() { } public static void addSession(Long userId, WebSocketSession session) { WebSocketSession oldSession = SESSIONS.put(userId, session); if (oldSession != null && oldSession.isOpen()) { try { oldSession.close(); } catch (IOException e) { // 忽略关闭异常 } } } public static void removeSession(Long userId) { SESSIONS.remove(userId); } public static WebSocketSession getSession(Long userId) { return SESSIONS.get(userId); } public static void sendToUser(Long userId, String message) { WebSocketSession session = SESSIONS.get(userId); if (session != null && session.isOpen()) { try { session.sendMessage(new TextMessage(message)); } catch (IOException e) { removeSession(userId); } } } public static void sendToAll(String message) { for (Map.Entry<Long, WebSocketSession> entry : SESSIONS.entrySet()) { WebSocketSession session = entry.getValue(); if (session != null && session.isOpen()) { try { session.sendMessage(new TextMessage(message)); } catch (IOException e) { removeSession(entry.getKey()); } } } } }比如用户重新登录、或者在两个浏览器标签页打开了同一个系统,会出现同一个userId发来多条连接的情况。我在addSession里做了个处理:新连接进来时,把旧连接直接关掉,保证一个用户最多只有一个活跃连接。这个设计在推送通知时很重要,否则一条消息推两次,前端界面上会出现重复弹窗。
关掉旧连接这个操作要放在finally或者确保SESSIONS.put执行成功后再做,避免新连接还没放进去旧连接已经被关了,造成空档期。
3.6 打通登录鉴权:Spring Security / Shiro 放行
如果只写代码不去改鉴权配置,前端会一直握手失败,因为若依的 Spring Security 会拦截所有请求,未经过认证的 WebSocket 握手请求会被弹回登录页。
分离版若依里,我都是找到SecurityConfig里配置放行路径的地方,在permitAll列表里加上 WebSocket 的端点:
.permitAll() .antMatchers("/login", "/ws/**").permitAll()注意这里的细节:如果你的端点注册路径是/ws,建议写成/ws/**。如果你注册的是/ws/{userId}这种多级路径,一定要把子路径的形式覆盖到。有些项目里路径匹配规则没统一,就漏了一个层级的放行,结果连前端浏览器控制台都报 404。
Shiro 版本不在这套配置里。Shiro 版要改的是ShiroConfig中的ShiroFilterFactoryBean,在filterChainDefinitionMap里把/ws/**配置为anon,也就是匿名可访问,同时实际的身份校验放到握手拦截器里做。这里要提醒一句:anon是"放行给拦截器自研校验"的意思,不是安全漏洞,因为你已经在beforeHandshake里做了 token 校验,握手不通过的人是建立不了连接的。
3.7 业务侧如何主动推送消息
连接管理和身份绑定都齐了之后,最后一步就是在业务代码里调用推送方法。举个例子,发布通知公告时,要给所有在线用户推一条消息。
@Service public class NoticeService { public void publishNotice(Notice notice) { // 省略原有的业务处理逻辑 Map<String, Object> pushData = new HashMap<>(); pushData.put("type", "NOTICE"); pushData.put("noticeId", notice.getId()); pushData.put("title", notice.getTitle()); pushData.put("time", System.currentTimeMillis()); WebSocketSessionPool.sendToAll(JSON.toJSONString(pushData)); } }如果是给指定用户推消息,就换成WebSocketSessionPool.sendToUser(userId, JSON.toJSONString(pushData))。像审批流这种场景,审批人提交后,当前审批人的配置单发生变化,在负责人节点判断出下一步处理人时,直接把待办消息推过去,用户页面不用任何请求操作就能看到列表更新,体验非常顺。
我项目里通常会把推送的内容约定成一个统一的 JSON 结构,至少包含消息类型(type)、业务 ID、标题、时间这几个字段,前端收到后根据 type 决定是弹提示、刷新某个列表还是跳转页面。不要让前端去猜这个推送是干嘛的,结构清晰比省几个英文字符重要。
4. 前端页面对接、心跳与断线重连
4.1 前端 JS 建立连接
后端全部就绪,前端这边其实很简单。若依的前端一般是 Vue + axios,而 WebSocket 不用 axios,浏览器原生支持。下面是前端连接的核心代码。
let ws = null let heartbeatTimer = null let url = '' function connectWebSocket() { const token = getToken() const protocol = location.protocol === 'https:' ? 'wss://' : 'ws://' url = protocol + location.host + '/ws?token=' + token ws = new WebSocket(url) ws.onopen = function () { console.log('WebSocket 连接成功') startHeartbeat() } ws.onmessage = function (event) { const data = JSON.parse(event.data) handlePushMessage(data) } ws.onclose = function () { console.log('WebSocket 连接关闭') stopHeartbeat() setTimeout(connectWebSocket, 3000) } ws.onerror = function (error) { console.error('WebSocket 异常', error) ws.close() } } function handlePushMessage(data) { // 根据 data.type 决定页面行为 // 例如 type === 'NOTICE' 时,刷新未读消息数量并弹提示 }通过location.protocol自动区分ws://和wss://这个写法很实用。部署在 HTTPS 环境下的系统,浏览器会强制要求使用wss://,如果前端还写死ws://,连接会被浏览器直接拦掉,控制台提示混合内容错误。
4.2 心跳保活机制
WebSocket 连接有个特点:长期不传数据,中间的网络设备(比如 Nginx、云负载均衡)有可能把连接闲置断掉,而服务端和客户端可能都是"假活"状态,谁都没感知。最稳妥的做法是客户端定期发一个心跳包,服务端回一个包,双方都确认连接是真的活着。
我这里约定的是文本协议里最简单的一种:客户端每 30 秒发送ping,服务端在handleTextMessage里收到ping就回pong。前端代码如下:
function startHeartbeat() { stopHeartbeat() heartbeatTimer = setInterval(function () { if (ws && ws.readyState === WebSocket.OPEN) { ws.send('ping') } }, 30000) } function stopHeartbeat() { if (heartbeatTimer) { clearInterval(heartbeatTimer) heartbeatTimer = null } }如果超过一定时间没有收到服务端的任何消息,前端也可以认为连接断了,主动触发onclose重连逻辑。通常我在前端约定的是:60 秒内既没有收到消息也没有收到 pong,就主动ws.close(),让重连机制接管。
服务端这边还有一个配置可以配合使用:在注册 Handler 时可以设置空闲会话超时。
registry.addHandler(webSocketHandler, "/ws") .addInterceptors(handshakeInterceptor) .setAllowedOriginPatterns("*");如果要控制服务端空闲断开时间,可以在 Handler 里调用:
session.setMaxSessionIdleTimeout(60000);不过我不建议把这个值设置得太小,因为如果心跳间隔 30 秒、空闲超时 60 秒,网络稍微抖动一下,连接就可能被服务端主动断开,前端还没反应过来就被重连了。一般设置在 120 秒到 300 秒之间比较安全。
5. 高频问题排查与避坑实录
5.1 连接一直 404 或握手失败
WebSocket 连接失败最常见的现象是浏览器 Network 面板里请求状态是 404,或者 101 没出现。这个问题我从两个方向查。
第一优先查鉴权放行。确认若依的 Spring Security 或 Shiro 配置里,/ws/**确实已经在放行列表里。这是个很容易忽略的点,因为 WebSocket 握手请求本身是一次普通的 HTTP GET 请求,会被项目的安全过滤器拦截。改了配置之后注意要重启服务,开发者工具里的缓存经常让人误判。
第二查 Nginx 配置。如果项目是 Nginx 反向代理过的,光有后端放行还不够,Nginx 默认不会转发 WebSocket 的升级请求。要在 Nginx 的 server 或 location 配置里加上下面这段:
location /ws/ { proxy_pass http://你的后端服务地址; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 3600s; proxy_send_timeout 3600s; }proxy_read_timeout和proxy_send_timeout我习惯设到 3600 秒,也就是一小时,避免代理层因为长时间没有数据传输把连接断掉。心跳能续命,但代理层超时是硬配置,超了照样断。
第三查握手拦截器。如果beforeHandshake返回了false,后端日志里会存在安全校验失败的记录。这种情况前端看到的可能不是 404,而是一个 403 或 401。可以临时在拦截器里打一行日志,看是否走到了这个方法,快速定位是哪一环拒绝的。
5.2 连上了,但消息推不过去
连接能建立,说明握手和鉴权都没问题了,这时候推消息失败,十有八九出在 Session 管理上。最常见的坑是:Handler 注册成多例了。
如果WebSocketHandler上用@Scope("prototype"),每个连接进来都是新实例,那afterConnectionEstablished里调用 Session 池的代码没问题,但业务侧调用sendToUser时用的池是同一个静态池,理论上也不冲突。真正常见的冲突点是:连接建立后没往池里放,或者放进去的 session 已经关闭了。
排查方法很简单,在sendToUser里先判断session.isOpen(),再做发送,发送失败后立即从池里移除。上面代码里我已经这么写了,如果你自己排查,可以先看池里的 session 数量和实际在线人数是否一致,不一致就是清理逻辑漏了。
还有一种情况:多个后端实例部署。如果你用 Nginx 负载均衡搭了两台后端服务,用户 A 的连接挂在实例 1,业务推送请求命中实例 2,实例 2 的内存池里根本没有用户 A 的 session,于是推送"成功"但实际消息丢了。这是单机内存 Session 池的天然限制。解决思路有两个方向:一是简单粗暴,把 WebSocket 的连接固定到某个实例上,或者 Nginx 按 IP 哈希分配;二是引入 Redis 的发布订阅,业务推送发到 Redis 频道,所有实例订阅这个频道,谁内存里有这个 session 谁就负责推给客户端。后者是分布式场景的正路,这里不展开完整实现,但可以明确告诉你会遇到这个问题。
5.3 页面刷新后连接没有及时清掉
刷新页面时,旧的 WebSocket 连接会关闭,服务端afterConnectionClosed会被调用,池里的 session 也会被移除。但有一种情况让人很头疼:用户直接拔网线、电脑休眠、或者关掉笔记本盖,TCP 连接并不会立刻感知网络断了,服务端的afterConnectionClosed可能要等很久才触发。
这也是我为什么要在handleTransportError里也做清理。除此之外,Service 层推送时如果发现 session 已经关闭,就顺手清掉池里的记录,也是一种兜底。另外,心跳是把双刃剑,服务端也可以定期扫描池里的 session,检查isOpen(),关掉的直接移除,防止内存慢慢被死连接占满。
5.4 同用户多开页面的消息重复推送
用户开两个标签页,都登录了系统,前后端都建立了 WebSocket。如果不做控制,同一个业务推送会同时到两个页面,用户看到两条一样的通知。我在addSession里用"同用户只保留最新连接"的办法处理了这个问题:同一个userId的新连接建立后,把之前的连接关掉。
这样设计的取舍是:用户在两台设备上同时登录时,消息只会推到最后连接的那台设备,另一台收不到。如果你的业务要求多个端同时收消息,那就不能这样处理,Session 池要从Map<Long, WebSocketSession>改成Map<Long, List<WebSocketSession>>,每条连接维护独立的 session。业务上怎么选要提前想清楚,上线后再改代价会稍微大一点。
5.5 服务端集群问题
前面已经提到了集群部署下内存 Session 池的局限。我再补充一点实操建议:如果是简单项目,直接让 Nginx 对 WebSocket 连接做 IP 哈希就行,因为同一个用户一般不会横跳两台服务器。但业务推送如果可能被调度到任意一台后端节点,这个问题依旧存在,除非所有推送请求也按同样的哈希策略打到同一条 WebSocket 连接所在的节点上。
更工程化的做法是引入 Redis 消息广播:业务模块推送消息时,先把消息写入 Redis 的 pub/sub 频道,所有后端实例订阅这个频道,收到消息后在自己的内存 Session 池里查找目标用户,谁找到了归谁推。这样处理之后,集群节点数随便扩,推送逻辑不用动。代价是维护多一个 Redis 订阅通道,但对于已经用了 Redis 做缓存的项目来说,成本很低。
6. 集成后的一些实操体会
整套集成走下来,我最大的感受是:WebSocket 本身不难,难的是跟已有的鉴权体系、部署架构融合好。很多项目翻车都是翻在这些地方,编码层面的 Handler 反而只是一小段代码。
我习惯在项目里建立一套统一的消息推送规范,字段包含 type、业务 ID、标题、内容、时间,前端只要实现一个handlePushMessage统一处理这些结构,后面任何新业务要推送,后端直接往 Session 池发即可,前端根本不用每次都改。另一个小习惯是把推送动作从业务逻辑里解耦出来,比如发布公告后不要直接在 Service 里写一堆推送代码,而是抽一个专门的推送服务,这样以后要扩展消息类型、增加推送记录历史,都不用动原有业务代码。
最后劝一句:做长连接功能,一定要把心跳和断线重连当成必须实现的部分,而不是可选项。上线之前最好模拟弱网环境测试一下,把网线拔了再插上,看看客户端能不能自动恢复、服务端池里有没有残留死连接。这些小细节提前处理好,后面运维阶段能省掉大量半夜被叫起来处理"用户收不到消息"的麻烦。