☰
SpringBoot+WebSocket轻量级在线聊天室:从原理到避坑
2026/10/10 7:35:47 网站建设 项目流程

简介:实时通信是互联网应用的高频需求,从轮询到长轮询,再到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> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency>

然后注册 WebSocket 端点。SpringBoot 内嵌 Tomcat 的场景下,缺少这个 Bean,@ServerEndpoint不会被扫描生效,这是新手最容易忽略的一步:

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 Map<String, Map<String, Session>> ROOMS = new ConcurrentHashMap<>(); // 房间 -> 房间名,从 URL 路径参数里解析 private static final Map<String, 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"); Map<String, 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"); Map<String, 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 Map<String, 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 Map<String, 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 里多存活几分钟。如果前面还挂了 Nginx,proxy_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()。异步发送不阻塞当前线程,但回调里要处理发送失败逻辑,复杂度略高。我一般维护一个ConcurrentHashMap<String, 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.yml:

server: 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 配一把写锁,在发送前加锁。会话管理器里加一个ConcurrentHashMap<String, 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字段,查询历史时按房间和时间范围倒序查。这个动作做完,聊天室就从一个演示工具变成了有留存能力的内部系统。

我自己的教训是:第一次做内部答疑工具时偷懒没做心跳,上线第二天运维就投诉连接数打满、进程占着端口不释放。后来把心跳扫描和超时清理补上,连接数稳定在两位数,再也没出过问题。做聊天室这类长连接系统,精妙的业务逻辑都在其次,先把连接生命周期管住,系统就稳了一大半。希望帮到你。

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

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

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

立即咨询