☰
Spring Boot + WebSocket 实战:实时通信系统搭建与避坑指南
2026/10/14 22:49:24 网站建设 项目流程

实时通信这四个字,几乎每个做Web开发的人都绕不过去。聊天、消息推送、在线协同编辑、订单状态刷新、行情看板……用户早就习惯了“打开页面就有最新数据”,而HTTP那个一问一答的老模型,在真正的高频双向通信场景里根本撑不起来。WebSocket + Spring Boot是我在Java生态里最常用的一套实时通信落地组合:Spring Boot把协议接入封装得足够干净,WebSocket本身又给了我们一条真正全双工的长连接通道。这篇文章会把我实际做过项目的整个套路摊开讲——从为什么选WebSocket、怎么搭建环境,到核心处理器怎么写、断线重连怎么设计,再到上线之后遇到的各类连接问题和集群部署思路,全部一次说透。不管你是刚接触实时通信的初级开发者,还是已经被线上连接异常磨得没脾气的后端老手,都能从中拿到可直接抄作业的方案。

我印象最深的一个项目是设备状态监控:起初用2秒一次的短轮询,高峰期在线设备几千台,光是空轮询就能把后端和数据库打得喘不过气,页面实时性还稀烂。换到WebSocket之后,推送链路一周内重构完成,服务端的无效请求几乎归零,这才意识到一条长连接省下的资源有多夸张。

1. 为什么是WebSocket:实时通信方案选型

1.1 短轮询和长轮询的代价在哪里

先说最朴素的短轮询。它的逻辑很简单:客户端每隔X秒发一个HTTP请求,问服务端“有没有新数据”,有就返回新数据,没有也返回一个空响应。这种做法最大的问题是浪费。HTTP请求自带一整套Header,一个空轮询请求从客户端到服务端再回来,网络传输、容器线程、序列化反序列化全都走了一遍,最后可能只换回一句“没变化”。

我做个粗略估算你就明白了:假设系统里有1万个在线用户,前端每5秒轮询一次,那么平均每秒就有1万除以5,也就是2000个请求打到服务器。这2000个请求里,超过九成是空转的。如果每次轮询还要查一次数据库或者缓存,那么这个流量就会被进一步放大。就算你用Nginx做缓存,也只是缓解了后端压力,网络层面的轮询流量和前端CPU消耗依旧在那里。

长轮询看起来聪明一点:客户端发出请求后,服务端先把这个请求“挂住”,等有新数据了再返回,客户端拿到数据后立刻发起下一次请求。这样空响应少了,但本质上还是半双工的模拟。服务端挂着请求,意味着要占用一个连接和一个处理线程,连接多了容器资源就紧张。更麻烦的是数据到达和下一次请求发起之间存在一个窗口期,如果数据恰好在这个窗口里到达,客户端就只能等下一次请求才能拿到,实时性又打了折扣。

1.2 SSE:单向实时场景的备选

在讲WebSocket之前,我得提一句SSE(Server-Sent Events),因为它是不少场景里更省事的替代品。SSE走的就是普通HTTP,服务端把数据以特定格式源源不断推给客户端,浏览器原生支持EventSource接口,断线了还能自动重连。

它的优点是简单、兼容性好,不需要额外协议,也能穿透大多数代理。但缺点是单行道:只能服务端往客户端推,客户端想发消息还是得另走HTTP接口。所以它适合服务端进度通知、公告推送这类单向场景,不适合聊天、协同编辑这种双方都要说话的需求。

1.3 WebSocket凭什么成为主角

WebSocket和上面这些方案的本质区别在于,它在一次HTTP握手之后,直接升级成了一条全双工的TCP长连接。握手的过程其实不复杂:客户端发一个HTTP GET请求,带上Upgrade: websocket、Connection: Upgrade和Sec-WebSocket-Key这几个头,服务端校验通过后返回101 Switching Protocols状态码,之后的通信就不再走HTTP的请求响应模型,而是双方随时可以往这条连接上写消息帧。

