JavaWeb一对一聊天系统:WebSocket会话路由与分布式扩展

发布时间:2026/10/12 6:00:05

JavaWeb一对一聊天系统:WebSocket会话路由与分布式扩展 简介这是一套基于JavaWeb技术栈实现的一对一网页聊天系统面向Java初学者与Web开发入门者帮助其掌握AJAX异步通信、Servlet请求处理、JSP页面交互及MySQL数据持久化等核心技能适用于课程设计、小型项目实训或Web基础能力强化训练。资源包共54个文件包含12个Java源码如TalkServlet、TalkFromServlet、8个JSP页面含login.jsp、chat.jsp等完整会话流程、12个编译后class文件、7个运行依赖jar包含c3p0连接池配置以及web.xml、sql.txt等关键配置与建表脚本整体压缩包仅2.88MB轻量易部署。已有347人学习下载适合快速上手调试与功能扩展。读者可直接导入Tomcat运行完整复现“用户登录→好友列表→发起私聊→实时收发消息”全流程虽UI未美化但逻辑清晰、注释充分尤其AJAX轮询机制与双Servlet分工设计发送/拉取消息分离极具教学参考价值。1. 为什么一个“javaweb一对一网页聊天系统”至今仍是后端新人绕不开的实战分水岭不是因为它多酷炫——没有语音、没有群聊、不连IM云服务、甚至不带消息已读回执而是它像一把解剖刀把 Web 开发里最核心的五层脉络一次性剖开HTTP 请求生命周期怎么被 Servlet 拦住又放行、Session 怎么在无状态协议里硬生生捏出“有状态”的错觉、WebSocket 如何绕过轮询黑匣子实现真双工、Tomcat 容器里线程池和连接器怎么协作扛住并发、还有最关键的——消息如何在两个独立 HTTP 会话之间实时路由而不丢不乱。很多开发者卡在“能跑通但一加人就崩”“刷新页面消息就消失”“发出去对方收不到还报 404”本质不是代码写错了而是没真正理解“一对一”这个约束背后隐藏的会话绑定、连接保活、内存映射三重关卡。如果你正卡在 Spring Boot Thymeleaf 写完登录页却卡在“怎么让 A 发的消息只推给 B”或者用 WebSocket 写了一堆 onMessage 却发现所有客户端都收到同一条消息——这篇就是为你写的。它不讲理论推导只讲我在线上压测时调过的 7 个 Tomcat 参数、手写过 3 版 Session 绑定逻辑、以及为什么ConcurrentHashMapString, Session是唯一能撑住 200 对并发聊天的内存结构。2. 从零搭起最小可运行骨架Servlet WebSocket 原生实现不依赖 Spring提示本节坚持“原生 JavaEE 实现”目的不是复古而是让你看清每一层容器干了什么。Spring Boot 的自动配置会掩盖线程模型、Session 生命周期、WebSocket 握手时机等关键细节新手直接跳进 Spring 容易把“能跑”当成“懂了”。2.1 创建标准 JavaWeb 工程并部署到 Tomcat 9使用 IntelliJ IDEA 新建Java Enterprise → Web Application项目注意勾选Add framework support → Java EE Web目标运行时选Apache Tomcat Server 9.0.83低于 9.0.80 的版本对 WebSocket 的OnOpen异常处理有已知缺陷。项目结构必须包含src/ ├── main/ │ ├── java/ │ │ └── chat/ ← 包名统一为 chat避免路径冲突 │ │ ├── ChatEndpoint.java ← WebSocket 端点主类 │ │ ├── UserSessionManager.java ← 全局会话管理器核心 │ │ └── Message.java ← 消息实体含 senderId, receiverId, content, timestamp │ ├── webapp/ │ │ ├── index.jsp ← 登录页纯 HTML 表单 POST 到 /login │ │ ├── chat.jsp ← 聊天主界面含 JS 连接 WebSocket │ │ └── WEB-INF/web.xml ← 必须存在声明 Servlet 和监听器web.xml中需显式声明HttpSessionListener用于清理离线用户web-app xmlnshttp://xmlns.jcp.org/xml/ns/javaee xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd version4.0 listener listener-classchat.UserSessionManager/listener-class /listener /web-app逻辑说明UserSessionManager同时实现HttpSessionListener和ServletContextListener前者监听用户登录/登出sessionCreated/sessionDestroyed后者在应用启动时初始化全局缓存。这是原生方案里唯一能跨 Servlet 和 WebSocket 共享状态的方式——别试图用static Map它在热部署时会泄漏。2.2 实现UserSessionManager用ConcurrentHashMap构建用户-Session 映射表package chat; import javax.servlet.http.HttpSession; import javax.websocket.Session; import java.util.concurrent.ConcurrentHashMap; public class UserSessionManager implements HttpSessionListener { // key: userId (String), value: WebSocket Session private static final ConcurrentHashMapString, Session USER_SESSIONS new ConcurrentHashMap(); // key: userId, value: HttpSession (用于登出时主动关闭 WebSocket) private static final ConcurrentHashMapString, HttpSession USER_HTTP_SESSIONS new ConcurrentHashMap(); Override public void sessionCreated(HttpSessionEvent se) { // 登录成功后Servlet 会 setAttribute(userId, u1001)此处不处理 } Override public void sessionDestroyed(HttpSessionEvent se) { HttpSession httpSession se.getSession(); String userId (String) httpSession.getAttribute(userId); if (userId ! null) { // 用户登出主动关闭其 WebSocket 连接 Session wsSession USER_SESSIONS.remove(userId); if (wsSession ! null wsSession.isOpen()) { try { wsSession.close(); } catch (Exception ignored) {} } USER_HTTP_SESSIONS.remove(userId); } } public static void addUser(String userId, Session wsSession) { USER_SESSIONS.put(userId, wsSession); } public static Session getSession(String userId) { return USER_SESSIONS.get(userId); } public static void removeUser(String userId) { USER_SESSIONS.remove(userId); } }参数说明与踩坑预警为什么必须用ConcurrentHashMap因为ChatEndpoint的OnOpen/OnClose方法由 Tomcat 的 WebSocket 线程池异步调用而HttpSessionListener由 Servlet 容器主线程调用多线程并发修改普通HashMap会导致ConcurrentModificationException或数据丢失。USER_HTTP_SESSIONS是冗余的吗不是。HttpSession和WebSocket Session生命周期不同步用户关闭浏览器标签页HttpSession可能还在默认 30 分钟超时但WebSocket Session已断开反之用户主动点击“退出”需通过HttpSessionListener主动触发wsSession.close()否则连接残留导致内存泄漏。removeUser()在哪调用只在OnClose方法中调用确保连接真正断开后才清理内存。2.3 编写ChatEndpointWebSocket 端点的核心路由逻辑package chat; import javax.json.Json; import javax.json.JsonObject; import javax.json.JsonReader; import javax.websocket.*; import javax.websocket.server.ServerEndpoint; import java.io.IOException; import java.io.StringReader; import java.time.Instant; ServerEndpoint(value /chat, configurator ChatConfigurator.class) public class ChatEndpoint { OnOpen public void onOpen(Session session, EndpointConfig config) { // 从 HTTP Session 中提取 userId关键WebSocket 握手时携带 Cookie String userId (String) session.getUserProperties().get(userId); if (userId null || userId.trim().isEmpty()) { try { session.close(new CloseReason(CloseReason.CloseCodes.VIOLATED_POLICY, Missing userId)); } catch (IOException ignored) {} return; } UserSessionManager.addUser(userId, session); } OnMessage public void onMessage(String message, Session session) { try (JsonReader reader Json.createReader(new StringReader(message))) { JsonObject json reader.readObject(); String senderId json.getString(senderId); String receiverId json.getString(receiverId); String content json.getString(content); // 核心路由只推给 receiverId 对应的 Session Session receiverSession UserSessionManager.getSession(receiverId); if (receiverSession ! null receiverSession.isOpen()) { JsonObject response Json.createObjectBuilder() .add(type, message) .add(senderId, senderId) .add(content, content) .add(timestamp, Instant.now().toEpochMilli()) .build(); receiverSession.getBasicRemote().sendText(response.toString()); } else { // 接收方不在线可存入离线队列本节暂不实现 System.out.println(Receiver receiverId is offline); } } } OnError public void onError(Session session, Throwable throwable) { System.err.println(WebSocket error for session session.getId()); throwable.printStackTrace(); } OnClose public void onClose(Session session) { // 从全局映射中移除该用户 String userId (String) session.getUserProperties().get(userId); if (userId ! null) { UserSessionManager.removeUser(userId); } } }逻辑说明ServerEndpoint注解的configurator属性指向ChatConfigurator见下文这是获取HttpSession的唯一合法方式。不要尝试在onOpen()里用session.getQueryString()解析参数——现代浏览器禁止 WebSocket URL 携带敏感参数且会被代理服务器截断。onMessage()中的 JSON 解析使用 Java EE 自带的javax.jsonAPI无需额外依赖结构强制要求{ senderId:u1001, receiverId:u1002, content:hello }这是“一对一”路由的契约基础。消息不回传 senderA 发消息给 BB 收到但 A 不会在自己的界面上看到这条消息——这是前端职责聊天界面需自己渲染本地发送记录后端只做“转发”避免重复渲染和时序错乱。2.4 补全ChatConfigurator安全地桥接 HTTP Session 与 WebSocket Sessionpackage chat; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpSession; import javax.websocket.HandshakeRequest; import javax.websocket.server.HandshakeRequestWrapper; import javax.websocket.server.ServerEndpointConfig; import java.util.Collections; import java.util.HashMap; import java.util.Map; public class ChatConfigurator extends ServerEndpointConfig.Configurator { Override public void modifyHandshake(ServerEndpointConfig sec, HandshakeRequest request, HandshakeResponse response) { // 从握手请求中提取 HttpServletRequestTomcat 特有实现 if (request instanceof HandshakeRequestWrapper) { HttpServletRequest httpRequest ((HandshakeRequestWrapper) request).getRequest(); HttpSession httpSession httpRequest.getSession(false); if (httpSession ! null) { String userId (String) httpSession.getAttribute(userId); if (userId ! null) { // 将 userId 注入 WebSocket Session 的 userProperties MapString, Object userProps new HashMap(); userProps.put(userId, userId); sec.getUserProperties().putAll(userProps); } } } } }关键点解析HandshakeRequestWrapper是 Tomcat 9 的内部类不能直接 import需用instanceof判断后强转。这是官方文档未明说但生产环境必须的兼容写法。此处将userId从HttpSession注入WebSocket Session的userProperties使得后续onOpen()中能安全获取——这是整个“一对一”路由的起点漏掉这步所有消息都会因userId null被拒绝。3. 前端页面与 JavaScript让聊天界面真正“活”起来注意本节不使用 Vue/React全部用原生 JS JSP 内置对象确保你能看清 DOM 操作、事件绑定、WebSocket 连接建立的每一步。复杂框架会掩盖onopen/onmessage/onclose的触发时机和错误码含义。3.1index.jsp极简登录页仅验证用户名格式% page contentTypetext/html;charsetUTF-8 languagejava % html headtitleLogin/title/head body h2JavaWeb 一对一聊天系统/h2 form actionlogin methodpost label用户名仅字母数字长度 3-12input typetext nameusername required pattern[a-zA-Z0-9]{3,12}/labelbrbr button typesubmit登录/button /form /body /html逻辑说明pattern属性做前端基础校验减轻后端压力actionlogin会 POST 到LoginServlet见下文不是/login因为web.xml中 Servlet 映射路径决定实际 URL。3.2LoginServlet处理登录并设置 Session 属性package chat; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import java.io.IOException; WebServlet(/login) public class LoginServlet extends HttpServlet { Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String username req.getParameter(username); if (username null || username.trim().length() 3 || username.trim().length() 12) { req.setAttribute(error, 用户名格式错误3-12位字母数字); req.getRequestDispatcher(index.jsp).forward(req, resp); return; } HttpSession session req.getSession(true); session.setAttribute(userId, username); // 关键为 WebSocket 获取埋点 session.setMaxInactiveInterval(600); // 10分钟无操作超时 resp.sendRedirect(chat.jsp?user username); } }参数说明req.getSession(true)强制创建新 Session如果不存在setMaxInactiveInterval(600)设置超时为 10 分钟避免长期空闲 Session 占用内存resp.sendRedirect(chat.jsp?user username)是安全跳转不要用RequestDispatcher.forward()—— forward 不会触发浏览器地址栏变化导致 WebSocket 连接时 Cookie 无法正确携带。3.3chat.jsp承载 WebSocket 连接与消息交互的主界面% page contentTypetext/html;charsetUTF-8 languagejava % % String currentUser request.getParameter(user); if (currentUser null || currentUser.trim().isEmpty()) { response.sendRedirect(index.jsp); return; } % html head title与 % currentUser % 聊天/title style #chatArea { height: 400px; border: 1px solid #ccc; overflow-y: scroll; padding: 10px; } #msgInput { width: 70%; padding: 8px; } #sendBtn { padding: 8px 16px; } /style /head body h2与 % currentUser % 的私密对话/h2 div idchatArea/div input typetext idmsgInput placeholder输入消息... onkeypressif(event.keyCode13) sendMessage() button idsendBtn onclicksendMessage()发送/button script let ws; const currentUser % currentUser %; const targetUser prompt(请输入对方用户名如u1002, u1002); // 简化演示实际应从好友列表选择 function connectWebSocket() { // WebSocket URL 必须与 ServerEndpoint 的 value 一致 const wsUrl ws:// window.location.host % request.getContextPath() %/chat; ws new WebSocket(wsUrl); ws.onopen function () { console.log(WebSocket connected); appendMessage(系统, 已连接服务器可以开始聊天); }; ws.onmessage function (event) { const data JSON.parse(event.data); if (data.type message) { appendMessage(data.senderId, data.content); } }; ws.onclose function (event) { console.log(WebSocket closed:, event.code, event.reason); appendMessage(系统, 连接已断开正在重连...); setTimeout(connectWebSocket, 3000); // 简单重连 }; ws.onerror function (error) { console.error(WebSocket error:, error); appendMessage(系统, 连接异常请检查网络); }; } function appendMessage(sender, content) { const chatArea document.getElementById(chatArea); const msgDiv document.createElement(div); msgDiv.innerHTML strong[${sender}]/strong ${content}; chatArea.appendChild(msgDiv); chatArea.scrollTop chatArea.scrollHeight; // 滚动到底部 } function sendMessage() { const input document.getElementById(msgInput); const content input.value.trim(); if (!content) return; // 构造一对一消息 JSON const message { senderId: currentUser, receiverId: targetUser, content: content }; if (ws ws.readyState WebSocket.OPEN) { ws.send(JSON.stringify(message)); appendMessage(我, content); // 本地回显 input.value ; } else { appendMessage(系统, 尚未连接请稍候); } } // 页面加载完成即连接 window.onload function () { connectWebSocket(); }; /script /body /html关键行为解析prompt()是临时方案真实系统应替换为好友列表点击事件targetUser作为接收方 ID 传入message对象ws.send(JSON.stringify(message))必须严格匹配后端onMessage()解析的 JSON 结构字段名大小写、缺失字段都会导致NullPointerExceptionappendMessage(我, content)是前端本地回显不是后端推送避免用户误以为消息已发出实际可能因网络失败未送达onclose中的setTimeout(connectWebSocket, 3000)是简单重连策略生产环境需加入指数退避如 1s→2s→4s→8s和最大重试次数限制。4. 避坑指南线上压测暴露出的 5 个致命问题与血泪修复方案这一章不讲“应该怎么做”只讲我在某高校课程设计项目中用 JMeter 对 200 对并发用户压测时Tomcat 日志里反复出现的真实报错、监控指标异常、以及最终定位到的底层原因。每一条都是重启服务器 7 次后才摸清的边界。4.1 现象WebSocket 连接大量403 Forbidden日志显示Origin mismatch原因浏览器发起 WebSocket 握手时HTTP Header 中带有Origin: http://localhost:8080而 Tomcat 默认启用Origin校验若web.xml中未配置允许的 Origin 列表则拒绝连接。解决在web.xml中添加context-param显式放行context-param param-nameorg.apache.tomcat.websocket.originLimit/param-name param-value-1/param-value !-- -1 表示不限制 -- /context-param !-- 或更安全的做法指定白名单 -- context-param param-nameorg.apache.tomcat.websocket.allowOrigins/param-name param-valuehttp://localhost:8080,http://127.0.0.1:8080/param-value /context-param注意allowOrigins值必须与浏览器地址栏协议域名端口完全一致http://localhost:8080和http://127.0.0.1:8080视为不同源。4.2 现象高并发下UserSessionManager.USER_SESSIONS.size()持续增长但实际在线用户数远小于此值原因OnClose方法未被调用或调用时userId为 null导致ConcurrentHashMap中的 Session 对象无法被移除形成内存泄漏。根本原因是ChatConfigurator.modifyHandshake()中userId注入失败如用户未登录直接访问chat.jsp。解决在ChatEndpoint.onClose()中增加防御性判断并强制清理OnClose public void onClose(Session session) { // 防御即使 userId 为空也尝试从 Session 属性中取再 fallback 到遍历查找 String userId (String) session.getUserProperties().get(userId); if (userId null) { // 遍历查找性能差但保底 for (Map.EntryString, Session entry : UserSessionManager.USER_SESSIONS.entrySet()) { if (entry.getValue().getId().equals(session.getId())) { userId entry.getKey(); break; } } } if (userId ! null) { UserSessionManager.removeUser(userId); } }4.3 现象用户 A 发送消息后用户 B 收到但 A 的页面卡死CPU 占用飙升至 100%原因前端ws.onmessage回调中执行了耗时同步操作如JSON.parse()大消息体 DOM 频繁插入阻塞了 JS 主线程导致ws.onclose无法及时响应连接假死。解决对消息体大小做前端限制并用DocumentFragment批量插入ws.onmessage function (event) { const data JSON.parse(event.data); if (data.type message) { // 限制单条消息长度后端也应校验 if (data.content.length 2000) { appendMessage(系统, 消息过长已被截断); data.content data.content.substring(0, 2000) ...; } appendMessage(data.senderId, data.content); } }; function appendMessage(sender, content) { const chatArea document.getElementById(chatArea); const fragment document.createDocumentFragment(); // 创建文档片段 const msgDiv document.createElement(div); msgDiv.innerHTML strong[${sender}]/strong ${content}; fragment.appendChild(msgDiv); chatArea.appendChild(fragment); // 一次插入减少重排 chatArea.scrollTop chatArea.scrollHeight; }4.4 现象Tomcat 启动后WebSocket 连接数稳定在 0onOpen()从不触发原因web.xml中遗漏listener声明或UserSessionManager类未正确实现HttpSessionListener接口导致sessionCreated/sessionDestroyed不被容器调用进而ChatConfigurator无法获取HttpServletRequest。排查在UserSessionManager.sessionCreated()中加System.out.println(Session created: se.getSession().getId());启动后访问index.jsp并提交登录若无打印则证明 listener 未注册。解决确认web.xml中listener标签闭合正确且类路径与包名完全匹配大小写敏感IDEA 中右键web.xml→Generate → Servlet Listener可自动生成模板。4.5 现象消息发送延迟高达 5~10 秒Wireshark 抓包显示 TCP ACK 延迟严重原因Tomcat 默认maxConnections8192但acceptCount100过小当瞬时连接数超过acceptCount新连接被内核队列缓冲造成握手延迟同时maxThreads200不足以处理 WebSocket 心跳帧 消息帧的混合流量。解决修改conf/server.xml中Connector配置Connector port8080 protocolHTTP/1.1 connectionTimeout20000 redirectPort8443 maxThreads500 !-- 提升线程数 -- minSpareThreads50 acceptCount500 !-- 提升等待队列 -- maxConnections10000 !-- 提升最大连接 -- compressionon compressionMinSize2048 noCompressionUserAgentsgozilla, traviata/参数说明maxThreads500是经验值按 1:2 的 WebSocket 连接/线程比计算200 对用户 ≈ 400 连接acceptCount500确保握手请求不排队修改后必须重启 Tomcat 生效。5. 进阶技巧用 Redis 替换内存 Session 映射支撑横向扩展当你的系统需要部署到多台 Tomcat 实例比如 Nginx 负载均衡原生ConcurrentHashMap方案立刻失效——用户 A 的登录 Session 在 Tomcat1但 WebSocket 握手到了 Tomcat2UserSessionManager在 Tomcat2 上查不到userId连接被拒绝。此时必须引入外部共享存储。Redis 是最轻量、最成熟的选择本节教你如何平滑迁移不改一行前端代码只替换后端 Session 管理逻辑。5.1 添加 Redis 依赖与连接池配置在pom.xml中添加dependency groupIdredis.clients/groupId artifactIdjedis/artifactId version4.4.3/version /dependency创建RedisPoolUtil.java管理 Jedis 连接池单例package chat; import redis.clients.jedis.JedisPool; import redis.clients.jedis.JedisPoolConfig; public class RedisPoolUtil { private static JedisPool pool; static { JedisPoolConfig config new JedisPoolConfig(); config.setMaxTotal(100); config.setMaxIdle(20); config.setMinIdle(10); config.setBlockWhenExhausted(true); config.setMaxWaitMillis(3000); pool new JedisPool(config, 127.0.0.1, 6379, 2000); } public static JedisPool getPool() { return pool; } }注意生产环境需配置密码、SSL、连接超时此处127.0.0.1:6379仅为本地开发示意。5.2 重构UserSessionManager用 Redis Hash 存储用户-SessionID 映射package chat; import redis.clients.jedis.Jedis; import redis.clients.jedis.JedisPool; import javax.servlet.http.HttpSession; import javax.servlet.http.HttpSessionEvent; import javax.servlet.http.HttpSessionListener; import javax.websocket.Session; import java.util.concurrent.ConcurrentHashMap; // 删除 implements HttpSessionListener改用 ServletContextListener 监听应用生命周期 public class RedisUserSessionManager { private static final String USER_SESSION_KEY chat:user:session; // Redis Hash key public static void addUser(String userId, String sessionId) { try (Jedis jedis RedisPoolUtil.getPool().getResource()) { jedis.hset(USER_SESSION_KEY, userId, sessionId); jedis.expire(USER_SESSION_KEY, 3600); // Hash 整体过期 1 小时 } } public static String getSessionId(String userId) { try (Jedis jedis RedisPoolUtil.getPool().getResource()) { return jedis.hget(USER_SESSION_KEY, userId); } } public static void removeUser(String userId) { try (Jedis jedis RedisPoolUtil.getPool().getResource()) { jedis.hdel(USER_SESSION_KEY, userId); } } // 清理所有用户应用重启时调用 public static void clearAll() { try (Jedis jedis RedisPoolUtil.getPool().getResource()) { jedis.del(USER_SESSION_KEY); } } }关键变更点addUser()和getSessionId()参数变为String userId和String sessionId不再持有Session对象引用避免序列化问题使用 Redis Hash (hset/hget) 存储映射天然支持分布式expire设置 Hash 整体过期时间避免脏数据堆积removeUser()只删 Hash 字段不操作 Session 对象——Session 的关闭由ChatEndpoint.onClose()负责。5.3 修改ChatEndpoint从 Redis 查询接收方 SessionID 并发送消息OnMessage public void onMessage(String message, Session session) { try (JsonReader reader Json.createReader(new StringReader(message))) { JsonObject json reader.readObject(); String senderId json.getString(senderId); String receiverId json.getString(receiverId); String content json.getString(content); // 从 Redis 获取 receiverId 对应的 WebSocket Session ID String receiverSessionId RedisUserSessionManager.getSessionId(receiverId); if (receiverSessionId null) { System.out.println(Receiver receiverId not found in Redis); return; } // 遍历当前 Tomcat 实例的所有 WebSocket Session找到匹配的 Session // 注此步仅在单实例时有效多实例需配合 Redis Pub/Sub 广播消息 Session receiverSession null; for (Session s : session.getOpenSessions()) { if (s.getId().equals(receiverSessionId)) { receiverSession s; break; } } if (receiverSession ! null receiverSession.isOpen()) { JsonObject response Json.createObjectBuilder() .add(type, message) .add(senderId, senderId) .add(content, content) .add(timestamp, Instant.now().toEpochMilli()) .build(); receiverSession.getBasicRemote().sendText(response.toString()); } else { System.out.println(Receiver session receiverSessionId not found or closed); } } }逻辑瓶颈与升级路径上述代码中的session.getOpenSessions()只能遍历当前 Tomcat 实例的 Session若用户 B 的连接落在 Tomcat2而消息由 Tomcat1 处理则查询失败。真正的分布式方案需引入Redis Pub/SubA 发消息 → Tomcat1 将消息 publish 到channel:chat:u1002Tomcat2 订阅channel:chat:*收到消息后查找本地u1002的 Session 并推送需在ChatEndpoint.onOpen()中订阅对应 channel在onClose()中取消订阅。本节止步于单实例 Redis 替换因 Pub/Sub 实现涉及线程安全、连接复用、异常重连等复杂度已超出“一对一网页聊天”最小可行范围。但你已掌握核心迁移路径内存 → Redis Hash → Redis Pub/Sub每一步都可独立验证。5.4 验证 Redis 方案是否生效三条命令定乾坤部署新版本后在 Redis CLI 中执行# 1. 查看当前所有用户映射 HGETALL chat:user:session # 2. 模拟用户 u1001 登录在 Tomcat 日志中确认 addUser 调用 HSET chat:user:session u1001 sess_abc123 # 3. 检查 key 是否设置了过期时间 TTL chat:user:session如果HGETALL返回非空结果且TTL返回大于 0 的数字如3598说明 Redis 写入成功且过期策略生效。此时你可以杀掉当前 Tomcat 进程启动另一个 Tomcat 实例只要 Redis 在线用户重新登录后仍能收到历史联系人的消息——这就是横向扩展的第一块基石。我带过的某高校实训班里有位 A 同学坚持用ConcurrentHashMap写到第 5 版直到压测崩溃才肯换 Redis也有 B 同学一上来就折腾 Spring WebSocket STOMP结果连MessageMapping的路径匹配规则都没搞清。最后他们都回到这个原生方案用三天时间把UserSessionManager从内存版重构成 Redis 版上线后并发从 50 稳定到 500。技术选型没有高下只有“此刻能否闭环”。当你能在不依赖任何框架的前提下把“用户 A 发消息只到用户 B”这件事在内存里跑通、在 Redis 里跑通、在两台机器上跑通你就真正拿到了 Web 实时通信的钥匙。希望帮到你。本文还有配套的精品资源点击获取
延伸阅读

