Spring Boot WebSocket后台消息推送实战:STOMP与SockJS选型及避坑指南 1. 为什么后台消息推送这件事值得单独拎出来讲做过管理后台或者运营系统的朋友大概率都遇到过这类需求运营人员在后台点了一下“审核通过”前端页面要立刻弹出一个提示或者系统监控到某个指标异常需要主动把告警推到管理员的浏览器上而不是让管理员每隔几秒去刷新一次页面。这种“服务端主动推、客户端被动收”的场景就是后台消息推送要解决的核心问题。传统的做法是前端定时轮询写个setInterval每隔几秒发一次请求问服务端“有没有新消息”。这个方案能跑通但问题也很明显请求大部分是空跑浪费带宽和服务器资源消息延迟取决于轮询间隔间隔短了压力大间隔长了体验差连接数一多服务端的压力是线性增长的。所以只要对实时性有一点要求轮询基本都会被淘汰。WebSocket 就是在这个背景下进入视野的。它在单个 TCP 连接上提供全双工通信服务端可以随时主动给客户端发消息客户端也能随时给服务端发消息握手阶段借用 HTTP 协议完成协议升级之后就是纯粹的帧通信。放到 Spring Boot 里官方提供了spring-boot-starter-websocket配合 STOMP 协议和 SockJS 兜底能比较优雅地把后台推送这套东西搭起来。这篇内容适合谁看如果你正在做后台管理系统、运营平台、监控大屏需要把服务端的实时事件推给浏览器那这套方案可以直接参考。如果你只是听说过 WebSocket 但没实际落地过我也会把踩过的坑和参数选择的逻辑讲清楚。下面我按“整体设计思路 → 核心细节 → 实操落地 → 问题排查”这个顺序展开尽量让你看完就能动手。2. 整体方案设计与技术选型思路2.1 为什么是 WebSocket 而不是 SSE 或长轮询在动手之前先把几个候选方案摆出来对比一下选型逻辑清楚了后面写代码才不会摇摆。方案通信方向协议基础浏览器兼容适用场景短轮询客户端拉HTTP全兼容实时性要求极低长轮询客户端拉挂起HTTP全兼容兼容性优先、消息频率低SSE服务端单向推HTTP现代浏览器基本支持只需服务端推、不需要客户端回传WebSocket双向独立协议HTTP 升级现代浏览器全支持双向实时通信SSE 其实在“后台推送”这个场景里是够用的因为它本身就是服务端单向推送实现比 WebSocket 简单走的是普通 HTTP不用处理协议升级。但它有两个硬伤一是只能服务端推客户端客户端要发消息得另开 HTTP 请求二是很多场景下需要客户端回传心跳或确认SSE 做起来别扭。WebSocket 双向通信的能力让它在稍微复杂一点的交互里更从容比如客户端要主动订阅某个主题、要发心跳保活这些都很自然。长轮询在兼容性上确实无敌但服务端要维持大量挂起的请求Tomcat 的线程模型下每个挂起请求占一个线程并发一高就顶不住。WebSocket 在握手完成后基本不占业务线程靠事件驱动扩展性明显更好。所以结论是只要不是必须兼容非常古老的浏览器后台消息推送优先选 WebSocket。2.2 STOMP 协议要不要上WebSocket 本身只是提供了通信通道它没有规定消息的格式和语义。裸用 WebSocket 的话你得自己定义消息结构、自己处理订阅关系、自己做路由。STOMP 是一个面向消息的简单文本协议它在 WebSocket 之上定义了一套帧格式包括 CONNECT、SUBSCRIBE、SEND、MESSAGE 等命令Spring 对 STOMP 有一等公民级别的支持。上 STOMP 的好处很直接Spring 的SimpMessagingTemplate可以按目的地destination发消息前端按主题订阅天然支持一对多广播和点对点发送。如果不用 STOMP这些路由逻辑都得自己写。代价是前端要引入stompjs和sockjs-client两个库稍微重一点。我的建议是后台推送这种“按主题广播 按用户点对点”的典型场景直接上 STOMP省下来的路由代码远比引入两个库的成本高。2.3 SockJS 兜底到底要不要留SockJS 的作用是在浏览器不支持 WebSocket 或者网络环境不允许 WebSocket 时自动降级到 XHR 流、XHR 轮询等方式。它的存在让“连接一定能建立”这件事更有保障。但 SockJS 也有代价降级后的通信效率不如原生 WebSocket而且 SockJS 的 URL 格式和原生不一样前端连接时要区分。如果你的用户群体都是现代浏览器内网环境也稳定其实可以不用 SockJS直接用原生 WebSocket 端点少一层封装调试也更直观。我个人的做法是对外网、用户环境不可控的系统保留 SockJS纯内网、浏览器可控的后台系统直接用原生 WebSocket。下面实操部分我会把两种配置都写出来你按需取用。3. 核心细节拆解与关键配置解析3.1 依赖引入与版本匹配Spring Boot 的 WebSocket 支持在spring-boot-starter-websocket里它传递依赖了spring-websocket和spring-messaging。引入方式很简单dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-websocket/artifactId /dependency这里有个容易被忽略的点Spring Boot 版本和前端 stompjs 版本的匹配。Spring Boot 2.x 和 3.x 在 WebSocket 配置上差异不大但 3.x 基于 Jakarta EE包名从javax变成了jakarta如果你从 2.x 升级上来配置类里的注解包名要跟着改。前端这边stompjs从 5.x 开始 API 有变化老版本用Stomp.client()新版本推荐用Client类配合brokerURL。版本对不上连接会莫名其妙失败而且报错信息往往不直观。3.2 端点注册与消息代理配置这是整个方案的核心配置类我把它拆成三块来讲端点注册、消息代理、跨域处理。Configuration EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint(/ws-push) .setAllowedOriginPatterns(*) .withSockJS(); } Override public void configureMessageBroker(MessageBrokerRegistry registry) { registry.enableSimpleBroker(/topic, /queue); registry.setApplicationDestinationPrefixes(/app); registry.setUserDestinationPrefix(/user); } }registerStompEndpoints注册的是握手端点前端连接时用的就是这个地址。withSockJS()开启 SockJS 支持如果不需要可以去掉。setAllowedOriginPatterns(*)是跨域配置生产环境一定要收紧到具体域名否则存在安全风险。configureMessageBroker里三个前缀的含义要分清楚/topic和/queue是消息代理前缀客户端订阅这些前缀开头的目的地服务端往这些目的地发消息代理负责广播或点对点投递。/topic用于广播/queue用于点对点。/app是应用前缀客户端发到/app开头的消息会被路由到MessageMapping注解的方法上走的是业务处理逻辑不经过消息代理。/user是用户前缀配合SimpMessagingTemplate.convertAndSendToUser()使用实现精准推送给某个用户。这三个前缀如果搞混最典型的表现就是消息发出去了但前端收不到或者前端发的消息后端不响应。我见过不少新手把广播消息发到/app前缀上结果消息代理根本不处理自然就丢了。3.3 心跳与连接保活机制WebSocket 连接建立后如果长时间没有数据往来中间的代理服务器比如 Nginx可能会因为空闲超时把连接掐掉。表现就是前端过一段时间就断连重连后又断循环往复。Spring 的 STOMP 配置里可以设置心跳Override public void configureWebSocketTransport(WebSocketTransportRegistration registration) { registration.setMessageSizeLimit(128 * 1024); registration.setSendBufferSizeLimit(512 * 1024); registration.setSendTimeLimit(20 * 1000); }心跳本身在 STOMP 协议层协商服务端和客户端会约定一个心跳间隔双方定期互发心跳帧。Spring 默认的心跳是服务端 10 秒、客户端 10 秒一般够用。但如果你前面有 Nginx还要在 Nginx 配置里把proxy_read_timeout调大否则 Nginx 会先于应用层把连接断掉。location /ws-push { 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设成 3600 秒是个经验值配合应用层心跳基本不会出现被 Nginx 误杀的情况。Upgrade和Connection两个头是 WebSocket 协议升级必需的漏了的话握手直接失败。3.4 消息大小与缓冲区限制setMessageSizeLimit限制单条消息的大小默认是 64KB。后台推送的消息一般不大但如果推送的是列表数据或者带附件的通知可能会超。setSendBufferSizeLimit是发送缓冲区上限超过会阻塞或抛异常。setSendTimeLimit是发送超时时间。这三个参数调优的逻辑是消息体越大缓冲区要相应放大但放太大会增加内存压力。我的经验值是消息限制 128KB、缓冲区 512KB、超时 20 秒能覆盖绝大多数后台推送场景。如果推送的是大对象更好的做法是推一个消息 ID前端拿着 ID 再去拉详情而不是把大对象直接塞进 WebSocket 消息里。4. 实操落地从服务端推送到前端接收的完整链路4.1 服务端推送的三种典型姿势服务端主动推送核心工具是SimpMessagingTemplate。它有三种常用发送方式对应不同的业务场景。第一种广播给所有订阅者。适合系统公告、全局通知这类场景。Autowired private SimpMessagingTemplate messagingTemplate; public void broadcast(String message) { messagingTemplate.convertAndSend(/topic/notice, message); }所有订阅了/topic/notice的客户端都会收到这条消息。第二种点对点推送给指定用户。适合“你的审核任务有新进展”这类个性化通知。public void sendToUser(String userId, String message) { messagingTemplate.convertAndSendToUser(userId, /queue/task, message); }这里有个关键点convertAndSendToUser的第一个参数是用户的 Principal 名称不是随便传个 ID 就行。用户必须已经通过认证Principal 里存了这个名称Spring 才能把消息路由到对应的会话。如果没做认证这个方法是发不出去的。第三种按业务主题分组推送。适合多租户或者多业务线的场景不同业务线订阅不同主题。public void sendToTopic(String topic, Object payload) { messagingTemplate.convertAndSend(/topic/ topic, payload); }这种方式灵活度高但要注意主题命名规范避免主题爆炸导致管理混乱。4.2 用户认证与 Principal 绑定点对点推送依赖 Principal所以认证这一步绕不开。在 WebSocket 握手阶段可以通过拦截器把用户信息塞进 Principal。Component public class AuthChannelInterceptor implements ChannelInterceptor { Override public Message? preSend(Message? message, MessageChannel channel) { StompHeaderAccessor accessor MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); if (StompCommand.CONNECT.equals(accessor.getCommand())) { String token accessor.getFirstNativeHeader(Authorization); String userId parseUserIdFromToken(token); accessor.setUser(new StompPrincipal(userId)); } return message; } }StompPrincipal实现Principal接口getName()返回用户 ID。这样在convertAndSendToUser里传这个 ID消息就能精准投递。这里有个坑要提醒拦截器要注册到clientInboundChannel上不是随便找个 Channel 注册就行。注册错了拦截器根本不触发Principal 永远是 null点对点推送全部失效。Override public void configureClientInboundChannel(ChannelRegistration registration) { registration.interceptors(new AuthChannelInterceptor()); }4.3 前端连接、订阅与断线重连前端这块我用原生 WebSocket 加 stompjs 来演示SockJS 的写法类似只是连接方式不同。import { Client } from stomp/stompjs; const client new Client({ brokerURL: ws://localhost:8080/ws-push, reconnectDelay: 5000, heartbeatIncoming: 10000, heartbeatOutgoing: 10000, onConnect: () { client.subscribe(/topic/notice, (frame) { console.log(收到广播:, frame.body); }); client.subscribe(/user/queue/task, (frame) { console.log(收到个人消息:, frame.body); }); }, onStompError: (frame) { console.error(STOMP错误:, frame.headers[message]); } }); client.activate();reconnectDelay是断线重连间隔设成 5000 毫秒比较合理太短会造成重连风暴太长用户体验差。heartbeatIncoming和heartbeatOutgoing要和服务端协商一致不一致的话心跳可能不生效。订阅/user/queue/task这个目的地时前端写的是/user/queue/task但服务端发的时候用的是convertAndSendToUser(userId, /queue/task, ...)。Spring 会自动把/user前缀和用户会话关联起来这个映射关系是框架处理的不用手动拼。4.4 消息格式设计与序列化消息体建议统一用 JSON字段设计上留一个type字段区分消息类型前端根据type做不同的处理。{ type: TASK_UPDATE, timestamp: 1700000000000, payload: { taskId: T001, status: APPROVED } }服务端发送时convertAndSend会自动用MappingJackson2MessageConverter把对象序列化成 JSON。如果推送的对象里有LocalDateTime这类 Java 8 时间类型要确保 Jackson 的 JavaTimeModule 已经注册否则序列化会抛异常。Spring Boot 默认已经注册了但如果你自定义了ObjectMapper要记得手动加上。5. 常见问题排查与避坑经验实录5.1 连接建立失败类问题现象可能原因排查方向握手返回 404端点路径不对检查registerStompEndpoints的路径和前端连接地址是否一致握手返回 403跨域被拦截检查setAllowedOriginPatterns配置连接后立即断开认证失败或拦截器异常看服务端日志检查拦截器是否抛异常Nginx 环境下握手失败缺少 Upgrade 头检查 Nginx 的proxy_set_header配置握手 404 是最常见的多半是路径写错了。注意withSockJS()开启后实际连接地址会变成/ws-push/info之类的格式前端用 SockJS 连接时不用手动拼但用原生 WebSocket 连 SockJS 端点就会 404。5.2 消息发送成功但收不到这个问题排查起来最费时间因为服务端日志显示消息发出去了但前端就是没反应。按下面这个顺序排查基本能定位检查订阅目的地和发送目的地是否完全一致。/topic/notice和/topic/notice/是两个不同的目的地差一个斜杠就收不到。检查消息代理前缀。发到/app前缀的消息不会经过消息代理前端订阅/app也收不到广播。检查用户是否在线。点对点推送时如果用户会话已经断开消息会被丢弃不会报错。检查前端订阅时机。必须在onConnect回调里订阅连接还没建立就订阅会失败。我踩过最坑的一次是前端订阅写在了client.activate()之后但没放在onConnect里结果连接是异步建立的订阅请求发出去的时候连接还没就绪订阅直接丢了。后来改成在onConnect里订阅就正常了。5.3 断线重连后的消息丢失WebSocket 连接断开期间服务端发的消息是收不到的。如果业务上不允许丢消息需要做补偿机制。常见做法是前端重连成功后主动调一次 HTTP 接口拉取断连期间的消息。服务端给每条消息分配递增序号前端记录最后收到的序号重连后带上序号拉增量。对于重要通知服务端同时落库前端重连后查未读。纯靠 WebSocket 保证消息不丢是不现实的它本身不提供持久化和确认机制。把 WebSocket 当成“实时通道”把数据库当成“可靠存储”两者配合才是稳妥方案。5.4 并发连接数上不去单机 WebSocket 连接数受限于文件描述符和内存。Linux 默认单进程文件描述符上限是 1024连接数一多就会报Too many open files。调优方式ulimit -n 65535同时 Tomcat 的maxConnections参数也要相应调整。但单机连接数终究有上限真要支撑大规模连接得考虑多实例部署加消息中间件比如用 Redis 的发布订阅做跨实例消息转发。这块展开就大了后台推送场景一般单机几千连接够用暂时不用过度设计。5.5 内存泄漏隐患SimpMessagingTemplate本身不持有会话但如果你自己维护了一个用户会话 Map用户断开时没清理时间长了就是内存泄漏。Spring 的SessionDisconnectEvent可以监听断开事件EventListener public void onDisconnect(SessionDisconnectEvent event) { String sessionId event.getSessionId(); // 清理会话相关资源 }另外enableSimpleBroker用的是内存消息代理消息不持久化重启就没了。如果对可靠性要求高要换成外部消息代理如 RabbitMQ 的 STOMP 插件但配置复杂度会上升不少。6. 一些实操后的个人体会这套方案我在几个后台系统里都用过整体跑下来比较稳。有几点体会值得单独说一下。第一别把 WebSocket 当银弹。它解决的是实时推送不是可靠投递。重要消息该落库落库该补偿补偿WebSocket 只是让用户更快看到不是唯一通道。第二认证和授权要在握手阶段就做完。连接建立后再做权限校验消息可能已经推过去了。拦截器里把用户身份确认清楚后面推送才安全。第三前端重连逻辑要写扎实。网络抖动、服务重启、代理超时都会导致断连重连间隔建议用指数退避避免所有客户端同时重连把服务端打挂。第四监控要跟上。连接数、消息发送量、发送失败率这几个指标要能观测到否则出了问题只能靠猜。Spring Boot Actuator 配合 Micrometer 可以比较方便地采集这些指标。最后分享一个小技巧调试 WebSocket 的时候浏览器开发者工具的 Network 面板里WS 类型的连接可以看帧级别的收发记录比看日志直观得多。如果前端收不到消息先在这里看订阅帧有没有发出去、服务端有没有回 MESSAGE 帧能省很多排查时间。