用生活里的场景类比:短轮询就像每隔几分钟给朋友打个电话问一句“有新消息没”;WebSocket则是从你家到朋友家拉了一根专线,两边任何时候想说话,拿起话筒就能说,不用每次重新拨号。这个差别在消息频率高的场景里是决定性的。

WebSocket的URL前缀也值得记一下:加密连接用wss://,明文连接用ws://,一个常见错误是项目本身已经挂了HTTPS,前端代码里却写死了ws://,浏览器直接拦截。协议层面还定义了多种帧类型:文本帧、二进制帧、Ping/Pong心跳帧、关闭帧,后面讲心跳机制的时候会用到。

1.4 选型对照,别一上来就无脑上WebSocket

我见过不少团队把WebSocket当成万能药,结果一个简单的后台通知需求也引入了WebSocket,还要维护一条长连接的状态。其实选型完全可以按需来,我整理了一张表:

方案数据方向实时性连接开销典型场景
短轮询单向(客户端拉取)取决于间隔高,大量空请求低频状态查询
长轮询模拟双向中等,有窗口期高,挂起连接历史遗留系统过渡
SSE服务端推送高低,一条HTTP长连接通知、进度推送
WebSocket全双工最高低,一条长连接聊天、协同、实时看板

判断标准很简单:客户端需要高频推送数据,且双方有双向交互需求,才值得用WebSocket;如果只是服务端单方向推消息,SSE往往更划算;如果数据变化频率很低,短轮询也不是不行,先把业务跑起来再说。WebSocket的维护成本主要在长连接的生命周期管理上,这一点后面会花大篇幅讲。

2. Spring Boot集成WebSocket:环境搭建与核心组件

2.1 依赖引入与版本选择

Spring Boot对WebSocket的支持是从spring-boot-starter-websocket这个起步依赖开始的。只要你的项目是Spring Boot 2.x或者3.x,直接在pom.xml里加依赖就行,版本跟随父级管理,不需要自己操心:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency>

需要注意的是Spring Boot 3.x要求Java 17+,如果你的项目还在Java 8上,老老实实用Spring Boot 2.x的最后一个稳定版,功能上对WebSocket支持是完全够用的。这个starter会把你需要的spring-websocket、spring-messaging以及内嵌容器的WebSocket实现都带进来,默认情况下内嵌的Tomcat已经实现了WebSocket服务端能力,不需要额外引入别的依赖。

我实际用下来,这套起步依赖在不同容器上的表现有一点差异:默认的Tomcat没问题,如果用Jetty或者Undertow,WebSocket配置项的名字会略有不同,但Spring做了适配,绝大多数场景无感知。如果你部署到独立的外部容器,记得确认外部容器开启了WebSocket对应版本的实现,有些老容器默认压根不支持。

2.2 两种实现路线:Handler接口与@ServerEndpoint

Spring生态里写WebSocket服务端,主流有两条路。

第一条路是用Spring自己封装的WebSocketHandler接口加WebSocketConfigurer注册。这是Spring官方推荐的做法,最大的好处是处理器会被Spring容器管理,可以直接@Autowired注入业务Service,还能用HandshakeInterceptor做握手阶段的鉴权。后面实战代码全部走这条路。

第二条路是JSR-356标准的@ServerEndpoint注解。这是JavaEE规范里的标准写法,用注解声明一个端点类就行。它的好处是标准化程度高,不依赖Spring特定API,但坏处也很明显:这个类由容器管理,反而拿不到Spring的Bean,想在Endpoint里调Service还得用ApplicationContext手动去取,很不方便。Spring Boot项目里我基本不推荐这条路,除非你要在非Spring容器里复用同一个Endpoint类。

配置类这边其实很轻量,就两步:实现WebSocketConfigurer,在registerWebSocketHandlers里把处理器和路径绑定起来:

