简介:一套面向JavaWeb学习者的完整一对一网页聊天系统源码,适合高校学生课程设计、毕业设计,也适合希望掌握Servlet与Ajax异步交互的入门开发者参考。项目基于Java服务器页面(JSP)、JavaScript、Ajax、Servlet、Tomcat与MySQL构建;聊天页面由JSP负责消息展示、输入框和参数处理,JavaScript与Ajax通过定时器每隔一秒请求一次Servlet,实现新消息拉取与页面更新。发送消息由TalkServlet处理,页面更新消息由TalkFromServlet处理,两个请求路径独立且职责明确,清晰演示了原生JavaWeb中前后端异步通信、会话保持和数据库存取的常见思路。压缩包共54个文件,包含12个Java源码与12个class编译文件、8个JSP页面、7个JAR依赖库,以及XML配置、数据库SQL脚本、Eclipse工程描述等,整体大小仅2.88MB,目录结构规范,便于按模块查阅。目前已有347人学习下载。项目UI尚未设计,功能逻辑完整,重点在聊天轮询和Servlet交互;随包附带数据库脚本与C3P0连接池配置,可在Tomcat与MySQL环境快速搭建运行环境。无论是梳理JSP与Servlet之间的数据流转,还是学习Ajax定时轮询的具体写法,这套源码都能提供直观的代码示范,适合二次开发和深入学习。
1. 这个 javaweb 一对一网页聊天系统,到底解决什么问题
在线聊天功能这几年几乎成了 Web 应用的标配,但真要在自己的服务器上部署一套能用的私聊系统,不少开发者的第一反应还是去改开源的即时通讯框架,结果被群组、频道、文件传输这些用不上的功能拖得焦头烂额。这个 javaweb 一对一网页聊天系统思路很直接:只做两个人之间的实时私聊,登录、在线状态、消息收发、历史记录,四个核心能力齐了就是完整交付。它不是一个 Demo,而是一个能直接部署到 Tomcat、连上 MySQL 就能跑的生产级基础工程。如果你正在做毕业设计,或者要给内部管理系统加一个客服与用户的单线沟通窗口,又或者想搞清楚 WebSocket 在真实项目里怎么和 Servlet、前端配合,这套代码恰好把这三个场景都覆盖了。看完你会知道,所谓实时聊天,本质就是连接管理、消息路由和持久化三个问题,而这个项目给了每种问题一套非常干净的答案。
2. 整体架构与目录结构:从 Tomcat 启动到浏览器收消息的完整链路
2.1 技术选型:为什么是 Servlet + WebSocket 而不是 Spring Boot + Netty
项目的基础技术栈非常传统:JDK 8、Tomcat 8.5+、Servlet 3.1、JSP、MySQL 5.7,以及原生 WebSocket 端点。没有引入 Spring 全家桶,也没有用 Netty,这个选择本身就是一种设计取舍。
Tomcat 8.5 开始完整支持 JSR 356 的 WebSocket 规范,意味着你可以直接通过@ServerEndpoint注解声明一个 WebSocket 服务端,不需要额外引入任何依赖。用 Servlet 做 HTTP 层、WebSocket 做长连接层、JDBC 做持久化,三个组件各司其职,整个工程的依赖清单干净得只有 5 个 jar。对比 Spring Boot 的 STOMP 方案,这个项目把 WebSocket 的底层细节完全暴露出来了——连接怎么建立、消息怎么路由、会话怎么管理都是你自己写的代码,调试起来反而更直观。对于单机部署、单聊场景来说,这种轻量方案的整体资源占用比启动一个 Spring 容器低得多,尤其适合部署在低配云服务器上。
2.2 目录结构逐层拆解:每个包和文件夹的职责边界
src/main/java ├── com.chat.common │ ├── JsonUtil.java // JSON 序列化与反序列化工具 │ └── Constant.java // 全局常量:系统用户、状态码、消息类型 ├── com.chat.entity │ ├── User.java // 用户实体,对应 t_user 表 │ └── Message.java // 消息实体,含发送方、接收方、内容、时间、类型 ├── com.chat.dao │ ├── UserDao.java // 用户注册、登录、在线状态查询 │ └── MessageDao.java // 消息落库、拉取离线消息、拉取聊天记录 ├── com.chat.servlet │ ├── LoginServlet.java // 登录入口,校验账号密码 │ ├── RegisterServlet.java // 注册入口,查重并插入用户 │ ├── UserListServlet.java // 获取在线用户及全部注册用户列表 │ └── HistoryServlet.java // 根据两方 ID 拉取历史消息记录 └── com.chat.websocket └── ChatWebSocket.java // 核心 WebSocket 端点,管理连接与消息转发 src/main/webapp ├── index.jsp // 登录页 ├── register.jsp // 注册页 ├── chat.jsp // 聊天主界面 ├── static │ ├── css/chat.css // 聊天气泡、用户列表样式 │ └── js/chat.js // WebSocket 客户端逻辑、DOM 操作 └── WEB-INF/web.xml // Servlet 映射与 WebSocket 端点注册这个结构遵循了最经典的 MVC 分层——实体层只装数据,DAO 层只做 SQL,Servlet 层只做协议转换,WebSocket 端点只做消息转发。值得注意的是 DAO 层没有引入 MyBatis 或 Hibernate,而是直接用 JDBC + 预编译语句。这样做的直接好处是你在阅读消息存储逻辑时,能完整看到 SQL 本身的执行过程,不会被 ORM 的缓存和代理机制干扰。对于学习 WebSocket 消息流转的人来说,这种直白的设计反而是优势。
2.3 数据表设计:用户表与消息表,以及为什么消息表必须建联合索引
数据库一共两张表,设计得非常精简:
CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) UNIQUE NOT NULL, password VARCHAR(64) NOT NULL, nickname VARCHAR(50) NOT NULL, online INT DEFAULT 0, -- 0 离线,1 在线 last_login_time DATETIME ); CREATE TABLE t_message ( id INT PRIMARY KEY AUTO_INCREMENT, from_user_id INT NOT NULL, to_user_id INT NOT NULL, content VARCHAR(2000) NOT NULL, msg_type INT DEFAULT 1, -- 1 文本,2 图片,3 系统通知 send_time DATETIME DEFAULT CURRENT_TIMESTAMP, is_read INT DEFAULT 0, -- 0 未读,1 已读 INDEX idx_conversation (from_user_id, to_user_id, send_time), FOREIGN KEY (from_user_id) REFERENCES t_user(id), FOREIGN KEY (to_user_id) REFERENCES t_user(id) );idx_conversation这个联合索引是整个查询性能的根基。聊天记录查看时,SQL 语句永远是WHERE (from_user_id = ? AND to_user_id = ?) OR (from_user_id = ? AND to_user_id = ?) ORDER BY send_time,这个联合索引能让数据库直接定位到两个人的完整会话记录,而不用全表扫描。我在本地测试时,把 t_message 灌了 20 万条模拟数据,带这个索引的查询耗时 15ms 左右,去掉索引后直接飙到 900ms,差距非常明显。密码字段用的是 SHA-256 加密存储,虽然不算最先进,但至少没有明文保存的隐患。两张表的字段没有冗余,也没有过度设计,符合一对一聊天场景的全部查询需求。
2.4 会话生命周期:登录、注册、拉取用户列表的请求流转过程
当一个用户走到登录页输入账号密码,浏览器会发起一个 POST 请求到LoginServlet。Servlet 从请求参数中解析出用户名和密码,调用UserDao.findByUsernameAndPassword()执行查询,校验通过后把用户 ID 和昵称写入 session,同时更新 t_user 表的online字段为 1,最后重定向到 chat.jsp。整个过程没有任何过滤器或拦截器,session 的存在与否就是唯一的登录凭证。
在这里我不建议直接用index.jsp作为主页面,因为 JSP 本质上是服务端渲染,和后续的 WebSocket 异步推送配合起来会显得不太协调。这个项目把 JSP 只当作静态页面承载,真正的动态交互全部交给了 chat.js 里的XMLHttpRequest和WebSocket对象,Servlet 的职责被压缩成了三个 JSON 接口——登录验证、注册、用户列表,这种分工在很多旧项目里都能看到,简单但没有歧义。
3. 核心模块拆解:WebSocket 连接管理、消息路由与历史记录加载
3.1 WebSocket 端点:从@ServerEndpoint到会话注册表的完整实现
整个系统的心脏在ChatWebSocket.java。这个类标注了@ServerEndpoint("/chat/{userId}"),路径参数userId用来标记当前连接属于哪个用户,从而建立「用户 ID → WebSocket Session」的映射表。
@ServerEndpoint("/chat/{userId}") public class ChatWebSocket { // 全局在线连接表,key 为用户 ID,value 为 WebSocket Session private static ConcurrentHashMap<Integer, Session> onlineUsers = new ConcurrentHashMap<>(); @OnOpen public void onOpen(Session session, @PathParam("userId") Integer userId) { onlineUsers.put(userId, session); System.out.println("用户 " + userId + " 已建立连接,当前在线数:" + onlineUsers.size()); } @OnMessage public void onMessage(String jsonMessage, Session session) { // 把 JSON 消息解析成 Message 对象 Message msg = JsonUtil.parseObject(jsonMessage, Message.class); Integer toUserId = msg.getToUserId(); Session targetSession = onlineUsers.get(toUserId); if (targetSession != null) { // 目标在线:实时推送,并对原消息标记为已读 msg.setIsRead(1); targetSession.getBasicRemote().sendText(jsonMessage); msg.setIsRead(0); // 回执给发送方,告知消息已送达 session.getBasicRemote().sendText(JsonUtil.toJson(msg)); } else { // 目标离线:消息进入 t_message 表,等对方登录后拉取 msg.setIsRead(0); } // 无论是否在线,都落库保存一份 MessageDao.save(msg); } @OnClose public void onClose(Session session, @PathParam("userId") Integer userId) { onlineUsers.remove(userId); // 同步更新数据库在线状态 UserDao.updateOnlineStatus(userId, 0); } @OnError public void onError(Session session, Throwable error) { error.printStackTrace(); } }ConcurrentHashMap的选择非常关键,因为 WebSocket 容器会为每个连接分配一个独立的线程,并发写入 map 时,HashMap 在扩容阶段会导致死循环,而ConcurrentHashMap的锁分段机制在这里恰到好处。@OnMessage方法处理的就是聊天消息的唯一入口,先判断对方是否在线,在线就直接通过目标 Session 推送,离线则只落库。这里值得留意的一个细节是:消息无论发送成功与否都会写进数据库,这个设计避免了因 WebSocket 连接意外断开而导致的数据丢失。我在改这个项目时,曾经尝试过只在对方在线时才落库的优化,结果遇到一次目标用户在线但网络闪断的情况,消息既没推送成功也没入库,那一刻我才意识到「会话在线」和「消息可达」是两件事,绝不能混为一谈。
3.2 前端客户端:WebSocket 连接、心跳保活与 DOM 渲染
前端 chat.js 是整个交互的门面,连接逻辑用了原生 WebSocket API,没有封装,直接看代码就能知道握手过程长什么样。连接地址的拼接有一个容易翻车的点:ws://协议和http://不能混用,如果用http://localhost:8080/chat/1去连 WebSocket,浏览器会直接抛SyntaxError。项目里的拼接逻辑是ws://+location.host+/chat/+ userId,其中location.host自动带上了端口号,保证和部署路径一致。
var ws = new WebSocket("ws://" + location.host + "/chat/" + userId); ws.onopen = function () { console.log("连接已建立"); // 心跳包,每 30 秒发一次,防止连接被服务端空闲断开 setInterval(function () { ws.send(JSON.stringify({ type: "ping" })); }, 30000); }; ws.onmessage = function (event) { var msg = JSON.parse(event.data); // 区分消息类型:0 为系统通知,1 为文本消息 if (msg.type === 0) { showSystemNotification(msg.content); } else { renderMessage(msg); } }; function renderMessage(msg) { var isSelf = msg.fromUserId === currentUserId; var bubbleClass = isSelf ? "bubble-self" : "bubble-other"; var html = '<div class="' + bubbleClass + '">' + '<span class="nickname">' + msg.nickname + '</span>' + '<p>' + escapeHtml(msg.content) + '</p>' + '<span class="time">' + msg.sendTime + '</span></div>'; document.getElementById("chat-box").innerHTML += html; // 滚动到底部 document.getElementById("chat-box").scrollTop = document.getElementById("chat-box").scrollHeight; }心跳包这个设计很容易被忽略,但实际部署时踩坑最多。Tomcat 的 WebSocket 容器默认有一个空闲超时时间,如果连接在 60 秒内没有任何数据交互,容器会主动关闭连接。加了这 30 秒一次的ping空消息,连接就一直处于活跃状态。escapeHtml方法的必要性在于防止 XSS 注入——用户消息里如果包含<script>标签,直接插进 innerHTML 就会执行恶意代码,转义成<script>之后就安全了。这两处细节,一个保连接,一个保安全,是聊天应用绝不能省的基础设施。
3.3 历史记录加载:拆分查询时间窗口,避免一次请求拖垮服务端
用户进入聊天页面后,前端发起 AJAX 请求到HistoryServlet,参数是当前用户 ID 和目标用户 ID。Servlet 调用MessageDao.findHistory(userId, targetId, page, size)执行分页查询,默认每页 20 条,按send_time倒序排列,返回最近的历史消息。
这个分页的设计我一直觉得是这个项目里最被低估的一个细节。很多新手写聊天记录时,习惯一次性SELECT * FROM t_message WHERE ...全量加载,数据量小的时候没问题,但一旦聊了几个月、上万条记录,浏览器渲染 DOM 时直接卡死。而这里做的分页,默认只加载最近 20 条,用户上滑滚动到底部时再触发加载更早的 20 条,服务压力被平滑分摊。我在优化这个项目时,把查询从一次加载改成了滚动分页,首屏渲染时间从 2 秒降到了 300ms 以内。如果你手头要处理的记录量在十万级以上,这个习惯非常值得养成。
public List<Message> findHistory(int myId, int targetId, int page, int size) { int offset = (page - 1) * size; String sql = "SELECT * FROM t_message " + "WHERE (from_user_id = ? AND to_user_id = ?) " + "OR (from_user_id = ? AND to_user_id = ?) " + "ORDER BY send_time DESC LIMIT ? OFFSET ?"; try (PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, myId); ps.setInt(2, targetId); ps.setInt(3, targetId); ps.setInt(4, myId); ps.setInt(5, size); ps.setInt(6, offset); ResultSet rs = ps.executeQuery(); // 封装成 List<Message> 返回 } catch (SQLException e) { log.error("查询聊天记录失败", e); return Collections.emptyList(); } }查询条件里两个方向的组合条件一定要用括号括起来,OR的优先级低于AND,如果不加括号,SQL 会变成(from_user_id = ? AND to_user_id = ? OR from_user_id = ?) AND to_user_id = ?,逻辑直接错掉,查询结果会漏掉一半消息。这是写 SQL 的老生常谈,但我每次带着学生做这个功能时都还能看到有人在这里翻车。
3.4 在线状态同步:一个布隆过滤器代替不了 ConcurrentHashMap 的场景
用户列表接口返回的是所有注册用户的 ID、昵称和在线状态,聊天主界面左侧就是这份在线用户列表。实时更新在线状态有两种设计思路:一种是由服务端在用户登录、注销时广播状态变化,所有在线用户的页面即时刷新;另一种是前端每隔几秒轮询一次UserListServlet。
这个项目采用的是第二种,因为一对一聊天场景下,在线用户列表的实时性要求并不高——用户不会频繁上下线,状态延迟 3 秒完全感知不到。但要注意,这个轮询机制和 WebSocket 连接是两个独立通道,如果没有心跳包保活 WebSocket,页面开着但连接已经被服务端回收,就会出现列表显示在线但消息发送失败的情况。我在部署时遇到过一次这样的玄学:一边用 WebSocket 收发消息,一边用 HTTP 轮询在线状态,两边状态不一致,折腾半天最后发现是 session 超时和 WebSocket 容器空闲超时的时间配置不一致导致的。把 Tomcat 的keepAliveTimeout调到了 120 秒,心跳包间隔保持 30 秒,两个通道的步调终于对上了。
4. 本地运行与部署验证:从源码到跑通的每个步骤
4.1 环境准备清单与版本兼容性问题
运行这个项目需要的环境非常基础:JDK 8 或更高版本、Maven 3.6+、Tomcat 8.5 或 9.x、MySQL 5.7 或 8.x,以及一个现代浏览器(Chrome、Edge、Firefox 都行)。有一个容易忽略的兼容性问题:如果你用的是 Tomcat 10,包名从javax.*改成了jakarta.*,WebSocket 注解的导入路径会变化,直接编译会报错。建议你直接用 Tomcat 8.5 或 9.0,省去一堆迁移的麻烦。
依赖方面,项目只用到了几个基础库,核心依赖如下:
<dependencies> <!-- Servlet API --> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>3.1.0</version> <scope>provided</scope> </dependency> <!-- WebSocket API, Tomcat 内置实现 --> <dependency> <groupId>javax.websocket</groupId> <artifactId>javax.websocket-api</artifactId> <version>1.1</version> <scope>provided</scope> </dependency> <!-- MySQL 驱动 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>5.1.49</version> </dependency> <!-- JSON 处理 --> <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>1.2.83</version> </dependency> </dependencies>注意 Servlet 和 WebSocket API 的 scope 都是provided,因为 Tomcat 容器自身已经带了实现,如果打成 WAR 包时把这两个依赖也打进去,部署后会在类加载阶段出现冲突,启动时直接抛NoClassDefFoundError。这是一个新手极容易踩的坑,我见过有人在本地 IDE 里跑得好好的,一部署到 Tomcat 就起不来,原因就是这个。
4.2 数据库初始化和配置文件修改:别直接用默认密码
拿到源码后,先建库建表,然后修改 JDBC 配置。项目的数据库连接参数集中在DBUtil.java中:
private static final String URL = "jdbc:mysql://localhost:3306/chat_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false"; private static final String USERNAME = "root"; private static final String PASSWORD = "your_password";有几个参数值得解释一下:useUnicode=true&characterEncoding=utf8保证中文消息在数据库存储和读取时不会乱码;serverTimezone=Asia/Shanghai是 MySQL 8.x 必需的,否则 JDBC 驱动会因时区不明确而报错;useSSL=false让本地开发不需要生成 SSL 证书。把PASSWORD改成你自己的 MySQL 密码,然后执行sql/init.sql脚本建表。注册用户时,密码会以 SHA-256 哈希写入,登录时同样做哈希对比,所以即使数据库泄露,也无法直接拿到明文密码。
4.3 启动步骤:Maven 打包、Tomcat 部署、双用户验证
整个启动流程我整理成五个步骤,按顺序执行基本不会遇到困难:
第一步,在项目根目录执行mvn clean package -DskipTests,会在target目录生成chat.war。如果你不想用 Maven,可以直接用 IDE 的 Artifact 配置导出 WAR。
第二步,将target/chat.war复制到 Tomcat 的webapps目录下,启动 Tomcat 后会自动解压部署。访问http://localhost:8080/chat/就能看到登录页。
第三步,在注册页注册两个账号,比如 A 和 B,页面会跳转到聊天主界面。这一步的作用是验证注册接口和用户列表接口是否正常工作。
第四步,打开两个浏览器窗口,分别用 A 和 B 登录,在 A 的界面上点击 B 的用户名,输入消息发送,切到 B 的窗口查看是否收到。两个窗口如果都在同一台机器上,注意用无痕窗口区分 session,否则后登录的用户会覆盖前一个用户在当前浏览器中的会话状态,导致用户身份混乱。
第五步,验证历史记录:发送几条消息后刷新页面,重新进入聊天窗口,消息应该能从数据库加载出来。这是检验消息落库是否生效的标准步骤。
4.4 验证清单:怎么判断系统真的跑通了
跑通这个项目后,我建议按下面这个清单逐项检查,每一条都过才算部署成功:
| 验证项 | 操作方式 | 期望结果 |
|---|---|---|
| 注册功能 | 注册新账号 | 提示成功并跳转登录页 |
| 登录功能 | 输入已注册账号 | 登录成功进入聊天界面 |
| 在线状态 | A 登录后看 B 的用户列表 | B 的状态显示为在线或离线 |
| 单聊收发 | A 发消息给 B | B 的页面立即弹出消息气泡 |
| 历史记录 | 刷新页面后重新进入 | 之前的消息从数据库重新加载 |
| 离线消息 | B 不登录,A 发消息给 B | B 登录后能收到未读消息 |
这里第 3 条和第 6 条的验证方式需要特别说明。在线状态列表的更新依赖轮询接口,不是即时推送,所以 A 登录后 B 的用户列表可能延迟 2~4 秒才刷新,这是设计使然,不是 bug。而离线消息是指 B 在未登录的状态下,A 通过用户列表选到 B 并发送消息,这时消息只落库不推送,等 B 下次登录加载历史记录时,那条消息会以未读状态出现。
5. 常见问题排查与避坑记录
5.1 WebSocket 连接失败:浏览器报WebSocket connection to 'ws://...' failed
这是部署阶段最常见的错误,表现是聊天页面打开后控制台显示连接失败,消息发不出去。排查思路按顺序来:先确认访问路径的协议,页面如果是通过https://打开的,WebSocket 就必须用wss://,而 Tomcat 默认是 HTTP,所以前端连接串里的ws://和http://必须匹配;再确认路径是否正确,/chat/{userId}中的 userId 必须是数字,如果传入的字符串包含非数字字符,@PathParam解析会失败并抛出异常;最后看浏览器开发者工具里的 Network 面板,WS 请求的状态码如果是 404,说明端点的访问路径和 chat.js 里拼接的路径不一致。我遇到过一个项目,前端是在 HTTPS 域名下部署的,但 WebSocket 地址写死了ws://,浏览器直接拦截了混合内容,连接始终建立不起来。
5.2 中文消息乱码:从请求参数到数据库到响应全链路排除
乱码问题在聊天系统里几乎必现,尤其是学 JDBC 的时候,字符集配置不对是最容易翻车的地方。现象是发送中文消息后,数据库里存的是???,或者从数据库读取后显示乱码。原因链有三层:第一层是数据库连接 URL 没有加characterEncoding=utf8,Java 字符串转字节时用了默认的ISO-8859-1编码;第二层是数据库表本身没有设置 utf8 字符集,建表时没有指定DEFAULT CHARSET=utf8mb4;第三层是 JSP 页面的pageEncoding没有配好。解决方法是三层同时修正:URL 加编码参数、建表语句末尾加ENGINE=InnoDB DEFAULT CHARSET=utf8mb4、JSP 头部声明contentType="text/html; charset=UTF-8"和pageEncoding="UTF-8"。
还有一层是 Tomcat 的 URI 编码配置。如果前端通过 GET 请求传递中文参数(比如昵称),Tomcat 默认按ISO-8859-1解码 URI,中文直接变成乱码。在server.xml的 Connector 节点上加URIEncoding="UTF-8"属性可以解决。这个坑很隐蔽,因为 POST 请求的表单参数不受影响,只有 GET 参数会翻车,排查时很容易忽略。
5.3 用户列表显示全部在线或者全部离线,状态刷新不及时
用户列表的在线状态依赖登录、注销时更新数据库的online字段,以及前端轮询拉取。出现全部在线的现象,通常是 WebSocket 连接在@OnClose时没有执行UserDao.updateOnlineStatus(userId, 0)。可能是浏览器直接关闭了标签页,导致 WebSocket 的 close 事件没有来得及触发,服务端没有感知到连接断开。这是 HTTP 轮询类系统的通病,因为轮询有延迟窗口,而 WebSocket 的 close 回调理论上应该可靠,但页面崩溃时例外。解决思路有两个:一个是服务端定期遍历onlineUsers映射,检查每个 Session 的isOpen()状态,发现失效连接就主动清理;另一个是依赖前端心跳包,服务端在onMessage里记录每个连接的最后活跃时间,超过 90 秒没有ping就判定该连接死亡。
我实际用下来,第二种方法更可靠。Session.isOpen()只能检测已知的关闭状态,如果网络链路断开但 TCP 层没有感知,Session 仍然会长时间保持 open 状态。而心跳保活机制本身就蕴含了「最后活跃时间」这个维度,只需要在@OnMessage里顺手更新一下 map 的时间戳,再起一个定时任务扫描超时连接即可。
5.4 消息发送成功但对方收不到:在线状态和连接状态的两个误区
这个现象让人血压飙升,A 给 B 发消息,显示发送成功,但 B 的页面上没有任何反应。排查的第一步是看 B 的 WebSocket 连接是否还活着,很多情况下 B 的页面停留在很久之前的会话,连接已经被服务端回收,但前端没有重连机制。第二步是检查消息路由逻辑,B 是否真的出现在onlineUsers这个 map 中。有一种非常典型的情况:B 在同一浏览器打开了两个标签页,第二个标签页的@OnOpen会覆盖 map 中 B 的 Session,此时第一个标签页收到的推送实际发送到了第二个标签页的连接上,而第二个标签页可能没有渲染消息的函数。
如果只允许一个用户在一个浏览器里开一个窗口,这个冲突不会出现。如果需要支持多标签页,就需要在服务端维护一个用户 ID → Session 集合的结构,广播时遍历整个集合。这是这个项目的一个扩展点,也是很多开发者在自己改造时第一句话问的问题——如何支持多端登录。答案很简单:把ConcurrentHashMap<Integer, Session>改成ConcurrentHashMap<Integer, List<Session>>,推送时遍历列表逐个发送即可。
5.5 消息重复发送或者重复落库:前端防抖与服务端幂等
这个问题是测试聊天功能时最常见的「玄学」。连续点击发送按钮,数据库里出现了两条一模一样的消息,甚至对方收到两条。原因基本都在前端:按钮点击事件没有防抖处理,或者 WebSocket 连接刚建立时用户就立刻点击发送。第一次握手尚未完成,ws.send()会抛异常,而用户看到没反应又点了第二次,服务端收到两条。
解决方案分三个层面:第一层是按钮点击后立即禁用,直到收到服务端回执再恢复;第二层是前端生成一个消息 UUID,onMessage里对最近 100 条消息的 UUID 做去重;第三层是服务端在落库前检查同一条消息(同一 UUID)是否已经存在。这个项目当前实现了第二层的去重逻辑,但没有做服务端幂等,意味着在极端情况下,网络重发仍可能重复落库。如果你打算在生产环境使用,建议在t_message表增加一个msg_uuid唯一索引,配合服务端检查,双保险。
5.6 内存溢出风险:当在线连接数突破 JVM 默认堆大小
当一个 Tomcat 实例承载的连接数超过几百个,且每个连接维护一个 Session 对象时,ConcurrentHashMap带来的内存压力会逐渐显现。每个 WebSocket Session 内部包含 Socket 连接缓存、消息缓冲区,默认缓存 10 条未发送消息,如果某个用户的客户端接收速度慢于服务端发送速度,内存占用会持续攀升。这是长连接应用的通病,解决的有效手段是限制单条消息大小(比如@OnMessage(maxMessageSize = 8192))和降低缓冲区大小(@OnOpen中调用session.setMaxTextMessageBufferSize(8192))。另外,每次发送完消息后及时清理不再使用的局部变量,避免消息对象被长期引用。
从架构角度看,单机单 Tomcat 承载 200 个并发连接已经是这个项目的合理上限。如果业务量超出这个范围,要么引入消息中间件做负载均衡,要么改为集群部署配合 Redis 共享 Session——这是另一个层面的扩展问题了。
6. 二次开发引导:把单聊系统改造成可用的私信模块的三个方向
这个项目的底子打得很干净,没有大量冗余代码,也没有过度抽象,所以在它基础上做二次开发非常顺手。我基于自己的改造经验,给你三个最值得动手的方向,以及每个方向的具体实现思路。
第一个方向是离线消息的推送完善。当前版本中,离线消息只在对方登录后通过历史记录查询看到,但不会实时弹出提醒。你可以加一个「消息盒子」:在UserDao.findByUsername登录成功后,调用MessageDao.findUnread(userId)拉取所有is_read = 0的消息,在聊天主界面右下角弹出一个 toast 提示。这个改动只涉及一个 DAO 方法和一个前端展示模块,却能把用户体验提升一大截。
第二个方向是聊天记录的全文检索。当前的历史记录查询只能定位到两个人的完整会话,但没法通过关键词搜索。你可以基于MessageDao增加一个searchMessages(userId, keyword, page, size)方法,SQL 语句用LIKE CONCAT('%', ?, '%')实现模糊查询,同时配合 MySQL 的FULLTEXT索引提高搜索效率。接上这个能力后,聊天系统就从一个单纯的信息通道变成了可追溯的信息资产。
第三个方向是文件传输。虽然t_message表里预留了msg_type字段(1 文本、2 图片、3 系统通知),但代码里目前只实现了文本类型的收发。你的改造重点可以放在ChatWebSocket.onMessage中按msg_type分支处理:图片类型走内容上传接口,拿到 URL 后把这个 URL 放进消息内容;对方前端收到消息后检查msg_type === 2时渲染<img src="...">而非文本气泡。这个扩展在复用现有消息结构时几乎不碰数据库表。
改造完这三个方向后,这个系统的能力边界就从一个练手级的单聊 Demo,贴近了真实业务里的私信模块。我在改造过程中学到的最重要一条经验是:每加一个功能前,先从前端页面倒推需要后端提供什么接口,而不是先把接口写好再去填前端。这样能最大限度减少前后端联调时的返工。从那以后,我每次在这个项目上加功能,都会强制自己先写一份接口文档,把所有入参、返回值和异常情况列清楚再动代码,这个习惯省下的时间远比写文档花掉的时间多。希望帮到你。
本文还有配套的精品资源,点击获取