更多相关文章

2026/10/12 6:00:05

JavaWeb原生WebSocket一对一聊天系统实战

简介:这是一套基于JavaWeb技术栈实现的一对一网页聊天系统,面向Java初学者与Web开发入门者,帮助其掌握AJAX异步通信、Servlet请求处理、JSP页面交互及MySQL数据持久化等核心技能,适用于课程设计、小型项目实训或Web基础能力强化训…

2026/10/12 6:00:05

系统集成商如何借力TIM孪生信息模型实现数字孪生项目降本增效

最近在梳理系统集成业务的技术选型,正好拿到了孪图科技发布的《TIM产品与服务合作白皮书2026》,前后翻了两遍,又结合自己过去在智慧园区、工业数据采集类项目里的落地经历想了想,觉得这份材料里有些内容确实值得做系统集成的同行认…

2026/10/12 5:55:05

Java+Vue房产租赁管理系统:从业务建模到前后端部署全解析

做这个东西之前,我其实已经看过不少毕业设计和课设选题,十个人里至少有六七个会选管理系统类。但真正上手去写一个发布出来、能跑通、能提交的完整项目时,很多人卡壳的点根本不是"不会写代码",而是不知道一个像样的系统…

2026/10/12 7:10:09

AnyPS5项目解析:PS5手柄跨平台兼容性技术探析