@Configuration @EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { private final ChatWebSocketHandler chatHandler; public WebSocketConfig(ChatWebSocketHandler chatHandler) { this.chatHandler = chatHandler; } @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(chatHandler, "/ws/chat") .setAllowedOrigins("https://your-frontend-domain.com"); } }

setAllowedOrigins这个方法值得多说一句。很多教程为了省事直接写*,也就是允许任意来源跨域连接。开发阶段没问题,生产环境里这相当于把你家的门拆了,任何网站都可以往你的WebSocket端点建立连接。正规做法是白名单配置,至少把前端站点的域名写进去。如果前端有多个站点,这里也支持传一个字符串数组。

2.3 核心组件:WebSocketSession会话与消息类型

连接建立之后,服务端拿到的就是WebSocketSession对象,这是整个实时通信的核心载体。它的getId()返回连接的唯一标识,isOpen()判断连接是否仍然可用,getAttributes()能拿到握手阶段塞进去的会话属性,而sendMessage()是推送消息的唯一入口。我习惯把用户ID、登录态这些业务标识在建连时就放到attributes里,后续所有的业务处理都会用到。

Spring对WebSocket消息做了几个封装类,使用时按需选用:

  • TextMessage:文本消息,JSON字符串这类,日常开发用最多。
  • BinaryMessage:二进制消息,适合图片、音频等字节流。
  • PingMessage和PongMessage:协议层的心跳帧,保活关键。
  • CloseMessage:关闭帧,携带状态码和原因。

还有一个容易被忽略的配置类ServletServerContainerFactoryBean,它控制着WebSocket容器层的几个关键参数:

@Bean public ServletServerContainerFactoryBean createWebSocketContainer() { ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean(); container.setMaxTextMessageBufferSize(64 * 1024); container.setMaxBinaryMessageBufferSize(64 * 1024); container.setMaxSessionIdleTimeout(120_000L); return container; }

MaxTextMessageBufferSize默认只有8192字节,也就是8KB,一旦单条文本消息超过这个值,连接会被直接断开,这是线上一个非常隐蔽的坑。MaxSessionIdleTimeout默认值因容器而异,有的容器不设置就等于无限,有的默认只有几十秒。我建议显式设成120秒上下,再配合后面的心跳机制,避免幽灵连接堆积。

3. 核心实战:从处理器到前端接入

3.1 消息处理器:接收、解析与业务分发

前面的基础设施铺完了,现在进入正题。一个WebSocket服务端的核心就是一个继承了TextWebSocketHandler的处理器类,它接收客户端连接、处理上行消息、管理会话生命周期。我以聊天场景为例,写一个完整的处理器:

@Slf4j @Component public class ChatWebSocketHandler extends TextWebSocketHandler { private static final ConcurrentHashMap<String, WebSocketSession> SESSION_MAP = new ConcurrentHashMap<>(); private final ObjectMapper objectMapper = new ObjectMapper(); @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { String userId = session.getAttributes().get("userId").toString(); WebSocketSession oldSession = SESSION_MAP.put(userId, session); if (oldSession != null && oldSession.isOpen()) { oldSession.close(CloseStatus.NORMAL); } log.info("用户 {} 已接入WebSocket,当前在线数:{}", userId, SESSION_MAP.size()); sendMessageToAll(buildSystemMessage(userId + " 上线了")); } @Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { JsonNode json = objectMapper.readTree(message.getPayload()); String targetUserId = json.get("to").asText(); String content = json.get("content").asText(); WebSocketSession targetSession = SESSION_MAP.get(targetUserId); if (targetSession != null && targetSession.isOpen()) { targetSession.sendMessage(new TextMessage(message.getPayload())); } else { log.warn("用户 {} 不在线,消息进入离线通道", targetUserId); } } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { String userId = session.getAttributes().get("userId").toString(); SESSION_MAP.remove(userId); log.info("用户 {} 已断开,状态码:{},当前在线数:{}", userId, status.getCode(), SESSION_MAP.size()); sendMessageToAll(buildSystemMessage(userId + " 下线了")); } @Override public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception { log.error("WebSocket传输异常:{}", exception.getMessage()); if (session.isOpen()) { session.close(CloseStatus.SERVER_ERROR); } } private void sendMessageToAll(String jsonMessage) { SESSION_MAP.values().forEach(s -> { try { if (s.isOpen()) { s.sendMessage(new TextMessage(jsonMessage)); } } catch (IOException e) { log.error("广播消息失败,sessionId:{}", s.getId(), e); } }); } private String buildSystemMessage(String content) { return "{\"type\":\"system\",\"content\":\"" + content + "\"}"; } }

