☰
SpringBoot WebSocket 实战:长连接、心跳重连与避坑
2026/10/26 12:26:50 网站建设 项目流程

简介:面向 SpringBoot 初学者,以及需要实现实时通信功能的开发者,这份 SpringBoot+WebSocket Demo 以可运行的示例工程,演示如何在 Spring Boot 项目中接入 WebSocket,完成连接建立、会话维护与消息广播,可作为聊天室、在线通知等场景的入门骨架,技能层级偏基础到中级。压缩包共 106 个文件,约 169KB,以 82 个 xml 配置文件、5 个 java 源码及对应 class、4 个 html 测试页面为主,另含 properties 配置、js 脚本与 jar、mvnw 等构建文件;工程结构符合 Maven 项目规范,便于直接导入 IDE 调试。已有 412 人学习这份资源。示例覆盖 WebSocket 配置类、自定义消息处理器与原生 HTML 客户端,能帮助读者理解连接注册、消息转发和服务端广播的完整链路,并在此基础上扩展鉴权、编解码与异常处理等功能。

1. 从轮询到长连接:SpringBoot+WebSocket Demo 解决的是什么问题

后台系统里最常见的伪实时方案是定时轮询:前端每三秒请求一次未读数接口,量小的时候看着挺正常,一旦在线人数上去,数据库连接池先扛不住,接口响应从 80ms 抖到 800ms,用户看到的还是「消息延迟」。SpringBoot WebSocket 就是把这层轮询掐掉的标准解法——服务端主动推、连接只建一次、消息到达即渲染。这份 Demo 把两条主流路线都放进去了:原生 WebSocketHandler 和 STOMP 消息代理,配套会话注册表、心跳空闲检测、断线重连和反向代理配置。适合正在给管理后台加消息中心、给大屏加实时刷新、给工单系统加状态推送的开发者,也适合那些 Demo 跑通了但一上生产就掉线、一多实例就哑火的人。

2. 骨架拆解:一份 pom 与三个配置类怎么把端点跑起来

拿到一个 WebSocket Demo,最忌讳上来就mvn spring-boot:run然后盯着控制台看有没有报错。这个包的结构很清晰,核心就四个文件:pom.xml、一个WebSocketConfig、一个握手拦截器、一个消息处理器。先把这四个位置认全,后面所有排错都能定位到具体层。

2.1 依赖对齐:spring-boot-starter-websocket 里到底装了什么

很多人以为要单独引spring-websocket和spring-messaging,其实一个 starter 就够了,它已经把这两个传递依赖带进来。Demo 的 pom 只有一段关键配置,版本统一交给 parent 管,不要自己写<version>,这是版本冲突的头号来源。

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency> <!-- 只有走 STOMP + SockJS 路线时才需要,原生 WebSocket 不用引 --> <dependency> <groupId>org.webjars</groupId> <artifactId>sockjs-client</artifactId> <version>1.5.1</version> </dependency>

逻辑说明:spring-boot-starter-websocket内部依赖spring-websocket(提供WebSocketHandler、HandshakeInterceptor)和spring-messaging(提供 STOMP 的@MessageMapping、SimpMessagingTemplate)。也就是说,只要你引了这一个,两条路线都能写,不需要额外操作。

参数说明:sockjs-client的 webjars 版本要和你前端实际使用的 SockJS 版本一致,不一致会出现握手成功但帧解析失败。如果前端用 npm 装sockjs-client,这个依赖直接删掉,别留在 pom 里当摆设。

这里有一处版本分水岭必须提前说清楚:Spring Boot 2.x 用的是javax.websocket包,3.x 换成了jakarta.websocket。如果你从网上抄了一段@ServerEndpoint的 JSR-356 写法贴进 3.x 工程,编译期就会报找不到javax.websocket.server.ServerEndpoint。这个 Demo 走的是 Spring 自己的WebSocketHandler体系,不碰 JSR-356 注解,所以跨 2.x/3.x 迁移时只要改包名和少量 API,比@ServerEndpoint那套省心得多。我一般的做法是:新项目一律用WebSocketHandler或 STOMP,@ServerEndpoint只在维护老系统时才碰。