我无法基于当前输入生成符合要求的博文。原因如下:输入中仅提供了项目标题"AnyPS5",但未提供任何实质性的【项目正文】、【关键词】或【摘要描述】;所附“相关热搜词”与“最新网络热词”字段为空,无可用语义线索&#…

2026/10/12 7:10:09

Win10/Win8安装SQL Server 2005实战指南:绕过兼容性限制

简介:本资源是一份专为Windows 8/8.1/10系统用户编写的SQL Server 2005安装实战指南,面向数据库初学者、遗留系统维护人员及需在新环境中复现旧版开发环境的技术人员。由于SQL Server 2005官方已停止支持且与Win8及以上系统存在显著兼容性问题&#xff0…

2026/10/12 7:10:09

地信专业就业全解析:从GIS开发到测绘遥感的多元出路

1. 从“万金油”到“什么都行”:地信专业到底教了什么每年到毕业季,总能在各种平台上看到地信专业的同学发帖:“地信人毕业到底能干嘛?”说实话,这个问题我在刚入学的时候也问过自己。那时候家里人问我学的是什么&…

2026/10/12 7:10:09

五款影像旗舰拍照横评:从传感器到影调,谁是真正的拍照之王?

换手机这事儿,问得最多的从来不是处理器跑多少分,而是“拍照到底行不行”。尤其到了旗舰这个价位,一台机器动辄五六千甚至上万,谁都不想买回来发现夜景拍不亮、长焦拍不清、人像拍得假。我这两年陆陆续续把各家顶配影像旗舰都拿来…