先说生命周期回调。afterConnectionEstablished在连接建立后触发,这里最大的教训就是:如果同一个用户重复建连,一定要把旧的连接先关掉。我遇到过用户在网络切换后自动重连,旧连接没及时关闭,结果同一个用户在SESSION_MAP里留下了两个会话,消息被重复推送,而且旧连接占用着资源迟迟不释放,直到容器超时踢掉它。

handleTextMessage处理业务消息,这里我只是简单解析了JSON做了单聊转发。实际项目中,这里会调用业务Service做消息鉴权、敏感词过滤、落库,然后才广播或者点对点推送。一个关键点是消息解析别用String拼接去组装JSON返回,一律用Jackson或者Fastjson序列化,避免手拼JSON转义出问题。

3.2 会话管理与广播的正确姿势

SESSION_MAP这个数据结构,我用的是ConcurrentHashMap而不是普通的HashMap。WebSocket的各个回调方法是并发触发的,多个连接同时建立、同时断开,如果是普通HashMap,扩容期间插入数据轻则数据丢失,重则死循环挂掉整个进程。ConcurrentHashMap的并发读写在大多数场景下性能足够,是这类会话管理的标配。

广播的时候,有人会图省事直接遍历SESSION_MAP,在遍历的过程中调用s.sendMessage()。这里有个隐患:如果某个会话的写操作抛异常,异常会中断遍历,后面连接的广播全部被跳过。所以我在广播方法里用了try-catch把每次发送隔离起来,一个连接的失败不能影响其他连接。另外,ConcurrentHashMap的迭代器是弱一致的,遍历过程中别的线程插入新会话不会报错,但也不保证能看到最新数据,这个特性在我们这种“发给当前在线所有连接”的场景下是可接受的。

还有一个很多人忽略的点:sendMessage不是绝对可靠的。客户端断网、断电、进程被杀,服务端在一段时间内是感知不到的,TCP层面上连接看起来还活着。所以SESSION_MAP里其实有大量“僵尸会话”,直到服务端真的往这条连接上写数据时才发现写不进去。这就要靠心跳来兜底。

心跳我习惯用Spring的定时任务实现,每30秒遍历一次所有会话,发送PingMessage:

@Scheduled(fixedRate = 30_000) public void heartbeatCheck() { SESSION_MAP.forEach((userId, session) -> { try { if (session.isOpen()) { session.sendMessage(new PingMessage(ByteBuffer.wrap(new byte[]{1}))); } } catch (IOException e) { log.warn("心跳发送失败,移除并关闭会话:{}", userId); SESSION_MAP.remove(userId); try { session.close(); } catch (IOException ignored) { } } }); }

注意别忘记在启动类或者配置类上加@EnableScheduling,否则这个定时任务不会生效。浏览器端的WebSocket API会在协议层面自动应答Ping帧,不需要前端额外写pong的代码,服务端只要发现Ping发不出去,基本就能判定连接已死,果断清理。

3.3 前端接入:原生WebSocket API与断线重连

