首页 / 资讯中心 / 文章详情

JSP+Servlet多人聊天系统:AJAX轮询与增量消息拉取实战

JSP+Servlet多人聊天系统:AJAX轮询与增量消息拉取实战 ★ FEATURED ARTICLE
简介一套基于JSP与Servlet的网页多人聊天系统源码包面向正在学习Java Web、希望理解多用户实时会话与消息广播机制的初、中级开发者。项目围绕聊天页面、服务端请求处理、会话状态维护与客户端无刷新交互展开可用于课程设计参考、小型交流模块原型搭建或Java Web综合练习。压缩包共11个文件约12KB包含2个java源文件、6个class编译文件以及工程类路径、项目描述与JDT首选项等Eclipse配置便于导入IDE后查看源码、编译产物与工程结构的对应关系。已有677人浏览学习。受包体较小的限制它更适合作为精简示例帮助读者梳理JSP页面、Servlet控制流程与前后端数据传递的基本脉络并在此基础上补充数据库存储、在线用户管理和界面样式等模块逐步完善可运行的多人聊天应用。1. 多人聊天系统JSP 加 Servlet 这套老组合跑通一次比看十篇原理强前段时间有个做课程设计的朋友找我说他的 Java 多人聊天系统卡在一个特别原始的地方A 窗口发出去的消息B 窗口刷新一下才能看见不刷新就永远是死的。我让他把项目发过来一看是 JSP 页面套着 ServletAJAX 没写定时器也没写消息发完只是往数据库一插就完事了。这个问题其实特别典型——很多人手里这套 JSP 多人聊天系统的骨架是对的缺的只是「怎么把新消息推到页面上」这一小段链路没打通。这类项目适合谁适合想把 Servlet 会话、JDBC 落库、AJAX 轮询这三件事串成一条完整链路的人也适合拿它当二次开发底座、往里加房间、私聊、表情、已读的开发者。它不适合直接拿去扛几千人在线那得上 WebSocket 加消息队列。但作为一套能跑、能改、结构清楚的 Java Web 项目它的价值在于每一层都摊开在你面前不用猜框架帮你做了什么。2. 请求链路与目录结构一条消息从 JSP 输入框到 MySQL 再回到页面2.1 分层设计与一次完整请求的走向这套多人聊天系统没有用 Spring 那一套走的是最朴素的 Servlet JSP JDBC。分层很清楚JSP 负责出页面Servlet 负责收请求和校验DAO 负责碰数据库model 是纯数据对象filter 管编码和登录拦截。浏览器这边靠 jQuery 发 AJAXServlet 回一段 JSON前端把消息节点追加到列表里。具体到「发一条消息」这件事链路是这样的用户在 chat.jsp 的输入框敲字回车chat.js 拦下表单提交$.post打到/send请求先过EncodingFilter统一成 UTF-8再过LoginFilter检查 session 里有没有loginUser没有就回 401SendMessageServlet拿到内容做长度和空值校验组装成Message对象交给MessageDao.save()DAO 用PreparedStatement插库并返回自增主键Servlet 回{code:200,id:123}前端拿到 id 之后清空输入框。「收一条消息」是反过来的方向。chat.jsp 加载完成后chat.js起一个 2 秒的定时器带着当前的sinceId去打/pollPollMessageServlet转给MessageDao.listAfter(roomId, sinceId, 50)SQL 是WHERE room_id ? AND id ? ORDER BY id ASC LIMIT 50查出来拼成 JSON 数组回给前端前端逐条渲染并把sinceId推到最新那条的 id 上。下一次请求就只会拿比这个 id 更大的消息天然做到增量。为什么选轮询而不是 WebSocket因为这套项目的定位是「跑得起来、看得懂」。Tomcat 7 之后虽然支持 WebSocket但要引入ServerEndpoint、维护连接集合、处理断线重连代码量翻一倍新手第一次跑大概率卡在依赖和容器版本上。轮询的代价是延迟 2 秒左右、请求数多一些但链路是同步的出问题看 Tomcat 日志一眼就能定位。等把功能改顺了再换 WebSocket那是第 6 章要聊的事。2.2 目录结构与关键文件职责工程按 Maven 的标准 Web 结构摆源码在src/main/java页面和静态资源在src/main/webappSQL 脚本单独放一个目录方便第一次跑的人直接导入。chat-system/ ├── pom.xml ├── sql/ │ └── chat.sql # 建库建表 初始测试数据 └── src/main/ ├── java/com/example/chat/ │ ├── servlet/ │ │ ├── LoginServlet.java # 登录校验 写 session │ │ ├── RegisterServlet.java # 注册密码摘要后入库 │ │ ├── SendMessageServlet.java# 发消息校验 落库 │ │ ├── PollMessageServlet.java# 增量拉消息返回 JSON │ │ └── OnlineUserServlet.java # 在线用户列表 │ ├── dao/ │ │ ├── UserDao.java │ │ └── MessageDao.java │ ├── model/ │ │ ├── User.java │ │ └── Message.java │ ├── filter/ │ │ ├── EncodingFilter.java # 统一 UTF-8 │ │ └── LoginFilter.java # 拦未登录请求 │ └── util/ │ ├── DBUtil.java # 拿连接、关连接 │ ├── JsonUtil.java # 手写 JSON 转义 │ └── OnlineHolder.java # ServletContext 里的在线表 └── webapp/ ├── WEB-INF/web.xml ├── login.jsp ├── register.jsp ├── chat.jsp ├── css/chat.css └── js/chat.js几个文件值得单独说一句。OnlineHolder不是数据库表它把在线用户挂在ServletContext上key 用 sessionId这样同一个账号开两个浏览器窗口不会互相覆盖重登也不会把旧记录顶掉。JsonUtil看起来多余但它是后面第 5 章「消息里有引号就整段崩」那个坑的解药手拼 JSON 时所有字符串都要过它。文件职责第一次跑要不要改DBUtil.java数据库地址、账号、密码必须改chat.sql建表与初始数据直接导入web.xml欢迎页、过滤器注册一般不用chat.js轮询间隔、接口地址按需调pom.xml依赖版本、打包方式看 JDK 版本2.3 从零把环境跑起来先把依赖和容器对齐JDK 8、Maven 3.6 以上、Tomcat 8.5 或 9、MySQL 5.7 或 8.0。Tomcat 10 要慎重它把javax.servlet换成了jakarta.servlet这套代码里所有import javax.servlet.*都会编译不过这个坑在第 5 章还会展开。第一步建库导表。用 root 登录 MySQL 执行脚本脚本里已经写了CREATE DATABASE chat DEFAULT CHARSET utf8mb4不用手动建库。mysql -uroot -p sql/chat.sql第二步改数据库连接。打开DBUtil.java把地址、账号、密码换成自己机器上的。连接串里三个参数别删useUnicodetruecharacterEncodingutf8保证中文不乱码serverTimezoneAsia/Shanghai保证时间不差 8 小时useSSLfalse避免本地连接时的证书告警。第三步打包部署。mvn clean package -DskipTests cp target/chat.war $CATALINA_HOME/webapps/ $CATALINA_HOME/bin/startup.sh第四步验证。浏览器打开http://localhost:8080/chat/login.jsp用脚本里预置的测试账号登录一般是admin / 123456和一个普通账号登录成功会跳到chat.jsp。开两个不同浏览器或者一个正常窗口一个隐私窗口各登一个账号在 A 里发一句话B 页面 2 秒内应该自己冒出来。如果 B 没反应但 A 刷新后能看到自己的消息说明发送链路是通的问题出在拉取或前端渲染上直接去看/poll这个请求的返回值。提示部署后先看 Tomcat 的logs/catalina.out有没有启动异常再看浏览器 Network 面板里/poll的响应体。这两处能覆盖八成的首次运行失败。3. 核心模块拆解登录会话、消息落库、增量拉取三段代码3.1 登录与会话HttpSession 里存什么、在线表怎么挂登录这块最容易写歪的地方是把用户对象往 session 里一塞就完事然后在线列表单独维护一个静态 Map结果重启就乱、多开就串。这套项目的做法是session 里只放User在线表挂ServletContextkey 是 sessionId。WebServlet(/login) public class LoginServlet extends HttpServlet { private final UserDao userDao new UserDao(); Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding(UTF-8); String username req.getParameter(username); String password req.getParameter(password); // 空值先挡掉不然后面拼 SQL 参数没意义 if (username null || username.trim().isEmpty() || password null || password.isEmpty()) { req.setAttribute(error, 账号或密码不能为空); req.getRequestDispatcher(/login.jsp).forward(req, resp); return; } User user userDao.findByUsernameAndPassword(username.trim(), md5(password)); if (user null) { // forward 而不是 redirecterror 才能带到页面上 req.setAttribute(error, 账号或密码不正确); req.getRequestDispatcher(/login.jsp).forward(req, resp); return; } HttpSession session req.getSession(true); session.setAttribute(loginUser, user); session.setMaxInactiveInterval(30 * 60); // 30 分钟不动就过期 // 在线表挂在 ServletContext 上key 用 sessionId支持同账号多开 MapString, User online OnlineHolder.get(req.getServletContext()); online.put(session.getId(), user); resp.sendRedirect(req.getContextPath() /chat.jsp); } private String md5(String raw) { // 练手项目够用正式环境换 BCrypt别拿 MD5 存密码 return DigestUtils.md5Hex(raw); } }这段代码里有三个判断值得注意。第一空值校验放在查库之前避免拿null去拼参数。第二失败时用forward不用redirect因为request.setAttribute(error, ...)只在同一次请求里有效重定向会把它丢掉页面上就什么提示都没有新手经常在这里以为「登录没反应」。第三sendRedirect后面拼了req.getContextPath()因为 war 包部署后上下文路径是/chat硬编码/chat.jsp换个部署名就 404。在线表用ServletContext而不是静态变量是因为ServletContext跟随应用生命周期应用停了表就没了不会残留脏数据。清理动作放在LogoutServlet和 session 监听器里session 过期时把对应 key 移掉在线人数才不会只增不减。3.2 发消息校验、落库、只回一个 id发送接口的职责要收窄只负责校验和写库不负责返回整条消息内容。前端拿到 id 之后下一轮轮询自然会把它拉回来这样发送和展示只有一条数据通路不会出现「本地先插一条、服务端又推一条」的重复消息。WebServlet(/send) public class SendMessageServlet extends HttpServlet { private final MessageDao messageDao new MessageDao(); Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { resp.setContentType(application/json;charsetUTF-8); User loginUser (User) req.getSession().getAttribute(loginUser); if (loginUser null) { resp.getWriter().write({\code\:401,\msg\:\not login\}); return; } String content req.getParameter(content); // 空内容、超长内容都挡掉长度上限和前端 maxlength 保持一致 if (content null || content.trim().isEmpty() || content.length() 500) { resp.getWriter().write({\code\:400,\msg\:\content invalid\}); return; } long roomId 1L; try { roomId Long.parseLong(req.getParameter(roomId)); } catch (Exception ignore) { // 没传就进默认房间不抛异常 } Message msg new Message(); msg.setRoomId(roomId); msg.setUserId(loginUser.getId()); msg.setUsername(loginUser.getNickname()); msg.setContent(content.trim()); msg.setType(text); long id messageDao.save(msg); resp.getWriter().write({\code\:200,\id\: id }); } }参数上content前端用maxlength500限制后端再判一次两边必须一致不然用户粘贴超长文本会绕过前端。roomId缺省为 1是为了让单房间版本不用改前端就能跑后面加多房间只要前端把真实房间号带上。username存的是昵称而不是登录名这是展示用的冗余字段第 4 章建表时会解释为什么不 join。MessageDao.save里用PreparedStatement.RETURN_GENERATED_KEYS拿自增主键这个 id 是整套增量拉取机制的基石必须返回。public long save(Message m) { String sql INSERT INTO message(room_id, user_id, username, content, type) VALUES(?,?,?,?,?); Connection conn null; PreparedStatement ps null; ResultSet rs null; try { conn DBUtil.getConnection(); ps conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS); ps.setLong(1, m.getRoomId()); ps.setLong(2, m.getUserId()); ps.setString(3, m.getUsername()); ps.setString(4, m.getContent()); ps.setString(5, m.getType()); ps.executeUpdate(); rs ps.getGeneratedKeys(); return rs.next() ? rs.getLong(1) : -1L; } catch (SQLException e) { throw new RuntimeException(save message failed, e); } finally { DBUtil.close(conn, ps, rs); } }3.3 拉消息sinceId 游标与 JSON 转义拉取接口是这套系统的核心逻辑只有一句给我 id 比sinceId大的、指定房间的、最多 50 条消息按 id 正序。SQL 走(room_id, id)联合索引数据量到几十万行也是毫秒级。WebServlet(/poll) public class PollMessageServlet extends HttpServlet { private final MessageDao messageDao new MessageDao(); Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { resp.setContentType(application/json;charsetUTF-8); resp.setHeader(Cache-Control, no-store); // 别让浏览器缓存轮询结果 long sinceId parseLong(req.getParameter(sinceId), 0L); long roomId parseLong(req.getParameter(roomId), 1L); ListMessage list messageDao.listAfter(roomId, sinceId, 50); StringBuilder sb new StringBuilder({\code\:200,\list\:[); for (int i 0; i list.size(); i) { Message m list.get(i); if (i 0) { sb.append(,); } sb.append({\id\:).append(m.getId()) .append(,\user\:\).append(JsonUtil.escape(m.getUsername())).append() .append(,\content\:\).append(JsonUtil.escape(m.getContent())).append() .append(,\time\:\).append(m.getCreateTime()).append(\}); } sb.append(]}); resp.getWriter().write(sb.toString()); } private long parseLong(String raw, long def) { try { return Long.parseLong(raw); } catch (Exception e) { return def; } } }sinceId解析失败时回退到 0意思是「从头拉」第一次进页面就是这个行为。limit固定 50 是个保守值能保证一次响应不会太大也不会因为积压太多消息把页面卡死。真正要防的是JsonUtil.escape这一层消息里只要出现双引号、反斜杠、换行、script不转义就会把整段 JSON 结构撑破前端$.getJSON直接进 error 分支表现就是「聊天页面突然不刷新了」这个现象在第 5 章还会再提。DAO 那一侧就一行 SQL 的事public ListMessage listAfter(long roomId, long sinceId, int limit) { String sql SELECT id, room_id, user_id, username, content, type, create_time FROM message WHERE room_id ? AND id ? ORDER BY id ASC LIMIT ?; ListMessage result new ArrayList(); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setLong(1, roomId); ps.setLong(2, sinceId); ps.setInt(3, limit); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { result.add(mapRow(rs)); } } } catch (SQLException e) { throw new RuntimeException(list message failed, e); } return result; }3.4 前端轮询定时器、游标推进和防堆积前端这块决定了「消息会不会自己冒出来」。核心是三个变量sinceId记游标polling防请求堆积ctx存上下文路径。var sinceId 0; var polling false; var ctx $(#ctx).val(); // chat.jsp 里用一个隐藏域把 contextPath 带过来 function appendMessage(m) { var $item $(div classmsg-item/div); var $name $(span classmsg-user/span).text(m.user ); // 用 text() 而不是 html()消息里的标签不会被当成 DOM 执行 var $text $(span classmsg-content/span).text(m.content); var $time $(span classmsg-time/span).text(m.time); $item.append($name).append($text).append($time); $(#msgList).append($item); $(#msgList).scrollTop($(#msgList)[0].scrollHeight); } function poll() { if (polling) { return; // 上一次还没回来就跳过避免请求越堆越多 } polling true; $.getJSON(ctx /poll, { roomId: 1, sinceId: sinceId }) .done(function (res) { if (!res || res.code ! 200) { return; } (res.list || []).forEach(function (m) { appendMessage(m); if (m.id sinceId) { sinceId m.id; // 渲染成功之后才推进游标 } }); }) .always(function () { polling false; }); } $(function () { poll(); // 进页面先拉一次历史 setInterval(poll, 2000); // 之后每 2 秒一次 });游标的推进时机很关键必须放在appendMessage之后。如果先推进再渲染渲染抛异常的时候游标已经跑了那几条消息永远拉不回来用户刷新才能看见现象就是「偶尔丢消息」。polling标志解决的是另一个问题——网络慢的时候 2 秒内上一次请求没回来定时器又发一次越积越多最后 Tomcat 线程池被这一批请求占满整个应用卡死。发送侧就简单了$(#sendBtn).on(click, function () { var content $(#msgInput).val(); if (!content || !content.trim()) { return; } $.post(ctx /send, { content: content, roomId: 1 }, function (res) { if (res.code 200) { $(#msgInput).val(); // 清空输入框消息等下一轮轮询回来 } else { alert(发送失败 res.msg); } }, json); });4. 数据库与部署两张表、一个连接池和一次完整的 Tomcat 启动4.1 表结构设计与索引取舍整个项目两张表就够user和message。看着简陋但字段和索引的取舍都有说法。CREATE DATABASE IF NOT EXISTS chat DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_general_ci; USE chat; CREATE TABLE user ( id BIGINT NOT NULL AUTO_INCREMENT, username VARCHAR(32) NOT NULL COMMENT 登录名, password CHAR(32) NOT NULL COMMENT MD5 摘要, nickname VARCHAR(32) NOT NULL COMMENT 聊天展示名, avatar VARCHAR(255) DEFAULT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE message ( id BIGINT NOT NULL AUTO_INCREMENT, room_id BIGINT NOT NULL DEFAULT 1 COMMENT 房间号单房间版本恒为 1, user_id BIGINT NOT NULL, username VARCHAR(32) NOT NULL COMMENT 发送时昵称冗余字段, content VARCHAR(1000) NOT NULL, type VARCHAR(16) NOT NULL DEFAULT text, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_room_id (room_id, id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; INSERT INTO user(username, password, nickname) VALUES (admin, e10adc3949ba59abbe56e057f20f883e, 管理员), (tom, e10adc3949ba59abbe56e057f20f883e, 汤姆);第一个设计点是message.username冗余。拉消息的 SQL 里不 joinuser表展示名直接来自消息行。好处是查询只碰一张表、走一个索引代价是用户改昵称之后历史消息还是旧名字。聊天场景里这反而符合直觉——消息发出去那一刻的称呼就该定住。第二个设计点是idx_room_id (room_id, id)联合索引。WHERE room_id ? AND id ? ORDER BY id ASC正好能被这个索引完整覆盖扫的是索引区间不用回表排序。如果只建room_id单列索引ORDER BY id还得额外排一次序数据量大了就明显了。第三个设计点是字符集用utf8mb4而不是utf8。MySQL 的utf8只存三字节emoji 和部分生僻字插进去会直接报错或者变问号聊天项目里用户发个表情太正常了这个不改迟早翻车。4.2 连接管理与 DBUtil 的写法练手规模用DriverManager每次新建连接完全够用代码短、看得懂。它的上限也明确并发几十个轮询请求的时候每次建连接的开销会变成瓶颈。真要多人在线把getConnection()换成连接池的dataSource.getConnection()外面调用的代码一行不用改。public class DBUtil { private static final String URL jdbc:mysql://127.0.0.1:3306/chat ?useUnicodetruecharacterEncodingutf8 useSSLfalseserverTimezoneAsia/Shanghai; private static final String USER chat; private static final String PASS chat123; static { try { // MySQL 8 用 com.mysql.cj.jdbc.Driver5.x 用 com.mysql.jdbc.Driver Class.forName(com.mysql.cj.jdbc.Driver); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError(mysql driver not found); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASS); } public static void close(Connection c, Statement s, ResultSet r) { // 关闭顺序和打开顺序相反每个都单独 try前一个抛异常不影响后面的 if (r ! null) { try { r.close(); } catch (SQLException ignore) {} } if (s ! null) { try { s.close(); } catch (SQLException ignore) {} } if (c ! null) { try { c.close(); } catch (SQLException ignore) {} } } }连接串里characterEncodingutf8和建表时的utf8mb4不冲突驱动会按连接字符集协商。真正要留意的是serverTimezone不写这一项MySQL 8 驱动启动就抛The server time zone value is unrecognized很多人第一次跑就卡在这。写Asia/Shanghai之后CURRENT_TIMESTAMP落库和 Java 侧读出来的时间才对得上。4.3 web.xml 与过滤器注册Servlet 3.0 之后可以用WebServlet注解web.xml就只剩下欢迎页和过滤器这些不方便用注解的部分。过滤器顺序按声明顺序执行编码过滤器必须排在登录过滤器前面否则登录校验读参数的时候还是乱码。?xml version1.0 encodingUTF-8? 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_3_1.xsd version3.1 display-namechat-system/display-name welcome-file-list welcome-filelogin.jsp/welcome-file /welcome-file-list filter filter-nameencodingFilter/filter-name filter-classcom.example.chat.filter.EncodingFilter/filter-class init-param param-nameencoding/param-name param-valueUTF-8/param-value /init-param /filter filter-mapping filter-nameencodingFilter/filter-name url-pattern/*/url-pattern /filter-mapping filter filter-nameloginFilter/filter-name filter-classcom.example.chat.filter.LoginFilter/filter-class /filter filter-mapping filter-nameloginFilter/filter-name url-pattern/chat.jsp/url-pattern url-pattern/send/url-pattern url-pattern/poll/url-pattern url-pattern/online/url-pattern /filter-mapping session-config session-timeout30/session-timeout /session-config /web-appLoginFilter的url-pattern只列需要登录的路径别图省事写/*。写成/*会把login.jsp、register.jsp、css、js全拦下来未登录用户连登录页都打不开浏览器表现是「重定向次数过多」。这个坑在第 5 章单独说。EncodingFilter里除了req.setCharacterEncoding(UTF-8)还要resp.setCharacterEncoding(UTF-8)。GET 请求的参数编码在 Tomcat 8 之后默认就是 UTF-8但如果你把项目部署到 Tomcat 7就得去server.xml的 Connector 上加URIEncodingUTF-8否则 GET 带中文一定乱码。4.4 启动验证与最小冒烟流程打包部署之后按下面这个顺序验证每一步失败都能定位到具体层# 1. 确认端口和上下文路径默认 http://localhost:8080/chat/ curl -I http://localhost:8080/chat/login.jsp # 2. 用测试账号登录拿到 JSESSIONID curl -i -c cookie.txt -d usernameadminpassword123456 \ http://localhost:8080/chat/login # 3. 带着 cookie 发一条消息 curl -b cookie.txt -d contenthelloroomId1 \ http://localhost:8080/chat/send # 4. 拉一次消息确认刚才那条能查出来 curl -b cookie.txt http://localhost:8080/chat/poll?roomId1sinceId0第 2 步返回 302 并且Location指向chat.jsp说明登录和 session 都正常。第 3 步返回{code:200,id:N}说明落库成功。第 4 步返回的 JSON 数组里能看到刚才那条说明查询和序列化没问题。这四步过了剩下的问题基本都在前端渲染上打开浏览器控制台看有没有 JS 报错就行。注意curl测试时-c和-b要成对用第一次存 cookie 到文件后面每次带上否则每一步都是未登录状态/send会一直回 401。5. 排查避坑第一次跑这套聊天系统最容易翻车的五个点5.1 中文全变问号或者乱码方块现象登录页输入中文昵称聊天窗口里显示成???或者测试这样的串。原因有三层得逐个排查。第一层是请求体编码LoginServlet和SendMessageServlet里如果没写req.setCharacterEncoding(UTF-8)POST 参数按容器默认的 ISO-8859-1 解中文必乱。第二层是数据库连接连接串缺characterEncodingutf8驱动会用平台默认编码传数据。第三层是表字符集建表时用了utf8而不是utf8mb4emoji 直接插不进去。解决顺序是先确认EncodingFilter挂在了/*上并且排在登录过滤器前面再核对连接串里那两个参数最后SHOW CREATE TABLE message看字符集不对就ALTER TABLE ... CONVERT TO CHARACTER SET utf8mb4。5.2 消息重复出现或者偶尔丢一条现象A 发一句话B 那边同一条消息显示了两遍或者偶尔有一条消息谁都看不见刷新页面才出来。重复的原因一般是发送成功后前端手动appendMessage了一次下一轮轮询又拉回来一次。正确做法是发送接口只回 id展示完全交给轮询一条数据通路就不会重。如果确实想「发完立刻显示」那就要在本地维护一个已渲染 id 集合渲染前判重。丢失的原因多半在游标推进时机。sinceId m.id写在appendMessage之前某次渲染因为 DOM 异常中断游标已经跳过去了那几条就永远拉不回来。把游标推进放到渲染之后问题消失。还有一种隐蔽情况limit 50遇上积压。如果用户离开页面十分钟回来时积压了 200 条一次只拉 50 条且游标推到第 50 条剩下的要等下一轮看起来像「消息慢慢往外蹦」。想一次拉完就把 limit 调大或者在前端做「如果返回条数等于 limit立刻再拉一次」的循环。5.3 轮询把连接池和 Tomcat 线程打满现象人一多页面越来越卡最后所有接口都超时Tomcat 日志里出现大量连接等待或者Too many connections。原因是轮询频率和并发数相乘。2 秒一次、20 个人在线就是每秒 10 个请求每个请求用DriverManager新建一次连接再加上连接关闭不及时MySQL 的连接数很快见顶。如果finally里漏了close几分钟就能把max_connections耗尽。解决分三步一是把所有 DAO 的关闭逻辑统一走DBUtil.close用 try-with-resources 更稳二是把轮询间隔从 2 秒放到 3 到 5 秒或者做成「有新消息就快、没消息就慢」的自适应三是换连接池HikariCP 或 Druid 都行maximumPoolSize设 10 到 20getConnection()改成从池里取业务代码基本不动。5.4 登录之后所有 AJAX 都返回登录页 HTML现象登录成功进了聊天页但消息发不出去、列表也不刷新Network 面板里/poll的响应是一整段login.jsp的 HTML。原因基本是LoginFilter拦到了不该拦的路径或者 session 没带上。前者是把url-pattern写成了/*未登录时把login.jsp和静态资源也重定向了浏览器一直跳后者是 AJAX 跨域或者 cookie 被丢$.getJSON默认会带同源 cookie但如果页面地址是127.0.0.1而接口写的是localhost就变成了跨域cookie 不带session 拿不到。排查方法是在LoginFilter里打一行日志把requestURI和session.getId()打出来看每次请求到底带没带同一个 session。解决就是把过滤路径收窄到真正的业务接口前端所有请求统一用ctx /poll拼相对路径不写死域名。5.5 换个 Tomcat 版本就启动报 ClassNotFound现象本地 Tomcat 8.5 跑得好好的换到另一台机器的 Tomcat 10 上启动就报java.lang.ClassNotFoundException: javax.servlet.http.HttpServlet或者页面 404 但日志没明显错误。原因是 Tomcat 10 把 Servlet API 从javax.servlet换成了jakarta.servlet包名整体迁移。这套代码所有import javax.servlet.*和web.xml里的 schema 都是旧的在 Tomcat 10 上编译能过如果依赖里还是旧 API运行必挂。解决有两条路要么把容器降到 Tomcat 8.5 或 9最省事要么做一次包名迁移全局替换javax.servlet为jakarta.servletweb.xml的 schema 换成 5.0 版本pom.xml里javax.servlet-api换成jakarta.servlet-api并且确认 JDK 至少 8 以上。提示拿到任何一个 Java Web 项目先看pom.xml里的 servlet-api 是javax还是jakarta再看本机 Tomcat 大版本这两个不对齐后面所有调试都是白费。6. 二次开发从「能聊天」改到「能交付」的四条改造路线这套骨架最大的好处是改造点很明确不用在框架的迷宫里面找入口。我一般按「先加数据维度、再换传输通道、最后补体验」的顺序推。改造方向改动位置工作量关键注意点多房间/群组message.room_id、PollMessageServlet、前端房间切换小前端切房间时sinceId必须重置为 0私聊新增to_user_id字段、listAfter加条件中查询条件变成(room_id? OR to_user_id?)索引要重设计换 WebSocket新增ServerEndpoint、前端换new WebSocket()中在线表从ServletContext迁到ConcurrentHashMap注意线程安全历史消息分页新增/history接口按 id 倒序翻页小别和轮询的sinceId共用游标方向相反多房间是最容易上手的一个。数据库那边不用动room_id字段本来就留着只要把SendMessageServlet和PollMessageServlet里写死的1L换成前端传上来的真实值再在chat.jsp顶部加一排房间按钮。这里有个细节切换房间时前端的sinceId必须清零否则新房间的消息 id 比旧房间小会被id sinceId这个条件全部过滤掉页面看起来像「进了新房间一条消息都没有」。换 WebSocket 是很多人想做的下一步。Tomcat 8 以后原生支持加一个ServerEndpoint(/ws/{roomId})的类onOpen时把 session 存进ConcurrentHashMapString, SessiononMessage里落库之后遍历同房间的连接推出去。原来的/send接口可以保留做兜底前端优先用 WebSocket连不上再退回轮询。要注意的是Session的getBasicRemote().sendText()不是线程安全的多线程同时推必须加锁或者用getAsyncRemote()配合队列发送。我自己踩得最深的一次是给轮询加「未读消息数」。当时图省事在select count(*)里直接按create_time 上次登录时间算结果用户改系统时间、服务器时区不一致、消息时间精度只到秒三个问题叠在一起未读数一会儿多一会儿少特别玄学。后来改成用消息 id 做未读游标服务端存每个用户在每个房间的最后已读 id计数就是count(*) where id last_read_id问题一次消失。从那以后我每次给聊天类项目加「基于时间的状态」都会先问自己一句这个状态能不能换成基于自增 id 的游标能换就换时间的坑太多后悔药不好买。这套项目的sinceId设计就是这么来的希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站