Spring Boot + WebSocket 即时聊天系统:从握手到断线重连全解析 📅 发布时间:2026/9/12 19:06:04 👁 浏览次数: 简介基于Spring Boot与WebSocket并结合JavaScript实现的即时聊天系统面向需要了解Web实时通信机制的初中级开发者。资源压缩包内含99个文件总大小10.7MB其中包含25个Java源文件、13个JS文件、9个CSS文件及4个HTML页面并配有pom.xml、mvnw等Maven构建配置和README说明文档目录划分清晰便于定位服务端与前端代码。项目采用Spring Boot搭建后端通过WebSocket实现消息推送前端利用原生JavaScript与WebSocket API完成实时收发同时包含若干图片、PSD设计稿等辅助素材。已有109人学习该资源适合作为课程设计或个人练习WebSocket通信时的参考案例。读者可从中获取一套可运行的服务端与客户端代码结构理解Spring Boot集成WebSocket的配置方式、消息映射与前端连接逻辑并在此基础上扩展多人会话或消息持久化功能。1. WebSocket 即时聊天Springboot 和 JS 分别解决什么问题把一个「基于Springboot websocket js实现的即时聊天系统.zip」拆开看真正值钱的部分不在 WebSocket 本身而在连接建立之后那套状态管理。WebSocket 只是一个长连接通道HTTP 协议下那种「请求-响应」模型在这里变成了双工帧服务端可以主动把消息塞给浏览器。但连接建立只是开始用户在线状态怎么维护、消息怎么路由、断线之后前端怎么恢复才是这个项目里最容易写乱的三块。这个标题对应的技术栈很典型Spring Boot 负责端点注册、握手拦截和 session 管理JS 负责连接生命周期和消息渲染。适合想把即时通讯的完整闭环跑通的人也适合准备面试时被问到「WebSocket 和 HTTP 的区别到底在工程上意味着什么」的人。接下来按一条可复现的路径把整个系统搭出来。2. Springboot 端搭建 websocket 服务依赖、端点和握手拦截2.1 引入依赖spring-boot-starter-websocket 与内嵌容器Spring Boot 集成 WebSocket 不需要额外安装独立中间件内嵌的 Tomcat 或 Jetty 从 Servlet 3.1 起就原生支持 WebSocket。引入一个 starter 即可dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-websocket/artifactId /dependency这个 starter 会把spring-websocket和spring-messaging拉进来同时自动配置WebSocketHandlerRegistry相关的组件。项目中如果同时存在spring-boot-starter-web两者可以共存HTTP 接口负责登录、历史消息查询WebSocket 只管实时帧。需要注意Spring Boot 2.x 与 3.x 在这块的依赖坐标没有变化但 3.x 底层换成 Jakarta 命名空间网上大量 2.x 时代的代码片段在 3.x 下会遇到编译级别的报错后面第 6 章专门讲这个兼容问题。2.2 注册端点和 allowedOriginPatterns 参数用 Spring 官方推荐的WebSocketHandler方式注册端点先写一个配置类Configuration public class WebSocketConfig implements WebSocketConfigurer { Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(new ChatWebSocketHandler(), /ws/chat) .addInterceptors(new AuthHandshakeInterceptor()) .setAllowedOriginPatterns(*); } }addHandler第一个参数是消息处理器实例第二个参数是客户端连接的 URL 路径。addInterceptors注册握手拦截器在这里做 token 校验和用户身份绑定。setAllowedOriginPatterns控制哪些页面来源允许发起 WebSocket 握手这个参数在前后端分离场景下经常踩坑。setAllowedOriginPatterns(*)表示允许所有来源生产环境建议收紧为前端具体域名。与setAllowedOrigins的区别在于setAllowedOrigins不支持通配符与携带凭证的请求同时使用而setAllowedOriginPatterns内部基于OriginHandshakeInterceptor实现匹配逻辑更灵活。曾有线上事故是前端从http://localhost:5173访问后端只配了http://localhost:8080握手直接被拒onclose回调先于onopen触发现象非常迷惑。参数作用开发环境建议setAllowedOriginPatterns(*)放行任意 Origin 来源方便本地调试setAllowedOriginPatterns(https://chat.example.com)只放行指定域名生产环境必配setAllowedOrigins精确来源列表不支持通配符携带凭证少用容易误伤2.3 握手拦截器做 token 校验握手拦截器是在 HTTP 升级为 WebSocket 之前执行的此时可以拿到 query 参数、Header 和 Cookie。常见做法是前端连接时带上token后端在beforeHandshake里校验并把用户 ID 塞进 attributespublic class AuthHandshakeInterceptor implements HandshakeInterceptor { Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, MapString, Object attributes) { String query request.getURI().getQuery(); String token parseQueryParam(query, token); String userId authService.validateToken(token); // 校验失败返回 null if (userId null) { response.setStatusCode(HttpStatus.UNAUTHORIZED); return false; } attributes.put(userId, userId); return true; } private String parseQueryParam(String query, String key) { if (query null || query.isEmpty()) { return ; } return Arrays.stream(query.split()) .map(pair - pair.split()) .filter(kv - kv.length 2 key.equals(kv[0])) .map(kv - kv[1]) .findFirst() .orElse(); } Override public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) { // 握手完成后如果需要记录日志可以在这里做 } }拦截器方法返回false意味着拒绝握手客户端会收到非 101 状态码的响应相应触发onerror而不是onclose。attributes 里的数据在afterConnectionEstablished中通过session.getAttributes()取出来这样就完成了匿名 TCP 连接到业务用户身份的映射。实际开发中不要学示例里那样裸传 token至少要用HttpHeaders里的Authorization头避免 token 出现在 Nginx access log 的 query 里。2.4 消息处理器TextWebSocketHandler 的职责边界消息处理器继承TextWebSocketHandler只需要关注三个时机连接建立、收到文本消息、连接关闭public class ChatWebSocketHandler extends TextWebSocketHandler { private static final MapString, WebSocketSession SESSIONS new ConcurrentHashMap(); Override public void afterConnectionEstablished(WebSocketSession session) { String userId (String) session.getAttributes().get(userId); WebSocketSession old SESSIONS.put(userId, session); if (old ! null) { try { old.close(CloseStatus.NORMAL); } catch (IOException e) { log.warn(close old session failed, e); } } } Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { String payload message.getPayload(); // payload 是 JSON 字符串解析后按 type 分发路由逻辑见第 4 章 routeMessage(payload, session); } Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { SESSIONS.values().remove(session); } }TextWebSocketHandler只处理文本帧BinaryWebSocketHandler处理二进制帧聊天场景用文本足够。SESSIONS用ConcurrentHashMap保证线程安全WebSocket 的回调方法工作在不同的 Tomcat 线程上用普通HashMap会出现ConcurrentModificationException。afterConnectionClosed里SESSIONS.values().remove(session)是 O(n) 操作用户量级上万以后要考虑反向索引。2.5 WebSocketHandler 与 ServerEndpoint 的取舍另一个常见写法是ServerEndpoint(/ws/chat)加OnOpen、OnMessage注解这套是 Java WebSocket 标准 JSR-356 的 API。两种方案并存新手经常在知乎和博客里看到两套代码不知道选哪个。建议选WebSocketHandler原因是它由 Spring 容器管理可以直接注入UserService、MessageService等 Bean。ServerEndpoint每个连接都是独立实例由 WebSocket 容器创建不经过 Spring 的依赖注入流程想用 Service 得写静态方法或自定义SpringContextHolder绕一圈。对于即时聊天这种业务逻辑复杂、需要查库写库的场景WebSocketHandler的侵入性更小。提示面试时被问到「两种方式区别」时核心答端点由谁管理、依赖注入如何解决不要停留在注解 vs 继承这种表面差异。3. JS 客户端接入 websocket连接参数、心跳与断线重连3.1 最小接入代码new WebSocket 与四个回调浏览器原生支持 WebSocket不需要引入 socket.io 之类的库。最小可用代码把四个生命周期回调写全const token document.getElementById(token).value; const ws new WebSocket(ws://${location.host}/ws/chat?token${token}); ws.onopen function () { console.log(connection established); ws.send(JSON.stringify({ type: chat, to: user123, content: hello })); }; ws.onmessage function (event) { const msg JSON.parse(event.data); renderMessage(msg); }; ws.onerror function (event) { // 不会接收到具体的 HTTP 状态码只能从 event 里拿通用错误 console.error(websocket error, event); }; ws.onclose function (event) { console.log(closed code${event.code} reason${event.reason}); };URL 用location.host拼出来避免把 IP 和端口写死在 JS 里。HTTPS 页面必须用wss://否则浏览器直接拒绝混合内容。onclose里的event.code是关闭码1000表示正常关闭1006表示异常断开这个状态码是排查连接稳定性的关键线索。收消息后先JSON.parse再交给渲染层不要把原始字符串直接插入 DOM否则消息内容里的 HTML 会变成 XSS 注入点。3.2 readyState 状态机与 binaryType 参数WebSocket 实例有一个readyState属性常见误用是在onclose之外的地方判断连接是否可用然后拿到一个已经 CLOSED 的实例去send结果抛InvalidStateError。readyState 值常量含义0CONNECTING正在握手1OPEN可以收发帧2CLOSING正在发送关闭帧3CLOSED连接已关闭send 会抛异常发送前统一判断function safeSend(ws, payload) { if (ws.readyState WebSocket.OPEN) { ws.send(payload); } else { console.warn(socket not open, current state:, ws.readyState); } }binaryType参数影响二进制帧的接收格式默认是blob如果需要接收 ArrayBuffer 要显式设置。聊天系统基本不涉及二进制帧保持默认即可。真正值得关注的是bufferedAmount大消息场景下用它判断数据是否已经真正进入网络栈避免积压。3.3 前端心跳区分「空闲」与「假死」WebSocket 底层有 TCP 的 keepalive 机制但默认探测周期很长且经过代理后 TCP 状态可能不一致。实际开发中一个晚上挂机的浏览器标签页第二天打开时连接往往已经死了但onclose迟迟不触发。解决办法是应用层心跳前端定期发 ping服务端回 pong。let heartbeatTimer null; function startHeartbeat(ws) { stopHeartbeat(); heartbeatTimer setInterval(() { if (ws.readyState WebSocket.OPEN) { ws.send(JSON.stringify({ type: ping, ts: Date.now() })); } }, 30000); } function stopHeartbeat() { if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer null; } }心跳间隔设为 30 秒onmessage里收到pong时更新lastActiveTime。如果连续两个心跳周期没收到任何消息主动调用ws.close()并触发重连逻辑而不是干等对端把连接断开。Nginx 对空闲连接默认proxy_read_timeout 60s会把 60 秒内没有数据的连接掐断心跳间隔必须小于这个值通常取 25 到 30 秒。3.4 断线重连与指数退避连接断开后立刻重连是错误做法服务端 gc 或网络抖动时会导致大量客户端同时发起握手造成连接风暴。指数退避是标准解法let retryCount 0; function connect() { const ws new WebSocket(ws://${location.host}/ws/chat?token${token}); ws.onopen function () { retryCount 0; startHeartbeat(ws); }; ws.onclose function (event) { stopHeartbeat(); if (event.code ! 1000) { const delay Math.min(1000 * Math.pow(2, retryCount), 30000); retryCount; setTimeout(connect, delay); } }; ws.onmessage function (event) { const msg JSON.parse(event.data); if (msg.type pong) { return; } renderMessage(msg); }; }每次onopen成功重置retryCount失败后按 1s、2s、4s……退避封顶 30 秒。code 1000意味着服务端主动正常关闭比如用户被顶下线此时不重连。code 1006是异常断开必须重连。Math.random()可以再叠加一个 0 到 1000ms 的随机抖动防止大量客户端同时重连。4. 即时聊天消息协议会话管理、单聊与群聊广播4.1 消息 JSON 协议type、from、to 与 mid 字段长连接通道建立后消息承载格式需要设计。常见做法是统一走 JSON最好在前后端约定一个稳定的 schema避免每人一套字段命名。实际项目里沉淀下来的一套最小字段如下字段类型说明typestringping/pong/chat/group/ackmidstring客户端生成的消息唯一 ID用于去重和确认fromstring发送方用户 IDtostring接收方用户 ID 或群组 IDcontentstring文本消息内容tsnumber客户端毫秒时间戳mid是最容易被忽略的字段。前端每次发送前用Date.now() - Math.random()生成一个 ID服务端处理完回一个ack帧前端收到ack后把消息状态从「发送中」改为「已送达」。没有mid就无法做消息确认和重复消息去重网络抖动时的重发机制就无从谈起。public class ChatMessage { private String type; private String mid; private String from; private String to; private String content; private long ts; // getter / setter 省略 }4.2 在线状态管理双 Map 结构与 ConcurrentHashMap服务端维护在线 session 的方式直接决定消息路由的正确性。最简单的方案是用一个全局 Map 把 userId 映射到 WebSocketSessionprivate static final MapString, WebSocketSession ONLINE_USER_MAP new ConcurrentHashMap();用户上线时put下线时remove。但只有这一个 Map 不够场景是同一用户多端登录手机和电脑同时在线put会把旧 session 顶掉微信那种「手机电脑同时收消息」的需求无法实现。更完整的结构是「userId - session 集合」private static final MapString, CopyOnWriteArrayListWebSocketSession ONLINE_USER_MAP new ConcurrentHashMap(); public void addSession(String userId, WebSocketSession session) { ONLINE_USER_MAP.computeIfAbsent(userId, k - new CopyOnWriteArrayList()).add(session); } public void removeSession(String userId, WebSocketSession session) { ONLINE_USER_MAP.computeIfPresent(userId, (k, list) - { list.remove(session); return list.isEmpty() ? null : list; }); }CopyOnWriteArrayList保证遍历时并发修改不抛异常聊天场景下单个用户的 session 数量很小写时复制的开销可以接受。遍历时逐个判断session.isOpen()防止往已关闭的连接上发消息导致IOException。4.3 单聊消息路由代码收到一条typechat的消息后路由逻辑分三步查在线表、转发、确认。转发层不落库只负责把消息送到目标用户的 session落库由独立的消息服务处理public void routeChat(ChatMessage msg, WebSocketSession fromSession) throws Exception { ListWebSocketSession targets ONLINE_USER_MAP.get(msg.getTo()); if (targets null || targets.isEmpty()) { // 目标不在线进入离线消息逻辑 offlineService.save(msg); return; } boolean delivered false; for (WebSocketSession target : targets) { if (target.isOpen()) { target.sendMessage(new TextMessage(objectMapper.writeValueAsString(msg))); delivered true; } } if (delivered) { fromSession.sendMessage(new TextMessage(objectMapper.writeValueAsString( Map.of(type, ack, mid, msg.getMid()) ))); } }注意sendMessage是阻塞操作目标端网络慢会拖慢当前线程。生产环境建议为每个 session 配置独立的发送队列或用ConcurrentWebSocketSessionDecorator设置发送缓冲上限防止一个慢客户端拖垮整个 Tomcat 线程池。单机版把在线表放在内存即可多实例部署时ONLINE_USER_MAP是节点本地的消息会路由到错误节点需要引入 Redis 发布订阅做跨节点转发。这也是从 demo 到真正能上线的分水岭。4.4 群聊广播与离线消息的兜底群聊广播本质上是对群成员列表做一次路由循环public void routeGroup(ChatMessage msg) { ListString memberIds groupService.getMemberIds(msg.getTo()); for (String memberId : memberIds) { ListWebSocketSession sessions ONLINE_USER_MAP.get(memberId); if (sessions null || sessions.isEmpty()) { offlineService.save(msg); // 离线成员进入离线队列 continue; } for (WebSocketSession session : sessions) { if (session.isOpen()) { session.sendMessage(new TextMessage(objectMapper.writeValueAsString(msg))); } } } }群聊场景要排除发送者本人否则前端会收到自己发出的消息再渲染一次产生重复气泡。离线消息的兜底方案先落库用户下次上线时拉取未读消息即可。用内存ConcurrentLinkedQueue做离线队列只适合解决眼前问题服务重启内存消息就丢了。生产环境常见的做法是把离线消息落到 Redis List 或者直接进消息表上线时按WHERE receiver_id ? AND read_status 0查询补发通过mid做幂等去重。5. websocket 鉴权、可靠性保障与常见排错5.1 握手与 token 校验解析 URL 参数或 Header握手阶段的 token 校验是第一步防线但只有握手校验远远不够。WebSocket 每次连接只有一次 HTTP 握手之后所有帧都不走 HTTP 头token 不存在「过期后主动失效」的可能。服务端需要额外做两件事握手时把 token 的过期时间写入 session attributes服务端定时任务扫描并关闭过期连接同时设置PreDestroy钩子应用停机时优雅关闭所有 session。用 Header 传 token 比 query 参数安全但浏览器原生 WebSocket API 无法自定义 Header需要在 URL 上携带或者用protocols参数传。一个务实的折中方案是WebSocket URL 只传一个短期有效的 ticket比如有效期 60 秒的一次性凭证连接建立后服务端返回正式 token之后的业务消息都带这个 token 做逐条校验。虽然逐条校验开销较大但能有效防止连接被中间人劫持后直接收发消息。5.2 消息确认与重发机制即时聊天大多不需要 100% 可靠投递但消息丢失对用户感知非常糟糕。HTTP 层天然有 responseWebSocket 没有所以要在应用层做确认回执。服务端收到消息后立即回ack客户端收到ack后从发送中列表移除该消息超时未收到ack则自动重发带相同的mid服务端用ConcurrentHashMap缓存最近收到的mid做去重private static final CacheString, Boolean MID_CACHE Caffeine.newBuilder() .maximumSize(100_000) .expireAfterWrite(Duration.ofMinutes(5)) .build(); public boolean isDuplicate(String mid) { return MID_CACHE.getIfPresent(mid) ! null; }Caffeine是本地缓存库这里用它做滑动窗口去重避免同一个mid被处理两次造成消息重复入库。5 分钟窗口大于重试退避上限足以覆盖丢包场景。这里不需要引入消息队列WebSocket 场景下客户端重发 服务端去重是最实用的组合。5.3 onclose code 1006、H5 能连但 App 连不上等高频故障表聊天系统上线后线上反馈最多的几个问题高度集中逐一列在下面现象大概率原因排查方向onclose触发code: 1006, reason: 对端没有正常关闭帧可能是服务端异常退出、Nginx 掐断、网络中断查服务端日志有没有异常堆栈lsof -i :8080看连接数Nginx error log 有没有 timeoutH5 浏览器能连打包成 App 后连不上Origin 校验冲突或证书问题确认setAllowedOriginPatterns是否放行file://或 webview 的 Originwss 证书是否匹配 App 内配置的域名空闲一段时间后自动断开应用层心跳缺失或心跳间隔大于代理超时前端每 25-30 秒发 ping服务端收到 ping 回 pongfailed to send websocket request: io发送途中连接被强制断开客户端依然尝试写入打印客户端堆栈发送前检查readyState服务端关闭 session 前主动发送关闭帧页面刷新后收不到消息旧 session 未清理消息发到死连接afterConnectionClosed里必须从 Map 移除 session并在关闭前关闭旧连接1006 是排查成本最高的错误码因为它没有 reason。本质是 TCP 层被 RST服务端看在Tomcat的 access log 里只会看到非 101 响应或空记录需要把WebSocketSession.isOpen()的检查提前到每次 send 之前同时给 session 增加心跳检测连续 N 个周期没有收到任何帧就主动close(CloseStatus.POLICY_VIOLATION)让客户端收到一个明确的关闭码而不是一直悬着。5.4 用 Python 脚本验证长连接稳定性浏览器开发者工具适合人工验证自动化压测或稳定性验证建议用 Python 脚本。假设环境中已执行pip install websocket-client一段能持续并发保活并统计断线次数的脚本如下import json import threading import time import websocket class ChatClient: def __init__(self, name, url): self.name name self.url url self.ws None self.connected False self.reconnect_count 0 def on_message(self, ws, message): # 收到 pong 不需要额外处理连接活着就够了 pass def on_open(self, ws): self.connected True ws.send(json.dumps({type: ping, ts: int(time.time() * 1000)})) def on_close(self, ws, code, reason): self.connected False self.reconnect_count 1 print(f{self.name} closed: {code} {reason}) def on_error(self, ws, error): self.connected False print(f{self.name} error: {error}) def run(self): self.ws websocket.WebSocketApp( self.url, on_messageself.on_message, on_openself.on_open, on_closeself.on_close, on_errorself.on_error, ) self.ws.run_forever(ping_interval25, ping_timeout10) def start(self): t threading.Thread(targetself.run, daemonTrue) t.start() if __name__ __main__: url ws://localhost:8080/ws/chat?tokendemo clients [ChatClient(fclient-{i}, url) for i in range(10)] for c in clients: c.start() time.sleep(300) # 跑 5 分钟观察输出里是否有异常关闭 print(done)ping_interval25和ping_timeout10是WebSocketApp自带的心跳参数会每 25 秒发 ping 并按 10 秒超时判定连接健康度。脚本输出里如果出现code: 1006或频繁的error说明服务端或网络链路有问题结合服务端日志的时间点比对基本能定位到是 Nginx 超时还是应用代码抛异常导致连接被回收。6. 落到生产前的一步session 管理、版本兼容与验收技巧6.1 用 userId 维度管理 session 的实践细节第 4 章的双 Map 结构解决了多端在线的问题但还有一个容易被忽略的细节用户踢下线的场景。同一账号在另一台设备登录后旧设备经常收到「账号在其他设备登录」的提示实现方式是握手成功后把同 userId 的旧 session 全部关闭让旧设备触发onclose前端拿到code 4000后跳转登录页public void kickoutOldSessions(String userId, WebSocketSession currentSession) { ListWebSocketSession sessions ONLINE_USER_MAP.get(userId); if (sessions null) { return; } for (WebSocketSession session : sessions) { if (!session.getId().equals(currentSession.getId())) { session.close(new CloseStatus(4000, kickout)); } } }关闭旧 session 必须用自定义关闭码40001000会被前端当作正常下线而执行重连逻辑踢下线就失去了意义。前端onclose里只对1000不重连其他关闭码统一走重连或提示流程。另一个细节是心跳超时判定放在服务端而不是前端服务端维护lastReceiveTime每 30 秒扫描一次超过 60 秒没收到任何帧的连接主动关闭避免死连接持续占着内存和文件描述符。6.2 Spring Boot 3.x 的 Jakarta 命名空间与代码兼容Spring Boot 2.x 项目升级到 3.x 后WebSocket 相关代码最容易踩的坑是javax.websocket变成jakarta.websocket。spring-boot-starter-websocket依赖里实际用的是org.springframework.web.socket包这个包名在两个大版本间没有变化受影响的只有依赖了 JSR-356 标准 APIjavax.websocket.*的代码。如果项目里的 WebSocket 实现是ServerEndpoint注解风格升级时会看到一堆Package javax.websocket does not exist的编译错误需要批量把javax换成jakarta。用WebSocketHandler方式实现的项目不受影响。另外 Spring Boot 3.x 要求 JDK 17项目里用到的javax.validation相关依赖也要一并调整到jakarta.validation。6.3 浏览器 DevTools 的 Frame 面板验证收发联调阶段最实用的验证手段在浏览器开发者工具里。打开 Network 面板筛选 WS点击连接记录后切到 Messages 页签可以看到每一帧的发送方向和数据内容支持过滤二进制帧与文本帧。验证顺序建议是先确认握手请求的响应状态码是 101再逐步触发登录、发消息、多端登录、断网重连四个场景每次onmessage触发后对照帧数据确认服务端返回的 JSON 字段与前端解析逻辑一致。断网模拟可以直接在 Console 执行window.dispatchEvent(new Event(offline))观察心跳是否在恢复后正确重启。这一步过滤掉绝大多数字段名不匹配、大小写不一致、时间戳精度不符等联调问题。所有帧走通之后再回到SESSIONS的清理逻辑上做一轮复查服务端重启、用户退出、token 过期三种场景下Map 里是否残留失效 session。内存中的WebSocketSession对象不会因为客户端关闭而自动消失清理逻辑不写在finally块里早晚会内存泄漏。聊天的技术难点从来不在链路建立而在连接的生命周期管理把这一点抠干净这个项目才算真正落地。本文还有配套的精品资源点击获取