服务端做得再稳,前端如果只有一个裸的new WebSocket(),依然撑不起生产环境。我见过太多线上事故都是因为前端没处理断线重连,用户手机切个Wi-Fi,连接就永久断掉了。下面这个是我长期在用的前端接入模板,带指数退避的重连策略:

const WS_URL = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/chat'; let ws = null; let retryCount = 0; function connect() { ws = new WebSocket(WS_URL); ws.onopen = () => { console.log('WebSocket连接建立'); retryCount = 0; }; ws.onmessage = (event) => { const data = JSON.parse(event.data); renderMessage(data); }; ws.onclose = (event) => { if (event.code !== 1000) { reconnect(); } }; ws.onerror = (err) => { console.error('WebSocket异常', err); }; } function reconnect() { const delay = Math.min(30000, 3000 * Math.pow(2, retryCount)); retryCount++; console.log(delay + 'ms后进行第' + retryCount + '次重连'); setTimeout(connect, delay); } connect(); function sendMessage(toUserId, content) { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ to: toUserId, content: content })); } }

这里的重连逻辑是标准的指数退避:第一次3秒,第二次6秒,第三次12秒,封顶30秒。为什么不能固定间隔重连?因为如果服务端故障正在重启,固定短间隔的疯狂重连会形成连接风暴,把刚刚恢复的服务端瞬间压垮。指数退避让重连节奏越来越慢,等服务端恢复的时候客户端正好每隔一段时间试探一次,是一个非常成熟的降级策略。

前端还要做的另一件事是发送前检查ws.readyState,只有等于WebSocket.OPEN(也就是1,值为1:数字1表示连接已建立并可以通信)的时候才调用send,否则消息会直接抛异常。对于那些用户已经发出但连接还没恢复的消息,最好先存到一个本地队列里,等onopen触发后再补发,这样能最大程度减少消息丢失的体感。

4. 踩坑实录:那些连接问题的真相

4.1 反向代理与跨域配置

WebSocket上线之后,第一个绊倒大多数人的通常是反向代理。现在几乎没有服务直接裸奔在公网端口上,前面必然挂一层Nginx。Nginx默认配置对WebSocket是不友好的,因为它不认识Upgrade协议升级的头,会把连接当成普通HTTP请求处理。我最初部署时前端一直报WebSocket connection failed,F12看到请求卡在握手阶段,排查半天才反应过来是Nginx没配置。

正确配置长这样:

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 3600s; }

proxy_http_version 1.1是必须的,HTTP 1.0不支持Upgrade语义。proxy_read_timeout也特别关键,Nginx默认读超时是60秒,WebSocket连接一旦超过60秒没有数据流动,就被Nginx掐断了。设长一点,比如3600秒,让心跳机制去管理连接的生命周期。

跨域问题上,后端setAllowedOrigins配置只是服务端层面的校验,真正的首道防线是浏览器Origin校验。如果前端页面域名不在白名单里,浏览器会在握手阶段就直接拒绝连接,控制台里能看到Origin相关的报错。排查这类问题,先在浏览器Network面板里找到WS连接,点击查看握手请求和响应头,Sec-WebSocket-Accept是否正常返回、状态码是不是101,这一眼就能定位握手是否成功。

4.2 静默断连:服务端看不见的死亡连接

WebSocket最磨人的问题就是“静默断连”。客户端网络切来切去、运营商NAT超时、防火墙空闲清理,这些都会把TCP连接默默掐断,但服务端和客户端在短时间内都感知不到。表现就是:服务端SESSION_MAP里的连接数只增不减,用户那边却已经收不到任何推送。

要治这个病,心跳机制是关键,上一节已经写了服务端的Ping定时发送。但如果数据流量比较大,服务端持续往连接上写数据,Ping帧自然就发得少,这时候反而要注意另一种情况:服务端认为连接活着,客户端那边却因为本地网络问题早就断开了,写操作一直成功(因为TCP缓冲区还没满),消息全部丢进虚空里。

