ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

JavaWeb一对一聊天系统实战:WebSocket+MySQL优化全解析

JavaWeb一对一聊天系统实战:WebSocket+MySQL优化全解析 简介这是一套基于JavaWeb技术栈实现的一对一网页聊天系统面向Java初学者与Web开发入门者解决实时通信功能在B/S架构下的基础落地问题适用于课程设计、毕业设计或Web交互功能模块学习。资源共54个文件包含12个Java源码、12个编译后class文件、8个JSP页面如login.jsp、chat.jsp等核心交互页、7个Jar依赖包含数据库连接池c3p0、4个XML配置文件如web.xml、c3p0-config.xml及SQL建表脚本整体压缩包仅2.88MB轻量易部署。已有347人学习下载体现了其作为教学级实战案例的实用价值。读者可直接导入Tomcat运行完整掌握JSPServlet前后端协同逻辑、Ajax轮询消息机制、MySQL数据持久化流程以及标准JavaWeb项目目录结构含.settings、.project、WEB-INF等典型配置尤其适合理解无框架时代Web通信的核心实现路径。1. 为什么一个“javaweb一对一网页聊天系统”在2024年依然值得从零手敲一遍不是为了复刻微信也不是为了造轮子——而是因为当你把 HttpSession、WebSocket、MySQL事务隔离级别、Tomcat线程模型和前端长轮询/Socket.IO降级逻辑全串起来跑通的那一刻javaweb才真正从“能跑”变成“懂它在怎么活”。这个项目表面是两个浏览器窗口发消息背后却卡着三个真实痛点一是传统Servlet同步阻塞模型下100个在线用户就吃光Tomcat默认200线程二是消息“已发送→已送达→已读”状态在无状态HTTP里如何不丢不重三是MySQL里聊天记录表设计稍有不慎单聊历史查询就从O(1)退化成全表扫描。它适合两类人刚学完ServletJSP但总卡在“学了不会用”的新人以及想验证自己是否真理解“web容器生命周期”“会话一致性边界”“前后端实时通信权衡”的进阶者。别被“网页聊天”四个字骗了——这是一块检验javaweb底层肌肉的试金石不是玩具项目。2. 用原生ServletWebSocket搭起最小可行通信骨架绕开Spring Boot的“黑匣子”很多教程一上来就甩Spring BootSTOMP看似省事实则把Tomcat如何接管WebSocket握手、Servlet容器如何管理Endpoint生命周期、甚至HTTP Upgrade请求头怎么被解析这些关键链路全藏起来了。我们从最原始的javax.websocketAPI开始亲手把“连接建立→消息路由→连接销毁”三步钉死。2.1 创建WebSocket Endpoint用ServerEndpoint注解但必须理解它的容器依赖ServerEndpoint(value /chat, configurator ChatConfigurator.class) public class ChatEndpoint { private static final MapString, Session onlineUsers new ConcurrentHashMap(); OnOpen public void onOpen(Session session, EndpointConfig config) { String userId (String) session.getUserProperties().get(userId); if (userId ! null !userId.trim().isEmpty()) { onlineUsers.put(userId, session); } } OnMessage public void onMessage(String message, Session session) { try { // 解析JSON格式消息{to:u2,from:u1,content:hello} JSONObject json new JSONObject(message); String toUserId json.optString(to); Session targetSession onlineUsers.get(toUserId); if (targetSession ! null targetSession.isOpen()) { targetSession.getBasicRemote().sendText(message); } } catch (Exception e) { e.printStackTrace(); } } OnClose public void onClose(Session session) { // 从onlineUsers中移除但注意这里不能直接remove(session)因为session.getUserProperties()可能为空 onlineUsers.values().removeIf(s - s.getId().equals(session.getId())); } }提示ServerEndpoint不是独立运行的——它必须被Servlet容器如Tomcat扫描到。这意味着你的web.xml里不能删掉servlet和servlet-mapping配置即使你没写Servlet类且ChatEndpoint类必须放在WEB-INF/classes可扫描路径下。IDEA里常见错误是把该类放在src/test/java或resources目录导致启动时根本看不到Endpoint注册日志。2.2 自定义Configurator把HTTP Session里的用户身份透传到WebSocket Session单纯靠URL参数传?userIdu1太危险易伪造而WebSocket握手阶段又拿不到HttpSession。解决方案是写一个继承ServerEndpointConfig.Configurator的类在HTTP Upgrade请求完成前把当前HTTP Session中的用户信息注入WebSocket Sessionpublic class ChatConfigurator extends ServerEndpointConfig.Configurator { Override public void modifyHandshake(ServerEndpointConfig sec, HandshakeRequest request, HandshakeResponse response) { // 从HTTP请求中提取HttpSession并获取其中的登录用户ID HttpSession httpSession (HttpSession) request.getHttpSession(); if (httpSession ! null) { String userId (String) httpSession.getAttribute(loginUserId); if (userId ! null) { // 注入到WebSocket Session的userProperties中 sec.getUserProperties().put(userId, userId); } } } }参数说明HandshakeRequest对象里getHttpSession()方法返回的就是当前HTTP请求绑定的Session前提是你的WebSocket请求是从同一个域名、同一次登录会话发起的即前端页面已通过/login接口登录并持有JSESSIONID Cookie。这是实现“一对一”而非“群聊”的关键前提——没有这个透传后端根本不知道消息该发给谁。2.3 前端WebSocket连接与心跳保活别让Nginx/Tomcat静默断连let ws; function connectWebSocket() { const wsUrl ws://${window.location.host}/your-webapp/chat; ws new WebSocket(wsUrl); ws.onopen function() { console.log(WebSocket connected); // 发送登录态校验消息可选 ws.send(JSON.stringify({type: auth, userId: getCookie(userId)})); }; ws.onmessage function(event) { const msg JSON.parse(event.data); appendMessage(msg.from, msg.content, received); }; ws.onclose function() { console.log(WebSocket closed, retrying in 3s...); setTimeout(connectWebSocket, 3000); }; // 关键每30秒发一次ping防止代理服务器超时断连 setInterval(() { if (ws ws.readyState WebSocket.OPEN) { ws.send(JSON.stringify({type: ping})); } }, 30000); }血泪经验本地开发时WebSocket一切正常部署到带Nginx的生产环境后频繁断连90%是因为Nginx默认60秒超时。必须在Nginx配置里加location /chat { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_read_timeout 300; # 把默认60秒改成300秒 }否则前端看到的只是WebSocket is already in CLOSING or CLOSED state查日志发现Nginx access log里全是499状态码——那是客户端主动断开的信号根源却是Nginx先斩断了连接。3. MySQL聊天记录表设计为什么用复合主键比自增ID更抗压很多人第一反应是建一张chat_message(id BIGINT AUTO_INCREMENT, from_id VARCHAR, to_id VARCHAR, content TEXT, create_time DATETIME)然后用WHERE (from_idu1 AND to_idu2) OR (from_idu2 AND to_idu1)查历史。这在100条消息时没问题当单聊记录超10万行时索引失效、慢查询报警、磁盘IO飙升——问题出在查询条件无法利用B树索引的最左匹配原则。3.1 正确的表结构用(user_a, user_b)作为联合主键并强制约定大小关系CREATE TABLE chat_record ( user_a VARCHAR(32) NOT NULL COMMENT 较小用户ID按字符串字典序, user_b VARCHAR(32) NOT NULL COMMENT 较大用户ID按字符串字典序, msg_id BIGINT NOT NULL COMMENT 本对话内消息序号从1开始递增, from_user VARCHAR(32) NOT NULL COMMENT 实际发送者, content TEXT NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (user_a, user_b, msg_id), KEY idx_from_user (from_user, create_time), KEY idx_create_time (create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT一对一聊天记录表;为什么这样设计主键(user_a, user_b, msg_id)确保同一对用户的所有消息按msg_id物理连续存储极大提升范围查询性能强制user_a user_b插入前用Math.min(u1,u2)和Math.max(u1,u2)处理使(u1,u2)和(u2,u1)被归一为同一组避免重复建索引msg_id不是全局唯一而是每对用户独立计数用MySQL的INSERT ... ON DUPLICATE KEY UPDATE或应用层Redis原子计数器实现这样SELECT * FROM chat_record WHERE user_au1 AND user_bu2 ORDER BY msg_id DESC LIMIT 20就能走主键索引毫秒级返回额外建idx_from_user是为了支持“查某用户所有聊天窗口”场景如消息通知栏但注意这个索引只在from_user高基数用户量大时有效若只有几十个用户反而增加写开销。3.2 插入消息的原子性保障用INSERT IGNORE SELECT LAST_INSERT_ID()替代自增ID// Java伪代码先获取当前对话最大msg_id再1插入 String sql INSERT INTO chat_record (user_a, user_b, msg_id, from_user, content) VALUES (?, ?, (SELECT COALESCE(MAX(msg_id), 0) 1 FROM chat_record WHERE user_a ? AND user_b ?), ?, ?) ON DUPLICATE KEY UPDATE content VALUES(content); // 注意这里用ON DUPLICATE KEY UPDATE是防并发插入冲突但实际更推荐用存储过程封装避坑点不要用SELECT MAX(msg_id)1再INSERT——高并发下必然重复。正确做法是在应用层用Redis的INCR chat:u1:u2:seq获取唯一序号Redis原子性保证或在MySQL里写存储过程用INSERT ... SELECT一次性完成“查最大值插入”或直接用INSERT IGNORE配合LAST_INSERT_ID()但需确保msg_id字段允许NULL并设为自增此时主键变成(user_a,user_b)msg_id单独自增——但会破坏物理连续性不推荐。3.3 查询历史消息的SQL写法永远用UNION ALL代替OR错误写法触发全表扫描SELECT * FROM chat_record WHERE (user_au1 AND user_bu2) OR (user_au2 AND user_bu1) ORDER BY create_time DESC LIMIT 20;正确写法走索引(SELECT * FROM chat_record WHERE user_au1 AND user_bu2 ORDER BY msg_id DESC LIMIT 20) UNION ALL (SELECT * FROM chat_record WHERE user_au2 AND user_bu1 ORDER BY msg_id DESC LIMIT 20) ORDER BY create_time DESC LIMIT 20;原理MySQL优化器对OR条件常放弃使用索引而UNION ALL让每个子查询都能精准命中(user_a,user_b,msg_id)主键。实测10万行数据下前者耗时1.2s后者0.015s——差80倍。4. 避坑javaweb一对一聊天系统上线前必须踩过的5个深坑4.1 现象用户A发消息给BB页面没收到但服务端日志显示sendText()成功原因WebSocket Session已关闭但未及时从onlineUsersMap中移除。常见于用户关浏览器标签页、网络闪断、手机切后台等场景OnClose方法未必被触发TCP连接异常中断时服务端可能收不到FIN包。解决前端在onbeforeunload事件里主动调用ws.close()后端增加心跳检测机制每30秒遍历onlineUsers用session.isOpen()检查假死连接立即清理更彻底方案用Redis Pub/Sub做消息中转WebSocket只负责推送给在线用户离线消息存DB由客户端上线后拉取。4.2 现象MySQL聊天表磁盘暴涨ibdata1文件每天增长2GB原因TEXT类型字段未指定ROW_FORMATCOMPRESSED且未开启innodb_file_per_tableON导致所有BLOB/TEXT数据挤进共享表空间。解决ALTER TABLE chat_record ROW_FORMATCOMPRESSED KEY_BLOCK_SIZE8; -- 并确认my.cnf中有 # innodb_file_per_tableON # innodb_large_prefixON # innodb_file_formatBarracuda4.3 现象Tomcat启动报错java.lang.NoClassDefFoundError: javax/websocket/Endpoint原因Tomcat版本与WebSocket API版本不匹配。Tomcat 7.x对应javax.websocket:javax.websocket-api:1.1Tomcat 8.5对应2.0而jakarta.websocket:jakarta.websocket-api:2.0.0是Jakarta EE 9新命名空间。解决查清你用的Tomcat版本$CATALINA_HOME/lib/catalina.jar的MANIFEST.MFMaven中排除冲突依赖dependency groupIdorg.springframework/groupId artifactIdspring-websocket/artifactId exclusions exclusion groupIdjavax.websocket/groupId artifactIdjavax.websocket-api/artifactId /exclusion /exclusions /dependency4.4 现象Chrome控制台报WebSocket connection to ws://... failed: Error during WebSocket handshake原因HTTPS站点尝试连接HTTP协议的WebSocket混合内容被浏览器拦截或WSS证书域名不匹配。解决开发环境用ws://生产环境必须用wss://且SSL证书域名要覆盖WebSocket路径如wss://chat.example.com/chat证书需含chat.example.com若用自签名证书Chrome需手动访问chrome://dino并启用chrome://flags/#unsafely-treat-insecure-origin-as-secure仅测试用。4.5 现象用户登录后刷新页面WebSocket连接丢失且无法重新获取loginUserId原因HTTP Session在刷新后依然存在但前端JavaScript未持久化userId导致WebSocket重连时getCookie(userId)为空。解决登录成功后后端不仅设HttpSession.setAttribute(loginUserId, userId)还写CookieCookie userIdCookie new Cookie(userId, userId); userIdCookie.setPath(/); userIdCookie.setMaxAge(30 * 60); // 30分钟 response.addCookie(userIdCookie);前端用document.cookie读取而非依赖内存变量。5. 进阶技巧用Servlet Filter实现消息内容审计与敏感词拦截聊天系统上线后运营方必然要求“消息可追溯、违规可拦截”。与其在业务代码里到处写if (containsSensitiveWord(msg))不如用Filter统一拦截——既解耦又便于开关。5.1 编写ChatMessageFilter拦截所有POST到/chat的JSON消息WebFilter(urlPatterns {/chat}) public class ChatMessageFilter implements Filter { private static final SetString SENSITIVE_WORDS new HashSet(Arrays.asList( 赌博, 毒品, 诈骗, 违法, 色情 )); Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; HttpServletResponse response (HttpServletResponse) resp; // 只拦截POST且Content-Type为application/json的请求 if (POST.equalsIgnoreCase(request.getMethod()) application/json.equals(request.getContentType())) { String body IOUtils.toString(request.getInputStream(), StandardCharsets.UTF_8); try { JSONObject json new JSONObject(body); String content json.optString(content, ); if (isSensitive(content)) { response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.getWriter().write({\error\:\消息包含敏感词\}); return; } } catch (JSONException e) { // JSON解析失败放行让后端处理可能是非标准格式 } } chain.doFilter(req, resp); } private boolean isSensitive(String content) { for (String word : SENSITIVE_WORDS) { if (content.contains(word)) { return true; } } return false; } }注意此Filter只能拦截HTTP阶段的请求如登录、消息上报API对WebSocket握手后的onMessage()无效。WebSocket消息走的是独立协议栈需在ChatEndpoint.onMessage()里二次校验。Filter的价值在于提前拒绝恶意构造的JSON如超长content触发OOM统一记录审计日志log.info(User {} sent: {}, userId, content)与公司已有风控系统对接如调用riskService.check(content)。5.2 敏感词匹配升级从contains到AC自动机适用于万级词库当敏感词超过1000个时String.contains()时间复杂度O(n×m)会拖慢吞吐。换成AC自动机Aho-Corasick预编译词典后匹配复杂度降至O(nm)// 使用开源库com.github.robert-bor:aho-corasick Trie trie Trie.builder() .addKeywords(SENSITIVE_WORDS) .build(); CollectionEmit emits trie.parseText(content); if (!emits.isEmpty()) { log.warn(Sensitive words detected: {}, emits.stream().map(Emit::getKeyword).collect(Collectors.toList())); throw new SensitiveWordException(); }参数调优Trie.builder().ignoreCase()开启忽略大小写Trie.builder().matchAll()匹配重叠词如“北京”和“北京市”词库建议存Redis用HGETALL sensitive:words加载支持热更新。5.3 消息状态回执用MySQL SELECT FOR UPDATE实现“已读”原子标记用户B看到消息后前端发/api/read?msgId123后端需将该消息状态从unread改为read且不能漏标、不能重复标。用普通UPDATE会因并发导致状态错乱-- 错误可能被覆盖 UPDATE chat_record SET statusread WHERE msg_id123 AND statusunread;正确做法加行锁// Java中执行 String sql SELECT * FROM chat_record WHERE msg_id ? AND status unread FOR UPDATE; try (PreparedStatement ps conn.prepareStatement(sql)) { ps.setLong(1, msgId); ResultSet rs ps.executeQuery(); if (rs.next()) { // 确认查到了才更新 String updateSql UPDATE chat_record SET statusread WHERE msg_id ?; try (PreparedStatement ups conn.prepareStatement(updateSql)) { ups.setLong(1, msgId); ups.executeUpdate(); } } }为什么FOR UPDATE有效它会在匹配行上加排他锁X锁其他事务对该行的SELECT ... FOR UPDATE或UPDATE会被阻塞即使两个请求同时到达第二个请求会等待第一个事务提交后再执行确保status只被改一次注意必须在事务中执行且事务不能过长否则锁住太久影响并发。我当年在一家教育SaaS公司做客服聊天模块时就栽在这条SELECT FOR UPDATE上——没加事务锁在autocommit模式下瞬间释放结果“已读”状态被并发请求反复覆盖。后来加了Transactional注解又发现MySQL隔离级别是REPEATABLE READ导致幻读最终锁定READ COMMITTED并加了唯一索引才稳住。希望帮到你。本文还有配套的精品资源点击获取
返回列表