简介:这是一套基于JavaWeb技术栈实现的一对一网页聊天系统源码,面向正在学习JSP、Servlet与Ajax异步通信的初学者及课程设计开发者,帮助理解浏览器与服务器之间实时消息交互的完整链路。压缩包共54个文件,约2.88MB,包含12个java源文件与12个class编译文件、8个jsp页面、7个jar依赖包以及4个xml配置,另附sql.txt数据库脚本,覆盖登录、注册、好友列表、聊天等模块。核心逻辑由TalkServlet处理消息发送、TalkFromServlet响应前端每秒一次的轮询请求,前端通过js与ajax完成消息展示与更新,后端依托Tomcat与MySQL运行,并采用c3p0连接池管理数据库连接。目前已有346人学习下载。资源完整呈现了从页面参数获取、请求分发到数据持久化的实现思路,适合作为理解Ajax轮询聊天机制的参考案例,也可在此基础上自行完善界面与功能。
1. 从零搭一套 JavaWeb 一对一网页聊天系统:为什么它比群聊更值得练手
很多人做 JavaWeb 项目时第一反应是搞个群聊,觉得人多热闹、功能全。但真上手你会发现,群聊的核心逻辑其实是"广播"——一个人说话,所有人收到,技术栈用 WebSocket 的session.getBasicRemote().sendText()遍历一遍就完事了,练不到什么真东西。一对一网页聊天系统不一样,它逼着你处理"消息该发给谁"这个问题:每个用户要有唯一标识、要有会话绑定、要有离线消息暂存、还要防止 A 发给 B 的消息被 C 截胡。这套东西做下来,你对 HTTP 会话、WebSocket 握手、线程安全、数据库设计的理解会直接上一个台阶。
这篇文章面向的是已经学过 JavaWeb 基础、能跑通 Servlet + JSP + MySQL 增删改查,但还没做过实时通信的开发者。我会从技术选型讲到数据库表设计,再到 WebSocket 服务端和前端页面的完整实现,最后把我在实际部署中踩过的坑一条条列出来。整套方案基于 SpringBoot + WebSocket + MySQL,用 IDEA 就能跑起来,不需要额外的中间件。做完之后你手里会有一个能注册登录、能选好友、能实时收发消息、能查历史记录的完整系统,拿去当课程设计或者简历项目都够用。
2. 技术选型与数据库设计:为什么不用轮询而选 WebSocket
2.1 轮询、长轮询、WebSocket 三种方案的真实差距
做网页聊天,第一个要拍板的就是通信方式。常见做法有三种:短轮询、长轮询、WebSocket。短轮询就是前端每隔两三秒发一次 AJAX 请求问"有没有新消息",实现最简单,但服务器压力大、消息延迟高,用户发完消息要等下一个轮询周期才能看到回复,体验很差。长轮询是请求发出去后服务器 hold 住不返回,直到有新消息或超时才响应,延迟降下来了,但每个在线用户都占着一个挂起的请求线程,并发一上来 Tomcat 的线程池直接被打满。
WebSocket 是 HTML5 带来的全双工协议,一次 HTTP 握手升级之后,连接保持不断,服务端和客户端可以随时互推消息。对于一对一聊天这种"消息触发频率不确定、要求低延迟"的场景,WebSocket 是最合适的。SpringBoot 从 2.x 开始对 WebSocket 的支持已经很成熟,用@ServerEndpoint注解或者WebSocketHandler接口都能快速搭起来。
我一般会选原生@ServerEndpoint方式,原因是它更贴近 Java WebSocket API(JSR-356),不依赖 Spring 的 STOMP 消息代理,少一层抽象,排查问题的时候链路更短。代价是要自己管理 session 映射和消息路由,但这恰恰是一对一聊天必须做的事。
2.2 数据库表设计:用户表、好友关系表、消息表
一对一聊天的数据模型不复杂,但有几个字段设计不好后面会很难受。核心三张表:
-- 用户表 CREATE TABLE `user` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL UNIQUE COMMENT '登录名', `password` VARCHAR(128) NOT NULL COMMENT 'BCrypt加密后的密码', `nickname` VARCHAR(50) DEFAULT NULL COMMENT '显示昵称', `avatar` VARCHAR(255) DEFAULT NULL COMMENT '头像URL', `online_status` TINYINT DEFAULT 0 COMMENT '0离线 1在线', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 好友关系表(双向存储,查起来快) CREATE TABLE `friendship` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `user_id` BIGINT NOT NULL COMMENT '拥有者', `friend_id` BIGINT NOT NULL COMMENT '好友', `remark` VARCHAR(50) DEFAULT NULL COMMENT '备注名', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY `uk_user_friend` (`user_id`, `friend_id`), KEY `idx_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 消息表 CREATE TABLE `message` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `from_user_id` BIGINT NOT NULL, `to_user_id` BIGINT NOT NULL, `content` TEXT NOT NULL, `msg_type` TINYINT DEFAULT 1 COMMENT '1文本 2图片 3文件', `is_read` TINYINT DEFAULT 0 COMMENT '0未读 1已读', `send_time` DATETIME DEFAULT CURRENT_TIMESTAMP, KEY `idx_from_to` (`from_user_id`, `to_user_id`), KEY `idx_to_read` (`to_user_id`, `is_read`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;用户表的password字段一定要留够长度,用 BCrypt 加密后是 60 个字符,别用 MD5——MD5 彩虹表早就烂大街了。online_status这个字段我建议保留,虽然 WebSocket 连接状态本身能判断在线,但数据库里存一份方便做离线消息推送和后台统计。
好友关系表用双向存储,A 加 B 为好友时插两条记录(A→B 和 B→A),查询"我的好友列表"时直接WHERE user_id = ?就行,不用 OR 条件,索引也能走得更顺。代价是加好友要插两条、删好友要删两条,用事务包起来就好。
消息表的索引设计是重点。idx_from_to用于查两人之间的历史消息,idx_to_read用于查"我有哪些未读消息"。注意content用 TEXT 而不是 VARCHAR,因为聊天消息可能很长,VARCHAR 超过一定长度会溢出到磁盘,性能反而更差。
2.3 SpringBoot 项目结构与依赖配置
项目用 Maven 管理,核心依赖就四个:
<dependencies> <!-- Web 基础 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- WebSocket --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency> <!-- MyBatis-Plus 简化 CRUD --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <!-- MySQL 驱动 --> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <!-- BCrypt 密码加密 --> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-crypto</artifactId> </dependency> </dependencies>这里没有引入完整的 Spring Security,只用了它的spring-security-crypto模块来做密码加密,避免引入整套安全框架带来的配置复杂度。对于课程设计级别的项目,用拦截器 + Session 做登录校验就够了。
包结构按职责分层:controller放 HTTP 接口,websocket放 WebSocket 端点,service放业务逻辑,mapper放数据访问,entity放实体类,config放配置类。这个结构不新鲜,但胜在清晰,后面加功能不会乱。
3. WebSocket 服务端实现:Session 管理与消息路由
3.1 @ServerEndpoint 端点的生命周期与线程安全
WebSocket 服务端的核心是一个用@ServerEndpoint注解的类。这个类有个关键特性:每次客户端连接都会创建一个新的端点实例,也就是说@OnOpen、@OnMessage、@OnClose这些方法里的this是每个连接独立的。但所有连接共享同一个静态的 Session 容器,所以容器必须是线程安全的。
@Component @ServerEndpoint("/ws/chat/{userId}") public class ChatEndpoint { // 静态共享:userId -> Session,用 ConcurrentHashMap 保证线程安全 private static final ConcurrentHashMap<Long, Session> ONLINE_SESSIONS = new ConcurrentHashMap<>(); // 每个连接独立的属性 private Long userId; private Session session; @OnOpen public void onOpen(Session session, @PathParam("userId") Long userId) { this.session = session; this.userId = userId; // 如果该用户已有连接,先关掉旧的(防止多标签页重复登录) Session old = ONLINE_SESSIONS.put(userId, session); if (old != null && old.isOpen()) { try { old.close(); } catch (IOException ignored) {} } // 更新数据库在线状态 // userService.updateOnlineStatus(userId, 1); System.out.println("用户 " + userId + " 上线,当前在线数:" + ONLINE_SESSIONS.size()); } @OnMessage public void onMessage(String message, Session session) { // message 是 JSON 字符串,解析出 toUserId 和 content JSONObject json = JSON.parseObject(message); Long toUserId = json.getLong("toUserId"); String content = json.getString("content"); // 1. 先落库 Message msg = new Message(); msg.setFromUserId(this.userId); msg.setToUserId(toUserId); msg.setContent(content); msg.setSendTime(new Date()); // messageService.save(msg); // 2. 构造推送消息 JSONObject push = new JSONObject(); push.put("fromUserId", this.userId); push.put("content", content); push.put("sendTime", System.currentTimeMillis()); // 3. 路由:如果对方在线就推,不在线就存离线 Session target = ONLINE_SESSIONS.get(toUserId); if (target != null && target.isOpen()) { try { target.getBasicRemote().sendText(push.toJSONString()); } catch (IOException e) { // 推送失败,从在线列表移除 ONLINE_SESSIONS.remove(toUserId); } } else { // 对方离线,消息已落库,标记为未读即可 // 下次对方上线时拉取未读消息 } } @OnClose public void onClose() { ONLINE_SESSIONS.remove(this.userId); // userService.updateOnlineStatus(this.userId, 0); System.out.println("用户 " + this.userId + " 下线"); } @OnError public void onError(Session session, Throwable error) { System.err.println("WebSocket 错误,用户 " + this.userId + ":" + error.getMessage()); ONLINE_SESSIONS.remove(this.userId); } }这段代码有几个关键点。第一,ONLINE_SESSIONS用ConcurrentHashMap,因为多个连接可能同时读写。第二,onOpen里用put的返回值处理重复登录——同一个用户开两个标签页,旧连接会被关掉,避免消息推送到错误的页面。第三,onMessage里先落库再推送,保证消息不丢。第四,推送失败时从在线列表移除,防止死连接堆积。
@PathParam("userId")从连接 URL 里取用户 ID,前端连接时写成ws://localhost:8080/ws/chat/123。这里有个安全隐患:用户 ID 是明文传的,别人可以伪造。生产环境应该在握手阶段用 token 校验,但课程设计级别先用 Session 拦截器在 HTTP 层做登录校验,WebSocket 连接时从 Session 里取用户 ID 更安全。我一般会在onOpen里通过session.getRequestParameterMap()拿 token 再校验一次。
3.2 消息的 JSON 协议设计与离线消息处理
前后端之间的消息格式必须提前定好,不然后面改起来牵一发动全身。我用的协议是这样的:
// 客户端发送 { "toUserId": 456, "content": "在吗?", "msgType": 1 } // 服务端推送 { "fromUserId": 123, "fromNickname": "张三", "content": "在吗?", "msgType": 1, "sendTime": 1700000000000 }msgType预留了扩展位,1 是文本,2 是图片(content 存 URL),3 是文件。sendTime用时间戳而不是格式化字符串,前端自己转成本地时间显示,避免时区问题。
离线消息的处理逻辑是:对方不在线时,消息已经落库且is_read = 0。当用户上线时,前端主动调一个 HTTP 接口拉取未读消息:
@GetMapping("/message/unread") public Result<List<MessageVO>> getUnread(@RequestParam Long userId) { // 查询 to_user_id = userId AND is_read = 0 的消息 List<Message> list = messageService.lambdaQuery() .eq(Message::getToUserId, userId) .eq(Message::getIsRead, 0) .orderByAsc(Message::getSendTime) .list(); // 拉取后标记为已读 if (!list.isEmpty()) { messageService.lambdaUpdate() .eq(Message::getToUserId, userId) .eq(Message::getIsRead, 0) .set(Message::getIsRead, 1) .update(); } return Result.ok(convertToVO(list)); }这里有个细节:拉取和标记已读最好放在同一个事务里,否则并发情况下可能重复拉取。另外,未读消息量大的时候要分页,别一次性全查出来。
3.3 用拦截器做登录校验与用户身份绑定
WebSocket 握手本质是一次 HTTP 请求,所以 HTTP 的拦截器对握手阶段是生效的。我一般会写一个HandlerInterceptor,在preHandle里检查 Session 中是否有userId,没有就返回 401。
@Component public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录、注册接口 String uri = request.getRequestURI(); if (uri.contains("/login") || uri.contains("/register")) { return true; } HttpSession session = request.getSession(); if (session.getAttribute("userId") == null) { response.setStatus(401); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"msg\":\"未登录\"}"); return false; } return true; } }然后在配置类里注册这个拦截器,把/ws/**也纳入拦截范围。这样 WebSocket 连接建立之前就会校验登录状态,未登录的连接直接握手失败。
注意:拦截器里不要用
response.sendRedirect跳登录页,WebSocket 握手不认重定向,直接返回 401 状态码让前端处理更可靠。
4. 前端页面与消息收发:从登录到聊天的完整链路
4.1 登录注册页面与 Session 保持
前端不用上 Vue/React 那套,原生 HTML + jQuery 就够,重点是逻辑清晰。登录页面提交表单到/user/login,后端校验成功后把userId写进 Session,返回用户信息。
// login.js $('#loginBtn').click(function () { const username = $('#username').val().trim(); const password = $('#password').val().trim(); if (!username || !password) { alert('用户名和密码不能为空'); return; } $.ajax({ url: '/user/login', type: 'POST', contentType: 'application/json', data: JSON.stringify({ username, password }), success: function (res) { if (res.code === 200) { // 登录成功,跳聊天页 localStorage.setItem('userId', res.data.id); localStorage.setItem('nickname', res.data.nickname); location.href = '/chat.html'; } else { alert(res.msg); } }, error: function () { alert('网络异常,请稍后重试'); } }); });这里把userId存到localStorage是为了 WebSocket 连接时拼 URL 用。但要注意,localStorage里的数据可以被篡改,所以服务端不能信任前端传来的userId,必须从 Session 里取。前端传的userId只用于建立连接路径,真正的身份以服务端 Session 为准。
4.2 WebSocket 连接建立与心跳保活
聊天页加载后第一件事就是建立 WebSocket 连接。但直接new WebSocket()有个问题:网络波动或者代理超时会导致连接断开,前端如果不重连,用户就收不到消息了。所以必须加心跳和重连机制。
// chat.js let ws = null; let heartbeatTimer = null; let reconnectTimer = null; function connectWebSocket() { const userId = localStorage.getItem('userId'); if (!userId) { location.href = '/login.html'; return; } ws = new WebSocket(`ws://${location.host}/ws/chat/${userId}`); ws.onopen = function () { console.log('WebSocket 已连接'); // 连接成功后拉取离线消息 loadUnreadMessages(); // 启动心跳,每 30 秒发一次 ping heartbeatTimer = setInterval(function () { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'ping' })); } }, 30000); }; ws.onmessage = function (event) { const msg = JSON.parse(event.data); if (msg.type === 'pong') return; // 心跳响应,忽略 appendMessage(msg); }; ws.onclose = function () { console.log('WebSocket 断开,5 秒后重连'); clearInterval(heartbeatTimer); // 避免重复重连 if (!reconnectTimer) { reconnectTimer = setTimeout(function () { reconnectTimer = null; connectWebSocket(); }, 5000); } }; ws.onerror = function (err) { console.error('WebSocket 错误', err); ws.close(); }; }心跳间隔设 30 秒是个经验值。太短了浪费资源,太长了 Nginx 默认 60 秒就会断开空闲连接。服务端收到{"type":"ping"}后回一个{"type":"pong"}即可,不需要落库。
重连逻辑里有个坑:onclose和onerror可能同时触发,导致重连定时器被设置两次。所以用reconnectTimer变量做去重,设置之前先判断是否已存在。
4.3 消息渲染、滚动定位与已读回执
消息渲染看起来简单,但细节很多。每条消息要区分"我发的"和"对方发的",显示不同的气泡样式;新消息到达时要自动滚到底部;历史消息加载时要保持滚动位置不跳。
function appendMessage(msg) { const myUserId = parseInt(localStorage.getItem('userId')); const isMine = msg.fromUserId === myUserId; const time = new Date(msg.sendTime).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }); const html = ` <div class="msg-row ${isMine ? 'msg-mine' : 'msg-other'}"> <div class="msg-bubble">${escapeHtml(msg.content)}</div> <div class="msg-time">${time}</div> </div> `; const $container = $('#msgContainer'); // 判断用户是否已经在底部,是则自动滚动 const isAtBottom = $container.scrollTop() + $container.innerHeight() >= $container[0].scrollHeight - 50; $container.append(html); if (isAtBottom || isMine) { $container.scrollTop($container[0].scrollHeight); } } // 防止 XSS:消息内容必须转义 function escapeHtml(str) { return str.replace(/[&<>"']/g, function (m) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m]; }); }escapeHtml这个函数千万别省。聊天消息是用户输入,直接innerHTML插入就是 XSS 漏洞,别人发一段<script>...</script>就能盗取你的 Session。这是血泪教训,我见过不止一个课程设计栽在这上面。
已读回执的实现:当用户打开某个好友的聊天窗口时,前端调/message/read接口,把该好友发来的所有未读消息标记为已读。对方那边如果想显示"已读",需要再推一条 WebSocket 消息过去,这个属于进阶功能,基础版可以先不做。
5. 避坑与排查:一对一聊天系统最容易翻车的五个地方
5.1 现象:消息偶尔丢失,刷新页面才看到
原因:onMessage里先推送再落库,推送成功但落库失败(比如数据库连接超时),消息就丢了。或者推送时对方刚好断线,sendText抛异常被 catch 吞掉,消息没存。
解决:严格按"先落库、后推送"的顺序。落库成功后再查在线状态决定是否推送。推送失败不影响落库结果,对方下次上线拉未读消息就能补上。另外,catch块里不要只打印日志,要把失败的 session 从在线列表移除,避免后续消息继续往死连接推。
5.2 现象:同一账号开两个浏览器标签,消息只在一个标签显示
原因:ONLINE_SESSIONS用put覆盖了旧 session,但旧连接没有关闭,两个连接同时存在,消息只推给了最后 put 进去的那个。
解决:onOpen里用put的返回值判断是否有旧连接,有就主动close()。同时前端在onclose里做重连时,要判断是不是被服务端主动踢下线的,如果是就不要重连,否则会陷入"踢掉-重连-再踢掉"的死循环。可以在关闭时传一个状态码,比如session.close(new CloseReason(CloseReason.CloseCodes.NORMAL_CLOSURE, "duplicate login")),前端根据状态码决定是否重连。
5.3 现象:Nginx 反代后 WebSocket 连接失败,报 400 或 502
原因:Nginx 默认不支持 WebSocket 升级,需要手动配置Upgrade和Connection头。另外,如果用了 HTTPS,前端必须用wss://而不是ws://。
解决:Nginx 配置里加这三行:
location /ws/ { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 3600s; # 防止空闲超时断开 }proxy_read_timeout默认 60 秒,聊天场景下用户可能几分钟不发消息,连接就被 Nginx 断了。设成 3600 秒,配合前端 30 秒心跳,基本不会断。
5.4 现象:消息内容里的 emoji 存到数据库变成问号
原因:MySQL 的utf8字符集只支持 3 字节,emoji 是 4 字节,存不进去。建表时用了CHARSET=utf8而不是utf8mb4。
解决:建表语句里明确写CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci。已经建好的表用ALTER TABLE message CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;转换。另外,JDBC 连接 URL 里也要加characterEncoding=utf8,MySQL 驱动 8.x 会自动协商成 utf8mb4。
5.5 现象:并发测试时消息乱序,A 发的两条消息 B 收到的顺序反了
原因:onMessage方法可能被多个线程同时调用(WebSocket 容器默认不是单线程处理消息),两条消息同时落库,数据库自增 ID 的顺序和实际发送顺序不一致。
解决:在@OnMessage方法上加synchronized关键字,保证同一个连接的消息串行处理。但注意,synchronized锁的是端点实例,不同连接之间不互斥,所以不会影响并发性能。如果对顺序要求极高,可以在消息表加一个客户端生成的seq字段,查询时按seq排序。
6. 进阶技巧:用 Redis 缓存在线状态与消息队列削峰
基础版跑通之后,如果想让系统更接近生产级别,有两个方向可以深入。第一个是用 Redis 替代ConcurrentHashMap存在线 session 映射。单机环境下ConcurrentHashMap够用,但一旦部署多个实例,用户 A 连在实例 1、用户 B 连在实例 2,A 发消息给 B 时实例 1 的内存里找不到 B 的 session,消息就推不过去。用 Redis 的 Hash 结构存userId -> instanceId映射,再配合 Redis 的 Pub/Sub 做跨实例消息广播,就能支持水平扩展。
第二个方向是消息队列削峰。当在线用户量大了之后,每条消息都同步落库会给 MySQL 造成压力。可以在onMessage里把消息先丢进 Redis List 或者 RabbitMQ,由消费者异步批量写库。这样 WebSocket 线程只负责推送,不被数据库 IO 阻塞。代价是消息可能短暂丢失(Redis 没持久化的话),所以要根据业务对可靠性的要求权衡。
我自己的习惯是:课程设计级别用ConcurrentHashMap+ 同步落库就够了,别过度设计;但如果是真实项目,Redis 存 session 映射几乎是必选项,因为单点部署的可用性太差,重启一次所有连接都断。另外,消息表的数据量增长很快,一对用户每天聊 100 条,一年就是 3 万多条,上线前就要考虑分表或者定期归档,别等到查询变慢了才想起来。
最后说一个验证方法:写完系统后,用两个浏览器(一个正常窗口、一个无痕窗口)分别登录两个账号,互相发消息,然后关掉一个窗口再发,重新打开看离线消息能不能补上。再模拟网络断开(Chrome DevTools 的 Network 面板切 Offline),看前端能不能自动重连。这两个场景过了,基本功能就稳了。希望帮到你。
本文还有配套的精品资源,点击获取