news 2026/10/18 10:23:25

Spring Boot WebSocket实战:从轮询到推送,搞定后台消息实时通知

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring Boot WebSocket实战:从轮询到推送,搞定后台消息实时通知

1. 从轮询到推送:为什么后台消息必须走WebSocket这条路

做过管理后台或者运营系统的朋友大概率都遇到过这个需求:用户提交了一个耗时任务,比如批量导入数据、生成报表、跑一次数据同步,前端页面得实时告诉用户"现在跑到哪一步了""还剩多少条""有没有报错"。最原始的做法是前端每隔几秒发一次HTTP请求去问后端"好了没",也就是轮询。这个方案能跑通,但问题非常明显——请求里绝大多数都是无效的,任务没完成时后端只能返回"还在处理",白白消耗连接和数据库查询。

我早期做过一个数据导出功能,前端用setInterval每3秒查一次进度,结果一个用户开着页面挂机,一天下来能打出上万次无效请求,日志里全是重复的查询记录,排查问题时噪音极大。后来换成WebSocket,服务端在任务状态变化时主动推一条消息,前端只负责监听,请求量直接降到个位数。这个对比让我彻底认清了:后台消息推送这种"服务端事件驱动、客户端被动接收"的场景,长连接才是正解。

WebSocket的本质是在一次HTTP握手之后,把这条TCP连接"升级"成全双工通道。握手阶段它借用HTTP的Upgrade头完成协议切换,之后双方就可以随时互发数据帧,不再有"请求-响应"的束缚。放到Spring Boot里,框架把这套底层细节封装得相当干净,你只需要关心"谁连上来了""给谁发""发什么"这三件事。

这篇文章适合两类人:一类是刚接触WebSocket、想在后端项目里落地消息推送的开发者;另一类是已经用过但踩过坑、想搞清楚连接管理、心跳、集群广播这些细节的人。我会从依赖引入讲到连接池设计,再到生产环境里那些文档不会写的坑,尽量把每一步的"为什么"讲透,让你看完能直接抄作业,也能明白抄的每一行在干什么。

2. 依赖选择与握手配置:别小看这两个starter的差别

2.1 spring-boot-starter-websocket到底带来了什么

在pom.xml里加依赖这一步看似简单,但很多人加完不知道它到底引入了什么。核心是spring-websocket和spring-messaging两个模块,前者提供底层的WebSocket支持(包括握手拦截、会话管理),后者提供STOMP协议的消息抽象。如果你只用原生WebSocket,其实spring-websocket就够了,但starter会一并带上,方便你后续切换到STOMP。

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency>

这里有个容易忽略的点:Spring Boot对WebSocket的自动配置是"半自动"的。它不会像DataSource那样帮你把一切配好,你需要自己注册一个ServerEndpointExporter,否则用@ServerEndpoint注解声明的端点根本不会被扫描到。这个Bean的作用是把标注了@ServerEndpoint的类注册到内嵌的容器里。

@Configuration public class WebSocketConfig { @Bean public ServerEndpointExporter serverEndpointExporter() { return new ServerEndpointExporter(); } }

注意:如果你把应用打成war包部署到外部容器(比如独立的Tomcat),这个Bean要删掉,否则会报错。因为外部容器的WebSocket由容器自己管理,ServerEndpointExporter只在内嵌容器场景下需要。这个坑我在一次从jar切war的迁移里踩过,启动直接抛IllegalStateException,排查了半天才发现是这行配置。

2.2 用@ServerEndpoint还是实现WebSocketHandler

Spring提供了两套写法,选哪套取决于你的需求复杂度。

第一套是JSR-356标准的@ServerEndpoint注解,写法最直观,一个类就是一个端点,生命周期方法用@OnOpen、@OnMessage、@OnClose、@OnError标注。它的优点是简单,缺点是每个连接都会new一个端点实例,也就是说这个类是无状态的,你不能在里面用@Autowired注入Spring管理的Bean(因为实例不是Spring创建的)。想注入怎么办?只能通过静态字段或者ApplicationContext手动获取,写起来别扭。

第二套是实现WebSocketHandler接口,配合WebSocketConfigurer注册。这种方式下Handler是单例的,能正常依赖注入,路径映射也更灵活,还能配置跨域、拦截器。缺点是代码量稍多。

我的建议是:如果只是简单的点对点推送,用@ServerEndpoint够了;如果涉及用户身份校验、多路径、需要注入Service,果断上WebSocketHandler。下面两种我都会给到,你可以按场景挑。

2.3 握手阶段就把用户身份认出来

