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> <groupId>org.springframework.boot</groupId> <artifactId>spring-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 帧,能省很多排查时间。