
先声明一下这篇是系列里的第二十七篇前二十六篇我们一直在 HTTP 接口、IOC 容器、AOP、事务这些请求-响应模型里打转这次换到 WebSocket算是把 Spring 的通信能力补上最后一块拼图。我去年在一家做客服系统的公司待过一段时间后端同时扛着 10 万在线客服连接中间经历了 Nginx 握手失败、集群丢消息、心跳超时断开等一系列事故把 Spring 官方文档翻来覆去看了好几遍也自己写了个简单的 WebSocket 监控中心。这篇文章就是把这些真实踩坑经验整理成一套可以直接落地的 Spring WebSocket 实践方案适合已经掌握 Spring Boot 基本用法、想做实时推送或在线交互功能的开发者也适合那些正准备把 HTTP 轮询改成 WebSocket 的团队参考。1. 为什么选 WebSocket 而不是轮询和 SSE1.1 HTTP 协议天然被动但业务需要服务端主动说话做了这么多年 Spring 后端你会发现 HTTP 协议骨子里是一个被动模型客户端发一个请求服务端返回一个响应然后这次对话就结束了。即使你用了 JSONP、CORS也逃不出这个模式。可现实中的很多业务场景恰恰需要服务端主动向客户端说点什么工单状态变了要推给客户、用户下单后要通知仓库同事、数据库里某张表被批处理任务更新了要实时反映到大屏幕上。这类需求用传统 HTTP 接口怎么做只能靠客户端一遍遍去问也就是轮询。轮询方案我以前也用过短期看确实能跑但用户量一上来就露馅。短信服务商的状态回执、客服系统的未读消息、支付回调后的前端页面刷新这些都让我被轮询坑过。最夸张的一次是后端接口被前端每 5 秒打一次高峰期 2000 个在线用户每 5 秒产生 2000 个请求平均每分钟 24000 次 HTTP 交互Tomcat 线程池直接打满数据库连接池也跟着告警。后来我把这个系统整体改了 WebSocket同样是 2000 个在线用户单台机器上的维护连接数从每秒 400 次请求降到了20 个左右的心跳帧Tomcat 线程占用几乎可以忽略不计。这里有个容易忽略的底层逻辑HTTP 轮询的高成本不在于请求本身有多重而在于每一次请求都要走完整的 TCP 握手、HTTP 头解析、路由分发、过滤器链、SpringMVC 参数解析、序列化返回这一整套流程里真正有价值的业务计算可能只占 5% 都不到。所以你会看到轮询方案一旦并发量上来性能崩溃得特别快。1.2 三种主动推送方案我站在项目角度做的对比除了 WebSocket市面上还有两种常见的服务端主动推送思路长轮询Long Polling和 SSEServer-Sent Events。长轮询本质还是 HTTP但客户端发一次请求之后服务端不立即返回而是挂住这个请求等有数据了再返回返回后客户端立刻再发一个请求接着挂。这种方式比短轮询省很多请求量但服务端每个长连接都在占着一个 Tomcat 线程连接一多照样扛不住而且中间代理层、超时设置、断线重连都要自己处理。SSE 是我最近两年比较推崇的轻量方案。它也是单个 HTTP 连接但服务端可以持续向客户端吐出数据客户端用一个 EventSource 对象就能接收自带断线重连机制实现成本极低。但 SSE 是单向的客户端只能接收想给服务端发消息还是得走单独的 HTTP 接口而且它在部分抓包工具和跨域场景下稍微有点绕。WebSocket 则是真正的全双工客户端和服务端在同一个 TCP 连接上可以互相主动发消息没有请求-响应的约束。我把三者放在一张表里做过对比这张表现在依然贴在我们项目的 Wiki 首页上对比维度短轮询长轮询SSEWebSocket通信方向客户端到服务端客户端到服务端服务端可延迟返回服务端到客户端为主全双工建立连接成本每次请求都高中等低一次建立低一次建立连接复用服务端资源占用高线程连接高挂起线程多低极低长连接复用断线重连天然支持重新请求需自己实现浏览器原生支持需自己实现消息延迟取决于轮询间隔接近实时实时实时浏览器兼容性全部全部除 IE 外基本全部现代浏览器全部适合场景低频、可容忍延迟低频但需实时感知服务端单向广播双向高频交互做技术选型的时候团队里最容易出现的争论就是既然 SSE 都能做推送为什么还要用 WebSocket。我的回答很简单看你的消息流向。只有服务端往下推SSE 够用一旦客户端还要频繁往服务端发消息比如多人协同的每一次光标移动、聊天框里的每一个字、客服对话里的每一次转接SSE 就要额外维护一套 HTTP 接口体系复杂度反而更高。所以我在客服系统、实时监控大屏、协同编辑器这三个项目里都直接选了 WebSocket。1.3 什么场景才算必须用 WebSocket我很反感那种不管什么需求都先上个 WebSocket的做法。如果你只是每天晚上八点钟给用户推一条优惠券提醒用 SSE 或者干脆用消息队列 移动端推送就够了。WebSocket 真正适合的是下面这三类场景高频双向交互聊天、在线客服、IM 系统、多人白板、协同编辑这些场景下客户端和服务端的消息是频繁互相流动的HTTP 轮询的方案在架构上就已经不成立了。低延迟实时监控服务器 CPU/内存监控大屏、股票行情刷新、比赛实时比分、日志实时追踪。这类场景要求毫秒级更新轮询的固定间隔注定做不到。需要维持一套在线状态的场合在线用户列表、游戏中好友的上下线提醒。WebSocket 连接本身就是一种状态服务端可以准确知道这个用户当前到底在不在线而 HTTP 无状态模型无法天然表达这一点。换句话说WebSocket 适合的场景有一个共同特征连接的价值在于存在本身。这一点后面讲心跳和鉴权的时候会反复用到。2. Spring 里那两套 WebSocket 方案先搞清楚再动手2.1 原生 WebSocketHandler轻快但一切都得自己管Spring 对 WebSocket 的第一层支持是基于WebSocketHandler接口的原生 API。它非常薄薄到几乎就是 WebSocket 协议在 Java 侧的一个直接映射。你要写一个处理器就继承TextWebSocketHandler或者BinaryWebSocketHandlerpublic class ChatWebSocketHandler extends TextWebSocketHandler { Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { // 连接建立成功后可以把 session 存到一个全局 Map 里 WebSocketSessionRegistry.INSTANCE.add(session.getId(), session); // 也可以直接给客户端发一句欢迎语 session.sendMessage(new TextMessage(welcome!)); } Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { // 客户端发来消息结合业务处理后可以原样返回也可以转发给其他 session String payload message.getPayload(); session.sendMessage(new TextMessage(echo: payload)); } Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { // 连接关闭后一定要记得从注册表里移除否则会造成 session 泄漏 WebSocketSessionRegistry.INSTANCE.remove(session.getId()); } }还要写一个WebSocketConfigurer来注册端点比如Configuration EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(new ChatWebSocketHandler(), /chat) .addInterceptors(new MyHandshakeInterceptor()) .setAllowedOrigins(*); } }这个方案的优点是概念最少、没有额外协议、性能也好因为中间几乎没有框架开销。但缺点也很明显没有主题Topic、没有订阅Subscribe、没有消息路由。你想把消息发给指定的某一个用户得自己维护一个userId, WebSocketSession的映射表你想做群发得自己遍历所有 session你想做某个频道里所有在线客户端都能收到得自己设计一套频道管理逻辑。说白了原生 API 给你的只是一条能收发消息的管道至于管道怎么接、接几根、往哪送全都要自己造。2.2 STOMP 子协议把 WebSocket 变成消息队列STOMPSimple Text Oriented Messaging Protocol是为 WebSocket 量身定做的一个轻量消息子协议。它做的事情非常直观在 WebSocket 之上定义了几种帧比如CONNECT建立会话、SUBSCRIBE订阅某个目标、SEND发送消息到某个目标、UNSUBSCRIBE取消订阅。这样就把一条裸的双向管道升级成了一套带目的地和订阅关系的消息网络。Spring 对 STOMP 的支持做得非常优雅你需要做的事情很少。先加一个配置类Configuration EnableWebSocketMessageBroker public class WebSocketStompConfig implements WebSocketMessageBrokerConfigurer { Override public void registerStompEndpoints(StompEndpointRegistry registry) { // 客户端连接入口/ws 就是 WebSocket 握手地址 registry.addEndpoint(/ws).setAllowedOrigins(*); // 如果担心浏览器兼容性可以开启 SockJS 降级方案 registry.addEndpoint(/ws).setAllowedOrigins(*).withSockJS(); } Override public void configureMessageBroker(MessageBrokerRegistry registry) { // 客户端发给服务端的消息都要求以 /app 开头 registry.setApplicationDestinationPrefixes(/app); // 服务端广播给客户端的消息走 /topic 前缀 registry.enableSimpleBroker(/topic, /queue); } }再把消息处理写成类似 Spring MVC Controller 的写法Controller public class GameController { // 客户端发送到 /app/game/action 的消息会被这个方法接收 MessageMapping(/game/action) SendTo(/topic/game/state) public String handleAction(String action) { // 处理逻辑... return newState; } }SendTo表示这个方法的返回值会广播给所有订阅了/topic/game/state的客户端。如果你想主动推消息也可以注入SimpMessagingTemplateService public class PushService { Autowired private SimpMessagingTemplate messagingTemplate; public void notifyUser(Long userId, String message) { // 推给特定用户前提是客户端订阅了 /queue/notify-{userId} messagingTemplate.convertAndSendToUser(userId.toString(), /queue/notify, message); } public void broadcast(String message) { messagingTemplate.convertAndSend(/topic/broadcast, message); } }简单来说STOMP 方案的价值在于它把 WebSocket 从一根管道升级成了一套消息总线。你不再需要手动管理 session 列表而是要理解目的地和订阅者这两个概念开发心智负担降低了很多。我在客服系统里就用的是 STOMP因为客服侧需要按客服ID点对点推送、按工单组做频道广播这些路由逻辑如果让我用原生 WebSocketHandler 手写至少要加几百行代码。2.3 这套选型我想得很清楚分享给你作参考很多同事问我到底学原生还是学 STOMP我一般用下面这套判断逻辑回答如果你只是做一个页面上的实时通知横幅消息量不大、交互模式单一用原生WebSocketHandler就够简单直接。如果你要做聊天、协同、监控大屏这类复杂消息系统我建议直接用 STOMP SimpMessagingTemplate它帮你解决订阅关系和点对点路由这些恰恰是业务上最容易出问题的部分。如果你要兼容 IE 等老浏览器那就必须考虑 SockJS 降级SockJS 跟 STOMP 的配合最成熟原生 API 配 SockJS 也可以但意义不大。我再多说一句选型上的原则不要以框架之重决定方向要以业务里有没有目的地和订阅的概念决定方向。你想想如果你的业务里消息天然是发给某人或发给某群的那就想象成你正在设计中台的消息队列STOMP 就是为这种模型准备的。3. 从零搭建一个能跑的 WebSocket 服务3.1 环境准备与依赖引入这篇文章还是延续 Spring Boot 的实践路线项目基于 Spring Boot 2.7.x如果是 Spring Boot 3.x 也大同小异核心 API 没变太多。JDK 至少 8我建议直接用 JDK 17 以及对应的 Spring Boot 3.x因为 Spring 6 的EnableWebSocketMessageBroker和基础配置方式在 Boot 3 下依然兼容。在pom.xml里加入依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-websocket/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependencyspring-boot-starter-websocket 会把 spring-websocket 和 spring-messaging 都带进来前者负责最底层的 WebSocketHandler后者负责 STOMP 相关的那一套消息抽象。如果你的项目里已经存在 SpringMVC只需要加 websocket 这个 starter 即可它们不冲突。3.2 方案A原生 WebSocket 三步走第一步继承TextWebSocketHandler写处理逻辑代码在 2.1 已经给过。第二步写一个可选的握手拦截器实现HandshakeInterceptorpublic class MyHandshakeInterceptor implements HandshakeInterceptor { Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, MapString, Object attributes) throws Exception { // 在握手阶段可以校验参数、token校验通过再把用户信息放到 attributes 里 // attributes 里的值会在 WebSocketSession.getAttributes() 中拿到 attributes.put(remoteHost, request.getRemoteAddress().toString()); return true; } Override public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) { // 握手完成后的回调一般用来记录日志 } }第三步注册端点。这里有一个我觉得很关键的细节setAllowedOrigins(*)在 Spring Boot 2.7 之后的版本里泛域名白名单需要用setAllowedOriginPatterns(*)替代否则跨域配置可能不生效。我遇到过两次线上环境报Invalid origin排查半天发现就是通配符写法的问题。完成这三步之后前端就可以用一行new WebSocket(ws://localhost:8080/chat)连上去了。3.3 方案BSTOMP 全流程含前端联调服务端的配置类我在 2.2 已经写过了这里把完整流程补一下加配置类、定义MessageMapping方法、注入SimpMessagingTemplate推送。剩下主要就是前端怎么接。前端现在最常用的方式是用原生的WebSocket对象加上 tiny 的stomp/stompjs库import { Client } from stomp/stompjs; const stompClient new Client({ brokerURL: ws://localhost:8080/ws, // 如果用 SockJS则改成 webSocketFactory: () new SockJS(/ws) onConnect: (frame) { // 订阅服务端广播通道 stompClient.subscribe(/topic/game/state, (message) { console.log(收到广播:, message.body); }); // 订阅个人通知队列 stompClient.subscribe(/user/queue/notify, (message) { console.log(收到个人通知:, message.body); }); // 向服务端发送消息 stompClient.publish({ destination: /app/game/action, body: JSON.stringify({ type: START }) }); }, onDisconnect: (frame) { // 断线处理 } }); stompClient.activate();这里有个特别容易搞错的地方前端订阅个人消息用的是/user/queue/notify而不是convertAndSendToUser(userId, /queue/notify, message)里的完整路径。Spring 的convertAndSendToUser实际上会先拼出/user/{userId}/queue/notify这个地址发给 broker前端订阅/user/queue/notify时会自动映射到当前登录用户对应的地址。如果你在前端直接订阅了/queue/notify-{userId}那是把点对点机制理解偏了很可能收不到消息。3.4 天生就该加的一层SockJS 降级SockJS 解决的问题很朴素有些企业的办公网环境、老版本浏览器、或者中间设备不支持或者拦断了标准 WebSocket 升级。SockJS 会先尝试用 WebSocket 连接失败了就自动降级为 XHR 轮询、XHR 流或 JSONP 等方式让客户端看起来体验依然是实时的。启用方式就是我上面代码里的.withSockJS()。启用 SockJS 之后前端的连接地址就不能直接写ws://localhost:8080/ws了要么用new SockJS(/ws)要么用 stompjs 的webSocketFactory创建 SockJS 实例。还要注意一点SockJS 的路径会自动追加/info之类的路由如果你的项目有全局 SpringMVC 拦截器或网关路由要确保放行了这些路径否则第一次握手前的探测请求就会被拦截掉。这个坑我踩过表现为前端一直报SockJS requires a protocol但其实根本不是协议问题是路径被拦截了。3.5 定时推送的常用姿势实战里最常见的需求是服务端定期把数据推给所有在线客户端比如监控大屏每 3 秒刷新一次当前在线人数。直接用 Spring 的Scheduled定时任务就能实现Component public class MonitorScheduler { Autowired private SimpMessagingTemplate messagingTemplate; Scheduled(fixedRate 3000) public void pushCpuUsage() { int onlineCount WebSocketSessionRegistry.INSTANCE.getOnlineCount(); MapString, Object data new HashMap(); data.put(onlineCount, onlineCount); data.put(cpu, CpuMonitor.getUsage()); messagingTemplate.convertAndSend(/topic/monitor, data); } }注意要给启动类加上EnableScheduling否则定时任务不会启动。这个组合是Spring Boot WebSocket 定时推送的最小闭环也是我做监控类功能时的默认模板。4. 心跳机制连接不断靠的不是运气4.1 为什么说心跳是 WebSocket 的生命体征WebSocket 看起来是一条很持久的连接但现实里网络环境并不是真空管。用户把笔记本盖子合上、手机锁屏了很久、路由器 NAT 表项过期、公司的防火墙拦截空闲连接这些都可能导致一个物理上已经死亡的 TCP 连接在服务端依然表现为存在。Spring 的WebSocketSession.isOpen()返回true但实际发消息时才发现对端已经不可达。如果你维护一个在线用户列表这些假连接就会让列表变得不可信。心跳的作用通俗讲就是敲一敲墙看有没有人回应客户端或者服务端定期发送一个小数据包另一端收到后回应一下这样两边都知道对方还活着。更重要的是心跳本身能持续刷新网络中间设备NAT、负载均衡、代理的连接超时计时器让这条连接不被误杀。而不是等真正要传业务消息时连接已经凉了。4.2 三个层面的心跳我建议全部做第一层是 WebSocket 协议层的心跳也就是 RFC 6455 规定的ping和pong帧。服务端可以通过session.pingMessage()发 ping浏览器端收到 ping 后会自动返回 pong这个过程对 JavaScript 是透明的前端代码不需要做任何处理。这一层的好处是协议层面最干净坏处是你作为前端开发者无法感知 pong 是否回来了因为浏览器的 WebSocket API 根本没有暴露 pong 事件。第二层是 STOMP 协议层的心跳。如果你用了 STOMPCONNECT帧会携带heart-beat: cx,cy这种参数比如heart-beat: 10000,10000表示客户端每 10 秒发一个心跳帧也希望服务端每 10 秒发一个。Spring 的SimpleBroker和StompBrokerRelay都支持自动协商心跳配置一般在configureMessageBroker里调整。STOMP 层心跳的好处是它是在 STOMP 帧级别工作的前端通过stompjs能拿到心跳相关的日志更方便排查。第三层是应用层心跳也就是你在聊天协议里自定义{type:PING}和{type:PONG}消息。为什么有了前两层还要做应用层因为三层各自解决不同问题协议层告诉你TCP 连接还通着STOMP 层告诉你STOMP 会话还通着应用层才能告诉你对端应用程序还活着。而且应用层的心跳消息里可以携带时间戳、客户端版本号、会话ID等业务数据服务端收到之后能更新最后活跃时间这是做自动离线功能的基础。4.3 基于 Spring 实现服务端心跳扫描我在原生 WebSocket 项目里写过一个简单的服务端心跳扫描器。思路是启动一个定时任务每隔 20 秒遍历所有 WebSocketSession给每个 session 发送一个 ping 帧同时检查每个 session 的 最后收到消息时间超过 60 秒没收到任何消息的就判定为失活并关闭。这里用到了一个WebSocketSessionRegistry代码可以长这样Component public class HeartbeatScheduler { // 这个 Map 由 ChatWebSocketHandler 在连接建立/关闭时维护 Autowired private WebSocketSessionRegistry registry; Scheduled(fixedRate 20000) public void scanAndHeartbeat() { long now System.currentTimeMillis(); ListWebSocketSession deadSessions new ArrayList(); for (WebSocketSession session : registry.getAllSessions()) { // 1. 主动发 ping探测链路是否仍然畅通 try { if (session.isOpen()) { session.sendMessage(new PingMessage(() - ByteBuffer.wrap(keep.getBytes()))); } } catch (IOException e) { deadSessions.add(session); continue; } // 2. 检查最后活跃时间 Long lastActive (Long) session.getAttributes().get(lastActiveTime); if (lastActive ! null now - lastActive 60000) { deadSessions.add(session); } } for (WebSocketSession session : deadSessions) { try { session.close(CloseStatus.GOING_AWAY); } catch (IOException ignored) { } registry.remove(session.getId()); } } }对应的在handleTextMessage里每次收到消息都更新lastActiveTimesession.getAttributes().put(lastActiveTime, System.currentTimeMillis());这套逻辑看起来简单但有两个注意点。一是pingMessage的PingMessage构造器需要传一个SupplierByteBuffer我上面用了 lambda二是心跳扫描的间隔和超时阈值要留足余量别把网络抖动误判成连接死亡。我惯用的比例是心跳间隔 20 秒超时阈值给到 90 秒。如果网络半夜偶尔抖动 30 秒这个阈值足够容忍超过 90 秒还收不到任何消息基本可以断定这条连接已经不可用了。4.4 STOMP 心跳的一些配置细节和坑如果你用的是 STOMP enableSimpleBrokerSpring 内部会有一个TaskScheduler来自动处理心跳协商默认情况下它是开启的。但你如果要自定义可以在configureMessageBroker的enableSimpleBroker之后加一行.setHeartbeatValue(new long[]{10000, 10000})如果用的是外部 broker比如 RabbitMQ 的 STOMP 适配器StompBrokerRelay也需要配置setSystemHeartbeatSendInterval和setSystemHeartbeatReceiveInterval之类的方法。这里有一个特别值得注意的现象前端用 IPv6 或者经过某些移动网络时TCP 连接可能在没有数据流动的情况下被中间网关以 30~90 秒不等的间隔强杀。如果你的心跳周期是 30 秒但网关的杀连接时间是 25 秒那心跳就永远发不出去连接也永远建立不起来。遇到这种情况单纯调心跳周期不一定有用更靠谱的做法是让服务端和客户端互相协商一个小于网关阈值的心跳间隔并且在应用层也做一个兜底。我做过一个极端项目最后把心跳周期设成了 15 秒超时阈值设成了 90 秒才在客户的 Wi-Fi 环境里稳定运行。4.5 Nginx 代理下的心跳超时最容易被遗忘的一环很多团队开发环境直连 Spring Boot 没问题一上测试环境就发现 WebSocket 每隔 60 秒自动断线然后反复重连日志里全是Connection lost。这几乎可以断定是 Nginx 的proxy_read_timeout把空闲连接给清了。Nginx 默认的proxy_read_timeout是 60 秒意思是后端在 60 秒内没有发任何数据给 NginxNginx 会自动断开这个连接。WebSocket 连接平时是没有数据流动的只有心跳包——如果心跳间隔大于 60 秒那必然被切断。解决方法是在 Nginx 的 location 里加上location /ws/ { proxy_pass http://backend-server; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_read_timeout 86400s; proxy_send_timeout 86400s; }这里面的核心逻辑是Upgrade和Connection: upgrade是 WebSocket 握手成功的关键Nginx 必须原封不动地把这两个头传给后端proxy_read_timeout则必须大于你的心跳周期保险起见直接设成 24 小时让连接的生死完全交给应用层心跳去管。还有一个我容易踩的细节如果你用的是http://而不是https://上游proxy_set_header Connection upgrade不能写成upgrade小写Nginx 的大小写处理有时会坑你建议统一写成标准的upgrade。5. 线上才会遇到的坑握手、鉴权与集群5.1 握手阶段被拦截比什么都闹心第一个常见的坑是握手被拦截。表现形式很直接前端 WebSocket 一直onerror后端日志里没有收到任何连接请求。原因是很多网关或者全局拦截器把/ws当成普通 HTTP 请求处理了。如果项目里有 SpringMVC 的HandlerInterceptor或者网关层做了一个必须登录的全局校验那么在 WebSocket 握手阶段这个请求是要走正常 HTTP 处理流程的一旦被拦截器拒掉握手就失败。解决办法是在拦截器白名单里放行 WebSocket 路径或者把 WebSocket 路径做成独立域名。排查这类问题时我的固定动作是先看后端的访问日志里有没有出现 WebSocket 握手 URL 对应的一次GET请求。如果有但状态码是 4xx 或 5xx那就是被拦截了如果压根没有请求进来那问题多半在负载均衡或 NAT 层。我还遇到过一种情况Spring Security 全局权限校验把握手请求拦了返回 401前端 WebSocket 会反复重试后端的错误日志又不太像常规异常花了小半天才定位到。所以如果你在 Spring Security 项目里引入 WebSocket一定记得在SecurityFilterChain里放行握手路径。5.2 鉴权到底应该在握手前还是握手后做WebSocket 的连接建立很微妙它先是一个 HTTP 升级请求升级成功之后才变成完整的 WebSocket 连接。所以鉴权的最佳时机是在升级之前也就是HandshakeInterceptor.beforeHandshake里。这里如果校验失败返回falseSpring 会直接拒绝握手HTTP 层面表现为握手失败不会建立真正的 WebSocket 连接也基本不会占用什么长连接资源。如果你等到afterConnectionEstablished里再发现这是一个非法连接想关就得close(CloseStatus.NOT_ACCEPTABLE)这时候连接已经建立资源也已经分配了从效率和语义上都不对。浏览器原生的WebSocketAPI 有一个限制它不允许你随意设置自定义请求头所以前后端约定的鉴权方式一般是二选一在 URL 后面加参数如ws://host/ws?tokenxxx或者在 STOMP 的CONNECT帧里放 token。在原生 WebSocket 里最省事的做法是在握手拦截器里从 URL 查参或从 Cookie 里取 tokenOverride public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, MapString, Object attributes) throws Exception { String token request.getQueryParams().getFirst(token); if (token null || !authService.validate(token)) { return false; } attributes.put(userId, authService.getUserId(token)); return true; }在 STOMP 场景下因为连接有一步CONNECT帧更专业的做法是自定义ChannelInterceptor拦截CONNECTED/CONNECT消息从StompHeaderAccessor里拿 token。这样可以做到先鉴权、后订阅且不会污染 URL。不过如果团队规模不大我更推荐简单方案握手拦截器里校验 URL 参数省得引入一层 ChannelInterceptor 的理解成本。5.3 集群部署WebSocket 连接粘在单机上消息怎么跨机广播单机部署的 WebSocket 一切好说session 都在本地内存里。但线上为了高可用一个服务往往起 2 个甚至更多实例Nginx 会把请求负载均衡到不同节点。这时候就会出现一个经典问题客户端 A 连的是节点 1客户端 B 连的是节点 2用户 A 发消息给用户 B 时节点 1 的SimpMessagingTemplate只能在节点 1 本地的 session 里找 B找不到消息就石沉大海了。要解决这个问题需要引入一个分布式消息通道。思路是当节点 1 收到一条需要广播或点对点发送的消息时除了处理本地的 session还要把这条消息发布到 Redis Pub/Sub 或者 RabbitMQ Exchange所有节点订阅这个通道收到消息后各自检查自己的本地 session看看目标客户端是不是在自己这里。这样每个节点只需要维护自己的 session 集合跨节点的投递交给消息中间件。示意图我不想画了概念其实一句话就能说清每个节点是本地 session 的管理者消息中间件是跨节点的信使。用 Redis Pub/Sub 实现时订阅者是每个节点消息体里带上目标用户 ID、消息内容、目标 topic 等信息即可。要注意的是WebSocketSession对象绝对不能跨 JVM 传输它是绑定在单个连接实例上的能传输的只有发给谁、发什么内容这种纯数据。5.4 断线重连与消息补偿前端断线之后我不会让它傻等而是做成自动重连这个机制听起来简单但细节很重要。重连间隔建议用指数退避第一次 1 秒第二次 2 秒第三次 4 秒最多 30 秒封顶避免后端雪崩。在重连成功后前端要主动向服务端发起一次补拉消息请求把断线期间漏掉的消息拉回来。服务端侧的做法是为每个在线用户存一份最近 N 条消息的环形缓冲或者把离线消息存到 Redis 列表里等用户重连后自己减量拉取。还有一个小坑服务端convertAndSendToUser时如果目标用户当前不在线Spring 默认是静默丢弃的不会报错。所以如果你有必须确保送达的业务需要自己在业务层做一个消息表 状态字段的可靠性设计比如消息先落库状态是UNSENT推成功之后更新为SENT客户端重连后查询UNSENT消息并补发。这套模式我在客服系统里实现过代码量不大但能避免大量用户没收到消息的客诉。5.5 不得不说的 SockJS 局限我前面提到 SockJS 是兼容老浏览器的好方案但它不是万能的。SockJS 降级到 XHR 轮询时本质上又回到了 HTTP 长轮询的老路上服务端每个降级连接依然会占用一个线程所以可用性提升的代价是吞吐量下降。而且 SockJS 在跨域配置、代理配置、心跳参数上都有自己的讲究一旦出问题排查链路比纯 WebSocket 更长。如果你确定用户都使用现代浏览器Chrome、Firefox、Safari 最新两个大版本我建议直接关掉 SockJS省得引入一层复杂度和意想不到的坑。6. 最后的实践建议先做最小原型再上生产如果你现在正想把 WebSocket 引入到 Spring 项目里我给的建议是先不要急着铺开功能而是用一个小原型打通全链路。我习惯的最小闭环包含三步第一用原生WebSocketHandler或 STOMP 配置搭好连接第二前端用stompjs连上并订阅一个主题第三写一个Scheduled定时把消息推给前端。这三步通了再逐步加入业务功能。这个最小原型的意义在于它能让你尽早暴露环境层面的问题——代理超时、拦截器冲突、跨域配置、鉴权流程异常这些都是最快能在早期暴露出来的。我经常看到团队一上来就写一大堆业务消息类型结果折腾了两周连一次稳定的握手都没完成就是因为基础链路没有提前验证。我个人在实际操作中最大的体会是WebSocket 的代码量通常不大真正让项目复杂的永远是连接生命周期管理。心跳、超时、重连、鉴权、集群投递这些连接的边界问题才是线上事故的主要来源。所以我把这篇文章的大部分篇幅留给了这些边界场景希望你看完能少踩几个坑。下一篇文章我打算写 STOMP 跟外部消息中间件比如 RabbitMQ的对接到时候我们可以继续聊。