所以完整的心跳方案最好是双向的:除了服务端定时Ping,前端也要有个看门狗逻辑——如果超过N秒没有收到服务端的任何消息(包括Ping帧),就主动把连接关掉重连。这个N一般取心跳间隔的3倍左右,比如服务端30秒发一次心跳,前端90秒没收到任何数据就把连接杀掉重来。双向兜底之后,幽灵连接基本能控制在分钟级以内被清理掉。

4.3 消息大小限制与并发发送的冲突

前面提到MaxTextMessageBufferSize默认8KB,很多人第一次踩到这个坑是在做在线文档协同的时候,一个文档片段往往超过8KB,推送过去之后连接直接异常关闭,日志里出现类似“The valid text message was truncated”的错误。解决方案就是把容器配置里的限制调大,同时前端也要控制单帧大小,大文件别走文本帧,走二进制分段发送更合理。

比大小限制更隐蔽的是并发发送问题。Spring的WebSocketSession在进行sendMessage时,如果同一个会话被多个线程同时写入,或者服务器在高并发下连续对同一条连接发多条消息,有的容器实现会抛出TEXT_PARTIAL_WRITING之类的状态错误。原因是一条WebSocket消息可能被拆成多个帧发送,两个线程同时写就会把帧流搅乱。

处理办法可以粗暴直接:对每个会话的发送操作加锁,或者在业务层用一个发送队列串行化消息。我个人的习惯是广播方法里每个session的写入由session对象本身加synchronized关键字做串行,保证同一个连接的写操作不会交错。注意这个锁的粒度必须是“每个会话一个锁”,不能所有会话共用一把锁,否则广播量一上来,全部连接都被锁拖死。

4.4 线程安全与资源回收的细节

WebSocket的各个回调方法跑在容器的IO线程池里,线程模型是典型的多读多写,任何共享可变数据都必须考虑线程安全。SESSION_MAP用ConcurrentHashMap解决了容器的问题,但如果有人在这个Map上做“先判断存在再操作”的组合逻辑,依然会踩并发竞态的坑。比如判断用户在线然后发送消息,这两个动作之间用户可能刚好断开,所以发送前判断isOpen()永远只能作为参考,发送失败后的异常处理才真正兜底。

资源回收方面,我总结了几条硬规矩,每一条都是拿线上事故换来的:

  • 在afterConnectionClosed里清理SESSION_MAP,同时在handleTransportError里也做清理,因为传输异常后不一定会触发close回调。
  • 关闭会话前先移除Map里的引用,避免remove之后又收到该会话的消息导致空指针。
  • 遇到发送失败,直接把会话从Map里移除并调用close(),别留着等下个心跳周期才处理,那中间的时间窗口里所有推送给这个用户的业务消息都会白白构造一遍。
  • 线上系统建议在日志里记录每个会话的建立与关闭事件,配合用户ID,排查问题的时候能按时间线完整还原连接的生命周期。

5. 从能用变成好用:优化、监控与集群

5.1 性能优化:消息体积、频次与背压

WebSocket长连接本身很省资源,但如果你毫无节制地往上面发包,再长的连接也会被打爆。我见过一个行情类项目,后端每100毫秒推送一次全量行情,单条大小几十KB,结果用户的浏览器和移动网络全都被撑满了,掉线率直线上升。这类场景的正确做法是推送增量数据,或者做合并窗口:把100毫秒内的多次更新合并成一条消息再推送,实时性损失极其有限,但网络开销能少一个量级。

服务端写入慢的问题也不能忽视。当客户端消费速度跟不上服务端时,TCP发送缓冲区会被填满,sendMessage就会阻塞或超时。在Spring的标准容器实现里,直接在IO线程里调用sendMessage是有卡线程风险的。量大了以后,要么用多线程处理发送并把结果回调处理好,要么引入背压机制——检测到写失败或者超时,就主动断开连接,让客户端重连后从断点续传。这比无限堆积内存里的待发消息要健康得多。