2.2 WebSocketConfig 注册端点:三个参数决定握手能不能成

端点是客户端连接的入口地址,注册错了后面全白搭。Demo 里的配置类长这样:

@Configuration @EnableWebSocket public class RawWsConfig implements WebSocketConfigurer { @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(orderHandler(), "/ws/order") // 端点路径 .addInterceptors(new AuthHandshakeInterceptor()) // 握手拦截器 .setAllowedOriginPatterns("*"); // 跨域白名单 } @Bean public WebSocketHandler orderHandler() { return new OrderWebSocketHandler(); } }

逻辑说明:@EnableWebSocket是把 Spring 的WebSocketHandlerMapping装配进来的开关,漏了它就只是一个普通配置类,端点根本不会注册,客户端连上去直接 404。addHandler的第一个参数是处理器实例,第二个是路径,这个路径是相对于server.servlet.context-path的。

参数说明:setAllowedOriginPatterns("*")和setAllowedOrigins("*")不是同一回事。Spring 5.3 之后,setAllowedOrigins("*")在带 cookie 或凭证的场景下会被拒绝,必须换成setAllowedOriginPatterns。Demo 里为了开箱即用写了*,上生产前一定要收敛成具体域名,比如https://admin.example.internal,否则等于把连接入口对整个互联网敞开。

还有一个容易忽略的点:如果你的工程配了context-path: /demo,那客户端连的完整地址是ws://host:8080/demo/ws/order,漏掉/demo这一段就是 404。血泪经验是——把端点路径、context-path、网关前缀三段写在同一张纸上贴屏幕边上,比在浏览器控制台猜半天强。

2.3 握手拦截器:浏览器不让带 header,身份怎么传进去

这是新手最容易卡住的地方。浏览器的new WebSocket(url)构造函数只接受两个参数,第二个是子协议,没有 headers 选项。你想学 HTTP 那样塞一个Authorization: Bearer xxx,做不到。所以身份信息只能走两条路:URL query 参数,或者Sec-WebSocket-Protocol子协议。

public class AuthHandshakeInterceptor implements HandshakeInterceptor { @Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) { // 从 query 参数取 token:ws://host/ws/order?token=xxx String token = UriComponentsBuilder.fromUri(request.getURI()) .build().getQueryParams().getFirst("token"); if (token == null || token.isBlank()) { response.setStatusCode(HttpStatus.UNAUTHORIZED); return false; // 返回 false 直接拒绝握手,客户端收到 401 } Long uid = tokenResolver.resolve(token); // 换成你自己的校验逻辑 if (uid == null) { response.setStatusCode(HttpStatus.UNAUTHORIZED); return false; } attributes.put("uid", uid); // 关键:塞进会话属性 return true; } @Override public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) { // 握手成功后的埋点位置,不要在这里做重活 } }

逻辑说明:attributes这个 Map 是握手层和业务层的唯一通道。你在beforeHandshake里放进去的uid,会在afterConnectionEstablished里通过session.getAttributes().get("uid")取出来。这条链路打通了,后面所有「按用户推消息」的逻辑才有根。

参数说明:token 放在 query string 里会被 Nginx 的 access log 完整记录下来,属于凭据泄露风险点。常见的缓解做法是:用短时效 token(比如 5 分钟有效、用完即废的一次性票据),或者改走Sec-WebSocket-Protocol子协议传参。后者需要服务端在响应头里回显同一个子协议名,否则浏览器会主动断开连接——这个细节不写对,现象就是「握手 101 成功,紧接着连接被关闭」,很玄学。

3. 两条路线选型:原生 WebSocketHandler 与 STOMP 分别适合谁

Demo 里两套代码并存,不是炫技,是因为它们的适用面完全不同。原生 Handler 贴近协议层,你自己定义消息格式、自己管订阅关系,灵活但活多;STOMP 是建立在 WebSocket 之上的文本协议,自带订阅/目的地语义,Spring 还送了SimpMessagingTemplate,写起来像发 MQ。选错的代价不小,改造成本比一开始选对高得多。

3.1 原生 Handler 的四个回调:消息收发与会话状态

继承TextWebSocketHandler之后,你真正要关心的是四个方法,每个对应一种生命周期状态:

@Component public class OrderWebSocketHandler extends TextWebSocketHandler { private final SessionRegistry registry; public OrderWebSocketHandler(SessionRegistry registry) { this.registry = registry; } @Override public void afterConnectionEstablished(WebSocketSession session) { Long uid = (Long) session.getAttributes().get("uid"); registry.add(uid, session); // 会话入册 } @Override protected void handleTextMessage(WebSocketSession session, TextMessage message) { String payload = message.getPayload(); // 收到的原文 // 应用层心跳:约定客户端每 25s 发一次 "ping" if ("ping".equals(payload)) { sendSafely(session, "pong"); return; } // 业务消息:这里做命令分发 } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { Long uid = (Long) session.getAttributes().get("uid"); registry.remove(uid, session); // 一定记得摘除 } @Override public void handleTransportError(WebSocketSession session, Throwable exception) { registry.remove((Long) session.getAttributes().get("uid"), session); // 传输层异常基本等同于连接不可用,直接清账 } private void sendSafely(WebSocketSession session, String text) { try { if (session.isOpen()) { session.sendMessage(new TextMessage(text)); } } catch (IOException e) { // 发送失败不要抛出去,交给上层重连逻辑 } } }

逻辑说明:afterConnectionEstablished是会话注册的时机,afterConnectionClosed是摘除的时机,这两个必须成对出现。handleTransportError很多人不重写,结果就是网络闪断的会话永远留在 Map 里,内存缓慢上涨,一周后 OOM,排查时完全没有线索——这就是典型的黑匣子问题。

参数说明:sendMessage是阻塞调用,而且WebSocketSession不是线程安全的。同一秒内两个线程同时往一个 session 发消息,会抛IllegalStateException: The remote endpoint was in state [TEXT_PARTIAL_WRITING]。Demo 里的做法是用ConcurrentWebSocketSessionDecorator包一层:

// 包装成线程安全的会话,三个参数分别是:原会话、发送超时、缓冲区上限 WebSocketSession safe = new ConcurrentWebSocketSessionDecorator(session, 5000, 64 * 1024);

第一个参数是原始会话,第二个是单次发送超时(毫秒),第三个是发送缓冲区字节上限。超过缓冲区上限时装饰器会直接关闭连接,而不是无限堆积——这是保护服务端内存的正确姿势,比你自己写队列加锁稳。

3.2 STOMP 的 @MessageMapping:订阅模型与用户级推送

如果你要的是「订阅某个主题的人都能收到」或者「只推给某个用户」,STOMP 能省掉大量自己造轮子的时间。

@Configuration @EnableWebSocketMessageBroker public class StompConfig implements WebSocketMessageBrokerConfigurer { @Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws/stomp") .setAllowedOriginPatterns("*") .withSockJS(); // 不支持原生 WS 的客户端走 SockJS 兜底 } @Override public void configureMessageBroker(MessageBrokerRegistry registry) { registry.enableSimpleBroker("/topic", "/queue") .setHeartbeatValue(new long[]{10000, 10000}) // 服务端→客户端 10s,客户端→服务端 10s .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; } }

逻辑说明:enableSimpleBroker启用的是 Spring 内置的内存消息代理,/topic一般用于广播,/queue用于点对点。setApplicationDestinationPrefixes("/app")决定了客户端发消息时,服务端@MessageMapping的映射前缀。比如客户端发往/app/order/status,就会命中@MessageMapping("/order/status")的方法。

参数说明:setHeartbeatValue那个数组是{服务端发送间隔, 服务端期望接收间隔},单位毫秒。这个配置不配TaskScheduler是不生效的,很多人只写了setHeartbeatValue,结果心跳根本没跑,连接被中间设备 60 秒掐断,然后回来问「为什么 STOMP 一分钟后自动断」。这是踩坑率极高的一个组合配置。

用户级推送靠@SendToUser:

@MessageMapping("/order/subscribe") @SendToUser("/queue/order") // 只有发起订阅的用户能收到 public String subscribe(@Payload String body, Principal principal) { return "subscribed:" + principal.getName(); }

Principal从哪来?从握手阶段的DefaultHandshakeHandler里注入。如果你用 query token 的方式做鉴权,需要自定义HandshakeHandler覆写determineUser,把attributes里的 uid 转成Principal返回,否则@SendToUser拿到的principal是 null,消息发出去没人收。

3.3 前端连接代码:URL、子协议与心跳字段

后端配好了,前端一行写错照样连不上。原生路线的连接代码:

// 注意:ws 协议,不是 http;context-path 要带上 const token = encodeURIComponent(localStorage.getItem('access_token')); const ws = new WebSocket(`ws://127.0.0.1:8080/demo/ws/order?token=${token}`); ws.onopen = () => { console.log('connected'); // 应用层心跳:25s 一次,必须小于网关的 idle 超时 timer = setInterval(() => ws.readyState === 1 && ws.send('ping'), 25000); }; ws.onmessage = (evt) => { if (evt.data === 'pong') return; // 心跳回包,不渲染 renderMessage(JSON.parse(evt.data)); }; ws.onclose = (evt) => { clearInterval(timer); console.warn('closed', evt.code, evt.reason); };

逻辑说明:readyState === 1表示OPEN,发送前必须判断,否则向一个CLOSING状态的连接写数据会抛异常,而这个异常在部分浏览器里不会冒泡到控制台,看起来就是「代码没执行」。

参数说明:协议头是ws://,如果页面本身是 HTTPS,浏览器会强制要求wss://,混用会被直接拦截并报 mixed content,这个错误在控制台里往往只显示一行,很容易被忽略。生产环境记得把地址改成相对协议:

const proto = location.protocol === 'https:' ? 'wss' : 'ws'; const ws = new WebSocket(`${proto}://${location.host}/demo/ws/order?token=${token}`);

STOMP 路线的前端用@stomp/stompjs(v7 起已移除内置 SockJS,需要单独装sockjs-client并注入webSocketFactory):

import { Client } from '@stomp/stompjs'; import SockJS from 'sockjs-client'; const client = new Client({ webSocketFactory: () => new SockJS('/demo/ws/stomp'), reconnectDelay: 5000, // 框架自带重连,别自己再写一套 heartbeatIncoming: 10000, // 要和后端 setHeartbeatValue 的第二个值对齐 heartbeatOutgoing: 10000, // 要和后端第一个值对齐 connectHeaders: { Authorization: `Bearer ${token}` } // STOMP 帧头可以带,不受浏览器限制 }); client.onConnect = () => { client.subscribe('/user/queue/order', (msg) => renderMessage(JSON.parse(msg.body))); }; client.activate();

这里有个反直觉的点:STOMP 的帧头是可以带自定义字段的,因为它在 WebSocket 建立之后走应用层协议,浏览器那层限制管不到它。所以 STOMP 路线反而更适合放 token。心跳的两个值必须和后端交叉对齐,写错了现象是「连接建立,但每隔一段时间自动断开重连」,日志里只有一行Whoops! Lost connection to ...。

4. 会话、心跳与重连:让消息推得出去也推得准

连接建立只是起点。真正决定这个 Demo 能不能上生产的,是三个东西:会话怎么存、超时怎么设、断了怎么接回来。这三块没做对,功能演示时一切正常,一压测就原形毕露。

4.1 会话注册表:key 用 uid 还是 sessionId

单机场景下用ConcurrentHashMap<Long, WebSocketSession>就够了,但要注意同一用户可能在多个标签页、多台设备上登录,所以 value 应该是 Set。

@Component public class SessionRegistry { // uid -> 该用户的所有活跃会话 private final Map<Long, Set<WebSocketSession>> userSessions = new ConcurrentHashMap<>(); public void add(Long uid, WebSocketSession session) { userSessions.computeIfAbsent(uid, k -> ConcurrentHashMap.newKeySet()) .add(session); } public void remove(Long uid, WebSocketSession session) { Set<WebSocketSession> set = userSessions.get(uid); if (set == null) return; set.remove(session); if (set.isEmpty()) { userSessions.remove(uid); // 防止空 Set 长期占用内存 } } public Set<WebSocketSession> get(Long uid) { return userSessions.getOrDefault(uid, Collections.emptySet()); } // 广播给全部在线用户 public void broadcast(String text) { userSessions.values().forEach(set -> set.forEach(s -> sendQuietly(s, text))); } private void sendQuietly(WebSocketSession session, String text) { try { if (session.isOpen()) session.sendMessage(new TextMessage(text)); } catch (IOException ignored) { // 单条失败不影响其他会话 } } }

逻辑说明:computeIfAbsent配合ConcurrentHashMap.newKeySet()是并发安全的写法,避免两个线程同时首次注册同一 uid 时创建两个 Set 导致会话丢失。remove里判断空集后删除 key,是防止长期运行下 Map 里堆积大量空壳条目。

参数说明:key 用 uid 还是 sessionId,取决于你的推送模式。按人推(点对点)用 uid;按连接推(比如运维需要精准踢掉某一个连接)用 sessionId。Demo 里两者都存了一份,主键用 uid,另有一个Map<String, Long>做 sessionId 到 uid 的反查,用于「强制下线」这类运维操作。

发送失败的处理原则:单条失败不要抛,也不要重试。WebSocket 的失败通常意味着连接已死,重试只会拖慢其他会话的推送。正确的做法是记录一条日志,把会话从注册表摘掉,等客户端的重连机制自己接回来。

4.2 三个超时参数:容器空闲、STOMP 心跳、网关读超时

掉线问题九成出在超时参数没对齐。一份完整链路上有至少三个超时点,任何一个比另一个短,都会造成「莫名断开」。

层级参数Demo 取值作用设短的后果
WebSocket 容器maxSessionIdleTimeout120000 ms会话空闲多久被容器回收无消息时被强制关闭
STOMP 心跳setHeartbeatValue10000 / 10000 ms双向心跳间隔中间设备判定连接僵死
反向代理proxy_read_timeout300 s代理等待后端数据的时长60s(默认)后静默断连

容器层的配置写法:

@Bean public ServletServerContainerFactoryBean createWebSocketContainer() { ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean(); container.setMaxTextMessageBufferSize(8192); // 单条文本消息上限 8KB container.setMaxBinaryMessageBufferSize(8192); // 单条二进制消息上限 8KB container.setMaxSessionIdleTimeout(120000L); // 空闲 2 分钟回收 container.setAsyncSendTimeout(5000L); // 异步发送超时 5s return container; }

逻辑说明:这四个参数控制的是容器(Tomcat / Jetty / Undertow)对 WebSocket 的底层约束。maxTextMessageBufferSize不设或设太小,客户端发一条稍大的 JSON 就会收到CloseStatus 1009(消息过大)。setMaxSessionIdleTimeout在不同容器上的默认值不一样,显式写死比依赖默认值靠谱得多。

参数说明:空闲超时和心跳机制是互相配合的。如果 STOMP 心跳 10 秒一次,那maxSessionIdleTimeout必须远大于 10 秒,否则会出现「心跳还在跑、连接先被容器回收」的诡异现象,日志上看不到任何业务异常,只有一条容器关闭记录。

反向代理那块,Nginx 的典型配置是:

location /demo/ws/ { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_read_timeout 300s; # 默认 60s,必须调大 proxy_send_timeout 300s; }

Upgrade和Connection两行是 WebSocket 协议升级的必需头,少一行握手就降级成普通 HTTP,客户端报 200 而不是 101。proxy_read_timeout按项目经验至少设成心跳间隔的三倍,即 10 秒心跳对应 30 秒以上,Demo 里给了 300 秒,留足余量。

4.3 断线重连:退避曲线与离线消息补偿

客户端的重连不能是死循环while(true)直接重连,那样服务端一重启,所有客户端一秒内同时敲门,直接把刚起来的实例打挂。标准做法是指数退避加随机抖动:

let retry = 0; const MAX_DELAY = 30000; function connect() { const proto = location.protocol === 'https:' ? 'wss' : 'ws'; const ws = new WebSocket(`${proto}://${location.host}/demo/ws/order?token=${token}`); ws.onopen = () => { retry = 0; }; // 连通即重置计数 ws.onclose = () => { const base = Math.min(MAX_DELAY, 1000 * Math.pow(2, retry++)); const jitter = Math.random() * 1000; // 抖动,避免同时重连 setTimeout(connect, base + jitter); }; }

逻辑说明:1000 * 2^retry给出 1s、2s、4s、8s……的上限曲线,Math.min封顶 30 秒防止指数爆炸,Math.random() * 1000的抖动把同时断开的客户端打散,这是防止「重连风暴」的关键一行。

参数说明:retry在onopen里归零很重要。不归零的话,连接稳定一段时间后再次断开,重连间隔会从上次的位置继续涨,用户体感就是「越用越久才恢复」。

服务端这边要配合做离线补偿。Demo 的做法是给每条推送消息带一个自增序号,客户端在重连成功的onopen里上报最后收到的序号,服务端从缓存里把中间漏掉的补发一次:

// 重连后客户端上报 lastSeq,服务端补发区间内的离线消息 public List<String> replay(Long uid, long lastSeq) { Deque<SeqMessage> buffer = offlineBuffer.getOrDefault(uid, new ArrayDeque<>()); return buffer.stream() .filter(m -> m.seq > lastSeq) .map(SeqMessage::payload) .collect(Collectors.toList()); }

补发缓冲区要有上限(Demo 里是 200 条),超出就丢弃最旧的并在响应里带上truncated=true,让客户端知道有缺口、改用拉取接口补数据。无上限的缓冲等于给自己埋一颗内存泄漏的雷。

5. 避坑排查:五类翻车现场的现象、原因与改法

这套代码我前后在几个模拟项目里用过,翻车的地方高度集中。下面按「握手阶段」和「连接之后」两段拆开写,每条都是现象、原因、解决三段式,照着对号入座基本能定位。

5.1 握手阶段就被挡回来:三条高频现象

现象一:连接立刻失败,浏览器控制台报 403 或Unexpected response code: 403。原因:setAllowedOriginPatterns没配,或者配的是具体域名但页面实际来源带了端口、大小写不一致。还有一种情况是拦截器里 token 校验失败,response.setStatusCode(HttpStatus.UNAUTHORIZED)返回了 401,但浏览器统一显示成握手失败。 解决:先在浏览器 Network 面板找到那次101请求(失败的话是4xx),展开看响应头里有没有Sec-WebSocket-Accept。没有说明根本没进握手逻辑,把setAllowedOriginPatterns("*")临时放开验证一次,确认是跨域问题后再收敛成白名单。如果放开还不行,把拦截器里的校验逻辑打日志,确认 token 到底传到没有。

现象二:握手成功返回 101,但连接马上被关闭,服务端日志没有任何异常。原因:走的是Sec-WebSocket-Protocol子协议传 token 的写法,服务端没有在响应头里回显同一个子协议名,浏览器按协议规范主动断开。或者是 Nginx 转发时丢掉了Upgrade头。 解决:确认 Nginx 配置里proxy_set_header Upgrade $http_upgrade;和proxy_set_header Connection "upgrade";两行都在。如果是子协议问题,在握手响应中显式回写:response.getHeaders().set("Sec-WebSocket-Protocol", 客户端声明的子协议名)。

现象三:本地跑得好好的,部署到测试环境后路径 404。原因:context-path和网关前缀没拼全。本地是ws://localhost:8080/ws/order,测试环境是wss://gateway.example.internal/demo/ws/order,多了一段/demo。 解决:前端不要硬编码路径,用配置项注入前缀,比如VITE_WS_PREFIX=/demo,打包时按环境注入。这个改动很小,但能省掉每次换环境的手工排查。

5.2 连上了但消息不动:两条隐蔽现象

现象四:单条消息能收到,并发推送时随机丢消息,服务端日志出现TEXT_PARTIAL_WRITING。原因:多个线程同时调用同一个WebSocketSession的sendMessage,而它本身不是线程安全的。 解决:用ConcurrentWebSocketSessionDecorator包一层再放进注册表,所有发送都走装饰后的实例。包装要放在afterConnectionEstablished里做,注册表存的必须是包装后的对象,否则后面所有发送还是走原会话。

现象五:单机测试广播正常,部署两个实例后,用户连在 A 实例上收不到 B 实例产生的消息。原因:enableSimpleBroker是纯内存的消息代理,两个 JVM 之间的消息完全不互通,各自维护各自的订阅关系。 解决:两条路。轻量路线是用 Redis 的 pub/sub 做一层广播中继,每个实例订阅同一个 channel,收到后转发给本地会话;重量路线是换外部消息代理(如支持 STOMP 的独立 broker),并把enableSimpleBroker换成enableStompBrokerRelay。前者的改造成本低得多,中小规模够用,后者适合订阅关系复杂、需要持久化的场景。选型时先算清楚并发连接数和消息吞吐量,别一上来就上重型方案。

顺带说一个不算 bug 但很烦的现象:开发阶段用localhost连,一切正常;改成局域网 IP 访问页面后,握手报错。这通常是跨域白名单只写了localhost:8080,页面来源变成了192.168.x.x:8080,属于上面现象一的变种。我一般会在开发环境的配置里直接把白名单写成*,生产环境再收敛,省得来回改。

6. 上线前的压测与验证:把 Demo 跑成能扛量的连接服务

功能跑通和能扛量之间隔着一次压测。连接数和消息频率是两个独立维度,压测时必须分开测:先测纯连接保持能力(建连后不发言,看能挂多久、内存涨多少),再测推送吞吐(固定连接数,逐步加大每秒推送条数,看延迟分布)。

连接保持用wscat做一次小规模冒烟:

# 建 200 个连接并保持 10 分钟,观察服务端内存与连接数 for i in $(seq 1 200); do wscat -c "ws://127.0.0.1:8080/demo/ws/order?token=smoke-token" & done sleep 600

推送吞吐用压测工具自带的长连接采样器,参数按这几项配:并发连接数 1000、每连接每秒 1 条消息、持续时间 300 秒、超时 15 秒。重点看三个指标——建立连接的成功率(低于 99% 说明握手链路有问题)、消息端到端 P99 延迟(超过 1 秒说明发送线程被阻塞)、断连率(超过 1% 说明超时参数没对齐)。

实测下来最容易暴露问题的不是连接数,而是发送方式。同步sendMessage在几百连接时看不出问题,上千连接后消息延迟会呈阶梯式上升,原因是发送线程被慢客户端拖住。这时候要么给每个会话挂独立发送队列,要么调大setAsyncSendTimeout并接受少量丢弃。我在模拟项目里踩过这个坑,当时的现象是「压测前半段一切正常,5 分钟后延迟突然从 200ms 跳到 8 秒」,排查了两天才定位到某个客户端网络差导致发送阻塞,进而把整个广播线程拖垮。从那以后我每次上线前都强制走一遍「10 个慢客户端混在 1000 个正常连接里」的混合压测,专门验证慢客户端会不会拖累全局。

还有一项容易被跳过的验证:故障恢复。手动 kill 掉一个实例,观察客户端多久重连到其他实例,以及这期间漏掉的消息能不能补回来。这个场景在演示时永远不会发生,但在真实环境里隔三差五就出现。验证方法是把退避曲线和离线补发缓冲区一起看——如果客户端在 30 秒内全部恢复并且消息缺口被补上,这套机制就算立住了。希望这些细节能帮你在第一次上线时少走点弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询