资讯动态

RuoYi-Vue-Plus WebSocket实战:从鉴权到微服务跨节点推送

发布时间:2026/9/23 3:23:55 来源:尧图企业网站定制
被标题里的“(2)”点进来的朋友应该都看过我之前那篇WebSocket入门文章了。简单回顾一下上一篇主要讲了怎么在RuoYi-Vue-Plus里把原生WebSocket跑起来前端能连通、服务端能收到消息算是完成了“从0到1”。但说实话那篇只是解决了“通”的问题距离“能用”还很远——鉴权怎么做握手时怎么拿到用户信息消息怎么按用户精准推送重启服务会不会session全丢更别提微服务版RuoYi-Cloud-Plus下面临的跨节点推送问题。这篇我把实际项目里踩过的坑、写过的代码、最后沉淀下来的方案全部展开内容比较多建议收藏了慢慢看。这篇适合谁已经在RuoYi-Vue-Plus框架上做开发需要用WebSocket做消息通知、在线状态、扫码登录、实时进度这类功能的后端同学。如果你只是随手搜到这篇、对若依不熟我也尽量把原理讲明白了你拿着思路也可以迁移到别的Spring Boot项目里。1. 先理清思路WebSocket在若依框架里该怎么定位1.1 为什么不用现成的WebSocket注解方案很多教程上来就是ServerEndpoint加OnOpen、OnMessage十分钟就跑通。这种方案在普通Spring Boot项目里确实没毛病但放在RuoYi-Vue-Plus里会有两个问题第一ServerEndpoint的生命周期不受Spring容器管理。虽然通过ServerEndpointExporter把端点交给Spring管理后可以依赖注入但拦截器、握手阶段的鉴权逻辑写起来非常别扭社区里全是自己撸HandshakeInterceptor。第二若依框架和Spring家族结合得非常深安全验证用的是Sa-Token这套东西要跟WebSocket握手阶段打通用Spring原生WebSocketHandler配合HandshakeInterceptor是最顺的路径。所以我最终选了Spring WebSocket原生方案核心组件就三个WebSocketConfigurer注册WebSocket路由绑定Handler和拦截器HandshakeInterceptor在握手阶段拦截解析token、绑定用户身份TextWebSocketHandler处理连接建立、接收消息、连接关闭下面所有内容都基于这个骨架展开。1.2 当前方案的架构拆解我在RuoYi-Vue-Plus里把WebSocket相关功能单独拉了一个模块ruoyi-websocket没有塞进system模块。这样做的原因是WebSocket在业务上往往横跨多个模块——通知、日志推送、在线用户、大屏实时数据谁都要用。如果塞在某个业务模块里其他模块引用过来会造成循环依赖拆成独立模块后谁都能引清爽很多。模块内部按职责分了四块类名职责WebSocketConfig路由注册、拦截器装配WebSocketAuthInterceptortoken解析、用户身份绑定WebSocketSessionManager会话管理、按用户精准推送BusinessWebSocketHandler业务消息处理、生命周期回调这套结构下WebSocket连接从建立到关闭的完整流程是客户端发起握手请求URL带token参数ws://ip:port/ws?tokenxxxWebSocketAuthInterceptor拦截握手取出token调用Sa-Token的StpUtil.getLoginIdByToken解析用户解析成功把userId写进WebSocketSession的attributes里握手放行BusinessWebSocketHandler收到连接建立事件把session注册进WebSocketSessionManager后续所有消息收发、用户推送、状态变更都通过WebSocketSessionManager完成这个流程最核心的一点握手阶段必须完成身份识别绝不在收到第一条消息时才去鉴权。否则连接占用了消息才被拒绝客户端体验是“连上了但用不了”而且Service层的推送根本不知道往哪个session发。2. 核心实现鉴权握手、会话管理与消息推送2.1 握手鉴权和Sa-Token打通RuoYi-Vue-Plus用的是Sa-Token和原版若依的JWT方案不一样。JWT拿到token字符串自己解析就行Sa-Token的token是随机字符串真实用户信息存在Redis里必须调用它的API才能取到登录用户。这一点要注意。握手拦截器我写成了这样Component public class WebSocketAuthInterceptor implements HandshakeInterceptor { Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, MapString, Object attributes) { if (request instanceof ServletServerHttpRequest) { ServletServerHttpRequest servletRequest (ServletServerHttpRequest) request; String token servletRequest.getServletRequest().getParameter(token); if (StrUtil.isBlank(token)) { return false; } try { // Sa-Token按token解析登录用户 LoginHelper loginHelper SpringUtils.getBean(LoginHelper.class); LoginUser loginUser loginHelper.getLoginUser(token); // 把用户ID、用户类型、登录端写进attributesHandler里可以直接用 attributes.put(userId, loginUser.getUserId()); attributes.put(loginUser, loginUser); return true; } catch (Exception e) { // 解析失败直接拒绝握手 return false; } } return false; } Override public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) { // 握手中的后置逻辑一般不需要处理 } }注意几个细节握手阶段的HTTP请求是ServletServerHttpRequest拿参数和普通HttpServletRequest一样loginHelper.getLoginUser(token)这个方法是RuoYi-Vue-Plus里LoginHelper提供的有的版本API叫做StpUtil.getLoginIdByToken按你用的版本调整token从URL参数传能避免WebSocket API无法自定义Header的困境。原生WebSocket构造函数不支持带Header这也是很多WebSocket鉴权都走URL参数的原因握手失败返回false时客户端会收到握手失败的异常前端要在onerror里给出友好提示不能只靠onclose2.2 会话管理不重复造轮子但要做对若依框架本身带一个WebSocketUsers工具类维护了一个MapString, WebSocketSession。但我实际用下来觉得过于简单有两个硬伤一是key用的是sessionId想按userId精准推送到人非常别扭二是没有保存用户维度信息多个端比如PC端、手机端同时在线时后登录的会把先登录的顶掉。我自己重写的WebSocketSessionManager长这样Component public class WebSocketSessionManager { /** * userId - 多端session列表 * ConcurrentHashMap避免并发问题 */ private final MapLong, CopyOnWriteArrayListWebSocketSession sessionMap new ConcurrentHashMap(); public void addSession(Long userId, WebSocketSession session) { CopyOnWriteArrayListWebSocketSession sessions sessionMap.computeIfAbsent(userId, k - new CopyOnWriteArrayList()); sessions.add(session); } public void removeSession(Long userId, WebSocketSession session) { CopyOnWriteArrayListWebSocketSession sessions sessionMap.get(userId); if (sessions ! null) { sessions.remove(session); if (sessions.isEmpty()) { sessionMap.remove(userId); } } } public void sendToUser(Long userId, String message) { CopyOnWriteArrayListWebSocketSession sessions sessionMap.get(userId); if (sessions null || sessions.isEmpty()) { return; } for (WebSocketSession session : sessions) { if (session.isOpen()) { session.sendMessage(new TextMessage(message)); } } } public void sendToAll(String message) { sessionMap.forEach((userId, sessions) - { sessions.forEach(session - { if (session.isOpen()) { try { session.sendMessage(new TextMessage(message)); } catch (IOException e) { // 发送失败,记录日志,继续推下一个连接 } } }); }); } }为什么用CopyOnWriteArrayList而不是普通的ArrayList因为WebSocket的连接和断开是高频操作而且sentToUser在推送时可能正在遍历列表如果用ArrayList遍历时另一个线程remove会抛ConcurrentModificationException。CopyOnWriteArrayList虽然写操作性能稍差但读操作不加锁在这个场景下最合适。提醒发送消息无法保证绝对成功。如果网络断开session.isOpen()可能仍然返回true但sendMessage会抛IOException。实际项目中我给所有异常都加了try-catch并且会统计连续失败的session超过阈值就主动关闭。2.3 连接建立与关闭别漏了清理工作TextWebSocketHandler里有三个生命周期方法对应连接建立、消息到达、连接关闭。我把重点业务逻辑放在这里Component public class BusinessWebSocketHandler extends TextWebSocketHandler { Resource private WebSocketSessionManager sessionManager; Override public void afterConnectionEstablished(WebSocketSession session) { Long userId (Long) session.getAttributes().get(userId); if (userId null) { // 理论上握手拦截器已经保证userId存在,但防御一下 session.close(); return; } sessionManager.addSession(userId, session); // 可以顺便记录日志、发一条欢迎消息、推个上线通知给其他用户 log.info(WebSocket连接建立: userId{}, sessionId{}, userId, session.getId()); } Override protected void handleTextMessage(WebSocketSession session, TextMessage message) { // 收到客户端消息,按业务协议处理 String payload message.getPayload(); Long userId (Long) session.getAttributes().get(userId); // 这里做一个简单的ping/pong处理 if (PING.equals(payload)) { session.sendMessage(new TextMessage(PONG)); return; } // 其他业务消息,交给对应的Service处理 // 注意:不要在这里做耗时操作,如果需要,用异步线程池 } Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { Long userId (Long) session.getAttributes().get(userId); if (userId ! null) { sessionManager.removeSession(userId, session); log.info(WebSocket连接关闭: userId{}, sessionId{}, userId, session.getId()); } } }有个容易被忽略的点afterConnectionClosed并不是只有客户端主动断开时才触发。服务端重启、网络异常、防火墙掐断都会触发这个方法。所以关闭会话时一定不能只做“移除session”一件事还要考虑这个userId可能在别的地方有业务状态要清理——比如用户下线标记。2.4 心跳保活防Nginx和运营商掐连接WebSocket虽然基于TCP长连接但中间链路Nginx、云服务商的LVS、防火墙都可能因为空闲超时把连接掐断。所以一定要心跳保活。最常见的方案是客户端定时发PING服务端回PONG这个我在handleTextMessage里已经写了。但光有业务心跳不够还要在Spring层面配置好空闲超时Configuration public class WebSocketContainerConfig { Bean public ServletServerContainerFactoryBean createWebSocketContainer() { ServletServerContainerFactoryBean container new ServletServerContainerFactoryBean(); // 空闲超时,单位毫秒,超过这个时间没有收到任何消息,容器会自动关闭连接 container.setMaxSessionIdleTimeout(30000L); return container; } }这样设置后容器会在30秒内没有消息交互时自动断开连接。配合前端的定时PING比如每15秒发一次就能保证连接始终活跃又不会因为异常连接占用资源。注意setMaxSessionIdleTimeout的超时时间是容器层面的空闲检测跟业务心跳不冲突。如果你设了30秒空闲超时客户端每15秒ping一次连接永远不会被容器断开但如果客户端崩了、网络断了容器最迟30秒后就会回收这个连接不用等TCP层慢慢超时。2.5 服务端主动推送的两种姿势WebSocket最常见的使用场景就是服务端主动推消息。在RuoYi-Vue-Plus里我主要用两种姿势第一种是直接注入WebSocketSessionManager在任意Service里调用Service public class NoticeService { Resource private WebSocketSessionManager sessionManager; public void sendNotice(Long userId, String title, String content) { NoticeMessage message new NoticeMessage(); message.setType(NOTICE); message.setTitle(title); message.setContent(content); message.setTimestamp(System.currentTimeMillis()); sessionManager.sendToUser(userId, JSON.toJSONString(message)); } }第二种是配合Spring的事件机制。业务模块发出领域事件WebSocket模块监听事件后统一推送实现业务逻辑和推送逻辑的解耦。比如用户下单成功后订单模块发布OrderCreatedEventWebSocket的监听器收到后给用户推送订单状态变化。这个在微服务场景下还有妙用后面讲。消息格式我统一用JSON{ type: NOTICE, data: { id: 123, title: 你有一个新任务, content: 请尽快处理 }, timestamp: 1712390400000 }type字段用于前端路由不同的处理逻辑data放业务数据timestamp供前端做消息排序。这个协议建议提前定好不要等对接时再改。3. 分布式场景单节点够用微服务就得换思路3.1 单节点部署时的注意点如果RuoYi-Vue-Plus就部署一个节点不搞集群那上面的方案完全够用。session都在本机内存里sendToUser直接遍历本机map即可。但即便如此也有几个坑服务重启session全丢。客户端不会自动重连要前端做重连机制比如监听onclose后延迟3秒重新发起握手负载均衡下不能多实例。如果前面挂了Nginx轮询到后端两个节点客户端的两次连接可能落在不同节点上sendToUser推给A节点但用户session在B节点消息就丢了。必须做集群场景的处理注意Nginx的配置。Nginx默认对Upgrade协议支持不够需要单独配置location /ws/ { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_read_timeout 3600s; }proxy_read_timeout如果不设大默认60秒超过60秒没有数据交互Nginx就会断开连接。心跳机制只是让应用层维持活跃Nginx这层的超时也要一并调。3.2 微服务版本RuoYi-Cloud-Plus如何做跨节点推送在微服务架构下session管理不能继续用本地内存了。比如ruoyi-system服务有两个实例客户端连到了实例A但推送请求打到了实例BB的session map里根本没有这个用户消息就丢了。解决思路有两种方案一粘性会话Sticky Session。让Nginx或网关保证同一个userId的连接都路由到同一个后端实例。配置简单但一旦实例挂了该实例上的所有session全部丢失而且粘性路由是基于IP的多实例扩容缩容时会话分布会不均匀。适合对可用性要求不高的内部系统。方案二Redis发布订阅Pub/Sub。所有实例订阅同一个Redis频道。需要推送时实例A往频道里发布消息所有实例都收到这个通知然后检查本地session map里有没有目标用户有就推送没有就忽略。这个方案不需要session全局同步每个实例只管自己的连接跨节点推送交给Redis广播实现成本很低是RuoYi-Cloud-Plus下最务实的方案。Component public class RedisWebSocketPublisher { Resource private StringRedisTemplate stringRedisTemplate; private static final String CHANNEL ws:push; public void publish(String userId, String message) { // 组装一个带路由信息的对象 String payload JSONUtil.toJsonStr(new PushMessage(userId, message)); stringRedisTemplate.convertAndSend(CHANNEL, payload); } } Component public class RedisWebSocketSubscriber { Resource private WebSocketSessionManager sessionManager; PostConstruct public void init() { // 订阅频道,收到消息后,先判断目标用户是否在本机 stringRedisTemplate.getConnectionFactory().getConnection().subscribe( (message, pattern) - { String payload new String(message.getBody(), StandardCharsets.UTF_8); PushMessage pushMessage JSONUtil.toBean(payload, PushMessage.class); sessionManager.sendToUser(pushMessage.getUserId(), pushMessage.getMessage()); }, CHANNEL.getBytes(StandardCharsets.UTF_8) ); } }这里有个容易踩的坑subscribe是阻塞的如果直接写在业务线程里会把线程堵住。建议在PostConstruct里新开一个线程去订阅或者用ThreadPoolTaskExecutor异步初始化。另外Redis pub/sub的消息是即发即弃的如果某个实例正在重启期间发布的消息它会漏掉。好在WebSocket推送场景一般对实时性要求高、对可靠性要求不那么苛刻真要丢一条消息最多是用户没看到一个通知可接受。如果业务要求更高可靠性就得换Redis Stream或MQ了。3.3 微服务网关的WebSocket转发设置在RuoYi-Cloud-Plus里前端连接WebSocket要经过ruoyi-gateway。网关的默认路由是HTTP转发WebSocket的握手请求虽然也能转发但要确认spring-cloud-starter-gateway支持WebSocket转发它天然支持关键是路由配置spring: cloud: gateway: routes: - id: ruoyi-websocket uri: lb://ruoyi-websocket predicates: - Path/ws/**这里有个细节WebSocket的Sec-WebSocket-Protocol头和普通Header一样需要透传网关默认会转发所有Header一般不需要特殊配置。但如果你在Nginx层再挡一道就要注意Nginx是否把Upgrade和Connection头透传了漏一个都握手失败报Unexpected response code: 200这个错误是非常经典的WebSocket排障线索。另外网关层建议做一次token校验避免无效请求一路打到后端服务。在网关的GlobalFilter里对/ws/**路径做Sa-Token校验失败直接返回401后端服务只需要信任网关传过来的用户信息即可。这个在RuoYi-Cloud-Plus自带的SaTokenFilter基础上扩一下就行。4. 前端对接Vue3 TS怎么优雅地接入4.1 封装WebSocket客户端类若依前端用的是Vue3 TypeScript原生WebSocket在TS下用起来有几个痛点没有类型提示、没有自动重连、消息分发要靠一堆if-else。我的做法是封装一个WebSocketClient类把公共逻辑全收进去。type MessageHandler (data: any) void class WebSocketClient { private socket: WebSocket | null null private url: string private handlers: Mapstring, MessageHandler new Map() private heartbeatTimer: number | null null private reconnectTimer: number | null null private manualClose: boolean false constructor(path: string) { const protocol window.location.protocol https: ? wss:// : ws:// const token getToken() // 从Pinia或localStorage取token this.url ${protocol}${window.location.host}${path}?token${token} } connect() { this.manualClose false this.socket new WebSocket(this.url) this.socket.onopen () { // 连接建立后,启动心跳 this.startHeartbeat() } this.socket.onmessage (event) { const message JSON.parse(event.data) const handler this.handlers.get(message.type) if (handler) { handler(message.data) } } this.socket.onclose () { this.stopHeartbeat() if (!this.manualClose) { // 非手动关闭,自动重连 this.scheduleReconnect() } } this.socket.onerror () { // 错误后一般会触发onclose,这里只做日志 } } on(type: string, handler: MessageHandler) { this.handlers.set(type, handler) } send(type: string, data: any) { if (this.socket this.socket.readyState WebSocket.OPEN) { this.socket.send(JSON.stringify({ type, data })) } } close() { this.manualClose true this.socket?.close() } private startHeartbeat() { this.heartbeatTimer window.setInterval(() { if (this.socket?.readyState WebSocket.OPEN) { this.socket.send(JSON.stringify({ type: PING })) } }, 15000) } private stopHeartbeat() { if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer) this.heartbeatTimer null } } private scheduleReconnect() { if (this.reconnectTimer) { clearTimeout(this.reconnectTimer) } this.reconnectTimer window.setTimeout(() { this.connect() }, 3000) } }用的时候在需要的地方new一个实例订阅消息类型const ws new WebSocketClient(/ws) ws.on(NOTICE, (data) { // 更新通知中心 }) ws.connect()页面销毁时记得调用ws.close()否则组件卸载了消息还在往上冒容易引发内存泄漏。4.2 解决Vue3 TS的报错问题“若依vue3 ts报错”这个词条搜的人很多我实际踩下来的典型报错有两个第一个是TS不认识window上的自定义属性或者WebSocket实例类型不匹配。如果你有全局挂载socket的需求不要在window上乱挂正确姿势是写一个类型声明// env.d.ts declare global { interface Window { webSocketClient?: InstanceTypetypeof WebSocketClient } }第二个报错是Property send does not exist on type WebSocket之类多半是types/websocket和浏览器内置WebSocket类型定义冲突了。解决方法是显式使用WebSocket全局类型别引入websocket这个npm包——浏览器环境根本不需要它直接new WebSocket()就是原生实现。4.3 Postman 调试WebSocket的小技巧服务端写好了前端没对接之前怎么验证Postman从2020年就支持WebSocket调试了用法如下新建请求协议选择WebSocketURL填ws://localhost:8080/ws?tokenxxxx这里token要是真实有效的点击Connect如果握手成功消息区会出现“Connected to ws://...”在消息输入框发送{type:PING}如果服务端回了PONG说明通道是通的如果连接失败注意看返回码101是握手成功200说明被Nginx或网关拦截了401是鉴权失败Postman除了能发消息还能查看WebSocket的帧内容排查中文乱码非常实用。5. 常见问题、排查思路与避坑实录5.1 高频问题速查表现象可能原因解决办法握手失败报Unexpected response code: 200Nginx未配置Upgrade头检查proxy_set_header Upgrade和Connection配置握手失败报401token缺失或已过期确认token参数名、Sa-Token会话是否有效连接建立后十几秒就被断开容器空闲超时过短调整setMaxSessionIdleTimeout或启用心跳消息发不出去服务端无日志服务端连接实际已断开遍历时增加session.isOpen()判断并关掉无效连接集群部署时消息时有时无session未跨节点同步用Redis Pub/Sub或MQ做广播推送前端连接一直CONNECTING不触发open服务根本没启动或网关路由错误检查后端tomcat端口、网关路由、防火墙中文消息乱码字符集不一致统一使用UTF-8请求头Sec-WebSocket-Protocol不参与编码5.2 一个典型的“连上就断”排查实录有次做扫码登录功能前端扫描二维码后手机端确认PC端页面自动刷新。功能开发完联调时发现PC端的WebSocket连上一两秒就被断开服务端日志完全没打出来。排查过程是这样的第一步查看浏览器Network面板WebSocket的连接记录显示“Closed”状态但没有具体错误码。第二步打印了服务端日志发现afterConnectionClosed被触发了这说明连接确实建立过然后又在短时间内被关闭。但afterConnectionEstablished的日志没打出来这就奇怪了。第三步回头检查代码原来我在afterConnectionEstablished里加了用户状态校验如果用户已经在别处登录就把当前连接关闭。而测试时Postman和浏览器同时连着同一个账号后面的连接把前面的顶掉了。这个“顶掉”逻辑其实本意是好的避免同一账号反复建立无效连接但实现太粗暴。改成多端共存后问题消失。后来我又在关闭连接前给客户端发了一条带type: KICK_OUT的消息让前端能给用户提示“你的账号在别处登录”体验就好多了。这个坑说明WebSocket的生命周期很多回调是异步的日志也好、连接状态也好不要想当然。出现“连上就断”先确认是不是自己代码里主动关了连接再看框架层面配置最后才排查网络链路。顺序反了排查效率会很低。5.3 关于RuoYi-Vue-Plus版本差异的提醒若依生态有RuoYi-Vue、RuoYi-Vue-Plus、RuoYi-Cloud-Plus等好多个版本不同版本的依赖和工具类命名有差异。比如LoginHelper、LoginUser这些类在老版本里可能是SecurityUtils加LoginUser的搭配。如果是老版本RuoYi-Vue基于Spring Security握手鉴权那段要写Spring Security的token解析逻辑不能照抄Sa-Token这版。文章里用的SpringUtils.getBean方式获取Bean是为了在拦截器这种非Spring管理的场景下也能拿到容器里的Bean这个在RuoYi-Vue-Plus是有的。如果你用的版本没有可以试着手动注入HandshakeInterceptor为Spring Bean然后在WebSocketConfigurer里通过构造器或Resource注入也是一样的效果。5.4 如果遇到“Error adding module to project: null”这个报错虽然不是WebSocket直接相关的但搜若依的人常碰到。发生在把RuoYi-Plus导入IDE时多半是Maven的模块识别出了问题。解决办法一般是先把项目根目录的pom.xml用Maven reimport然后mvn clean install -Dmaven.test.skiptrue最后在IDE里重新导入。如果还不行检查JDK版本是否匹配RuoYi-Vue-Plus一般要求JDK17继续用JDK8会出各种幺蛾子。6. 从WebSocket到更多实时能力的一点扩展写完基础的消息推送之后我顺手把扫码登录也用同一套WebSocket通道做掉了效果出奇地好。这里分享一下扩展思路把WebSocket当作一个“实时通道底座”上面可以跑很多业务协议而不只是“通知推送”这一种。我按message.type区分业务NOTICE站内通知SCAN_LOGIN扫码登录。手机端确认后服务端把登录凭证推给PC端ONLINE_STATUS好友/协作者在线状态变更PROGRESS大文件导入导出的实时进度协议统一的好处是前端只需要维护这一个连接不用为每个业务场景都建立新的WebSocket。后端也只用维护一个WebSocketSessionManager避免session管理逻辑散落在各个模块。当然也有代价连接是所有业务共用的一个handler里会积压越来越多的if-else或switch-case。建议在handleTextMessage里做一层分发把不同type的消息派发到对应的Service而不是把所有业务代码堆在Handler里。再往后如果实时性要求不再满足于“秒级”比如要做多人协作编辑这种“毫秒级”同步Spring WebSocket自带的简单消息代理就不够用了得换STOMP协议配合消息代理RabbitMQ/ActiveMQ那是另一套架构了。大多数若依项目的业务场景用不上知道有这回事就行。最后分享一个实际运营中的心得WebSocket上线后一定要加监控。我见过生产环境一天两万多条推送某个session泄露连接没关闭、引用还在Map里导致内存涨了20%最后靠监控发现才定位到是有个定时任务里new了WebSocketSessionManager但没复用Spring的单例。这类问题如果不提前埋点排起查来真的头大。你在接入WebSocket时至少把当前在线连接数、推送成功/失败数、连接断开原因分布这几个指标打出来后面运维会感谢你的。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价