1. 项目概述:为什么点对点通信不能只靠原生 WebSocket?
“websocket(三)–基于sockjs和stomp实现点对点通信”这个标题,表面看是技术栈组合(WebSocket + SockJS + STOMP),但背后真正要解决的,是一个在真实业务中反复被踩坑、却极少被系统讲透的问题:原生 WebSocket 协议本身不定义消息语义,更不提供会话管理、路由寻址、错误重连、消息确认等应用层能力。你用new WebSocket('ws://...')成功连上服务端,发过去一个 JSON 字符串{ "to": "user123", "content": "你好" },然后呢?服务端怎么知道该把这条消息精准投递给 user123 而不是广播给所有人?如果连接断了,前端怎么自动恢复并补发未送达的消息?用户下线后新消息怎么暂存?这些都不是 WebSocket 协议关心的事——它只管“管道通不通”,不管“数据怎么走”。
这就是为什么标题里明确写了“基于 sockjs 和 stomp”。SockJS 不是 WebSocket 的替代品,而是它的兼容性兜底层:当浏览器不支持 WebSocket(比如 IE9)、或网络中间件(如某些企业防火墙、老旧代理)主动阻断 ws/wss 协议时,SockJS 会自动降级为 XHR Streaming、JSONP 或 iframe 等长轮询方案,整个过程对上层业务代码完全透明。而 STOMP(Simple Text Oriented Messaging Protocol)则是在 WebSocket 通道之上构建的一套轻量级消息协议规范,它定义了CONNECT、SEND、SUBSCRIBE、UNSUBSCRIBE、MESSAGE等标准帧格式,让客户端和服务端能像使用消息队列一样收发消息,并天然支持“目的地(destination)”概念——比如/user/queue/messages就是典型的点对点私有队列地址。
我做过三个不同规模的实时通信项目,从某高校实验室的在线协作白板,到某公司内部的工单即时通知系统,再到某跨平台设备监控后台,无一例外都在第二版迭代时推翻了纯原生 WebSocket 方案。原因很现实:开发周期压得紧,测试环境网络复杂,运维要求高可用,而团队里没有专职的协议栈工程师。直接手写心跳保活、重连退避算法、消息去重逻辑、会话绑定机制……光是调试不同浏览器在弱网下的重连行为,就耗掉两周。用 SockJS + STOMP,等于把这套成熟模式“打包买断”:Spring Boot 内置的spring-websocket模块开箱即用,前端@stomp/stompjs库封装了连接状态机和自动重连,连消息序列化、反序列化都帮你做了。这不是偷懒,而是把有限的工程资源,聚焦在真正的业务逻辑上——比如怎么设计消息体结构让前端渲染更高效,而不是纠结于onclose事件触发后该等 1 秒还是 3 秒再重连。
所以,这个标题的核心价值,从来不是“又学了一个技术组合”,而是提供了一条经过生产验证的、可快速落地的点对点通信实施路径。它适合三类人:一是正在做 IM、协同编辑、实时告警等需要精准定向推送功能的开发者;二是被 WebSocket 兼容性问题卡住、临时找不到资深网络协议工程师支援的中小团队;三是想理解“协议分层设计”如何在实际项目中降低复杂度的进阶学习者。接下来的内容,我会完全基于真实项目现场的配置、代码片段、调试日志和踩坑记录展开,不讲抽象理论,只说“当时怎么配的”“为什么这么配”“哪一行改错了导致页面一直显示‘connecting…’”。
2. 整体架构与选型逻辑:为什么是 SockJS + STOMP,而不是其他组合?
2.1 协议栈分层:从 TCP 到业务语义的四层穿透
要真正吃透这个组合的价值,得先画清它的协议栈位置。很多人误以为 SockJS 是 WebSocket 的“升级版”,其实它俩根本不在同一层:
- 最底层:TCP/IP—— 提供可靠字节流传输,所有上层协议的基础;
- 第二层:WebSocket 协议(RFC 6455)—— 在 TCP 上定义了握手升级机制(HTTP Upgrade)、帧格式(Text/Binary Frame)、控制帧(Ping/Pong/Close),但它只负责建立和维持双向通道,不定义任何业务消息格式;
- 第三层:传输适配层(SockJS)—— 它不替换 WebSocket,而是在 WebSocket 不可用时,用其他 HTTP 长连接技术模拟出“看起来像 WebSocket”的 API 接口。关键点在于:SockJS 客户端暴露给业务代码的 API,和原生 WebSocket 几乎一致(open/close/send/onmessage),这意味着你写业务逻辑时,可以假装自己在用 WebSocket,而 SockJS 内部默默处理降级;
- 第四层:应用消息协议(STOMP)—— 它运行在 WebSocket(或 SockJS 模拟的通道)之上,定义了标准的文本帧格式,例如:
这里的CONNECT accept-version:1.1,1.2 heart-beat:10000,10000 ^@ MESSAGE destination:/user/queue/messages content-type:application/json subscription:sub-0 message-id:ID:broker-1:1:1:1:1 {"from":"admin","text":"系统维护通知"} ^@destination字段,就是点对点通信的命脉——它让服务端能根据路径规则,将消息路由到特定用户的专属队列。
提示:STOMP 的
destination不是随意写的字符串,而是遵循 Spring Security 的@MessageMapping和@SendToUser注解约定的路径。比如/user/{username}/queue/messages中的{username}会被 Spring 自动解析为当前认证用户的用户名,这是实现“点对点”的核心绑定机制。
2.2 为什么不用纯原生 WebSocket + 自研协议?
我试过。在第一个项目里,我们用原生 WebSocket 实现了基础聊天,但很快遇到四个硬伤:
- IE10 兼容性崩盘:客户方内网强制使用 IE10,而它只支持 WebSocket 的早期草案(hybi-00),与 RFC 6455 不兼容。手动实现双协议解析,光握手逻辑就写了 300 行,且无法保证所有中间代理兼容;
- 消息乱序与丢失无感知:WebSocket 帧本身不带序号,当网络抖动导致多个
send()调用异步返回时,服务端收到的顺序可能错乱。我们没做应用层 ACK,结果用户看到的聊天记录是“你好”出现在“在吗?”之前; - 连接状态不可靠:
WebSocket.readyState在某些安卓 WebView 下会卡在OPEN状态,但实际已断连。我们依赖onclose事件触发重连,结果用户切到后台再回来,页面还显示“在线”,消息却发不出去; - 点对点逻辑臃肿:为了区分用户,我们在每条消息里加
senderId和receiverId字段,服务端用 HashMap 缓存所有在线连接的sessionId → userId映射。但用户频繁上下线时,这个 Map 的清理逻辑极易出错,出现“消息发给了已下线用户”的 bug。
这些问题,用 SockJS + STOMP 后全部消失:SockJS 的info接口自动探测最佳传输方式,STOMP 的heart-beat帧强制双向心跳,@SendToUser注解由 Spring 框架保障用户会话绑定,消息确认(ACK)和事务(BEGIN/COMMIT)机制也内置支持。这不是“多学一个框架”,而是用标准化协议,把本该由基础设施承担的职责,从应用代码里剥离出去。
2.3 为什么不是 MQTT 或 AMQP?
MQTT 确实更轻量,AMQP 功能更全,但它们在 Web 场景有明显短板:
- MQTT over WebSocket 需要额外 Broker 支持:主流 MQTT Broker(如 Mosquitto、EMQX)虽支持 WebSocket 端口,但其 Web 客户端 SDK(如
mqtt.js)在浏览器中默认不启用 TLS 证书校验,存在安全风险;更重要的是,MQTT 的QoS级别(尤其是 QoS2)在弱网下会导致大量重复帧,而 Web 页面无法像嵌入式设备那样精细控制内存和重传策略; - AMQP 过于重型:RabbitMQ 的 WebSTOMP 插件虽可用,但 AMQP 的 Exchange/Queue/Binding 概念对简单点对点场景属于过度设计。一个“发消息给指定用户”的需求,用 AMQP 要建 Exchange、声明 Queue、绑定 Routing Key,而 STOMP 直接
SEND到/user/{id}/queue/msg一行搞定; - 生态整合成本高:Spring 生态对 STOMP 的支持是深度集成的——
@EnableWebSocketMessageBroker一行开启,SimpMessagingTemplate直接注入使用,权限控制(@PreAuthorize("hasRole('USER')"))无缝对接 Spring Security。换成 MQTT,就得自己写@EventListener监听连接事件来维护用户会话,安全校验也要重写。
所以,选型结论很务实:在 Java/Spring 技术栈主导的 Web 后台,STOMP 是 WebSocket 生态中最平滑、最省心、最符合“约定优于配置”哲学的点对点消息协议。它不追求极致性能,但把 90% 的通用问题都标准化解决了。
3. 核心实现细节:从服务端配置到前端订阅的完整链路
3.1 服务端:Spring Boot 的 WebSocket 消息代理配置
我们以 Spring Boot 2.7.x 为例(Spring Framework 5.3.x),这是目前生产环境最稳定的版本。关键配置集中在WebSocketConfig类中,它必须继承AbstractWebSocketMessageBrokerConfigurer(Spring Boot 2.0+ 推荐用WebSocketMessageBrokerConfigurer接口,但底层逻辑一致):
@Configuration @EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { @Override public void configureMessageBroker(MessageBrokerRegistry config) { // 1. 启用简单消息代理(SimpleBroker),用于处理 /topic 和 /queue 类型的 destination config.enableSimpleBroker("/topic", "/queue"); // 2. 设置用户专属队列前缀,这是点对点通信的核心! config.setUserDestinationPrefix("/user/"); // 3. 配置客户端向服务端发送消息的根路径(对应前端 stompClient.send() 的 destination) config.setApplicationDestinationPrefixes("/app"); // 4. (可选)设置客户端订阅消息的前缀,通常与 enableSimpleBroker 的路径一致 config.setBroadcastSubscriptionPrefixes("/topic"); } @Override public void registerStompEndpoints(StompEndpointRegistry registry) { // 1. 注册 WebSocket 端点,路径为 /ws registry.addEndpoint("/ws") // 2. 启用 SockJS 支持,这是兼容性的关键开关 .setAllowedOrigins("*") // 生产环境请替换为具体域名 .withSockJS(); // 3. (可选)添加第二个端点用于测试,避免与主端点冲突 registry.addEndpoint("/ws-test").withSockJS(); } // (可选)自定义用户信息解析器,用于从 WebSocket Session 中提取用户标识 @Bean public ServletServerContainerFactoryBean createWebSocketContainer() { ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean(); container.setMaxTextMessageBufferSize(8192); container.setMaxBinaryMessageBufferSize(8192); return container; } }这段配置里,config.setUserDestinationPrefix("/user/")是点对点通信的“心脏”。它的作用是:当客户端订阅/user/queue/messages时,Spring 会自动将其转换为一个用户专属的临时队列,队列名形如/queue/messages-user123-abcde(其中user123是当前登录用户 ID,abcde是随机后缀)。这个转换过程对前端完全透明,你只需按约定写路径即可。
注意:
setUserDestinationPrefix必须配合 Spring Security 的用户认证才能生效。如果你没集成 Spring Security,或者用户未登录,调用@SendToUser会抛出IllegalStateException。我们曾在一个未登录的测试页面里误用了/user/xxx,结果服务端日志疯狂刷No principal found in request,排查了半小时才发现是漏了@EnableWebSecurity。
3.2 服务端:点对点消息发送的两种核心方式
方式一:通过SimpMessagingTemplate主动推送(推荐)
这是最常用、最可控的方式。你需要在 Service 层注入SimpMessagingTemplate,然后调用convertAndSendToUser方法:
@Service public class NotificationService { @Autowired private SimpMessagingTemplate messagingTemplate; /** * 向指定用户发送点对点消息 * @param username 用户唯一标识(需与 SecurityContext 中的 Principal 一致) * @param message 消息内容对象 */ public void sendToUser(String username, Object message) { // destination 格式:/user/{username}/queue/{suffix} // 注意:/queue/ 是固定前缀,/messages 是自定义后缀,必须与前端订阅路径匹配 messagingTemplate.convertAndSendToUser( username, "/queue/messages", message, createHeaders() // 可选:添加自定义消息头 ); } private MessageHeaders createHeaders() { Map<String, Object> headers = new HashMap<>(); headers.put("X-Message-Type", "NOTIFICATION"); return new MessageHeaderAccessor(headers).getMessageHeaders(); } }这里的关键细节:
username参数必须与 Spring Security 认证后的Principal.getName()返回值完全一致(通常是数据库中的用户 ID 或用户名);"/queue/messages"是相对于/user/{username}的路径,最终服务端会将其映射到该用户的专属队列;convertAndSendToUser内部会自动检查用户是否在线,如果用户离线,消息会被丢弃(除非你配置了消息持久化,这超出了本文范围)。
方式二:在 Controller 中响应客户端请求时发送(@MessageMapping)
这种方式适用于“客户端发请求,服务端处理后回消息”的典型 RPC 场景:
@Controller public class ChatController { @MessageMapping("/chat/send") @SendToUser("/queue/reply") // 指定回复消息的 destination public ChatReply handleChatMessage(@Payload ChatMessage message, SimpMessageHeaderAccessor headerAccessor) { // 1. 从 header 中获取当前用户信息 String username = (String) headerAccessor.getSessionAttributes() .get("username"); // 或从 SecurityContext 获取 // 2. 业务逻辑处理 String replyText = "已收到:" + message.getContent(); // 3. 构建回复对象 return new ChatReply(username, replyText, System.currentTimeMillis()); } }前端发送消息时:
stompClient.send("/app/chat/send", {}, JSON.stringify({ "content": "你好,能帮忙看下订单吗?" }));服务端处理完后,会自动将ChatReply对象发送到当前用户的/user/{username}/queue/reply队列,前端只需提前订阅该路径即可收到。
实操心得:
@SendToUser的destination必须以/queue/开头,不能写成/user/queue/reply。因为@SendToUser的作用就是“自动拼接/user/{username}前缀”,你再手动加/user/会导致路径变成/user/username/user/queue/reply,服务端找不到对应队列。这个错误我见过至少五次,每次都是前端同学在订阅时路径写错,后端日志却显示“消息已发送”,让人误以为是网络问题。
3.3 前端:StompJS 客户端的初始化与订阅
我们使用@stomp/stompjs6.1.x 版本(2023 年稳定版),它比老版本stompjs更轻量,API 更清晰。初始化流程分为三步:创建连接、处理连接成功、订阅队列。
import { Client, Frame } from '@stomp/stompjs'; class WebSocketClient { constructor() { this.client = null; this.isConnected = false; } // 1. 创建并连接客户端 connect() { this.client = new Client({ // WebSocket 端点地址,必须与后端 registerStompEndpoints 一致 brokerURL: 'ws://localhost:8080/ws', // 连接前的回调,可用于添加认证 token connectHeaders: { // 如果后端需要 token,可在此处添加 // 'Authorization': 'Bearer ' + localStorage.getItem('token') }, // 心跳配置:客户端每 10 秒发一次 ping,服务端每 10 秒回一次 pong heartbeatIncoming: 10000, heartbeatOutgoing: 10000, // 重连配置:首次失败后 5 秒重试,之后指数退避(5s→10s→20s→40s) reconnectDelay: 5000, // 连接成功回调 onConnect: (frame) => this.onConnected(frame), // 连接失败回调 onStompError: (frame) => this.onStompError(frame), // 断连回调(包括主动断开和被动断开) onDisconnect: () => this.onDisconnected(), }); this.client.activate(); // 激活连接 } // 2. 连接成功后的处理 onConnected(frame) { console.log('STOMP 连接成功', frame); this.isConnected = true; // 关键:订阅用户专属队列 // destination 必须与后端 @SendToUser 的路径严格匹配 this.client.subscribe('/user/queue/messages', (message) => { const payload = JSON.parse(message.body); console.log('收到点对点消息:', payload); // 此处更新 Vue/React 状态,或触发 UI 渲染 this.handleIncomingMessage(payload); }); // (可选)订阅公共主题,如系统通知 this.client.subscribe('/topic/notifications', (message) => { console.log('收到广播消息:', message.body); }); } // 3. 发送消息到服务端 sendMessage(destination, payload) { if (this.isConnected && this.client) { this.client.publish({ destination: destination, body: JSON.stringify(payload), headers: { // 可添加自定义头,如消息类型 'X-Message-Type': 'CHAT' } }); } } // 断连处理 onDisconnected() { console.log('STOMP 连接已断开'); this.isConnected = false; // 可在此处触发 UI 提示,如“网络不稳定,请稍候” } // 错误处理 onStompError(frame) { console.error('STOMP 协议错误:', frame); // frame.headers['message'] 通常包含具体错误信息 } } // 使用示例 const wsClient = new WebSocketClient(); wsClient.connect();这里有几个极易忽略的细节:
brokerURL必须是ws://或wss://协议,不能写成http://。即使后端addEndpoint("/ws"),前端也必须用 WebSocket 协议访问;subscribe的路径/user/queue/messages中,/user/是前缀,/queue/messages是后缀,两者缺一不可。少写/user/,订阅的就是公共队列;少写/queue/,Spring 无法识别为用户队列;publish发送消息时,destination是服务端@MessageMapping的路径(如/app/chat/send),不是订阅路径。新手常混淆“发送目的地”和“订阅目的地”,导致消息发出去了,但没人收到。
4. 实操全流程:从零搭建一个可运行的点对点聊天 Demo
4.1 环境准备与依赖清单
我们用最简配置启动一个可运行的 Demo,确保你能 10 分钟内看到效果。所需工具:
- JDK 11+(Spring Boot 2.7.x 最低要求)
- Maven 3.6+
- Node.js 16+(前端构建)
- IDEA 或 VS Code
后端pom.xml关键依赖:
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <!-- 用于模拟用户登录,生产环境应替换为真实认证 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> </dependencies>前端package.json关键依赖:
{ "dependencies": { "@stomp/stompjs": "^6.1.0", "sockjs-client": "^1.6.1" } }注意:
sockjs-client是@stomp/stompjs的 peer dependency,必须显式安装,否则运行时报Cannot find module 'sockjs-client'。这个错误在stompjs旧版本中不存在,是新版@stomp/stompjs的一个常见坑。
4.2 后端核心代码:极简版消息服务
创建ChatMessage.java消息体:
public class ChatMessage { private String sender; private String receiver; private String content; private long timestamp; // getter/setter 省略 }创建ChatReply.java回复体:
public class ChatReply { private String from; private String text; private long timestamp; public ChatReply(String from, String text, long timestamp) { this.from = from; this.text = text; this.timestamp = timestamp; } // getter/setter 省略 }创建ChatController.java:
@Controller public class ChatController { @MessageMapping("/chat/send") @SendToUser("/queue/reply") public ChatReply handleChatMessage(@Payload ChatMessage message) { // 模拟业务处理延迟 try { Thread.sleep(200); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return new ChatReply("system", "已收到您的消息:" + message.getContent(), System.currentTimeMillis()); } }创建NotificationService.java(用于后台主动推送):
@Service public class NotificationService { @Autowired private SimpMessagingTemplate messagingTemplate; public void notifyUser(String username, String content) { messagingTemplate.convertAndSendToUser( username, "/queue/notifications", Map.of("type", "INFO", "content", content, "timestamp", System.currentTimeMillis()) ); } }4.3 前端 HTML 页面:单文件可运行
创建src/main/resources/static/index.html:
<!DOCTYPE html> <html> <head> <title>点对点聊天 Demo</title> <script src="https://cdn.jsdelivr.net/npm/sockjs-client@1.6.1/dist/sockjs.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/@stomp/stompjs@6.1.0/bundles/stomp.umd.js"></script> <style> body { font-family: sans-serif; margin: 20px; } #messages { height: 300px; border: 1px solid #ccc; padding: 10px; overflow-y: auto; } .message { margin: 5px 0; } .sent { color: blue; } .received { color: green; } </style> </head> <body> <h2>WebSocket 点对点聊天 Demo</h2> <div> <label>当前用户:</label> <input type="text" id="username" value="user123" /> <button onclick="connect()">连接</button> <button onclick="disconnect()" disabled>断开</button> </div> <div> <label>发送给:</label> <input type="text" id="receiver" value="admin" /> <input type="text" id="messageInput" placeholder="输入消息..." /> <button onclick="sendMessage()">发送</button> </div> <div id="messages"></div> <script> let stompClient = null; let isConnected = false; function connect() { const username = document.getElementById('username').value; // 创建 Stomp 客户端,使用 SockJS 作为传输层 stompClient = Stomp.over(new SockJS('http://localhost:8080/ws')); const headers = { // 模拟登录用户,实际项目中应从 token 或 session 获取 'username': username }; stompClient.connect(headers, onConnected, onError); } function onConnected() { isConnected = true; document.querySelector('button[onclick="connect()"]').disabled = true; document.querySelector('button[onclick="disconnect()"]').disabled = false; console.log('Connected to WebSocket'); // 订阅当前用户的专属队列 stompClient.subscribe(`/user/queue/reply`, onMessageReceived); stompClient.subscribe(`/user/queue/notifications`, onNotificationReceived); } function onMessageReceived(payload) { const message = JSON.parse(payload.body); appendMessage(`[回复] ${message.text}`, 'received'); } function onNotificationReceived(payload) { const notification = JSON.parse(payload.body); appendMessage(`[通知] ${notification.content}`, 'received'); } function sendMessage() { if (!isConnected || !stompClient) return; const receiver = document.getElementById('receiver').value; const content = document.getElementById('messageInput').value.trim(); if (!content) return; // 发送消息到服务端的 /app/chat/send 端点 stompClient.send('/app/chat/send', {}, JSON.stringify({ sender: document.getElementById('username').value, receiver: receiver, content: content, timestamp: Date.now() })); appendMessage(`[我] ${content}`, 'sent'); document.getElementById('messageInput').value = ''; } function disconnect() { if (stompClient !== null) { stompClient.disconnect(); } isConnected = false; document.querySelector('button[onclick="connect()"]').disabled = false; document.querySelector('button[onclick="disconnect()"]').disabled = true; console.log('Disconnected'); } function appendMessage(text, className) { const messagesDiv = document.getElementById('messages'); const p = document.createElement('p'); p.className = 'message ' + className; p.textContent = text; messagesDiv.appendChild(p); messagesDiv.scrollTop = messagesDiv.scrollHeight; } function onError(error) { console.error('STOMP 连接错误:', error); appendMessage('[错误] 连接失败,请检查控制台', 'received'); } </script> </body> </html>4.4 启动与验证步骤
- 启动后端:运行 Spring Boot 应用,控制台看到
Tomcat started on port(s): 8080即成功; - 打开浏览器:访问
http://localhost:8080,加载index.html; - 连接 WebSocket:点击“连接”按钮,控制台应输出
Connected to WebSocket,Network 面板能看到ws://localhost:8080/ws连接建立; - 发送测试消息:在“发送给”框填
admin,“输入消息”填你好,点击“发送”; - 验证点对点:页面下方应立即显示
[回复] 已收到您的消息:你好,证明消息已精准送达当前用户(user123)的专属队列; - 验证多用户:新开一个浏览器窗口(或隐身模式),修改用户名为
admin,连接后发送消息,观察两个窗口是否各自收到对方的回复。
实测心得:第一次运行时,如果页面一直显示“connecting…”,90% 的概率是
brokerURL写错了。检查index.html中Stomp.over(new SockJS('...'))的 URL 是否与后端addEndpoint("/ws")的路径一致,且协议是http://(SockJS 用 HTTP)而非ws://(原生 WebSocket 用 WS)。这个细节文档里很少强调,但却是新手最高频的卡点。
5. 常见问题与排查技巧实录:那些官方文档不会告诉你的坑
5.1 连接失败的五大高频原因及定位方法
| 现象 | 可能原因 | 快速定位方法 | 解决方案 |
|---|---|---|---|
| 页面卡在 connecting…,Network 面板无 ws 请求 | 前端SockJSURL 错误 | 打开浏览器开发者工具 → Network → 切换到 XHR 标签,看是否有http://localhost:8080/ws/info请求 | 检查Stomp.over(new SockJS('...'))中的 URL,确保是http://协议,路径与后端addEndpoint一致 |
Network 面板看到ws://请求,但状态为failed或canceled | 浏览器不支持 WebSocket,或中间代理阻断 | 在控制台执行window.WebSocket,若返回undefined则不支持;或查看ws请求的 Response Headers 是否为空 | 启用 SockJS 降级:确保后端withSockJS()已调用,前端必须用SockJS初始化,不能直接new WebSocket() |
连接成功,但onConnect不触发,控制台无报错 | Spring Security 未放行/ws/**路径 | 查看后端日志,搜索Access is denied或Invalid CSRF token | 在SecurityConfig中添加.requestMatchers("/ws/**").permitAll(),或配置csrf().ignoringRequestMatchers("/ws/**") |
连接成功,onConnect触发,但subscribe后收不到消息 | 订阅destination路径与后端@SendToUser不匹配 | 在后端WebSocketConfig的configureMessageBroker中,确认setUserDestinationPrefix("/user/")已设置;检查前端subscribe路径是否含/user/前缀 | 前端订阅路径必须为/user/queue/xxx,后端@SendToUser的路径必须为/queue/xxx(不含/user/) |
消息发送成功,但服务端@MessageMapping方法不执行 | @MessageMapping的destination与前端send的destination不一致 | 在前端stompClient.send()调用前,console.log输出destination;对比后端@MessageMapping("/xxx")的值 | 确保两者完全一致,注意大小写和斜杠。例如前端send("/app/chat/send"),后端必须是@MessageMapping("/chat/send") |
5.2 消息丢失与乱序的实战排查
问题现象:用户 A 发送三条消息:“1”、“2”、“3”,用户 B 收到的顺序是“1”、“3”、“2”,或只收到“1”和“3”。
根本原因分析:
- WebSocket 帧本身无序号,
send()调用是异步的,多个send()可能因网络抖动导致服务端接收顺序错乱; - STOMP 协议虽定义了
ACK帧,但@stomp/stompjs默认不启用,消息发送后不等待服务端确认; - Spring 的
SimpleBroker不保证消息顺序,它只是将消息放入内存队列,由线程池消费。
解决方案:
- 前端加序号:在消息体中增加
seq字段,服务端收到后按序号缓存,再按序推送; - 启用 STOMP ACK:在前端
Client配置中添加ack: 'client',并在subscribe时指定ack: 'client',收到消息后手动调用message.ack(); - 改用
RedisMessageBroker:用 Redis 作为消息代理,它天然支持有序队列(List 数据结构),但会增加运维复杂度。
我们最终选择了方案 1,因为改动最小。在ChatMessage中加字段:
private long seq; // 时间戳毫秒级,足够区分顺序前端发送前:
stompClient.send('/app/chat/send', {}, JSON.stringify({ ...message, seq: Date.now() // 或用递增计数器 }));服务端收到后,不立即推送,而是存入ConcurrentHashMap<String, List<ChatMessage>>,按seq排序后批量发送。虽然增加了内存占用,但彻底解决了乱序问题。
5.3 SockJS 降级失败的隐蔽陷阱
问题现象:在 IE10 或某些企业内网环境下,WebSocket 连接失败,但 SockJS 没有自动降级到 XHR,页面直接报错。
排查过程:
- 查看 Network 面板,发现
http://localhost:8080/ws/info请求返回 404; - 进一步检查,发现后端
addEndpoint("/ws")的路径是/ws,但 SockJS 客户端默认请求/ws/info,而 Spring 的 `