多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

SpringBoot+WebSocket轻量级在线聊天室:从原理到避坑

SpringBoot+WebSocket轻量级在线聊天室:从原理到避坑 简介实时通信是互联网应用的高频需求从轮询到长轮询再到WebSocket全双工长连接技术演进始终围绕降低无效请求与提升消息时效。SpringBoot内置WebSocket支持无需引入额外中间件即可实现轻量级在线聊天室。通过ServerEndpoint管理连接生命周期ConcurrentHashMap维护会话结合心跳机制与超时清理确保长连接稳定。本文从连接原理、消息协议、参数调优到Nginx代理部署与常见故障完整梳理轻量级聊天室的落地路径适合内部工具与中小规模实时互动场景。1. 轻量级 SpringBoot WebSocket 在线聊天室不引中间件也能做到秒级送达“在线聊天室”这个需求在企业内部出现的频率比想象中高得多运维答疑、团队日报讨论、客服后台和用户实时沟通说到底都是同一个模型——一群人挂在浏览器页面上消息进来推给同房间的人。很多人一听到“聊天室”就想到 Redis Pub/Sub、消息队列、分布式长连接但在几百人在线的体量下SpringBoot WebSocket 的组合完全能把事情做干净代码量也能控制在一千行以内。这篇文章要讲的就是这个方案连接怎么管、消息怎么定协议、心跳怎么做、参数怎么调最后落到源代码和文档该怎么组织让新人照着能复现让熟手直接看到边界和坑。2. 先把模型立住SpringBoot 里 WebSocket 到底是怎么跑起来的2.1 从 HTTP 到 WS为什么聊天室需要长连接而不是轮询在 WebSocket 普及之前网页里做“实时”消息只能靠轮询前端每隔几秒发一个 AJAX 请求问服务端“有没有新消息”。这种做法在小流量内部工具里勉强能用但代价很明显——大量请求是空转服务端压力和网络开销都浪费在“没有消息”的查询上。长轮询是对轮询的改良请求挂住等有消息再返回但连接生命周期、超时重连和消息顺序都要自己处理复杂度一点不少。WebSocket 的思路完全不同。它复用 HTTP 的握手端口客户端发一个带Upgrade: websocket头的 GET 请求服务端同意后返回 101 状态码之后这条 TCP 连接就变成了全双工通道两端随时可以互推数据不再有请求-响应的循环。对聊天室这种“低频消息、高频空闲”的场景长连接是天然合适的模型一个人挂着页面两小时不说话这条连接除了偶尔的心跳包之外不产生任何流量服务端也能通过它随时把消息推给客户端。SpringBoot 对这个模型的支持是开箱即用的。加一个spring-boot-starter-websocket依赖写一个标注了ServerEndpoint的类再注册一个ServerEndpointExporter一个最小的 WebSocket 服务端就起来了。不需要额外引入第三方组件也不需要独立的消息中间件这正是“轻量级”三个字的落点依赖少、启动快、单机就能跑。2.2 最小可运行的服务端ServerEndpoint 与握手配置先看依赖和配置。Maven 项目里加依赖不需要写版本号交给 SpringBoot 的父工程管理dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-websocket/artifactId /dependency然后注册 WebSocket 端点。SpringBoot 内嵌 Tomcat 的场景下缺少这个 BeanServerEndpoint不会被扫描生效这是新手最容易忽略的一步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(); } }逻辑说明ServerEndpointExporter是 SpringBoot 提供给内嵌容器的 WebSocket 端点注册器它会把所有标注了ServerEndpoint的类扫描出来注册到 Tomcat 的 WebSocket 容器里。如果用外置 Tomcat 以 war 包部署这个 Bean 会冲突实际生产里要按部署方式做条件装配这个边界后面第 4 章再展开。接着写聊天室的核心端点类package com.example.chatroom.websocket; import javax.websocket.*; import javax.websocket.server.PathParam; import javax.websocket.server.ServerEndpoint; import org.springframework.stereotype.Component; import java.io.IOException; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; Component ServerEndpoint(/chat/{room}) public class ChatEndpoint { // 房间 - 该房间内所有会话key 用 sessionId 保证唯一 private static final MapString, MapString, Session ROOMS new ConcurrentHashMap(); // 房间 - 房间名从 URL 路径参数里解析 private static final MapString, String ROOM_NAMES new ConcurrentHashMap(); OnOpen public void onOpen(Session session, PathParam(room) String room) throws IOException { session.getUserProperties().put(room, room); ROOMS.computeIfAbsent(room, k - new ConcurrentHashMap()) .put(session.getId(), session); session.getBasicRemote().sendText({\type\:\SYSTEM\,\content\:\欢迎进入房间 room \}); } OnMessage public void onMessage(String rawMessage, Session session) throws IOException { String room (String) session.getUserProperties().get(room); MapString, Session sessions ROOMS.get(room); if (sessions null) { return; } String message {\from\:\ session.getId() \,\content\: rawMessage }; for (Session target : sessions.values()) { if (target.isOpen()) { target.getBasicRemote().sendText(message); } } } OnClose public void onClose(Session session) { String room (String) session.getUserProperties().get(room); MapString, Session sessions ROOMS.get(room); if (sessions ! null) { sessions.remove(session.getId()); } } OnError public void onError(Session session, Throwable error) { error.printStackTrace(); } }逻辑说明这段代码把核心流程串起来了。ServerEndpoint(/chat/{room})让路径变成带房间号的地址浏览器连ws://localhost:8080/chat/room1就能进入指定房间。ROOMS用双层 Map 做会话管理外层是房间名内层是sessionId - Session这样每个房间隔离互不干扰。OnOpen里把房间名存进session.getUserProperties()相当于给这条连接打上标签后续消息分发都能从上下文里拿到它。广播这段遍历用target.isOpen()做保护避免往已关闭的连接上写数据。参数说明ServerEndpoint的 value 支持路径参数{room}配合PathParam(room)注入Session.getUserProperties()是 WebSocket 规范提供的用户属性字典适合存连接级状态getBasicRemote().sendText()是同步发送简单直观但并发写时容易出问题第 5 章会讲到这个坑。这里的OnMessage直接拿 String 接收文本帧如果前端发二进制帧需要换成byte[]参数重载。2.3 连接生命周期onOpen / onMessage / onClose / onError 各管一段WebSocket 连接的生命周期比 HTTP 请求清晰很多四个注解方法正好对应四个阶段我习惯按这个分工来写OnOpen只做两件事注册会话、回一条欢迎消息。连接刚建立时前端还没有准备好处理复杂 JSON所以这里回的消息越简单越好。有些实现会在这里做身份鉴权从请求参数里拿 token再决定要不要拒绝连接——轻量版聊天室可以先不做内网工具默认信任即可。OnMessage是业务最重的地方。需要先解析 JSON 判断消息类型再分发给不同处理器。如果消息里既有心跳又有聊天、还有系统通知一定要在协议设计阶段就区分好类型字段不要在onMessage里堆 if 判断两三种类型看不出来到第五种类型时这个方法能写到两百行。OnClose负责清理从会话 Map 移除当前 session广播下线消息。这里要小心用户直接关浏览器页面的情况TCP 连接没有正常挥手onClose不会立刻触发只靠它清理的都会在连接数上踩坑必须配合心跳超时检测。OnError是最后的兜底。常见做法是记日志、关闭异常连接再把会话从 Map 里移除。注意不要在onError里再抛异常否则异常会沿着容器层继续传播把日志刷成噪音。3. 把聊天室拆成可落地的模块会话管理、消息协议与心跳机制3.1 会话管理用一个 ConcurrentHashMap 维护所有在线用户第 2 章的例子已经用到了ConcurrentHashMap管理会话但真实项目里我一般会把会话管理抽成独立的类而不是堆在端点类里。原因很简单心跳检测要扫描全部会话广播要遍历全部会话在线状态查询要统计会话数这些逻辑如果都写在ChatEndpoint里类会越来越肥。提供一个会话管理器核心接口就是增、删、查、广播package com.example.chatroom.websocket; import org.springframework.stereotype.Component; import javax.websocket.Session; import java.io.IOException; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; Component public class SessionManager { private final MapString, Session sessions new ConcurrentHashMap(); public void add(Session session) { sessions.put(session.getId(), session); } public void remove(Session session) { sessions.remove(session.getId()); } public void broadcast(String room, String message) { sessions.values().forEach(session - { try { if (session.isOpen() room.equals(session.getUserProperties().get(room))) { session.getBasicRemote().sendText(message); } } catch (IOException e) { // 写失败说明连接已不可用交给心跳清理 sessions.remove(session.getId()); } }); } public int onlineCount() { return sessions.size(); } }逻辑说明add和remove的 key 用session.getId()因为 WebSocket 规范保证同一容器内 sessionId 唯一。broadcast里用房间名过滤这样哪怕所有房间共用一个 SessionManager也能做到按房间隔离推送。写失败时顺手移除会话避免死连接一直躺在 Map 里。参数说明ConcurrentHashMap本身是线程安全的但这只保证put/remove不会把 Map 结构写坏不保证业务逻辑的原子性——比如“先判断isOpen()再sendText()”这两步之间连接可能刚好被关闭。所以广播方法里要捕获IOException并且在移除时用sessions.remove(session.getId())而不是remove(Object)避免误删同 id 的新连接。3.2 消息协议JSON 载荷怎么设计才不返工聊天室的消息不能只传一个字符串前端需要知道这条消息是谁发的、什么类型、发给谁、什么时候发的。我习惯在一开始就定好统一的 JSON 协议避免前后端各写一套package com.example.chatroom.dto; public class ChatMessageDto { private String type; // HEARTBEAT / CHAT / SYSTEM / ONLINE private String from; // 发送者昵称登录时注册 private String to; // 接收者 sessionId为空表示群发 private String content; // 消息内容 private String room; // 房间名 private long timestamp; // 客户端时间戳毫秒 public String getType() { return type; } public void setType(String type) { this.type type; } public String getFrom() { return from; } public void setFrom(String from) { this.from from; } public String getTo() { return to; } public void setTo(String to) { this.to to; } public String getContent() { return content; } public void setContent(String content) { this.content content; } public String getRoom() { return room; } public void setRoom(String room) { this.room room; } public long getTimestamp() { return timestamp; } public void setTimestamp(long timestamp) { this.timestamp timestamp; } }逻辑说明type字段是消息分发器的开关。HEARTBEAT只更新心跳时间、不广播CHAT走正常聊天逻辑转发给同房间或指定用户SYSTEM由服务端产生比如上线下线通知ONLINE用于前端主动查询在线人数。把to字段预留出来意味着从群聊扩展单聊时不需要改协议只需要在转发逻辑里加一个目标查找。关键设计点from字段不要在每条消息里由客户端随便填应该在登录时绑定到会话属性服务端转发前用会话里存的昵称覆盖from防止伪造。timestamp用客户端时间还是服务端时间我建议统一用服务端时间戳否则不同设备的时钟偏差会导致消息排序乱掉。这块在轻量版里可以用System.currentTimeMillis()在服务端生成。onMessage 里的分发逻辑OnMessage public void onMessage(String raw, Session session) throws IOException { ChatMessageDto msg JsonUtils.parse(raw, ChatMessageDto.class); if (msg null) { return; } if (HEARTBEAT.equals(msg.getType())) { session.getUserProperties().put(lastHeartbeat, System.currentTimeMillis()); return; } if (CHAT.equals(msg.getType())) { msg.setTimestamp(System.currentTimeMillis()); sessionManager.broadcast(msg.getRoom(), JsonUtils.toJson(msg)); } }逻辑说明HEARTBEAT分支只更新lastHeartbeat属性不做任何广播这是心跳消息不污染聊天记录的关键。CHAT分支在服务端补时间戳然后按房间广播。JsonUtils是自行封装的对象和 JSON 互转工具轻量项目直接用 Jackson 的ObjectMapper就行不用额外引库SpringBoot 已经内置了。3.3 心跳机制为什么必须自己实现间隔参数怎么定WebSocket 规范里有 Ping/Pong 帧但 SpringBoot 内嵌 Tomcat 默认不会自动回 Ping也不负责检测客户端死活。更现实的场景是浏览器直接关掉、用户断网、企业的路由器在 5 分钟无流量后切断空闲 TCP 连接——这些情况下服务端感知不到异常连接会一直躺在 Map 里直到操作系统在下次写数据时才发现错误。所以“客户端定期发心跳、服务端记录时间、超时即清理”是必须自己实现的机制这不是可选优化。客户端心跳最简单的方法是用浏览器定时器let heartbeatTimer setInterval(() { if (ws.readyState WebSocket.OPEN) { ws.send(JSON.stringify({ type: HEARTBEAT, room: currentRoom })); } }, 30000);服务端心跳超时检测用 SpringBoot 自带的Scheduled定时扫描package com.example.chatroom.websocket; import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import javax.websocket.CloseReason; import javax.websocket.Session; import java.io.IOException; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; Component EnableScheduling public class HeartbeatMonitor { private final MapString, Session sessions new ConcurrentHashMap(); public void register(Session session) { sessions.put(session.getId(), session); } public void unregister(Session session) { sessions.remove(session.getId()); } Scheduled(fixedRate 30000) public void scan() { long now System.currentTimeMillis(); sessions.forEach((id, session) - { Object last session.getUserProperties().get(lastHeartbeat); if (last ! null now - (Long) last 70000) { try { session.close(new CloseReason( CloseReason.CloseCodes.GOING_AWAY, 心跳超时)); } catch (IOException e) { // 连接已断忽略 } } }); } }逻辑说明Scheduled(fixedRate 30000)表示每 30 秒扫描一次所有连接lastHeartbeat由 3.2 的onMessage里的HEARTBEAT分支更新阈值 70 秒意味着客户端在 70 秒内没有发过任何心跳就直接关闭连接。这里CloseReason用GOING_AWAY前端收到后可以提示“连接超时请重新连接”。参数说明心跳间隔和超时阈值是一组要配套的参数。我一般设客户端心跳间隔 25 到 30 秒服务端超时阈值 心跳间隔 × 2 10 秒也就是 70 秒左右。太短会导致客户端轻微卡顿比如笔记本休眠唤醒就被误杀太长会让已死连接在 Map 里多存活几分钟。如果前面还挂了 Nginxproxy_read_timeout要大于心跳间隔否则 Nginx 会比服务端更早掐断连接这个在第 5 章会再次提到。3.4 单发、群发与广播WebSocketSession 的 sendMessage 边界聊天的三种消息模式对应三种发送范围。广播是遍历所有会话群发是遍历同一房间的会话单发是找到指定 sessionId 发一条。第 3.1 的SessionManager.broadcast实现了广播单发则依赖to字段public void sendTo(String sessionId, String message) { Session target sessions.get(sessionId); if (target ! null target.isOpen()) { try { target.getBasicRemote().sendText(message); } catch (IOException e) { sessions.remove(sessionId); } } }这里要强调一个容易踩的边界Session的sendText并不是线程安全的。多个线程同时往同一个 session 写消息时会出现“写了一半又插入另一条消息”的交错抛IllegalStateException。轻量聊天室如果广播只发生在某个业务线程里问题不突出但只要上线了多个入口比如一个入口是聊天、一个是系统通知、一个是心跳清理线程冲突概率就上来了。解决思路有两个给每个 session 挂一把写锁或者改用getAsyncRemote().sendText()。异步发送不阻塞当前线程但回调里要处理发送失败逻辑复杂度略高。我一般维护一个ConcurrentHashMapString, Object作为 writeLock发送前synchronized(lock)包住sendText代码直白且可控。4. 让工程能维护SpringBoot 项目结构、配置参数与部署方式4.1 一套清晰的 SpringBoot 项目结构源代码管理从目录开始“源代码”这三个字落到工程上就是目录结构。轻量级不代表可以乱放恰恰因为类少、文件少坏结构更容易被复制。我建议的最小分层如下chatroom/ ├── pom.xml └── src/main/ ├── java/com/example/chatroom/ │ ├── ChatroomApplication.java # 启动类 │ ├── config/ │ │ └── WebSocketConfig.java # ServerEndpointExporter 注册 │ ├── websocket/ │ │ ├── ChatEndpoint.java # 端点只管生命周期和消息分发 │ │ ├── SessionManager.java # 会话增删查 │ │ └── HeartbeatMonitor.java # 心跳扫描 │ ├── dto/ │ │ └── ChatMessageDto.java # 消息协议 │ ├── service/ │ │ └── ChatLogService.java # 消息持久化可推迟做 │ └── config/ 如有需要再放 WebMvc 配置 └── resources/ ├── application.yml └── static/ ├── index.html └── chat.js说明controller 层在聊天室项目里基本不需要——消息入口是 WebSocket 端点不是 HTTP 接口。如果后面要加“历史消息查询”再补一个HistoryController也不冲突。把config和websocket分开是为了让人一眼看出“哪些类是连接相关、哪些是配置相关”。这个结构在只有十几个类的规模下可能显得多余但一旦需要加鉴权、加消息持久化、加单聊每个新类都有明确归属不会乱成一锅粥。4.2 三个必调参数发送超时、消息缓冲区、线程池大小轻量聊天室能跑起来只需要默认配置但能稳定跑就得调参数。我最常动的是三个地方也基本是聊天室必调的参数默认值建议值作用server.tomcat.threads.max200按在线人数调300 到 500 足够容器处理 HTTP 和 WebSocket 请求的工作线程数server.tomcat.websocket.max-text-message-size8192 字节16384 或按需调大单条文本消息最大字节数超限直接发送失败session.setMaxIdleTimeout容器默认约 60 秒看心跳设计建议 0不限制连接空闲超时时间影响长连接存活对应的 application.ymlserver: port: 8080 tomcat: threads: max: 400 websocket: max-text-message-size: 16384逻辑说明threads.max不是越大越好线程切换本身有成本几百人在线的聊天室 400 足够。max-text-message-size直接决定前端能不能发稍长一点的内容默认 8192 字节8KB对纯文本聊天够用但一旦消息里夹带 base64 图片或长文本就必然触发缓冲区溢出表现为“消息发出去但别人收不到”。maxIdleTimeout是 WebSocket 容器层面的空闲超时它的“空闲”指没有数据交换会被心跳消息重置如果设成 60 秒默认值即使心跳没断容器也会在 60 秒后主动关闭——这个参数最容易被忽略。额外注意server.tomcat.websocket.*这套配置属性在不同 SpringBoot 版本里映射并不完全一致这里是出了名的玄学。升级 SpringBoot 后如果发现消息大小限制不生效先查版本对应的属性名是否变更。最稳妥的做法是在启动时打印一下容器参数或者在调试阶段故意发一条大消息验证。4.3 内嵌 Tomcat 还是外置部署轻量聊天室怎么选标题里写了“轻量级”默认答案就是内嵌 Tomcat打成 jar 包java -jar chatroom.jar直接跑不需要装独立 Tomcat不需要配 war 部署运维成本最低。这也是 SpringBoot 项目最舒服的形态。但有个场景必须切换思路如果聊天室要挂到已有 Nginx 后面通过域名或子路径提供服务WebSocket 连接就不是浏览器直接连 8080而是先到 Nginx 再转发到后端。这时 Nginx 的配置里必须带上协议升级头location /chat/ { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_read_timeout 600s; proxy_send_timeout 600s; }说明Connection: upgrade是 WebSocket 握手能通过代理的关键丢了这个头浏览器握手会直接失败。proxy_read_timeout如果还是默认的 60 秒连接空闲一分钟后被 Nginx 掐断这就是很多人“本地好好的一上服务器就断线”的原因。600s的取值要和心跳间隔匹配心跳 30 秒一发Nginx 超时 600 秒完全不会触发同时还给极端网络状况留了缓冲。内嵌 Tomcat 用得最多的还是 jar 包方式。如果你所在团队必须走独立 Tomcat 部署有些老运维体系改不动要在 pom 把打包方式改成 war并且ServerEndpointExporter的注册要改成条件装配——因为外置 Tomcat 自己管理 WebSocket 端点再注册一次会冲突。轻量聊天室我个人不建议主动选 war 路线除非被现有运维流程卡死。5. 避坑笔记SpringBoot 版本太高导致的字符包问题以及 WebSocket 的五个经典故障5.1 换 SpringBoot 版本后javax.websocket全家包名报红现象代码从 SpringBoot 2.x 工程拷到 3.x 工程后import javax.websocket.*集体红色编译直接失败。原因SpringBoot 3.x 把底层的 Jakarta EE 包全部升级WebSocket API 从javax.websocket迁到jakarta.websocket。包名换了旧代码自然编译不过去。这是“springboot版本太高”最常见的翻车点不是代码逻辑问题是命名空间迁移。解决SpringBoot 2.x 用javax.websocket.*SpringBoot 3.x 用jakarta.websocket.*其余代码不用动。升级的时候用 IDE 全局替换 import 语句替换完跑一遍握手流程确认。如果项目里混用了同事封装的旧 jar排查时优先看依赖树里有没有同时出现javax.websocket-api和jakarta.websocket-api两个都存在时容器会加载混乱报错也特别难查。5.2 页面挂起、消息发不出Nginx 把空闲连接掐了现象本地直连 8080 一切正常走 Nginx 后过一分钟左右页面无反应发消息没人收到过一会自己又恢复。原因Nginx 的proxy_read_timeout默认 60 秒WebSocket 连接在这段时间内没有任何数据帧就会被视为空闲被主动断开。浏览器端没监听onclose或者监听了但没做重连看起来就是“页面挂死”。解决客户端做心跳每 25 到 30 秒发一条HEARTBEAT消息Nginx 配置里proxy_read_timeout 600s。这两件事要一起做只做心跳而 Nginx 超时时间太短连接照样被掐只调 Nginx 而客户端不发心跳应用层依然无法感知死连接。排查时看服务端日志里有没有周期性的IOException有就基本锁定了这个原因。5.3 群发消息偶发IllegalStateException: TEXT_FULL_WRITING现象并发稍高时广播逻辑里抛IllegalStateException提示The remote endpoint was in state [TEXT_FULL_WRITING]消息丢失且服务端日志堆栈一大片。原因WebSocketSession不是线程安全的多个线程同时向同一个 session 写入时会互相打断。getBasicRemote().sendText()是同步写写的过程中另一个线程也往里写底层状态机就乱了。解决给每个 session 配一把写锁在发送前加锁。会话管理器里加一个ConcurrentHashMapString, Object锁对象按 sessionId 分布避免全局锁把广播拖慢。另一个方案是用getAsyncRemote().sendText()但要注意异步失败没有异常抛出要自己注册回调处理轻量场景直接用同步加锁更省心。5.4 关掉浏览器页面服务端 session 没有消失连接越攒越多现象在线人数明明不多服务端SESSIONS.size()却持续增长最后连接数打满。原因用户直接关浏览器、断网或电脑休眠时TCP 连接没有发送 FIN 包服务端的onClose不会立刻触发。只靠onClose清理会话死连接就会一直躺在 Map 里。解决必须靠心跳超时兜底。客户端定期发心跳服务端定期扫描lastHeartbeat超时主动session.close()并在finally里移除会话。这里有个细节onError也不一定每次都能触发所以心跳扫描是唯一可靠的清理通道。我习惯把扫描和清理写在同一个方法里超时的分支里直接close然后在onClose里做幂等移除重复移除同一个 id 不会报错。5.5 单条消息太大直接失败缓冲区溢出没报业务错误现象在聊天框里粘贴一段很长的文本或一张 base64 编码的图片点了发送自己这边显示发出去了其他人一直没收到。原因WebSocket 容器的maxTextMessageSize默认只有 8KB超出的消息帧不会被onMessage接收而是直接触发错误回调。服务端日志有异常但发送方在浏览器里完全看不到任何报错。解决按实际需求调大server.tomcat.websocket.max-text-message-size或者走“文件上传后发 URL”的路线聊天内容只传短链接。我是建议后者聊天室不适合塞大对象。如果业务确实要发图片就做独立的文件上传接口聊天消息只带 URL 和缩略图信息缓冲区设置维持 16KB 以内就够了。6. 收尾从“能跑”推到“能上线”的三个进阶动作先把验证方法说清楚这是理解这套方案能不能用的前提。后端启动后用 wscat 这类 WebSocket 调试工具直连wscat -c ws://localhost:8080/chat/room1然后手动发送 JSON 消息观察服务端广播行为。这个工具能在没有前端页面的情况下完整验证心跳、广播和连接关闭逻辑比盲目写页面再排错快得多。进阶动作第一条把“在线状态变化”纳入消息协议。现在聊天室能广播消息但“张三上线了”“李四退出了”没人通知。在OnOpen处理完会话注册后向当前房间广播一条SYSTEM消息引用session.getUserProperties()里存的昵称OnClose里也发一条前端收到后刷新在线列表。这样用户感知是实时的而不是刷新页面才发现人变了。进阶动作第二条前端断线重连加指数退避。重连太频繁会把服务端打爆固定间隔重连又会让人觉得卡。比较稳的做法是记录连续失败次数按 1 秒、2 秒、4 秒、8 秒递增达到 30 秒上限后保持每 30 秒重试一次。重连成功后前端要重新发送一条登录消息重新注册昵称否则服务端会话里缺用户信息消息会变成无主消息。进阶动作第三条聊天记录异步落库。轻量聊天室可以不做但上线给业务用之后回看历史几乎是必然需求。用Async把ChatMessageDto写入一张简单的消息表发送逻辑不等待数据库响应。注意表要带上room字段查询历史时按房间和时间范围倒序查。这个动作做完聊天室就从一个演示工具变成了有留存能力的内部系统。我自己的教训是第一次做内部答疑工具时偷懒没做心跳上线第二天运维就投诉连接数打满、进程占着端口不释放。后来把心跳扫描和超时清理补上连接数稳定在两位数再也没出过问题。做聊天室这类长连接系统精妙的业务逻辑都在其次先把连接生命周期管住系统就稳了一大半。希望帮到你。本文还有配套的精品资源点击获取
返回列表