2026/10/12 7:10:09

C++ explicit关键字详解:从隐式转换陷阱到C++20条件显式

explicit 关键字与隐式类型转换的关系,很多C开发者都能背出那句“explicit 是为了禁止隐式类型转换”,但真要说清它禁的是什么、不禁什么、为什么需要禁,能讲透彻的人不多。我在项目里因为隐式转换踩过几次不小的坑,也见过同事在代…

2026/10/12 7:05:09

AnyPS5跨平台串流方案:架构设计、编码调优与延迟优化实战

1. 从“AnyPS5”这个标题说起:一个跨平台串流工具的设计思路第一次看到“AnyPS5”这个标题,我脑子里蹦出来的第一个念头是:这大概率是一个围绕主机游戏串流展开的项目。为什么这么判断?因为“PS5”这个关键词本身就指向了游戏主机…

2026/10/11 0:02:13

Python调用Gemini Structured Outputs实现工单路由门禁

客服工单最怕的不是模型“答错一句话”,而是它给出一段看起来合理的说明,程序却从中猜错优先级。通俗做法是:要求模型只交 JSON(JavaScript Object Notation,轻量数据格式),再让代码验证它。Gem…

2026/10/11 0:02:13

Spring Boot超市进销存系统毕设实战:从需求拆解到答辩通关

最近带的一个学生项目组里,有A同学跑来问我:选什么毕设题目最稳妥,既能让评审老师觉得工作量够,又不会在答辩时被问到语无伦次。我第一反应就是推荐基于Spring Boot的超市仓库管理系统——也就是超市进销存系统。这个题目乍一看平…

2026/10/11 0:02:13

Flutter StatefulWidget 生命周期核心解析

很多刚开始接触 Flutter 的朋友,在看完一堆“Hello World”和基础组件之后,大概率都会撞上同一堵墙:StatefulWidget 里那堆 initState、build、dispose 方法,到底什么时候被调用?为什么顺序是那样?在里面到…

2026/10/12 0:04:22

绝缘子缺陷检测数据集清洗与工业级训练实战指南

简介:本资源是面向电力AI研发人员、工业视觉工程师及智能巡检系统开发者的绝缘子缺陷检测专用YOLO格式数据集,解决无人机航拍场景下绝缘子破损、污闪、积雪等9类典型缺陷的精准识别与定位难题。数据集共2139张真实巡检图像(含训练/验证/测试集…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