JavaWeb一对一网页聊天系统:WebSocket实时通信与消息持久化方案
简介这是一套基于JSPJavaScriptAjaxServletTomcatMySQL实现的一对一网页聊天系统源码面向正在学习JavaWeb技术栈、需要完成课程设计或练手项目的学生与初级开发者重点解决如何用Ajax轮询机制实现消息实时收发这一核心问题。压缩包共54个文件约2.88MB包含12个Java类与12个class文件承载Servlet与业务逻辑8个JSP页面负责登录、注册、好友列表与聊天界面另有7个jar依赖包及若干Eclipse工程配置文件导入后即可在Tomcat下运行。系统通过TalkServlet处理用户发送消息的请求TalkFromServlet每秒接收一次页面轮询以刷新最新消息配合c3p0连接池与MySQL完成数据持久化并附有建表SQL脚本。目前已有348人学习下载适合借此理解前后端交互流程、Ajax定时请求与Servlet分工设计UI部分尚未美化便于读者在此基础上自行扩展界面与功能。1. 一对一网页聊天系统从需求到交付的完整方案做 JavaWeb 课程设计时很多同学一上来就选“仿微信”“在线聊天室”结果做到一半发现消息发不出去、刷新页面记录全丢、两个浏览器窗口互相看不见对方。一对一网页聊天系统看起来简单真正落地时却卡在会话绑定、消息实时性和历史记录持久化这三件事上。这套方案要解决的就是两个已登录用户之间建立稳定的点对点会话消息实时到达、刷新不丢、断线可恢复。适合正在做 JavaWeb 毕设或课程设计、需要一套能跑通并讲清楚的学生也适合想快速搭一个内部沟通原型的开发者。下面按需求、架构、模块、避坑、验收的顺序拆开讲每个环节都给到能直接改的参数和代码。2. 需求与选型为什么不用轮询而选 WebSocket2.1 功能边界先划清楚一对一聊天系统的功能范围必须收窄否则很容易做成“四不像”。核心需求只有四条用户登录后能看到在线联系人列表选中一个联系人进入私聊窗口发送的消息实时出现在对方窗口双方都能翻看历史消息。非核心的群聊、文件传输、消息撤回、已读回执一律先砍掉等核心链路跑通再考虑扩展。这里有个血泪经验很多同学把“在线状态”做成数据库字段用户关掉浏览器后状态还是“在线”联系人列表里全是僵尸用户。正确的做法是在服务端维护一个内存中的在线会话表WebSocket 连接建立时写入、断开时移除数据库只存用户基本信息和消息记录。在线状态是运行时状态不该持久化。2.2 技术选型对比方案实时性实现复杂度服务端压力适用场景短轮询差秒级延迟低高频繁请求消息量极小的演示长轮询中中中兼容老旧浏览器WebSocket好毫秒级中低长连接本项目首选SSE好单向低低只需服务端推送一对一聊天需要双向通信SSE 只能服务端推客户端发消息还得另开接口反而更绕。WebSocket 在 JavaWeb 体系里有成熟支持Spring Boot 的spring-boot-starter-websocket直接提供ServerEndpoint或WebSocketHandler两种写法。对于课程设计推荐用原生ServerEndpoint注解方式代码量少、依赖轻答辩时也容易讲清楚握手和消息帧的关系。提示如果项目要求必须用 Servlet 3.0 以上容器确认web.xml的metadata-complete没有设为 true否则注解扫描会失效WebSocket 端点注册不上。2.3 数据模型设计消息表是一对一系统的核心字段设计直接决定查询效率。通常的做法是建一张message表包含发送者、接收者、内容、时间戳和会话标识。会话标识用“小 ID_大 ID”拼接保证同一对用户无论谁先发消息都落到同一个会话里查询时不用写 OR 条件。CREATE TABLE message ( id BIGINT AUTO_INCREMENT PRIMARY KEY, session_id VARCHAR(64) NOT NULL COMMENT 会话标识格式 minId_maxId, from_user_id BIGINT NOT NULL COMMENT 发送者ID, to_user_id BIGINT NOT NULL COMMENT 接收者ID, content TEXT NOT NULL COMMENT 消息内容, send_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, is_read TINYINT DEFAULT 0 COMMENT 0未读 1已读, INDEX idx_session_time (session_id, send_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;session_id上的联合索引是关键历史消息查询按会话加时间排序走这个索引可以避免全表扫描。is_read字段为后续扩展已读回执留了口子但第一版不实现避免范围蔓延。用户表沿用登录模块已有的结构不重复设计。3. 总体架构三层结构与四个核心模块3.1 分层职责划分系统按表现层、业务层、数据层三层组织。表现层是 JSP 或 Thymeleaf 页面加前端 JavaScript负责登录表单、联系人列表渲染和聊天窗口交互业务层是 Spring 管理的 Service 和 WebSocket 端点负责会话管理、消息路由和持久化数据层是 MyBatis 或 JPA 访问 MySQL。分层不是为了好看而是让 WebSocket 端点和普通 HTTP 接口共用同一套 Service消息落库逻辑只写一遍。四个核心模块分别是认证模块登录、Session 校验、在线管理模块WebSocket 连接注册与注销、消息路由模块根据接收者 ID 找到对应连接并推送、历史查询模块按会话分页拉取记录。模块之间通过用户 ID 和会话 ID 两个标识串联边界清晰。3.2 WebSocket 端点注册服务端用ServerEndpoint声明端点路径里带用户 ID 作为连接参数。连接建立时把当前Session存入一个线程安全的 Map键是用户 ID值是 WebSocket Session。断开时移除。这里必须用ConcurrentHashMap普通 HashMap 在多用户并发连接时会出现结构损坏表现为偶发的连接丢失排查起来非常玄学。ServerEndpoint(/chat/{userId}) Component public class ChatEndpoint { // 在线用户连接表key 为用户ID private static final MapLong, Session ONLINE new ConcurrentHashMap(); OnOpen public void onOpen(Session session, PathParam(userId) Long userId) { ONLINE.put(userId, session); // 广播在线状态变更给联系人列表可选 } OnClose public void onClose(PathParam(userId) Long userId) { ONLINE.remove(userId); } OnMessage public void onMessage(String text, PathParam(userId) Long userId) { // 解析 JSON取 toUserId 和 content ChatMessage msg JSON.parseObject(text, ChatMessage.class); msg.setFromUserId(userId); msg.setSessionId(buildSessionId(userId, msg.getToUserId())); messageService.save(msg); // 先落库 Session target ONLINE.get(msg.getToUserId()); if (target ! null target.isOpen()) { target.getAsyncRemote().sendText(JSON.toJSONString(msg)); } } }PathParam把 URL 里的用户 ID 注入方法参数省去从 Session 属性里再取一次。getAsyncRemote()是异步发送避免某个慢客户端阻塞整个端点线程。先落库再推送的顺序不能反否则对方收到消息但服务端崩溃时记录就丢了刷新页面看不到用户会以为消息没发出去。3.3 前端连接与重连前端在进入聊天页时建立 WebSocket 连接地址用ws://加当前主机和用户 ID。连接成功后把状态标记为在线发送按钮才可用。断线重连是必须做的网络抖动或服务重启后连接会断不重连的话用户发消息没反应体验直接翻车。let ws null; let reconnectTimer null; function connect(userId) { const proto location.protocol https: ? wss : ws; ws new WebSocket(${proto}://${location.host}/chat/${userId}); ws.onopen () { clearInterval(reconnectTimer); setStatus(在线); }; ws.onmessage (event) { const msg JSON.parse(event.data); appendMessage(msg); // 渲染到聊天窗口 }; ws.onclose () { setStatus(已断开重连中); reconnectTimer setTimeout(() connect(userId), 3000); }; }重连间隔设 3 秒太短会在服务端故障时形成请求风暴太长用户等不及。onclose里不要直接递归调用connect用setTimeout包一层否则断线瞬间可能触发大量并发重连。状态提示要明确用户看到“重连中”就不会反复点发送按钮。4. 核心模块实现消息路由与历史记录4.1 消息路由的完整链路一条消息从发送到对方看到经过四步前端组装 JSON 通过 WebSocket 发出服务端端点解析并补全发送者 ID 和会话 IDService 落库根据接收者 ID 查在线表并推送。任何一步失败都要有兜底落库失败返回错误帧给发送方对方不在线则只落库不推送等对方上线后通过历史接口拉取。会话 ID 的生成规则要统一通常取两个用户 ID 的较小值和较大值拼接中间用下划线连接。这样 A 发给 B 和 B 发给 A 得到同一个session_id历史查询只需一个等值条件。如果直接用发送者加接收者两个字段查询SQL 里要写(froma and tob) or (fromb and toa)索引利用不充分数据量上来后明显变慢。4.2 历史消息分页查询聊天窗口打开时先拉最近 20 条向上滚动再加载更早的记录。接口按session_id和send_time倒序取前端反转后渲染。分页用游标方式而不是offset因为新消息不断插入offset分页会出现重复或遗漏。public ListMessage history(String sessionId, Long beforeTime, int size) { return messageMapper.selectBySessionBeforeTime(sessionId, beforeTime, size); }对应的 SQL 是WHERE session_id ? AND send_time ? ORDER BY send_time DESC LIMIT ?。beforeTime传当前已加载的最早一条消息时间首次加载传当前时间。游标分页在消息持续写入的场景下稳定不会因为新数据插入导致翻页错位。4.3 未读消息与上线拉取对方不在线时消息只落库is_read保持 0。用户重新建立 WebSocket 连接后前端调用一个未读接口按会话分组返回未读数用户点进某个会话时再拉取具体消息并把is_read置 1。这个设计让离线消息不丢同时避免上线瞬间拉取全部历史造成卡顿。未读统计的 SQL 用GROUP BY session_id加COUNT(*)条件带上to_user_id 当前用户 AND is_read 0。数据量大时给to_user_id和is_read建联合索引否则每次上线都全表扫描用户多了服务端扛不住。5. 避坑与排查五个最容易翻车的点5.1 消息发出去对方收不到现象是发送方界面正常接收方毫无反应。原因通常是接收者的 WebSocket 连接不在在线表里或者键的类型不一致——比如存的时候用Long取的时候用StringConcurrentHashMap查不到但不报错。解决方法是统一用户 ID 类型在onOpen和onMessage里打印日志确认键值排查时先看在线表的实际内容。5.2 刷新页面后历史消息空白现象是聊天记录刷新即消失。原因多半是消息只推给了对方没有落库或者落库了但查询条件写错。检查session_id生成规则是否前后一致发送时用一套规则、查询时用另一套数据在库里但查不出来。建议把会话 ID 的生成抽成一个工具方法发送和查询都调它杜绝两处逻辑分叉。5.3 多标签页登录互相顶掉同一账号开两个标签页后开的把先开的连接覆盖先开的页面发消息没反应。原因是在线表以用户 ID 为键后连接覆盖前连接。如果业务允许同账号多端在线键要改成“用户 ID 连接标识”值是连接集合如果不允许就在新连接建立时主动关闭旧连接并提示用户。课程设计通常选后者逻辑简单且符合“一对一”的语义。5.4 中文消息乱码现象是英文正常、中文变成问号或乱码。WebSocket 文本帧默认按 UTF-8 处理乱码一般出在数据库连接串没指定字符集或者前端JSON.stringify后服务端用错编码解析。检查 JDBC URL 是否带characterEncodingutf8建表语句是否utf8mb4两端 JSON 序列化统一用 UTF-8。三处任一遗漏都会复现。5.5 连接数上来后服务端卡死现象是几个人测试正常十几个人同时在线后消息延迟明显甚至端点无响应。原因是在OnMessage里用了同步发送getBasicRemote().sendText()某个客户端网络慢就阻塞端点线程。改成getAsyncRemote()并把落库操作放到独立线程池或至少确认数据库连接池够用。默认连接池 10 个连接并发消息一多就排队调大到 20 到 30 并观察慢查询日志。6. 验收与答辩怎么证明这套系统真的能跑验收分三步走每一步都有可观察的证据不靠嘴说。第一步功能验收准备两个浏览器或一个正常窗口加一个无痕窗口分别登录两个账号互发十条中英文混合消息确认实时到达、刷新后记录还在、断网重连后能继续收发。第二步边界验收对方下线时发消息重新上线后能看到未读同一账号开两个标签页确认顶号逻辑符合预期连续快速发送二十条确认顺序不乱、不丢。第三步压力粗测用脚本模拟十个连接同时发消息观察服务端日志有无异常、消息落库条数是否与发送条数一致。答辩时老师最常问的三个问题提前准备好答案为什么选 WebSocket 不选轮询答实时性和服务端压力消息怎么保证不丢答先落库再推送加离线拉取在线状态存哪里答内存在线表不持久化。把这三个点讲清楚基本就稳了。验收项操作通过标准实时收发双账号互发对方 1 秒内显示历史持久化刷新页面记录完整不丢断线重连停服务再启3 秒内自动恢复离线消息对方下线后发送上线后可见未读并发发送快速连发 20 条顺序正确无丢失我一般会在答辩前一天把上面这张表完整走一遍任何一项不过就当场修不带着侥幸上场。有一次偷懒没测断线重连现场演示时服务刚好重启连接断了页面毫无提示场面相当尴尬。从那以后我每次交付前都强制走一遍“停服务再启”的重连测试确认状态提示和自动恢复都正常才敢收工。希望帮到你。本文还有配套的精品资源点击获取