去年接了一个上门烹饪预约平台的需求,核心功能之一是用户下单后,厨师端要实时收到新订单提醒,同时用户在网页上能看到厨师的接单状态变化。一开始我直接用 HTTP 轮询,每隔两秒请求一次订单状态接口,结果接口压力大得离谱,高峰期 Tomcat 线程池差点被打满,用户体验还差——订单到了好几秒才有反应。后来换了 WebSocket,问题一下清爽了。这篇就用实际项目里的踩坑经验,把 Spring Boot 集成 WebSocket 的思路、代码、部署细节一次讲透,覆盖从单体应用到多实例部署的完整链路。
这篇内容适合谁?后端开发做实时推送功能、前端同事对接长连接、还有那些要在自己项目里集成 WebSocket 但被各种断连、超时、代理问题折磨的人。如果你是第一次接触 WebSocket,看完能直接写出一套能跑的功能;如果你已经踩过坑,里面有几节排障思路应该能帮上忙。
1. 为什么实时通信选了 WebSocket:HTTP 轮询的痛点与协议演进
1.1 从 HTTP 半双工到全双工的刚需场景
HTTP 协议本身是“请求-响应”模式,客户端不发请求,服务端就不能主动推送数据。这个设计在普通网页浏览场景下没问题,但到了实时通信场景就特别别扭。
就拿预约平台来说,厨师端需要实时知道有没有新订单。如果用 HTTP 轮询,那就得让客户端定时去问服务端“有单吗?有单吗?”,服务端即便没有新数据也要回一个“没有”,没数据时的每次请求都是纯浪费。而且 HTTP 每次请求都要带完整的请求头,三次握手四次挥手来回折腾,高频轮询时网络开销非常大。
当时我统计了一下,500 个在线客户端、2 秒轮询一次,每秒就是 250 个请求,大部分还没新数据。如果把轮询改成 1 秒一次,服务端压力直接翻倍。WebSocket 解决的正是这个问题——一次握手建立连接后,服务端和客户端都可以随时主动发数据,真正实现了全双工通信。
1.2 WebSocket 协议工作流程和核心优势
WebSocket 并不是什么黑魔法,它本质上是 HTTP 协议的一次升级(Upgrade)。客户端发起一个带Upgrade: websocket头的 HTTP 请求,服务端返回101 Switching Protocols,之后双方就在这个 TCP 连接上直接收发帧数据了。
这个机制带来几个关键优势:
- 连接建立后不再有 HTTP 请求头开销,传输效率高
- 服务端可以主动推送数据,不需要客户端轮询
- 一个 TCP 连接支持双向通信,资源占用远低于 HTTP 轮询
- 消息支持文本和二进制格式,灵活性高
但从底层原理看,WebSocket 和 HTTP 不同,它需要保活机制。TCP 连接空闲太久会被中间设备(比如 Nginx、云服务商的网关)回收,所以生产环境里必须考虑心跳保活,这个后面细说。
1.3 常见传输方案对比:轮询、SSE 与 WebSocket
实际做技术选型时,并不一定非要用 WebSocket。我梳理了一个对比表:
| 方案 | 通信方向 | 实时性 | 连接开销 | 适用场景 |
|---|---|---|---|---|
| HTTP 轮询 | 客户端拉取 | 取决于轮询间隔 | 每次请求都要握手 | 低频、对实时性要求不高的状态查询 |
| SSE(Server-Sent Events) | 服务端单向推送 | 实时 | 单个长连接 | 服务端通知类场景,如系统公告、消息提醒 |
| WebSocket | 双向实时通信 | 实时 | 单个长连接 | 需要双向交互的场景,如聊天、协同编辑、实时订单推送 |
SSE 其实是个被低估的方案,如果只是服务端单向推数据,SSE 更轻量、自动重连也更省心。但预约平台里客户端要上报状态、厨师要回复操作指令,需要双向通信,所以最终选了 WebSocket。
2. Spring Boot 集成 WebSocket 的两种主流姿势
2.1 原生 WebSocket 端点(@ServerEndpoint)的轻量实现
Spring Boot 集成 WebSocket 有两条路线,第一条是直接用 Java 自带的 JSR-356 规范,也就是@ServerEndpoint注解端点的方式。
先说代码结构。建一个 WebSocket 配置类,把ServerEndpointExporter声明成 Bean:
@Configuration public class WebSocketConfig { @Bean public ServerEndpointExporter serverEndpointExporter() { return new ServerEndpointExporter(); } }然后定义端点类,用@ServerEndpoint指定路径,比如/websocket/order:
@Component @ServerEndpoint("/websocket/order") @Slf4j public class OrderWebSocketEndpoint { private static CopyOnWriteArraySet<Session> sessions = new CopyOnWriteArraySet<>(); @OnOpen public void onOpen(Session session) { sessions.add(session); log.info("新连接接入,当前连接数:{}", sessions.size()); } @OnClose public void onClose(Session session) { sessions.remove(session); log.info("连接关闭,当前连接数:{}", sessions.size()); } @OnMessage public void onMessage(String message, Session session) { log.info("收到客户端消息:{}", message); } @OnError public void onError(Session session, Throwable error) { log.error("连接异常", error); } public static void sendToAll(String message) { for (Session session : sessions) { try { session.getBasicRemote().sendText(message); } catch (IOException e) { log.error("发送消息失败", e); } } } }这套方式最大的好处是简单——不需要额外依赖,Spring Boot 的spring-boot-starter-websocket里面已经包含了相关支持。但要注意,@ServerEndpoint端点对象默认是多例的,也就是说每个连接都会创建一个端点实例,所以静态成员变量之类的东西要小心,连接会话集合必须用线程安全的CopyOnWriteArraySet。
2.2 基于 STOMP 的消息代理模式
第二条路线是 Spring 家族主推的 STOMP 协议方式。STOMP 是一个简单的文本消息协议,它在 WebSocket 之上定义了一套消息格式,支持“订阅-发布”模型。简单理解,WebSocket 是管道,STOMP 是管道里跑的报文规范。
配置类长这样:
@Configuration @EnableWebSocketMessageBroker public class StompWebSocketConfig implements WebSocketMessageBrokerConfigurer { @Override public void configureMessageBroker(MessageBrokerRegistry registry) { // 客户端订阅消息的前缀,即服务端推送给客户端的地址 registry.enableSimpleBroker("/topic", "/queue"); // 客户端发送消息的前缀,如 /app/sendMessage registry.setApplicationDestinationPrefixes("/app"); } @Override public void registerStompEndpoints(StompEndpointRegistry registry) { // WebSocket 握手端点,前端连接这个地址 registry.addEndpoint("/ws").withSockJS(); } }发送消息时,用SimpMessagingTemplate把消息推送到指定订阅地址:
@Service public class OrderNotifyService { @Resource private SimpMessagingTemplate messagingTemplate; public void notifyNewOrder(Long orderId) { Map<String, Object> payload = new HashMap<>(); payload.put("orderId", orderId); payload.put("type", "NEW_ORDER"); // 推送到所有订阅了 /topic/order 的客户端 messagingTemplate.convertAndSend("/topic/order", payload); } }STOMP 模式的好处是支持按主题订阅,天然就有“房间”的概念,适合业务上需要区分频道的场景。比如预约平台里,可以把订单消息发到/topic/order,把系统公告发到/topic/notice,客户端按需订阅。
2.3 集成方式选型:什么场景用哪种
如果你要做的功能只是“服务端主动推送消息给所有连接”,比如大屏数据刷新、系统公告,那用@ServerEndpoint就够了,代码量最小,也不容易出幺蛾子。
如果你的业务里有很多消息类型、要区分用户、要实现“点对点”推送,或者前端团队习惯用 STOMP.js 那套生态,那 STOMP 模式更合适。它自带消息路由,用户可以用订阅地址做细粒度隔离,比如/queue/order/{userId}。
我当时做的预约平台,需求比较简单,核心就是“有新单推送到所有在线厨师”和“用户端接收接单状态”,所以选了原生@ServerEndpoint,连前端都只需要一个原生 WebSocket 对象就能搞定,省了一堆依赖。但如果后续要加“按用户定向推送”,原生端点就得自己维护每个连接对应的用户标识,反而麻烦。选型时建议想清楚业务未来半年的演进方向。
3. 手把手实现一个实时消息推送服务
3.1 环境准备与依赖引入
我用的是 Spring Boot 2.7 版本,JDK 8,Maven 项目。引入 WebSocket 依赖只需要一个 starter:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency>这个 starter 会自动把 WebSocket 相关的容器初始化好,不需要额外的版本号,跟着 Spring Boot 父依赖走就行。有些老项目还在用 Spring Boot 2.1,集成方式也差不多。如果是 Spring Boot 2.6 以上的版本,要注意处理 SpringFox 3.0.0 的兼容问题,后面单独讲。
3.2 后端核心代码:连接管理、会话存储与消息推送
真正的项目里,@ServerEndpoint那个最简单的写法是不够的。因为你要能主动向指定用户推送消息,光有一个连接集合不够,还得把连接和用户关联起来。
我的方案是:客户端握手时在 URL 后面带上认证令牌,比如/websocket/order?token=xxx,服务端在@OnOpen里解析 token,拿到用户 ID,然后把 Session 存入 ConcurrentHashMap。
@Component @ServerEndpoint("/websocket/order") @Slf4j public class OrderWebSocketEndpoint { // 模拟用户标识,生产环境从 token 解析 private static final ConcurrentHashMap<Long, Session> USER_SESSION_MAP = new ConcurrentHashMap<>(); private Long userId; @OnOpen public void onOpen(Session session) { String queryString = session.getQueryString(); // 解析 token,得出 userId this.userId = parseUserId(queryString); USER_SESSION_MAP.put(userId, session); log.info("用户 {} 接入,当前在线人数:{}", userId, USER_SESSION_MAP.size()); } @OnClose public void onClose(Session session) { USER_SESSION_MAP.remove(userId); log.info("用户 {} 断开,当前在线人数:{}", userId, USER_SESSION_MAP.size()); } @OnMessage public void onMessage(String message, Session session) { log.info("用户 {} 发送消息:{}", userId, message); } @OnError public void onError(Session session, Throwable error) { log.error("用户 {} 连接异常", userId, error); } /** * 向指定用户推送消息 */ public static void sendToUser(Long targetUserId, String message) { Session session = USER_SESSION_MAP.get(targetUserId); if (session == null) { log.warn("用户 {} 不在线,消息丢弃:{}", targetUserId, message); return; } try { synchronized (session) { session.getBasicRemote().sendText(message); } } catch (IOException e) { log.error("向用户 {} 推送消息失败", targetUserId, e); } } /** * 向全部在线用户广播 */ public static void broadcast(String message) { USER_SESSION_MAP.values().forEach(session -> { try { session.getBasicRemote().sendText(message); } catch (IOException e) { log.error("广播消息失败", e); } }); } private Long parseUserId(String queryString) { // 简单起见,这里写死一个模拟逻辑,生产环境解析 token return 10001L; } }这里有几个关键设计点:
第一,ConcurrentHashMap必须用静态的,因为@ServerEndpoint端点实例是多例的,每个连接一个实例,不用静态就存不住东西。
第二,发送消息时加了synchronized (session)锁。这是因为 WebSocket 的BasicRemote在并发发送时可能会抛异常,多个线程同时给同一个 Session 发消息需要串行化。如果你用的是AsyncRemote,可以不用这个锁,但AsyncRemote又要自己处理发送完成的回调,各有利弊。
第三,用户不在线时直接丢弃消息,这在业务里要斟酌。预约平台里订单消息丢不得,所以我后来改成了“离线则落库待推”,等用户重新连接后拉取未读消息。这块可以结合自己的业务场景决定。
3.3 前端接入:JavaScript 连接与消息处理
前端方面,原生 WebSocket 就够了,代码非常简单:
const socket = new WebSocket('ws://localhost:8080/websocket/order?token=xxx'); socket.onopen = function () { console.log('WebSocket 连接已建立'); // 可以发送一条验证消息给服务端 socket.send(JSON.stringify({type: 'AUTH', token: 'xxx'})); }; socket.onmessage = function (event) { const message = JSON.parse(event.data); console.log('收到服务端消息:', message); // 根据 message.type 分发处理 if (message.type === 'NEW_ORDER') { // 渲染新订单 } else if (message.type === 'ORDER_ACCEPTED') { // 更新订单状态 } }; socket.onclose = function () { console.log('连接关闭,准备重连'); // 断线重连逻辑 }; socket.onerror = function (error) { console.error('WebSocket 错误:', error); };注意开发环境用ws://,生产环境如果用 HTTPS,前端就要用wss://,否则浏览器会拦截混用请求。这也是个容易忽略的细节——不 HTTPS 的页面访问ws://没问题,但 HTTPS 页面访问ws://就会被限流甚至直接拒绝。
3.4 心跳保活与断线重连设计
WebSocket 连接空闲久了会被中间层回收,这个是最容易踩的坑之一。我遇到过一两次,客户端明明没断,但服务器推消息死活推不过去。后来排查发现是连接被服务端关闭了,但客户端不知道。
解决办法是设计心跳机制。客户端每隔 30 秒发一个ping消息,服务端收到后回一个pong,如果客户端连续 3 次没收到pong,就主动断开重连。服务端也要做兜底,比如 60 秒没收到任何消息就关闭连接。
前端心跳逻辑:
let heartbeatTimer = null; function startHeartbeat() { heartbeatTimer = setInterval(() => { if (socket.readyState === WebSocket.OPEN) { socket.send(JSON.stringify({type: 'PING'})); } }, 30000); } // 收到任何消息都说明连接还活着,重置计时器 socket.onmessage = function (event) { const message = JSON.parse(event.data); if (message.type === 'PONG') { return; } // 处理其他业务消息 }; socket.onclose = function () { clearInterval(heartbeatTimer); // 断线重连,指数退避 reconnect(); }; let reconnectAttempts = 0; function reconnect() { setTimeout(() => { reconnectAttempts++; initWebSocket(); }, Math.min(1000 * Math.pow(2, reconnectAttempts), 30000)); }重连用指数退避策略,1 秒、2 秒、4 秒……最长 30 秒,避免断网时客户端疯狂重连把服务端打挂。
服务端对应处理PING消息并回PONG:
@OnMessage public void onMessage(String message, Session session) { if ("PING".equals(message)) { try { session.getBasicRemote().sendText("PONG"); } catch (IOException e) { log.error("发送心跳响应失败", e); } return; } // 处理业务消息 }有些小伙伴还会用session.setMaxIdleTimeout()来设置服务端的空闲超时时间,比如session.setMaxIdleTimeout(60000),意思是 60 秒没数据就自动断开。这个配置要根据心跳间隔来设置,要比心跳间隔大,否则会误杀正常连接。
4. 实战中常见的坑与排查技巧
4.1 握手 404 或握手失败:路径和拦截器问题
WebSocket 集成后第一步就是测握手。我见过最多的问题是请求打到/websocket/order返回 404。排查步骤:
- 确认
@ServerEndpoint的路径是否带了项目上下文路径。如果你的应用配置了server.servlet.context-path=/api,前端 WebSocket 地址就要写成/api/websocket/order。 - 确认
ServerEndpointExporterBean 是否被正确扫描。Spring Boot 项目里,主启动类和配置类不在同一包时,容易出现 Bean 没被注册的问题。 - 确认拦截器有没有挡掉握手请求。如果项目里有 Spring MVC 拦截器,注意放行 WebSocket 握手路径。
另外,部分云环境(比如某些负载均衡器或网关)默认不支持 HTTP Upgrade 请求,需要手动放行,这也是生产环境部署时要小心的点。
4.2 “stream disconnected before completion: websocket closed by server before res”解决实录
这个报错是热词里出现频率很高的一个,不少同事遇到这个错误时一脸懵。字面意思是“在流完成之前连接已断开:服务端在响应完成前关闭了 WebSocket”。我遇到过的场景是:后端收到客户端消息后处理业务逻辑时间比较长,超过了几十秒,然后客户端就报了这个错。
根因有两类:
第一类是服务端消息处理太慢,客户端的读超时先触发了,或者服务端主动关闭了连接。WebSocket 虽然是长连接,但这并不意味着服务端可以慢吞吞地处理消息。客户端通常都有自己的超时设置,长时间没有响应就会断开。
第二类是连接被 Nginx 或网关的超时机制回收。Nginx 默认proxy_read_timeout是 60 秒,如果服务端 60 秒内没有往这个连接上写任何数据,Nginx 就会主动断开。
我的解决办法:
- 客户端收到消息后立即返回,把耗时操作丢到线程池异步处理,让 WebSocket 连接及时获得响应。
- 调大
proxy_read_timeout和proxy_send_timeout。 - 加强心跳,让连接保持活跃。
proxy_read_timeout 300s; proxy_send_timeout 300s;这个问题的核心思路是:WebSocket 连接要“活”着,就要不断有数据流动,不管是业务数据还是心跳数据。
4.3 Spring Boot 2.6+ 与 SpringFox 3.0.0 的兼容问题
这个坑和 WebSocket 没有直接关系,但我在集成过程中踩到了,因为项目里同时用了 Swagger 做接口文档。Spring Boot 2.6 之后,Spring MVC 的路径匹配策略从AntPathMatcher改成了PathPatternParser,而 SpringFox 3.0.0 老版本不兼容这个变化,启动时会报:
java.lang.IllegalStateException: Failed to introspect Class ... Caused by: java.lang.NoSuchMethodError: org.springframework.util.AntPathMatcher.combine(...)最简单的解决方案:在application.yml里把路径匹配策略改回去。
spring: mvc: pathmatch: matching-strategy: ant_path_matcher如果你不想改全局配置,换成springdoc-openapi也可以,但项目里如果已经有很多 Swagger 注解,迁移成本也不小。我当时为了省事,直接改了匹配策略。注意这个配置是 Spring Boot 2.6+ 才有的,2.6 以下版本不用管。
4.4 连接数上限与内存溢出问题
WebSocket 长连接多了以后,服务端文件描述符和内存都是瓶颈。每个 WebSocket 连接在 Java 中对应一个Session对象,底层是 Socket,默认每个连接会占用一定的内存缓冲。
我在压测的时候发现,单机 2000 个连接时服务还能扛住,到 5000 个时开始频繁 GC,后来调大了 JVM 堆内存才勉强撑住。生产环境要注意几点:
- 操作系统文件描述符上限要调整,默认 1024 肯定不够。
- Spring Boot 内嵌 Tomcat 最大线程数要调大,WebSocket 消息处理也占用 Tomcat 线程。
session.setMaxTextMessageBufferSize()和session.setMaxBinaryMessageBufferSize()可以限制单条消息大小,防止超大消息打爆内存。
Tomcat 线程池配置:
server: tomcat: threads: max: 200 max-connections: 10000 accept-count: 200max-connections是 Tomcat 能接受的最大连接数,这个和 WebSocket 连接数直接相关。如果跑在默认配置下,线上连接数一上去就报“无法分配连接”,那大概率就是这里的问题。
5. 生产部署与分布式扩展
5.1 Nginx 反向代理 WebSocket 的配置要点
开发环境直连没问题,上了生产环境基本都是 Nginx 反代到后端服务。Nginx 支持 WebSocket 反向代理,但必须显式配置 Upgrade 头,否则连接建立不起来。
map $http_upgrade $connection_upgrade { default upgrade; '' close; } upstream backend_ws { server 127.0.0.1:8080 weight=1; keepalive 32; } server { listen 80; server_name example.com; location /websocket/ { proxy_pass http://backend_ws; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_read_timeout 300s; proxy_send_timeout 300s; } }重点是proxy_set_header Upgrade $http_upgrade和proxy_set_header Connection $connection_upgrade,这两行缺一不可。proxy_read_timeout和proxy_send_timeout我调成了 300 秒,再加上前面说的客户端心跳,连接基本上不会被 Nginx 掐断。
还有一个细节:如果 WebSocket 握手请求带有 Cookie 或鉴权 Header,需要确认 Nginx 默认的转发策略不会把 Header 丢掉。默认情况下Host、Connection这类头会被 Nginx 重写,其他自定义 Header 一般会透传,但要注意下划线开头的 Header 默认会被忽略,可以在http块里加underscores_in_headers on;。
5.2 Spring Boot 打包部署:开发环境命令行运行和 Tomcat 部署
Spring Boot 项目本身是内嵌 Tomcat 的,打包成 jar 之后直接命令行运行:
java -jar app.jar --server.port=8080开发环境想在命令行直接跑,也可以mvn spring-boot:run。这些都是常规操作,但有个点要注意:如果你打算把 Spring Boot 项目打 war 包丢到外部 Tomcat 里跑,WebSocket 的支持方式和内嵌 Tomcat 略有不同。
外置 Tomcat 部署时,ServerEndpointExporter这种 Bean 可能不用注册,因为外部容器会自己管理端点。Spring 官方文档的意思是:内嵌容器需要手动用 Bean 注册端点,外置容器则不用。但如果配置类留着也没关系,只要不重复注册就好。我实际部署中发现,同一个@ServerEndpoint路径在外置 Tomcat 下偶尔会报“路径冲突”,排查下来就是端点被注册了两次。
5.3 多实例部署的会话共享:Redis Pub/Sub 与 Redis Stream
前面讲的连接管理,都是基于单机内存的。如果服务有多个实例,用户的 WebSocket 连接落在实例 A,但业务逻辑跑在实例 B,B 想推送消息给这个用户怎么办?
这就是分布式 WebSocket 的经典问题:连接不共享,消息要跨节点路由。
最简单可靠的方案是借助 Redis。场景分类:
- 实时性要求高、消息量不大:用 Redis Pub/Sub
- 消息量大、需要消费确认或重放:用 Redis Stream
我先说 Pub/Sub 方案。所有实例启动时订阅同一个频道,比如ws:order:notify,当某个实例要向用户推送消息时,先把这个消息发布到 Redis 频道,所有实例都收到,然后各自检查“这个用户是不是在我这里”,在谁那里谁就真正推送。
@Service public class RedisNotifyService { @Resource private StringRedisTemplate stringRedisTemplate; public void publish(Long userId, String message) { stringRedisTemplate.convertAndSend("ws:order:notify", userId + "|" + message); } }监听端:
@Component public class WsNotifySubscriber { @Resource private OrderWebSocketEndpoint endpoint; @EventListener(ApplicationReadyEvent.class) public void subscribe() { // 伪代码,实际用 RedisMessageListenerContainer } }这个方案最大的坑是:Pub/Sub 消息是即发即弃的,如果某个实例在消息发布那一刻挂了,消息就丢了。所以对可靠性要求高的推送,要用 Redis Stream 或者本地先落库。
Redis Stream 的方式大致流程:消息先写入 Stream,每个实例用XREADGROUP去消费,消费到消息后判断目标用户是否在自己节点上,在自己节点就推送,不在就 ACK 掉。这样即使某个实例临时不可用,消息还在 Stream 里,其他实例可以接管。
说说订阅实现。这个也是热搜词里大家常问的点:Spring Boot 里“拉取队列消息”并不像 JMS 那样一个注解搞定。最常用的封装是RedisMessageListenerContainer,它本质上还是注册了一个监听器,订阅 Redis 频道,然后回调你的方法。如果你想用 Stream 协议手动XREAD拉取,缓存里的StreamOperations也可以搞,但还要自己处理消费组和 pending 列表,业务复杂度高不少。我个人的经验是:推送场景用 Pub/Sub,可靠性要求高的场景直接上 Stream 消费组,别在 Pub/Sub 上强行做补偿方案,最后只会发现“补偿逻辑比业务逻辑还复杂”。
5.4 结合业务场景:烹饪预约系统、婚庆预约平台里的实时通信
前面一直在聊技术,最后举两个实际业务场景,把 WebSocket 的应用串起来。
第一个是上门烹饪预约系统。用户预约厨师上门,核心链路是:用户下单 -> 平台推送给附近厨师 -> 厨师抢单/接单 -> 用户收到接单状态 -> 厨师上门打卡 -> 服务完成结算。这条链路里,WebSocket 的身影出现在:
- 用户下单后,平台实时推送新订单给在线的空闲厨师
- 厨师接单后,用户端马上收到状态变化,不用刷新页面
- 服务过程中,厨师可以给用户发送文字通知(比如“已出发”)
第二个是婚庆服务预约平台。这类平台的业务特点是参与角色多:新人、婚庆顾问、策划师、服务商。比如一个套餐被新人拍下了,顾问和服务商都要实时得到通知,协调档期。用@ServerEndpoint可以灵活地给不同角色推送不同消息,或者用一个连接配合消息类型字段区分业务,比 HTTP 轮询的体验好太多。
WebSocket 在这些场景里解决的核心问题是一样的:让状态变化即刻触达所有相关方。不用每个人靠刷新页面来同步信息,这带来的体验提升是质的,尤其是移动端网络差的时候,轮询频繁断连,长连接反而更省电省流量。
6. 再聊几个实操中的细节
6.1 消息内容建议统一为 JSON 并包含消息类型字段
一开始我偷懒,服务端直接推字符串“新订单来了”,前端拿到后也不知道该怎样处理。后来所有消息统一成 JSON:
{ "type": "NEW_ORDER", "timestamp": 1700000000000, "data": { "orderId": 123456, "customerName": "张三" } }前端拿到消息先看type,再分发逻辑,便于扩展。data里放业务数据,timestamp用于排查消息延迟。
6.2 生产环境记得做连接数监控和日志分级
WebSocket 连接数是个关键指标,我踩过无人值守的坑:晚上凌晨 2 点服务重启,连接全部断开,没有自动重连机制的客户端就完全“失联”了,第二天早上用户投诉订单提醒没收到。所以生产环境一定要监控:
- 当前在线连接数,掉到 0 就要告警
- 连接建立/关闭速率,异常波动往往是网络或服务问题
- 消息推送成功率,这个需要业务埋点
日志方面,连接建立和断开用 info 级别记录用户 ID 和时间;业务消息推送失败用 error 级别记录完整消息内容;心跳日志用 debug,不然一天的 PING/PONG 日志能占好几 GB 磁盘。
6.3 安全:WebSocket 的鉴权和消息校验
WebSocket 长连接建立后,如果鉴权有问题,等于给攻击者开了一扇长期窗口。我的实践是:
- 握手时通过
token参数或 Header 传递登录凭证,服务端校验通过才建立连接 - 连接建立后,定期校验 token 有效性(比如每 10 分钟),用户权限变了就主动断开
- 对消息内容限长,防止有人故意发超大消息消耗服务端内存
- 需要 HTTPS/WSS 的场景要上
wss://,避免消息被中间人窃听
配置消息大小限制:
@OnOpen public void onOpen(Session session) { session.setMaxTextMessageBufferSize(64 * 1024); // 64KB session.setMaxBinaryMessageBufferSize(64 * 1024); // 64KB session.setMaxIdleTimeout(120000); // 2分钟空闲超时 }setMaxIdleTimeout这个参数要和客户端心跳配合好。心跳 30 秒一次,空闲超时 120 秒,留足了余量,又不会让死连接长期占着资源。
6.4 关于 Spring Boot 中间件部署和 WebSocket 前置网关的整合经验
有的项目会把 WebSocket 服务部署在 API 网关之后,网关负责鉴权、限流、转发。这种架构下要注意:网关层通常会把 WebSocket 升级请求当成普通 HTTP 请求来处理,如果网关不支持 WebSocket 协议代理,升级就会失败。如果用了某些中间件转发,需要确认它是否支持长连接透传。这个从“spring boot 中间件部署”和“chales 监听 websocket”这两个热搜词能看出,不少人卡在工具链调试上。
排查这种问题,推荐先用浏览器开发者工具看 Network 面板的 WS 标签,能看到握手请求和每一条收发帧。再用curl做一次握手模拟测试,确认后端服务本身没问题,就能逐步定位到是不是网关或代理的问题。
curl -i -N \ -H "Connection: Upgrade" \ -H "Upgrade: websocket" \ -H "Sec-WebSocket-Version: 13" \ -H "Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==" \ http://localhost:8080/websocket/order如果返回101 Switching Protocols,说明后端 WebSocket 没问题,问题就出在前置链路上。
最后再分享一个真实的体会:WebSocket 本身并不难,难的是把连接生命周期管理好。我从一开始的简单 Demo,到后来踩了 Nginx 超时、集群会话共享、离线消息补偿这些坑之后,才真正理解长连接应用和普通 HTTP 接口在设计上的区别——HTTP 接口是一次性的,出错了重试就行;WebSocket 是一直活着的,你得时刻关心它是不是真的活着、消息是不是真的送达了。希望这篇实战记录能帮你少走几步弯路。