简介:这是一套基于Spring Boot与WebSocket开发的简易聊天室源码,主要面向具备Java Web基础、希望学习实时双向通信的开发者,也可用于课程设计或毕设参考。压缩包内含1093个文件,整体大小约5.42MB。其中图片资源最为丰富,631个PNG、369个GIF、50个JPG覆盖聊天表情、界面背景与装饰元素;15个CSS和9个JS负责页面响应式布局与交互动效;11个Java文件实现消息推送、会话保持、记录存储等后端核心逻辑;辅助的xml、properties、readme等文件让导入配置更加清晰。目前已有436人学习。研读该源码,不仅可以完整了解Spring Boot如何整合WebSocket完成即时通讯,还能掌握自定义消息帧、心跳检测、简化版用户接入等实践细节;前端界面中的表情面板、历史消息加载和移动端适配同样值得借鉴。不论用作个人练手还是小组作业,都能获得从页面到后端的整体工程思路。
1. 先分清这个聊天室项目里 Spring Boot 和 WebSocket 各干什么
一个基于 Spring Boot + WebSocket 的简易聊天室,标题里包含“设计源码”,意味着你要交付的不只是一段能跑的代码,而是一套能让别人看懂“连接怎么建立、消息怎么流转、断线怎么恢复”的最小实现。Spring Boot 负责把 WebSocket 端点注册进应用生命周期、管理连接会话、处理业务消息;WebSocket 负责全双工通信;而“简易”两个字决定了它不需要引入消息中间件,内存级广播就能满足需求。
我见过不少人在这个标题上踩的第一个坑:把 Spring Boot 当成聊天室业务本身,把连接、心跳、重连逻辑全塞进 Controller,最后代码只能在本地演示,一上服务器就断线。反直觉的结论是:一个能拿去面试的简易聊天室源码,难点不在“发消息”,而在连接生命周期、消息协议设计和断线重连这三个地方。下面按从选型到落地的顺序,把一套可复制的方案讲清楚。适合正在做课程设计、写技术博客,或者要应付 WebSocket 面试题的 Java 后端工程师。
2. 选型与端点落地:Spring Boot 集成 WebSocket 的两种路径
2.1 为什么聊天室必须用 WebSocket,而不是轮询和 SSE
HTTP 是半双工协议,要做聊天室这种双向实时通信,最简单粗暴的做法是轮询:客户端每隔几秒拉一次。问题很明显:消息延迟随间隔变大,服务器压力随用户数增长。而且轮询的 HTTP 请求头每次都在重复传输,1 万个在线用户、5 秒一轮,等于不停制造无效请求。
SSE(Server-Sent Events)解决了服务端推送,但它是单向的,客户端发消息还得走 HTTP。聊天室里“你一言我一语”是强双向交互,SSE 做出来要维护两套通道,不划算。WebSocket 一次握手后直接升级为长连接,客户端和服务端都能主动发数据,头部开销小,延迟可以做到毫秒级。这也是“为什么不用轮询和 SSE”这个 WebSocket 面试题里最高频的第一问。
2.2 路径一:使用 @ServerEndpoint 原生端点并注册到 Spring Boot
Spring Boot 内嵌 Tomcat 时,直接写jakarta.websocket的@ServerEndpoint是最省事的做法。注意 Spring Boot 3.x 开始包名从javax.websocket换成jakarta.websocket,网上大量旧文章直接粘代码会被编译期卡住。下面是一个能直接放进项目的房间版聊天室端点骨架。
package com.example.chatroom.ws; import jakarta.websocket.*; import jakarta.websocket.server.PathParam; import jakarta.websocket.server.ServerEndpoint; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; @Component @ServerEndpoint("/chat/{roomId}") public class ChatEndpoint { private static final Logger log = LoggerFactory.getLogger(ChatEndpoint.class); // roomId -> 该房间所有在线 Session private static final Map<String, Set<Session>> ROOM_SESSION_MAP = new ConcurrentHashMap<>(); @OnOpen public void onOpen(Session session, @PathParam("roomId") String roomId) { Set<Session> sessions = ROOM_SESSION_MAP.computeIfAbsent(roomId, k -> ConcurrentHashMap.newKeySet()); sessions.add(session); log.info("ws connected, sessionId={}, roomId={}", session.getId(), roomId); } @OnMessage public void onMessage(String message, Session session, @PathParam("roomId") String roomId) { // 完整实现里这里要解析 JSON,按 type 分发到不同逻辑 broadcast(roomId, message); } @OnClose public void onClose(Session session, @PathParam("roomId") String roomId) { Set<Session> sessions = ROOM_SESSION_MAP.get(roomId); if (sessions != null) { sessions.remove(session); } log.info("ws closed, sessionId={}, roomId={}", session.getId(), roomId); } @OnError public void onError(Session session, Throwable error) { log.error("ws error, sessionId={}", session.getId(), error); } private void broadcast(String roomId, String message) { Set<Session> sessions = ROOM_SESSION_MAP.get(roomId); if (sessions == null) { return; } for (Session s : sessions) { if (s.isOpen()) { s.getAsyncRemote().sendText(message); } } } }这段代码能跑起来的关键是:@Component让 Spring 管理ChatEndpoint,再配合ServerEndpointExporter把端点注册进去。如果你没有写下面这个配置类,Spring Boot 不会识别@ServerEndpoint,连接会一直 404。
package com.example.chatroom.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.socket.server.standard.ServerEndpointExporter; @Configuration public class WebSocketConfig { @Bean public ServerEndpointExporter serverEndpointExporter() { return new ServerEndpointExporter(); } }这里的要点有三个。第一,@ServerEndpoint的实例不是单例,每个 WebSocket 连接都会触发一次新建,所以上面用静态Map保存房间 Session 是惯用做法;不要尝试把 Session 存进一个普通实例字段。第二,computeIfAbsent配合ConcurrentHashMap.newKeySet()能避免并发加入时丢 Session。第三,getAsyncRemote().sendText()是非阻塞发送,简易聊天室够用,但面向几十万消息一天的业务时,要考虑发送队列和背压。
2.3 路径二:使用 Spring WebSocket 的 WebSocketHandler 和握手拦截器
如果你不想引入 WebSocket 原生注解,而是希望跟上 Spring MVC 的拦截器、安全体系整合,常见做法是走 Spring WebSocket。核心是实现TextWebSocketHandler,并在配置类里注册端点。
package com.example.chatroom.ws; import org.springframework.stereotype.Component; import org.springframework.web.socket.*; import org.springframework.web.socket.handler.TextWebSocketHandler; @Component public class ChatHandler extends TextWebSocketHandler { @Override public void afterConnectionEstablished(WebSocketSession session) { // 握手成功后,attributes 中是握手拦截器放进去的用户信息 Object userId = session.getAttributes().get("userId"); // 把 session 加到房间管理容器 } @Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { String payload = message.getPayload(); // 解析 JSON,按类型路由到广播或私聊逻辑 } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { // 正常关闭和异常关闭都会走这里,status.getCode() 可以拿 1006 等状态码 } }注册端点的配置如下,注意这里多了一个握手拦截器,后面第 4 章会单独讲鉴权,先把它挂在端点上:
package com.example.chatroom.config; import com.example.chatroom.ws.ChatHandler; import com.example.chatroom.ws.TokenHandshakeInterceptor; import org.springframework.context.annotation.Configuration; import org.springframework.web.socket.config.annotation.EnableWebSocket; import org.springframework.web.socket.config.annotation.WebSocketConfigurer; import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; @Configuration @EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { private final ChatHandler chatHandler; public WebSocketConfig(ChatHandler chatHandler) { this.chatHandler = chatHandler; } @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(chatHandler, "/ws/chat") .addInterceptors(new TokenHandshakeInterceptor()) .setAllowedOrigins("*"); } }WebSocketHandler是单例,所以不能在ChatHandler里用实例字段存单个 Session,必须另建连接管理容器。addInterceptors做的是握手阶段的前置校验,setAllowedOrigins("*")表示允许所有来源,简易聊天室可以这么写,生产环境建议收紧成具体域名,否则别人可以在他的网页里连你的端点。
2.4 两条路径的取舍:简易聊天室到底选哪个
| 对比项 | @ServerEndpoint 原生端点 | Spring WebSocket Handler |
|---|---|---|
| 端点注册 | 需要 ServerEndpointExporter | 实现 WebSocketConfigurer |
| 鉴权位置 | 自定义 Configurator 重写 modifyHandshake | HandshakeInterceptor,和 Spring 生态贴近 |
| 连接管理 | 每个连接一个实例,Session 用静态容器 | Handler 单例,Session 自建容器 |
| 消息收发 | getAsyncRemote().sendText() | session.sendMessage(TextMessage) |
| 与 Spring Security 整合 | 需要手动把认证信息传进去 | 可以直接复用 SecurityContext 思路 |
| 代码量 | 少,闭环短 | 略多,结构更正式 |
我一般会这么分:课程设计、源码演示、面试讲解用@ServerEndpoint,因为它把连接模型暴露得最直接;企业内网 IM、需要和后端权限体系紧密集成的项目,用WebSocketHandler。两种方式都能做广播、群组和设置用户属性,区别在于你愿意为多少仪式感买单。
3. 消息协议与会话管理:把“发消息”变成可调试的代码
3.1 上、下行 JSON 消息结构怎么设计
WebSocket 传输的是文本帧,聊天室源码里最容易出现的问题是:直接把用户的原始字符串广播出去,没有协议层。没有协议意味着私聊、系统通知、踢人这些逻辑将来全要推倒重来。简易聊天室也至少要约定一个包含 type 和 roomId 的 JSON 结构,客户端上行消息长这样:
{ "type": "CHAT", "roomId": "room-1001", "from": "张三", "content": "大家好", "ts": 1712345678901 }服务端向外广播时,建议追加一个 msgId,用于客户端去重和日志追踪:
{ "type": "CHAT", "roomId": "room-1001", "from": "张三", "content": "大家好", "ts": 1712345678901, "msgId": "uuid-xxxxxx" }type 字段用大写枚举值,常见有CHAT、SYSTEM、JOIN、LEAVE、ERROR、PING、PONG。ts 用毫秒时间戳,不要用yyyy-MM-dd HH:mm:ss字符串,因为前端要排序、算延迟,字符串比较既慢又容易踩时区坑。msgId 服务端用 UUID 生成,客户端收到重复消息时直接丢弃。
3.2 Session 容器:用 ConcurrentHashMap 管理房间维度
不管是哪条集成路径,源码里都要有一个专门管理连接集合的类,不要散落在 Handler 里。下面是一个最小版本的房间管理容器:
package com.example.chatroom.ws; import org.springframework.web.socket.WebSocketSession; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; public class RoomSessionManager { private final ConcurrentHashMap<String, Set<WebSocketSession>> rooms = new ConcurrentHashMap<>(); public void join(String roomId, WebSocketSession session) { rooms.computeIfAbsent(roomId, k -> ConcurrentHashMap.newKeySet()).add(session); } public void leave(String roomId, WebSocketSession session) { Set<WebSocketSession> sessions = rooms.get(roomId); if (sessions != null) { sessions.remove(session); } } public Set<WebSocketSession> onlineSessions(String roomId) { return rooms.getOrDefault(roomId, Set.of()); } public int onlineCount(String roomId) { Set<WebSocketSession> sessions = rooms.get(roomId); return sessions == null ? 0 : sessions.size(); } }这里用了ConcurrentHashMap.newKeySet(),返回的是一个并发安全的 Set,比手动加锁简单。computeIfAbsent保证同一个 roomId 的 Set 只初始化一次。广播时遍历返回值是个快照,但 session 本身可能已经关闭,所以发送前要判断isOpen(),发送异常要单独捕获。
3.3 广播与顺序性:同步发送还是异步发送
简易聊天室最常见的广播写法是同步遍历,代码直观,消息顺序有保证。注意这里说的顺序是指同一个服务端线程发出的顺序,而不是所有客户端的全局顺序。如果要严格全局有序,需要引入消息序号生成器,简易场景不划算。
private void broadcast(Set<WebSocketSession> sessions, String message) { TextMessage out = new TextMessage(message); for (WebSocketSession session : sessions) { if (session.isOpen()) { try { session.sendMessage(out); } catch (Exception e) { // 单个连接失败不能影响其他用户,也不能让广播线程中断 log.warn("broadcast failed, sessionId={}", session.getId(), e); } } } }参数要点是:TextMessage实例可以复用,sendMessage是阻塞调用,一个慢客户端会拖慢整个房间的广播循环。在线人数几十人没问题,几百人同时说话时,就要把同步发送换成每连接一个发送队列。@ServerEndpoint路径下的getAsyncRemote()本质也是异步,但要注意回调里仍然要处理发送失败。
3.4 源码里最常见的三个扩展点:广播、群组、属性设置
拿到一份聊天室源码,想要体现“我不只是会抄”,就从这三个位置改。
第一是广播:把广播从“全房间”改成“按群组”,需要维护 userId -> roomId 映射,再在 RoomSessionManager 里加一个findCommonRooms接口。第二是群组:增加群 ID 字段,消息协议里带上targetGroupId,广播时按群组找 Session 集合。第三是设置用户属性:客户端连接时带上 nickname、avatar,握手阶段放进 session attributes,消息里不再重复传 from。这三个点正好对应热搜里“可以广播、群组、设置属性等”的框架诉求,面试时能讲清楚任意一个,都比背一段聊天室代码更能说明源码是你读过的。
4. 心跳、断线重连与生产参数:聊天室上线前必调的 WebSocket 参数
4.1 H5 能连、打成 App 连不上,先查这三处
“WebSocket 运行到 H5 可以连接,打包为 App 连接不了”是很典型的排查场景,问题基本不在 Spring Boot 代码里。第一,Android 9 开始默认禁止明文流量,如果你的地址是ws://而不是wss://,需要在 manifest 里打开明文流量或者换 HTTPS 传输。第二,原生 App 没申请网络权限,连接直接失败,这属于入门级,但发生率不低。第三,服务端配置了来源限制,浏览器请求带 Origin,App 的 WebSocket 库往往不带或者带自定义头,被握手阶段拒绝,表现为 WebSocket handshake 返回 401/403。
4.2 onclose code 1006 与服务端主动断开
前端日志里最常见的异常是[websocket] onclose, code: 1006, reason: , reconnect: true。1006 的含义是连接非正常关闭,也就是 TCP 层断了但没有收到正常 Close 帧。Spring Boot 服务端重启、代理层空闲超时、客户端切网、服务器主动 kill 连接,都会触发 1006。而后面的 reason 为空是正常的,不是你的代码漏写了 reason。
要减少 1006 导致的用户掉线,第一道防线是心跳。WebSocket 协议本身没有强制心跳,常见做法是客户端每 30 秒发一条 PING,服务端回 PONG,同时记录最后活跃时间,超时连接主动关闭。客户端侧的心跳示意如下:
const HEARTBEAT_INTERVAL = 30000; let heartbeatTimer = null; function startHeartbeat(ws) { heartbeatTimer = setInterval(() => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'PING' })); } }, HEARTBEAT_INTERVAL); } function stopHeartbeat() { if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; } }服务端收到 PING 时不需要广播,只更新活跃时间并回 PONG。需要注意,@OnMessage的方法会被频繁调用,心跳消息要放在业务分发的最前面,否则一条心跳也去走 JSON 解析和广播逻辑,相当于白耗 CPU。服务端的兜底是定时扫描超时连接:
@Scheduled(fixedDelay = 10000) public void checkIdleSessions() { long now = System.currentTimeMillis(); lastActiveMap.entrySet().removeIf(entry -> { String sessionId = entry.getKey(); if (now - entry.getValue() > 90000) { WebSocketSession session = sessionMap.get(sessionId); if (session != null && session.isOpen()) { try { session.close(CloseStatus.GOING_AWAY); } catch (IOException e) { log.warn("close idle session failed, sessionId={}", sessionId, e); } } return true; } return false; }); }这里的lastActiveMap可以是ConcurrentHashMap<String, Long>,key 是 sessionId,value 是最后一次活跃时间。@Scheduled需要启动类加@EnableScheduling。关闭时用GOING_AWAY,客户端收到后能识别出这是服务端主动断开,从而走重连逻辑。客户端看到 1006 时,正确的处理是退避重连,不要 1 秒一次疯狂重连打爆服务端。
4.3 鉴权不要写在 onOpen 里:握手阶段才是正确位置
WebSocket 简历里最容易被追问的问题:token 怎么带的?答案是浏览器 WebSocket API 不能像 HTTP 那样自定义 Header,所以常见方案是把 token 放在查询字符串里。@ServerEndpoint的鉴权要写在 Configurator 的modifyHandshake,因为这里在升级 HTTP 协议进入 WebSocket 之前执行,可以设置 HTTP 状态码拒绝连接。
package com.example.chatroom.ws; import jakarta.websocket.HandshakeResponse; import jakarta.websocket.server.HandshakeRequest; import jakarta.websocket.server.ServerEndpointConfig; import java.util.List; public class TokenConfigurator extends ServerEndpointConfig.Configurator { @Override public void modifyHandshake(ServerEndpointConfig sec, HandshakeRequest request, HandshakeResponse response) { List<String> tokens = request.getParameterMap().get("token"); if (tokens == null || tokens.isEmpty() || !"期望的token值".equals(tokens.get(0))) { response.setStatusCode(401); } } }使用方式是在@ServerEndpoint注解里加上configurator = TokenConfigurator.class。modifyHandshake里通过request.getParameterMap()拿到的是 URL 查询参数,注意要做 null 判断,否则不带 token 的连接会直接抛空指针。真正生产级做法是校验 JWT 并把 userId 放进sec.getUserProperties(),在onOpen里读出来。
Spring WebSocket 的拦截器原理类似,beforeHandshake返回 false 则拒绝握手。很多 WebSocket 面试题考的“握手和 onOpen 的区别”,本质就是要你答清楚:握手阶段还能操作 HTTP 响应,onOpen 已经进入 WebSocket 协议域。
4.4 Nginx 反向代理里的 WebSocket 配置要点
Spring Boot 聊天室上线后通常会放在 Nginx 后面,如果 Nginx 没配 Upgrade 头,WebSocket 握手会直接失败。下面是常见做法里最小的一组配置:
location /ws/chat { proxy_pass http://chat-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 120s; }proxy_http_version 1.1是必须的,因为 HTTP/1.0 不支持 Upgrade。proxy_set_header Connection "upgrade"让 Nginx 知道这个连接要升级成 WebSocket。proxy_read_timeout是 Nginx 等待后端响应的超时时间,如果小于你的业务心跳间隔,后端长时间没有下发数据,Nginx 会先断掉这条连接,客户端就收到 1006。这里要保证 Nginx 的 read timeout 大于服务端和客户端的最大空闲周期,一般 120 秒比较稳。
4.5 并发相关参数与连接生命周期检查清单
聊天室的连接数不等于 Tomcat 工作线程数,但大量广播会瞬间占满线程。需要关注三个层次:Tomcat 的最大线程数、单连接的消息大小限制、系统文件描述符上限。消息过大时,服务端会以 1009 的状态关闭连接。@ServerEndpoint可以这样限制单条消息最大值:
@ServerEndpoint( value = "/chat/{roomId}", maxTextMessageSize = 8192, maxIdleTimeout = 90000 )maxTextMessageSize单位是字节,超过上限会抛异常并关闭连接;maxIdleTimeout是容器级别的空闲超时,和上文的业务心跳不冲突,可以理解为最后一道防线。简易环境这几个参数可以先按经验值设置:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 客户端心跳间隔 | 30s | 太密浪费流量,太疏容易触发代理超时 |
| 服务端空闲超时 | 90s | 大于三个心跳周期 |
| Nginx proxy_read_timeout | 120s | 大于服务端空闲超时 |
| maxTextMessageSize | 8192 | 聊天场景足够,传图片走另外通道 |
| Tomcat 最大线程数 | 200~400 | 配合压测结果调整,不要盲改 |
如果项目里引入了 actuator,记得给 /actuator 相关端点加访问控制,否则线程池状态、连接指标裸奔在公网,相当于把压测情报直接送给别人。
5. 验证技巧:用 Node 脚本模拟 20 个客户端并观察 JSON 日志
5.1 最小可运行的连接压测脚本
聊天室写完以后,不能只开一个浏览器说“通了”。常见做法是用 Node 的 ws 库写一个多客户端脚本,验证广播是否丢消息、连接是否能正常拆销。先安装依赖:npm install ws,然后创建 stress.js:
const WebSocket = require('ws'); const URL = 'ws://localhost:8080/chat/demo-room?token=test-token'; const CLIENT_COUNT = 20; const MESSAGE_COUNT_PER_CLIENT = 50; let connectedCount = 0; let totalSent = 0; let totalReceived = 0; function createClient(index) { const ws = new WebSocket(URL); let sent = 0; const timer = setInterval(() => { if (ws.readyState === WebSocket.OPEN && sent < MESSAGE_COUNT_PER_CLIENT) { ws.send(JSON.stringify({ type: 'CHAT', roomId: 'demo-room', from: `user-${index}`, content: `msg-${sent}`, ts: Date.now() })); sent++; totalSent++; } }, 200); ws.on('message', () => { totalReceived++; }); ws.on('open', () => { connectedCount++; }); ws.on('close', () => { clearInterval(timer); }); } for (let i = 0; i < CLIENT_COUNT; i++) { createClient(i); } setTimeout(() => { console.log({ connectedCount, totalSent, totalReceived, lossRate: ((totalSent * (CLIENT_COUNT - 1) - totalReceived) / (CLIENT_COUNT - 1) * 100).toFixed(2) + '%' }); process.exit(0); }, 15000);脚本里的URL可以直接换成线上地址;CLIENT_COUNT和MESSAGE_COUNT_PER_CLIENT决定压力规模;每个客户端用独立 timer 发送,避免一个慢连接阻塞其他客户端。输出的lossRate是丢包率的粗略估算:每个客户端发送 50 条,其他 19 个客户端都应该收到这些广播,用期望收到的总数减去实际收到的总数,就能看出广播是否在某些连接上失败了。
5.2 服务端要打点的日志关键词
跑脚本之前,先把服务端日志结构调整成好搜索的格式。下面这几个关键字覆盖了大多数聊天室故障场景:
| 关注点 | 日志关键字 | 说明 |
|---|---|---|
| 连接建立 | ws connected | 观察 connectedCount 是否线性增长 |
| 连接关闭 | ws closed | 对比 connected 与 closed 数量,排查泄漏 |
| 异常断开 | ws error / 1006 | 统计关闭码分布 |
| 广播耗时 | broadcast-cost-ms | 超过 50ms 说明广播逻辑或 GC 有压力 |
| 心跳超时 | heartbeat timeout | 出现频率高说明客户端断网或代理层有问题 |
| 消息大小 | message-size-too-large | 命中说明协议或参数要调整 |
广播耗时的埋点建议放在同步广播的循环外面,用 System.nanoTime 计算一次广播的完整耗时。如果出现持续上升,优先怀疑某个客户端的 TCP 窗口太小,导致服务端写阻塞。
5.3 复现和修复的闭环技巧
把CLIENT_COUNT改成 500,大概率能复现两个现象:一是服务端日志出现大量广播异常,二是客户端收到乱序或丢消息。先别急着改代码,把 Tomcat 线程数和单连接发送队列打印出来,确认瓶颈是线程不够还是单连接写阻塞。修复时优先做分段广播,比如每 50 个 Session 一个批次,批次之间不设间隔,但把同步 sendMessage 换成带队列的异步发送。最后把超时关闭连接的阈值从 90 秒调到 30 秒,观察客户端重连是否变平稳,这个动作能快速验证心跳逻辑是否真的在工作。
把这段脚本参数改写成读取环境变量,并放进 CI 里的启动后检查任务,就是一套不依赖浏览器的 WebSocket 健康检查。以后再遇到“偶尔掉线、重启就好”的线上问题,翻一遍 connected、closed、broadcast-cost-ms 三个关键字的日志,基本能定位到是代理层超时还是服务端线程被拖死。
本文还有配套的精品资源,点击获取