WebSocket握手本质是一次HTTP请求,所以它能带上Cookie、Header、URL参数。这是做身份识别的黄金时机——在连接建立的那一刻就把"这条连接属于哪个用户"绑定好,后面推送时才能精准投递。

用@ServerEndpoint时,可以通过ServerEndpointConfig的getUserProperties()或者直接解析路径参数拿到标识:

@ServerEndpoint("/ws/{userId}") @Component public class PushEndpoint { @OnOpen public void onOpen(Session session, @PathParam("userId") String userId) { session.getUserProperties().put("userId", userId); SessionManager.add(userId, session); } }

用WebSocketHandler时,可以在HandshakeInterceptor的beforeHandshake里解析请求,把用户信息塞进attributes:

public class AuthHandshakeInterceptor implements HandshakeInterceptor { @Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) { String token = request.getHeaders().getFirst("Authorization"); String userId = TokenUtil.parseUserId(token); if (userId == null) { return false; // 拒绝握手 } attributes.put("userId", userId); return true; } }

提示:生产环境别直接把userId明文放URL里,容易被伪造。正规做法是握手时校验token,服务端解析出真实身份。URL参数只适合内网或者已经过网关鉴权的场景。

3. 会话管理:一个能扛住并发的心跳与连接池设计

3.1 为什么不能直接用ConcurrentHashMap存Session

很多人第一版代码就是Map<String, Session>,用ConcurrentHashMap保证线程安全,看起来没问题。但实际跑起来会遇到两个麻烦:一是同一个用户可能开多个标签页,一个userId对应多个Session,用单值Map会互相覆盖,导致只有最后一个页面能收到消息;二是连接断开后Map里的僵尸Session不会自动清理,时间一长内存里堆一堆失效连接,推送时对着空气发消息还抛异常。

正确的结构应该是Map<String, Set<Session>>,一个用户对应一组会话。推送时遍历这个Set逐个发送,发送失败的顺手移除。

public class SessionManager { private static final Map<String, Set<Session>> USER_SESSIONS = new ConcurrentHashMap<>(); public static void add(String userId, Session session) { USER_SESSIONS.computeIfAbsent(userId, k -> ConcurrentHashMap.newKeySet()).add(session); } public static void remove(String userId, Session session) { Set<Session> sessions = USER_SESSIONS.get(userId); if (sessions != null) { sessions.remove(session); if (sessions.isEmpty()) { USER_SESSIONS.remove(userId); } } } public static void sendToUser(String userId, String message) { Set<Session> sessions = USER_SESSIONS.get(userId); if (sessions == null) return; for (Session session : sessions) { if (session.isOpen()) { session.getAsyncRemote().sendText(message); } else { sessions.remove(session); } } } }

这里用getAsyncRemote()而不是getBasicRemote()是有讲究的。getBasicRemote()是同步阻塞的,如果某个客户端网络卡顿,发送线程会被拖住,影响其他消息的投递。getAsyncRemote()把发送交给容器线程池,不阻塞调用方。在高并发推送场景下,异步发送几乎是必选项。

3.2 心跳机制:怎么判断对面还活着

TCP连接有个特性:如果一方断电或者网络中断,另一方可能长时间感知不到,连接会一直"假活"着。WebSocket建立在TCP之上,同样有这个问题。解决办法就是心跳——客户端定时发一个ping,服务端收到后回pong,或者反过来。

Spring的@ServerEndpoint没有内置心跳,需要自己实现。常见做法是客户端每30秒发一次约定好的心跳消息(比如{"type":"ping"}),服务端收到后回{"type":"pong"}。同时服务端维护一个最后活跃时间,超过一定阈值没收到心跳就主动关闭连接。

@OnMessage public void onMessage(String message, Session session) { if ("ping".equals(message)) { session.getAsyncRemote().sendText("pong"); session.getUserProperties().put("lastActive", System.currentTimeMillis()); return; } // 处理业务消息 }

服务端再起一个定时任务,扫描所有Session,把超过90秒没活跃的连接关掉:

@Scheduled(fixedRate = 30000) public void checkHeartbeat() { long now = System.currentTimeMillis(); SessionManager.getAllSessions().forEach(session -> { Long last = (Long) session.getUserProperties().get("lastActive"); if (last != null && now - last > 90000) { try { session.close(); } catch (IOException ignored) {} } }); }

提示:心跳间隔别设太短,30秒是个比较平衡的值。设成5秒会让服务端频繁处理无意义消息,设成几分钟又起不到及时清理的作用。另外,如果用了Nginx做反向代理,记得把proxy_read_timeout调大,否则Nginx会在默认60秒后主动断开空闲连接,你的心跳还没发出去连接就没了。

3.3 连接建立与关闭时的资源清理

@OnClose和@OnError里一定要做清理,否则SessionManager会越积越多。@OnError尤其容易被忽略——连接异常时如果不处理,异常会往上抛,日志里刷一堆堆栈,但Session还留在Map里。

@OnClose public void onClose(Session session) { String userId = (String) session.getUserProperties().get("userId"); SessionManager.remove(userId, session); } @OnError public void onError(Session session, Throwable error) { String userId = (String) session.getUserProperties().get("userId"); SessionManager.remove(userId, session); log.warn("WebSocket error for user {}, closing session", userId, error); }

这里有个细节:@OnError之后连接不一定会自动关闭,稳妥的做法是在里面手动session.close(),确保资源释放。

4. 消息推送的三种姿势:点对点、广播与分组

4.1 精准投递:给指定用户发消息

点对点推送是最常见的需求,比如"你的报表生成好了"。有了前面的SessionManager,实现起来就是一行调用:

public void notifyTaskComplete(String userId, String taskId) { String payload = JSON.toJSONString(Map.of( "type", "TASK_COMPLETE", "taskId", taskId, "timestamp", System.currentTimeMillis() )); SessionManager.sendToUser(userId, payload); }

关键在于消息体要有明确的type字段。前端拿到消息后根据type分发到不同的处理逻辑,而不是靠猜内容。这个约定在消息类型变多之后会救命——我见过一个项目所有推送都是纯文本,前端用字符串包含判断,后来加了一种新消息,老代码误判导致弹窗乱飞。

4.2 全员广播:系统公告怎么发才不卡

广播就是遍历所有在线Session发同一条消息。听起来简单,但如果在线用户上万,同步遍历发送会阻塞很久。优化思路是:把消息投递到线程池异步执行,调用方立即返回。

private final ExecutorService broadcastPool = Executors.newFixedThreadPool(4); public void broadcast(String message) { broadcastPool.submit(() -> { SessionManager.getAllSessions().forEach(session -> { if (session.isOpen()) { session.getAsyncRemote().sendText(message); } }); }); }

线程池大小别设太大,广播本身是IO密集型,4到8个线程足够。设成CPU核数乘以2那种公式在这里不适用,因为瓶颈在连接数不在计算。

4.3 分组推送:按业务维度圈定接收人

实际业务里经常需要"给某个部门的所有人发""给订阅了某类事件的人发"。这时候可以在SessionManager之上再包一层分组逻辑,用Map<String, Set<String>>维护"分组ID到用户ID集合"的映射,推送时先查出用户列表再逐个投递。

public void sendToGroup(String groupId, String message) { Set<String> userIds = GroupRegistry.getUsers(groupId); userIds.forEach(uid -> SessionManager.sendToUser(uid, message)); }

分组关系可以放内存,也可以放Redis。放内存的缺点是应用重启后分组信息丢失,需要客户端重连时重新上报;放Redis则多一次网络查询,但更可靠。如果分组是相对静态的(比如按组织架构),放Redis;如果是动态订阅关系,放内存加定期同步更划算。

推送方式适用场景实现要点性能关注点
点对点任务完成通知、私信按userId查Session集合单用户多标签页要全发
广播系统公告、全局事件异步线程池投递避免阻塞调用线程
分组部门通知、订阅推送维护分组映射关系分组数据存储位置选择

5. 集群部署下的推送难题:单机Session管不了多实例

5.1 问题本质:连接散落在不同实例上

单机跑的时候一切正常,一旦部署多个实例,问题立刻暴露:用户A的连接可能落在实例1上,但触发推送的业务逻辑跑在实例2上,实例2的SessionManager里根本没有A的Session,消息发不出去。

这是WebSocket集群的经典难题,根源在于WebSocket连接是有状态的,而集群里的实例是无状态对等的。解决办法的核心思路是:把"要推送的消息"通过一个所有实例都能访问的通道广播出去,每个实例收到后检查自己有没有对应的Session,有就发。

5.2 用Redis发布订阅做消息中转

最常用的方案是Redis的Pub/Sub。业务方不直接调SessionManager,而是往一个频道发消息,所有实例都订阅这个频道,收到后各自尝试投递。

// 发送端 public void push(String userId, String message) { redisTemplate.convertAndSend("ws:push", userId + "|" + message); } // 接收端(每个实例都注册) @Bean public RedisMessageListenerContainer listenerContainer(RedisConnectionFactory factory) { RedisMessageListenerContainer container = new RedisMessageListenerContainer(); container.setConnectionFactory(factory); container.addMessageListener((message, pattern) -> { String body = new String(message.getBody()); String[] parts = body.split("\\|", 2); SessionManager.sendToUser(parts[0], parts[1]); }, new ChannelTopic("ws:push")); return container; }

这样无论业务逻辑跑在哪个实例,消息都会经由Redis到达持有该用户连接的实例。代价是引入了一次Redis往返,延迟增加几毫秒,但对绝大多数后台推送场景完全可接受。

注意:Redis Pub/Sub不保证消息可靠投递,订阅者掉线期间的消息会丢失。如果推送的消息不能丢(比如支付结果通知),得用Redis Stream或者消息队列做持久化,消费成功后再确认。这个取舍要根据业务重要性来定。

5.3 会话粘滞:另一种思路及其局限

还有一种方案是让负载均衡器做会话粘滞,保证同一个用户的请求和WebSocket连接都落到同一个实例。这样单机SessionManager就够用了。但它的局限很明显:实例扩容或宕机时,粘滞关系被打破,用户需要重连;而且粘滞会让负载不均,某些实例连接数过多。所以粘滞只适合小规模、对可用性要求不高的场景,正经的集群推送还是得靠消息中转。

6. 那些文档不会告诉你的实战坑

6.1 跨域配置:握手被拦在门外的排查

前后端分离部署时,WebSocket握手经常因为跨域被拒。浏览器控制台报的是403或者握手失败,但不会告诉你具体原因。这时候要检查两处:一是WebSocketConfigurer里有没有设置setAllowedOrigins,二是如果走了网关,网关有没有放行Upgrade头。

@Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(myHandler(), "/ws") .addInterceptors(new AuthHandshakeInterceptor()) .setAllowedOrigins("*"); }

提示:生产环境别用*,写具体域名。另外注意,setAllowedOrigins只对WebSocketHandler方式生效,@ServerEndpoint方式需要在ServerEndpointConfig里配置,或者干脆靠网关统一处理。

6.2 消息体大小与序列化选择

WebSocket单帧默认有大小限制,不同容器不一样。Tomcat默认是8KB左右,超过会报Message too big。如果你的推送消息可能很大(比如带了一整张报表数据),要么调大限制,要么改成"推送通知+前端主动拉取详情"的模式——推送只带一个ID,前端收到后调HTTP接口拿完整数据。后者更推荐,因为长连接传大消息会占用连接资源,影响其他消息的实时性。

序列化方面,JSON是最通用的选择,前后端都好处理。如果对体积敏感,可以考虑MessagePack或者Protobuf,但会牺牲可读性。后台推送场景我一般直接用JSON,调试方便,体积也不是瓶颈。

6.3 断线重连:前端不能只连一次

服务端做得再好,客户端网络抖动、页面切后台、电脑休眠都会导致连接断开。前端必须实现自动重连,否则用户得手动刷新页面。重连逻辑要注意两点:一是退避策略,别断开后立刻疯狂重连,用指数退避(1秒、2秒、4秒、8秒,封顶30秒);二是重连成功后要重新拉取一次状态,因为断开期间可能错过了消息。

let retryDelay = 1000; function connect() { const ws = new WebSocket('wss://your-host/ws/' + userId); ws.onopen = () => { retryDelay = 1000; }; ws.onclose = () => { setTimeout(connect, retryDelay); retryDelay = Math.min(retryDelay * 2, 30000); }; ws.onmessage = (e) => handleMessage(e.data); }

6.4 日志与监控:出问题时怎么快速定位

WebSocket的问题往往不好排查,因为连接是长时的,出错时上下文已经丢失。我的做法是在关键节点打结构化日志:连接建立时记录userId和sessionId,连接关闭时记录关闭原因和持续时长,推送失败时记录目标userId和异常。再配合一个简单的监控指标——当前在线连接数、每分钟推送消息数、推送失败率。这三个指标一摆出来,是连接泄漏还是推送异常一目了然。

@OnOpen public void onOpen(Session session, @PathParam("userId") String userId) { log.info("WS_OPEN userId={} sessionId={} online={}", userId, session.getId(), SessionManager.count()); }

在线连接数这个指标特别有用。如果它只涨不跌,说明@OnClose没被正确触发,八成是心跳和超时清理没做好。我遇到过一回,连接数从几百慢慢爬到几千,最后OOM,查下来是客户端异常退出时没发关闭帧,服务端又没做超时清理,Session全堆在内存里。

7. 从能跑到好用:几个值得投入的优化方向

把基础功能跑通只是第一步,真正让推送系统"好用"还需要在几个地方下功夫。第一个是消息去重,网络重连或者服务端重试可能导致同一条消息推两次,前端要根据消息ID做幂等处理,服务端也可以在消息里带一个递增序号,前端丢弃序号小于已处理值的消息。第二个是离线消息补偿,用户不在线时产生的推送,等他下次连上要能补发,这需要把消息落库,连接建立时查询未读消息推送。第三个是推送优先级,紧急通知和普通进度更新走不同队列,避免大量低优先级消息把重要通知挤在后面。

这些优化不是一开始就要全上,而是随着业务量增长逐步补齐。我的经验是:先保证连接稳定和消息不丢,再考虑性能和体验优化。很多项目一上来就搞复杂的消息队列和持久化,结果基础的心跳和重连都没做扎实,用户还是抱怨"消息收不到"。

最后分享一个我踩过的坑:有次线上推送突然大面积失效,排查发现是运维调整了Nginx配置,把proxy_read_timeout从300秒改回了默认的60秒,而我们的心跳间隔是90秒,导致连接在心跳发出前就被Nginx掐断了。这件事之后,我把心跳间隔改成了30秒,并且在部署文档里明确写了Nginx必须配置的超时参数。WebSocket的稳定性是客户端、服务端、代理三层共同决定的,任何一层掉链子都会表现为"消息收不到",排查时一定要从整条链路去看。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/17 8:21:27

PPT编辑密码全攻略:设置、取消与避坑指南

你有没有遇到过这种情况&#xff1a;PPT做到最后一步&#xff0c;要给客户发一份“只能看不能改”的版本&#xff0c;可文件传过去之后第二天打开&#xff0c;发现对方顺手把联系方式改了&#xff0c;甚至把你的图表删了&#xff0c;版本一下子乱了套。我以前都是用另存为PDF来…

作者头像 李华
网站建设 2026/10/18 22:10:40

从零构建可靠Benchmark:样本、指标与可复现性的全面指南

做 benchmark 这件事&#xff0c;表面上看起来简单——不就是找一堆样本&#xff0c;跑一遍&#xff0c;算个分&#xff0c;出一个榜单吗&#xff1f;等真正上手了才发现&#xff0c;"建一个 benchmark"和"建一个好用的 benchmark"之间&#xff0c;隔着一条…

作者头像 李华
网站建设 2026/10/18 16:13:12

系统框架设计指南:从模块化拆分到微服务落地

简介&#xff1a;《软件工程中的系统框架与结构设计》PPT课件围绕软件工程核心知识体系展开&#xff0c;面向高校软件工程课程学员、刚入行的开发人员以及需要梳理系统架构思路的技术人员&#xff0c;系统讲解软件开发生命周期、质量保障、系统架构设计原则、模块化设计、体系结…

作者头像 李华
网站建设 2026/10/18 16:03:09

AnyPS5:跨平台串流与输入映射的通用化增强方案

1. 项目缘起与核心定位拆解1.1 这个标题到底在说什么第一次看到“AnyPS5”这个标题&#xff0c;我脑子里蹦出来的第一反应是&#xff1a;这大概率是一个围绕“跨平台远程串流”或者“多设备兼容层”做文章的项目。为什么这么判断&#xff1f;因为“Any”这个前缀在技术圈里几乎…

作者头像 李华
网站建设 2026/10/18 13:21:27

具身智能落地三支柱:工业鲁棒性、人机安全边界与末端执行精度

1. 这不是科幻预告片&#xff0c;而是工程机械与服务机器人领域正在发生的“具身智能”落地切片“午间AI速览&#xff5c;9.27&#xff1a;中联重科具身智能、优必选U1情感机器人、曦诺Flex1灵巧手”——看到这个标题&#xff0c;我第一反应不是点开看新闻&#xff0c;而是立刻…

作者头像 李华
网站建设 2026/10/18 3:22:17

LegoFlow:用智能体编排数据构建、训练与测评的自动化流水线

从凌晨三点那次实验事故说起吧。当时我在调一个文本分类的流程&#xff1a;上午换了一版清洗后的数据&#xff0c;下午改了两处预处理&#xff0c;晚上重跑训练&#xff0c;然后发现测试集的标签映射表跟前两天不一致&#xff0c;导致评测分数虚高。我花了整整一个通宵排查&…

作者头像 李华