1. 为什么后台消息推送这件事值得单独拎出来讲做过管理后台或者运营系统的人大概率都遇到过这种场景运营同学跑过来问“用户提交完表单之后能不能马上给我弹个提示别让我一直手动刷新页面”或者客服主管说“有新的工单进来能不能直接推给在线的客服而不是让他们每隔几秒点一次刷新”这类需求听起来简单但真动手做的时候很多人第一反应是轮询——前端定时发请求问后端“有没有新消息”。轮询能用但问题也很明显消息延迟取决于轮询间隔间隔短了服务器压力大间隔长了体验差而且大量请求里绝大多数都是空手而归纯属浪费资源。Spring Boot 使用 WebSocket 实现后台消息推送解决的正是这个痛点。WebSocket 在浏览器和服务器之间建立一条持久化的双向通道服务端有消息可以主动推给客户端客户端也能随时往服务端发数据不需要反复建连接、反复问“有没有新消息”。这套方案特别适合管理后台、客服系统、实时监控面板、订单状态提醒、站内信通知这类场景。如果你已经会写 Spring Boot 的增删改查但对“怎么让服务端主动说话”还没什么头绪那这篇内容基本可以照着抄作业如果你之前用过 WebSocket 但总在连接断开、消息丢失、多实例部署这些地方踩坑那中间的经验部分应该也能帮你省点时间。我下面会从整体设计思路讲起把协议握手、连接管理、消息路由、心跳保活、异常重连这些环节一个个拆开配上可以直接跑的代码和参数说明最后再把我自己踩过的坑整理成一张速查表。全程按一个真实项目的推进节奏来写不搞那种只贴一段配置就完事的敷衍教程。2. 整体方案设计与技术选型思路2.1 为什么是 WebSocket 而不是轮询或 SSE先把选型这件事说清楚因为很多项目一开始方向就选错了后面越做越别扭。后台消息推送常见的有三种路子短轮询、SSEServer-Sent Events和 WebSocket。短轮询就是前端setInterval定时请求。它的优点是实现简单、兼容性无敌但缺点前面说了延迟和服务器压力是一对矛盾你没法同时让两者都满意。SSE 是服务端单向推送基于 HTTP 长连接浏览器原生支持EventSource实现比 WebSocket 轻。但它只能服务端往客户端推客户端要发消息还得另开接口而且连接数多了之后对服务端的连接保持能力要求也不低。WebSocket 的优势在于真正的全双工。后台推送场景里虽然大部分时候是服务端主动推但客户端也需要发心跳、发订阅指令、发确认回执双向能力用起来更顺手。而且 WebSocket 有成熟的 STOMP 子协议支持Spring 对它的封装相当到位几行配置就能搭起一个带主题订阅的消息代理。所以这个项目我选 WebSocket并且用 Spring 官方的spring-boot-starter-websocket不引第三方库减少依赖风险。提示如果你的推送需求极其简单就是服务端单向往页面刷数据而且不需要客户端回传任何东西SSE 其实是更省事的选择。选 WebSocket 之前先确认自己真的需要双向通信别为了技术而技术。2.2 用原生 WebSocket 还是 STOMP 子协议Spring 支持两种玩法。一种是实现WebSocketHandler接口直接处理原始文本帧灵活但什么都得自己写消息格式、订阅关系、路由规则全手动。另一种是启用 STOMP把 WebSocket 当成底层传输上面套一层带目的地destination的消息协议Spring 内置了简单消息代理客户端用subscribe订阅某个主题服务端用convertAndSend往主题发消息订阅关系由框架维护。这个项目我选 STOMP。原因是后台推送天然就是“按主题分发”的模型——不同用户订阅不同频道比如某个运营只关心自己负责的订单某个客服只关心自己名下的工单。用 STOMP 的话订阅和广播的逻辑框架帮你管了你只需要关心往哪个目的地发。代价是要理解 STOMP 的几个概念但这点学习成本换来的是后面少写一大堆连接和订阅的管理代码很划算。2.3 单机内存代理与外部消息代理的取舍Spring 的 STOMP 方案默认用一个内存中的简单消息代理Simple Broker所有订阅关系存在当前应用进程里。单机部署、连接数几百到几千这个完全够用。但它有个致命限制一旦你部署多个实例做负载均衡用户在 A 实例建立连接、订阅了主题结果消息从 B 实例发出来B 实例的内存代理里根本没有这个订阅关系消息就丢了。所以选型时要先问自己一个问题这个系统未来会不会多实例部署如果会那从一开始就应该考虑接外部消息代理比如用 RabbitMQ 做 STOMP 的 relay或者干脆用 Redis 的发布订阅自己桥接。这个项目我按“先单机跑通、预留多实例扩展点”的思路来做配置上把 broker 那段单独抽出来后面要换外部代理只改这一处。3. 核心细节解析与实操要点3.1 依赖引入与基础配置第一步永远是依赖。在pom.xml里加这么一项就够了dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-websocket/artifactId /dependency这个 starter 会把spring-websocket和spring-messaging都带进来STOMP 相关的类都在里面不需要额外引别的。接下来是配置类。核心是EnableWebSocketMessageBroker注解加上WebSocketMessageBrokerConfigurer接口的实现。我把它拆成两个方法来看一个是registerStompEndpoints负责定义客户端从哪里连进来另一个是configureMessageBroker负责定义消息怎么路由。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) .setHeartbeatValue(new long[]{10000, 10000}) .setTaskScheduler(heartbeatScheduler()); registry.setApplicationDestinationPrefixes(/app); registry.setUserDestinationPrefix(/user); } Bean public TaskScheduler heartbeatScheduler() { ThreadPoolTaskScheduler scheduler new ThreadPoolTaskScheduler(); scheduler.setPoolSize(1); scheduler.setThreadNamePrefix(ws-heartbeat-); scheduler.initialize(); return scheduler; } }这里有几个点值得展开说。addEndpoint(/ws-push)定义的是握手地址客户端连的是这个路径不是消息目的地。withSockJS()是为了兼容那些不支持原生 WebSocket 的环境SockJS 会自动降级到其他传输方式。如果你的目标环境确定都支持原生 WebSocket可以不加 SockJS连接会更轻。enableSimpleBroker(/topic, /queue)里的两个前缀是订阅目的地的前缀。/topic一般用于广播一对多/queue一般用于点对点。setApplicationDestinationPrefixes(/app)定义的是客户端发消息到服务端时用的前缀比如客户端发到/app/xxx会被路由到MessageMapping(/xxx)的方法上。setUserDestinationPrefix(/user)是点对点推送的关键后面会专门讲。心跳那段setHeartbeatValue设的是 10 秒双向心跳配合一个TaskScheduler使用。心跳的作用是让双方都知道对方还活着避免中间网络设备把空闲连接悄悄掐掉。这个值不要设太短太短会增加无谓的流量也不要太长太长的话连接断了半天才发现。10 到 25 秒是比较常见的区间。3.2 连接建立与用户身份绑定WebSocket 握手阶段是 HTTP 请求所以能带上 Cookie、Token 这些认证信息。但握手成功之后后续的 STOMP 帧就不再走 HTTP 了怎么知道这条连接属于哪个用户这是后台推送必须解决的问题否则你没法做“只推给某个用户”。我的做法是在握手阶段拦截从请求里解析出用户标识塞进 WebSocket 的会话属性里。用HandshakeInterceptor实现Component public class AuthHandshakeInterceptor 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); String userId parseUserIdFromToken(token); if (userId null) { return false; } attributes.put(userId, userId); } return true; } Override public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) { } private String parseUserIdFromToken(String token) { // 实际项目里换成你自己的校验逻辑 return (token null || token.isEmpty()) ? null : user- token; } }然后在配置里把这个拦截器挂上去Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint(/ws-push) .addInterceptors(authHandshakeInterceptor) .setAllowedOriginPatterns(*) .withSockJS(); }beforeHandshake返回false就直接拒绝握手客户端连不上。返回true时attributes里的东西会跟着这条 WebSocket 会话走后面可以通过SimpMessageHeaderAccessor拿到。注意把 token 放在 URL 参数里虽然方便但会出现在访问日志里生产环境更稳妥的做法是放在 Cookie 或者握手请求的 header 里。这里为了演示直观用了参数实际项目请按自己的安全规范调整。3.3 订阅目的地与消息路由设计目的地设计是这套方案里最需要提前想清楚的部分因为它一旦定下来前端订阅和后端推送都要围着它转。我一般分三类第一类是广播主题比如/topic/notice所有订阅了这个主题的客户端都会收到适合系统公告、全局提醒。第二类是用户专属队列用/user/{userId}/queue/xxx这种形式Spring 会自动把消息路由到对应 userId 的会话上适合“只推给某个人”的场景。第三类是业务分组主题比如/topic/order/{regionId}按业务维度切分适合按区域、按团队分发的消息。用户专属队列的用法要特别说一下因为很多人第一次用会懵。服务端发送时用的是convertAndSendToUser(userId, /queue/msg, payload)注意这里的目的地写/queue/msg不带/user前缀Spring 会自动加上去并找到对应会话。客户端订阅时写的是/user/queue/msg也不带具体 userId因为框架会根据当前会话的身份自动匹配。这个不对称的设计是官方约定记牢就行。Controller public class PushController { Autowired private SimpMessagingTemplate messagingTemplate; public void pushToUser(String userId, Object payload) { messagingTemplate.convertAndSendToUser(userId, /queue/msg, payload); } public void broadcast(String topic, Object payload) { messagingTemplate.convertAndSend(/topic/ topic, payload); } }SimpMessagingTemplate是推送的核心工具注入之后哪里都能用。convertAndSend会自动把对象序列化成 JSON前提是配置了消息转换器Spring Boot 默认会用 Jackson一般不用额外配。3.4 心跳保活与断线重连机制WebSocket 连接不是建完就一劳永逸的。网络抖动、代理超时、浏览器休眠都会让连接悄悄断掉而客户端可能还以为自己连着。所以心跳和重连是必须做的两件事。服务端心跳前面配置里已经设了客户端也要配合。用 SockJS STOMP.js 的话客户端这样写const socket new SockJS(/ws-push?token token); const stompClient Stomp.over(socket); stompClient.heartbeat.outgoing 10000; stompClient.heartbeat.incoming 10000; stompClient.connect({}, function (frame) { stompClient.subscribe(/user/queue/msg, function (message) { handleMessage(JSON.parse(message.body)); }); }, function (error) { scheduleReconnect(); });heartbeat.outgoing是客户端多久发一次心跳incoming是期望服务端多久发一次。两边要对得上否则会有一方认为对方失联。重连逻辑我建议用指数退避第一次断线 1 秒后重连失败就 2 秒、4 秒、8 秒封顶 30 秒。这样既能快速恢复又不会在服务端真的挂了的时候疯狂重试把压力打满。let reconnectDelay 1000; function scheduleReconnect() { setTimeout(function () { connect(); reconnectDelay Math.min(reconnectDelay * 2, 30000); }, reconnectDelay); }重连成功之后记得重新订阅因为旧的订阅关系随着旧连接一起没了。把订阅逻辑封装成一个函数连接成功就调一次这样重连后自动恢复订阅。4. 完整实操流程与关键环节实现4.1 从零搭建一个可运行的后台推送 Demo光看配置容易飘我们把它串成一个能跑起来的最小系统。假设场景是运营后台需要实时收到新订单提醒每个运营只关心自己负责的区域。先建 Spring Boot 项目引入前面说的 websocket starter 和 web starter。然后按顺序写这几个类配置类WebSocketConfig、握手拦截器AuthHandshakeInterceptor、推送服务PushService、以及一个用来模拟触发推送的测试接口。推送服务我单独抽一层不直接把SimpMessagingTemplate散落在各个业务类里这样后面要换实现或者加日志都方便Service public class PushService { private static final Logger log LoggerFactory.getLogger(PushService.class); Autowired private SimpMessagingTemplate messagingTemplate; public void notifyUser(String userId, String type, Object data) { PushMessage msg new PushMessage(type, data, System.currentTimeMillis()); messagingTemplate.convertAndSendToUser(userId, /queue/msg, msg); log.info(push to user{}, type{}, userId, type); } public void notifyRegion(String regionId, String type, Object data) { PushMessage msg new PushMessage(type, data, System.currentTimeMillis()); messagingTemplate.convertAndSend(/topic/region/ regionId, msg); log.info(push to region{}, type{}, regionId, type); } }PushMessage就是一个简单的 POJO带type、data、timestamp三个字段。type用来让前端区分消息种类比如NEW_ORDER、ORDER_CANCELLED前端拿到之后 switch 一下决定怎么展示。再写一个测试接口模拟订单创建后触发推送RestController RequestMapping(/api/order) public class OrderController { Autowired private PushService pushService; PostMapping(/create) public String create(RequestBody OrderRequest request) { // 省略真实的落库逻辑 pushService.notifyRegion(request.getRegionId(), NEW_ORDER, request); pushService.notifyUser(request.getOwnerId(), NEW_ORDER, request); return ok; } }前端页面用原生 JS 加 STOMP.js 就能跑。引入sockjs.min.js和stomp.min.js然后按前面的客户端代码连接、订阅、处理消息。整个链路就通了调创建订单接口对应区域的运营页面立刻弹出提醒。4.2 消息格式设计与前后端约定消息格式这件事我强烈建议在项目初期就定死不然后面加字段、改结构会非常痛苦。我的习惯是统一包一层信封{ type: NEW_ORDER, data: { orderId: 1001, amount: 299.00 }, timestamp: 1710000000000 }type是必填的字符串枚举前后端共同维护一份常量表。data是业务负载结构随 type 变化。timestamp用于前端做去重和排序因为网络原因消息可能乱序到达。前端处理时按 type 分发function handleMessage(msg) { switch (msg.type) { case NEW_ORDER: showOrderToast(msg.data); break; case ORDER_CANCELLED: removeOrderRow(msg.data.orderId); break; default: console.warn(unknown message type, msg.type); } }提示data里不要塞太大的对象。WebSocket 帧虽然没硬性大小限制但一次推几 MB 的数据会阻塞这条连接上的其他消息。大文件、大列表应该推一个 ID让前端拿着 ID 再去拉详情。4.3 多实例部署下的消息桥接方案前面提过单机内存代理在多实例下会丢消息。真到了要扩容的时候有两种主流做法。第一种是接 RabbitMQ 作为 STOMP 的外部代理。Spring 支持把enableSimpleBroker换成enableStompBrokerRelay配置上指向 RabbitMQ 的 STOMP 端口。这样所有实例的订阅关系都注册到 RabbitMQ消息从任意实例发出RabbitMQ 负责投递到正确的实例。配置大概长这样Override public void configureMessageBroker(MessageBrokerRegistry registry) { registry.enableStompBrokerRelay(/topic, /queue) .setRelayHost(rabbitmq-host) .setRelayPort(61613) .setClientLogin(guest) .setClientPasscode(guest); registry.setApplicationDestinationPrefixes(/app); registry.setUserDestinationPrefix(/user); }第二种是用 Redis 发布订阅自己桥接。每个实例订阅同一个 Redis 频道收到消息后判断目标用户是否在本实例上是就通过本地SimpMessagingTemplate推出去。这种做法更灵活但要自己处理用户和实例的映射关系复杂度高一些。选哪种取决于团队的技术栈。已经有 RabbitMQ 的就用第一种省事只有 Redis 的就用第二种但要接受多写一些代码。不管哪种convertAndSendToUser的调用方式都不用变这是 Spring 抽象带来的好处。4.4 连接数监控与资源占用观察上线之后一定要能看见连接情况否则出了问题两眼一抹黑。Spring 提供了SimpUserRegistry可以拿到当前在线的用户和会话数RestController public class MonitorController { Autowired private SimpUserRegistry userRegistry; GetMapping(/ws/stats) public MapString, Object stats() { MapString, Object result new HashMap(); result.put(onlineUsers, userRegistry.getUserCount()); result.put(totalSessions, userRegistry.getUsers().stream() .mapToInt(u - u.getSessions().size()).sum()); return result; } }这个接口可以接到监控面板上观察连接数的变化趋势。正常情况下连接数应该和在线用户数大致相当如果发现某个用户挂了几百个会话那多半是前端重连逻辑有问题旧连接没关就建新的越积越多。这种情况要在客户端重连前先disconnect掉旧连接。另外要关注线程池。WebSocket 的消息处理默认走的是 Spring 的clientInboundChannel和clientOutboundChannel两个线程池连接数大的时候要适当调大Override public void configureClientInboundChannel(ChannelRegistration registration) { registration.taskExecutor().corePoolSize(8).maxPoolSize(32).queueCapacity(1000); } Override public void configureClientOutboundChannel(ChannelRegistration registration) { registration.taskExecutor().corePoolSize(8).maxPoolSize(32).queueCapacity(1000); }具体数值要压测之后定别照抄。原则是入站线程池要能扛住所有客户端同时发消息的峰值出站线程池要能及时把推送发出去不积压。5. 常见问题与排查技巧实录5.1 连接建立失败与跨域问题最常见的问题就是前端连不上控制台报 403 或者 CORS 错误。原因通常是setAllowedOriginPatterns没配或者配了setAllowedOrigins(*)但同时又开了allowCredentials这两者在规范上不能共存。用setAllowedOriginPatterns(*)可以绕开这个限制但生产环境建议明确列出允许的域名别图省事全放开。还有一种情况是握手被拦截器拒了。检查beforeHandshake的返回值如果 token 解析失败返回了false客户端只会看到一个握手失败不会有更详细的提示。调试时可以在拦截器里打日志把拒绝原因记下来。5.2 消息发送成功但客户端收不到这个问题的排查思路是逐段确认。先确认服务端convertAndSend的目的地和客户端subscribe的目的地是否匹配特别注意/user前缀的不对称规则。再确认用户身份绑定是否正确convertAndSendToUser里的 userId 和握手时存进 attributes 的 userId 必须完全一致大小写、前后空格都算。如果用的是 SockJS还要注意 SockJS 的会话 ID 和原生 WebSocket 的会话 ID 不是一回事某些依赖会话 ID 的逻辑会出问题。我一般建议在业务代码里只用 userId不碰会话 ID能避开这类坑。5.3 连接频繁断开与心跳配置连接每隔一段时间就断多半是心跳没配对。检查服务端setHeartbeatValue和客户端heartbeat.outgoing/incoming是否一致。还有一个容易被忽略的点如果中间有 Nginx 之类的反向代理代理自身也有空闲超时默认可能是 60 秒。心跳间隔必须小于代理超时否则代理会先把连接掐了。我一般把心跳设成 10 到 25 秒同时把代理的proxy_read_timeout调到 300 秒以上双保险。5.4 多实例下消息丢失单机好好的一上多实例就丢消息基本可以确定是内存代理的问题。按前面说的接外部代理或者用 Redis 桥接。排查时可以临时把实例数降到 1 验证如果单实例正常、多实例异常那就锁定是这个问题。5.5 常见问题速查表现象可能原因排查方向解决方式握手 403跨域配置缺失看浏览器控制台 CORS 报错配置 allowedOriginPatterns握手失败无提示拦截器返回 false拦截器加日志检查 token 解析逻辑订阅了收不到目的地不匹配对比前后端目的地字符串注意 /user 前缀规则只推给某人失败userId 不一致打印握手 attributes 和发送时的 userId统一 userId 来源连接定时断开心跳或代理超时看断开间隔是否固定调心跳和代理超时多实例丢消息内存代理单实例是否正常接外部代理或 Redis 桥接连接数暴涨重连未关旧连接看监控会话数重连前先 disconnect5.6 几个我踩过的坑第一个坑是序列化。有一次推送的对象里有个LocalDateTime字段Jackson 默认序列化成一串数字前端拿到一脸懵。后来统一配了JavaTimeModule并关掉时间戳输出才正常。涉及时间字段的一定要提前确认序列化格式。第二个坑是消息顺序。WebSocket 本身保证单条连接上的消息有序但如果你在服务端用了多个线程并发推送到达顺序就不一定了。涉及状态变更的消息我建议在消息里带一个递增的序列号前端按序列号排序后再处理。第三个坑是内存泄漏。早期版本我在SessionConnectedEvent里往一个静态 Map 里存会话断开时忘了移除跑几天内存就满了。后来改用SimpUserRegistry来查不再自己维护 Map省心很多。如果确实要自己维护务必在SessionDisconnectEvent里清理。第四个坑是 SockJS 的降级。有些内网环境禁了 WebSocketSockJS 会降级到 XHR 流这时候消息延迟会明显变大。如果对实时性要求高要在前端检测当前用的传输方式降级时给用户一个提示。6. 性能优化与扩展方向6.1 推送频率控制与消息合并后台推送最容易失控的地方是频率。比如一个批量导入操作每导入一条就推一次一万条就是一万次推送前端直接卡死。我的做法是在服务端做合并短时间内同一类型的消息攒一批用一个定时任务每 500 毫秒 flush 一次把批量数据一次性推出去。前端拿到批量消息后统一渲染体验反而更平滑。Component public class BatchPushBuffer { private final MapString, ListObject buffer new ConcurrentHashMap(); Scheduled(fixedDelay 500) public void flush() { buffer.forEach((destination, list) - { if (!list.isEmpty()) { messagingTemplate.convertAndSend(destination, list); list.clear(); } }); } }这个方案要注意线程安全用ConcurrentHashMap加同步块保护 list。另外 flush 间隔要根据业务容忍度调500 毫秒对大多数后台场景够用对实时性要求更高的可以降到 100 毫秒。6.2 按需订阅减少无效推送不是所有消息都需要推给所有人。我见过一个系统所有客户端都订阅了全局主题结果每条消息都广播给几千个连接其中绝大多数人根本不关心。优化方式是让客户端按需订阅比如运营只订阅自己区域的 topic客服只订阅自己的 user queue。服务端推送时也按业务维度精确投递不做无差别广播。6.3 后续可以扩展的方向这套基础搭好之后往上加东西就顺了。比如加消息持久化把推送过的消息存一份到数据库用户离线期间的消息可以在重连后拉取补发。再比如加已读回执客户端收到消息后回一个确认服务端记录投递状态用于排查“到底推没推到”。还可以把推送服务和业务事件解耦用 Spring 的ApplicationEventPublisher发事件推送服务监听事件再决定推给谁这样业务代码里就不用到处注入PushService了。我自己在实际项目里最深的体会是WebSocket 推送的难点从来不在“怎么把消息发出去”而在“怎么保证该收到的人一定收到、不该收到的人别被打扰、连接断了能自己恢复”。把身份绑定、目的地设计、心跳重连这三件事做扎实剩下的都是锦上添花。最后再分享一个小技巧上线前一定要用真实的弱网环境测一遍模拟断网、切后台、锁屏这些情况很多在办公室 Wi-Fi 下测不出来的问题一到真实环境就暴露了。
阅读完成 · 觉得有帮助?