还一个容易被忽略的优化点:WebSocket的二进制帧和文本帧。如果你的消息是结构化数据,统一走二进制帧加自定义序列化协议,会比JSON文本帧节省30%以上的流量。当然,这要以牺牲部分调试便利为代价,小项目不必这么激进。

5.2 集群环境:本地会话Map失效了怎么办

单机部署的时候,SESSION_MAP一把梭就行。一旦水平扩展到多台实例,问题立刻浮现:用户A连的是实例1,用户B连的是实例2,用户A给B发消息,实例1的本地SESSION_MAP里根本找不到B的会话,消息自然送不到。

这里我给三个方案,按复杂度和可靠性排序。最简单的是负载均衡层做会话粘滞,也就是让同一个用户的WebSocket请求始终落到同一台实例上,这样本地SESSION_MAP就可以继续用。缺点是故障转移时连接会断,且实例多了会话分布不均。

第二档是引入Redis的Pub/Sub做消息广播。各实例启动时订阅同一个频道,收到业务消息后在本机的SESSION_MAP里查找目标会话并推送。发送方只需要把消息publish到Redis频道,剩余的分发交给订阅机制。我用Spring Data Redis实现过这套,代码量不大,效果稳定,适合聊天、通知这类对消息可靠性要求不苛刻的场景。要注意Redis Pub/Sub是即发即弃的,订阅者不在线消息会丢,重要消息需要落库或者走消息队列。

第三档是接入完整的消息中间件STOMP体系,比如用Spring的WebSocketMessageBroker对接外部MQ。这套方案能把会话路由、消息持久化、消费者组这些全部交给专业组件,可靠性是三个里面最高的。代价是引入中间件后运维复杂度明显上升,只有系统规模到了那个程度才值得。

5.3 可观测性:让连接状态见光

实时通信服务最容易出现的问题就是“用户说不收消息了,但系统指标一切正常”。所以连接状态必须可以被观测。我做的第一件事是给SESSION_MAP加一个实时统计:当前在线连接数、去重后的在线用户数、24小时内连接峰值、平均消息发送耗时。用Actuator暴露一个自定义端点,或者简单点写一个HashMap自己存着,从运维面板能看到就行。

第二件事是给连接生命周期打日志。每个连接建立、正常关闭、异常关闭、心跳失败,都记一条带用户ID和连接ID的日志。正常关闭的状态码是1000,异常断开的状态码往往是1006(异常关闭)或者1001(设备离开),如果某个时段1006的比例突然飙升,多半是网络抖动或者代理超时,直接按这个方向排查就行。

至于更细的监控,可以统计每分钟的消息收发量、按小时聚合的断线率、重连次数分布,这些指标放在一起基本能还原一次线上故障的全过程。我甚至会在广播代码里埋一个计数器,如果某个用户连续N次发送失败,主动触发告警,把这个用户踢下线并通知运维,防止问题雪球越滚越大。

最后分享一个我个人的体会。WebSocket这套东西,写一个Demo大半天就够,但真正让它在线环境里稳定跑上半年,靠的是对连接生命周期的敬畏:每次断开都要有兜底,每次重连都要有退避,每个会话都要有归宿。我最初做设备监控那个项目,就是因为没做心跳清理,上线第二天“在线设备数”统计就开始虚高,排查到大半夜才发现是断线重连后旧会话堆积成了僵尸连接。后来把心跳、双向清理、重连退避这三件套补齐,系统连续运行了好几个月再没出过同类问题。这篇里写的每一个坑,基本都是从类似这种真实场景里踩出来的,希望你能拿来即用、少走弯路。如果你在实际落地中遇到其他稀奇古怪的连接问题,欢迎一起交流,这类报错往往比文档里写得有意思多了。

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

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

立即咨询