每年毕业季,“基于Java的即时聊天系统的设计与实现”这类题目都会火一轮。聊天系统演示效果好、技术点清晰——WebSocket、并发、数据库、前端交互全部覆盖,做出来的东西也看得见摸得着。但这恰恰也是它最大的陷阱:不少同学做到一半才发现,真正难的并不是“能聊天”,而是连接管理、消息路由、离线补拉这些看不见的底层逻辑。这篇内容我按做过的项目经验,把从需求拆分、数据库设计、WebSocket接入、消息收发链路、前端配合,到毕设报告和答辩准备的过程完整梳理一遍。源码级的示例代码会给,但更多是讲清楚每一步为什么这么做。适合正在准备毕业设计、面试项目复盘,或者想自己从零写一个聊天Demo的读者。
1. 开工前必须先想清楚:聊天系统的最小需求到底有哪些
1.1 我见过的最常见翻车现场
很多人在选题后第一件事,就是去搜“微信原型图”“QQ功能清单”,把语音通话、视频通话、朋友圈、表情商城、附近的人全部抄进需求文档。功能列表看着很唬人,实际开发两周后,能跑起来的只有登录注册,到答辩前一周才开始疯狂砍功能,最后连群聊都没保住,只剩下一个带WebSocket的单聊页面。
为什么会出现这种情况?因为需求分析阶段只看了“别人有什么”,没有问“我的核心场景是什么”。聊天系统的最小闭环是:一个用户能登录系统,找到另一个用户,给对方发一条消息,对方在线能实时收到、不在线能稍后收到。这个闭环的任何一环断了,系统都是不可用的。头像、昵称、签名只是锦上添花,不是骨架。
1.2 最小功能清单与可扩展功能清单
我建议在做需求分析时,直接把功能分成三档:必须、推荐、可选。
| 模块 | 功能 | 优先级 | 说明 |
|---|---|---|---|
| 用户 | 注册/登录 | 必须 | 注册成功即登录,后续鉴权用token或Session |
| 用户 | 个人信息修改 | 推荐 | 昵称、头像,用于聊天窗口展示 |
| 好友 | 好友列表 | 必须 | 单聊需要选择消息发送对象 |
| 消息 | 单聊实时收发 | 必须 | 核心中的核心 |
| 消息 | 群聊收发 | 必须 | 群成员之间广播消息 |
| 消息 | 历史消息 | 必须 | 刷新页面后仍可查看之前的记录 |
| 消息 | 离线消息 | 必须 | 对方不在线时消息不能丢 |
| 消息 | 未读计数 | 推荐 | 会话列表显示未读数量 |
| 扩展 | 文件/图片发送 | 可选 | 涉及文件上传与存储,可后续迭代 |
不要用“微信”“QQ”作为系统名,容易引来侵权争议,也容易被导师质疑工作量。系统名可以叫“轻聊系统”或者“XX学院即时聊天系统”,既朴素又贴合题目。
1.3 需求范围对后续开发的影响
需求边界直接影响数据库表设计。没有好友系统,就少一张好友表;没有图片发送,就少一套文件上传接口。开发前把需求砍到最小,但保留扩展余地。例如消息类型字段用int存储,0表示文本,1表示图片,2表示文件,后续想扩展时不用改表结构,只需要新增类型值和处理逻辑。
我自己的习惯是,把系统拆成四层来规划:用户层(注册登录、个人信息、好友关系)、会话层(单聊会话、群聊会话)、消息层(发送、接收、历史、离线)、辅助层(图片上传、未读计数、在线状态)。每一层独立开发,联调时问题能快速定位到模块。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据模型怎么设计才不会在联调时返工
2.1 围绕核心闭环拆解实体关系
围绕上面确定的需求,实体关系其实很清晰:用户、好友关系、群组、群成员、消息。用户和用户通过好友关系表关联;用户和群组是多对多,由群成员表支撑;用户和消息是一对多(发送者)。
先看用户表:
sql复制CREATE TABLE `sys_user` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`username` VARCHAR(50) NOT NULL,
`password` VARCHAR(100) NOT NULL COMMENT 'BCrypt加密后的密码',
`nickname` VARCHAR(50) DEFAULT NULL,
`avatar_url` VARCHAR(255) DEFAULT NULL,
`create_time` BIGINT NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
这里有几个细节值得注意:表名我建议用sys_user,不要用user,因为user在MySQL里属于保留字,每次查询都得加反引号,处理起来很烦;group更是SQL关键字,群组表我一般叫chat_group。create_time字段用BIGINT存储毫秒级时间戳,而不是DATETIME,因为聊天场景下按时间排序极其频繁,BIGINT比较大小比DATETIME更快,也避免时区转换问题,前端拿到时间戳直接new Date()渲染即可。
2.2 单聊消息和群聊消息:拆成两张表更省心
聊天系统的核心表是消息表。这里我强烈建议拆成两张:单聊消息表、群聊消息表。
sql复制CREATE TABLE `chat_message` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`from_user_id` BIGINT NOT NULL,
`to_user_id` BIGINT NOT NULL,
`content` TEXT NOT NULL,
`msg_type` TINYINT NOT NULL DEFAULT 0 COMMENT '0文本 1图片 2文件',
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '0未读 1已读',
`create_time` BIGINT NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_user_time` (`from_user_id`, `to_user_id`, `create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `group_message` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`from_user_id` BIGINT NOT NULL,
`group_id` BIGINT NOT NULL,
`content` TEXT NOT NULL,
`msg_type` TINYINT NOT NULL DEFAULT 0,
`create_time` BIGINT NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_group_time` (`group_id`, `create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
为什么拆成两张而不是一张表加target_type字段?因为单聊消息需要记录每条消息的已读状态——对方是否已经看到了这条消息,这是一对一的“接收确认”。而群聊消息如果也给每条消息标记每个成员的已读状态,数据量会爆炸,一个100人群发一条消息,就要写100条已读记录。
群聊的未读/离线消息怎么处理?我给每个群成员维护一个“已读游标”:
sql复制CREATE TABLE `group_read_cursor` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`group_id` BIGINT NOT NULL,
`user_id` BIGINT NOT NULL,
`last_read_message_id` BIGINT NOT NULL DEFAULT 0,
`update_time` BIGINT NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_group_user` (`group_id`, `user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
用户打开某个群聊窗口时,更新游标到自己已读的最后一条消息id。要算未读数量,一条SQL即可:SELECT COUNT(*) FROM group_message WHERE group_id = ? AND id > ?。用户不在线期间群里有新消息,上线后也能通过游标位置把未读消息全部拉出来。
2.3 好友关系:存一条还是存两条
好友关系表设计有两条路:A加B成功后,只存一条记录(user_id=A,friend_id=B);或者同时插入两条对称记录。
很多教程推荐只存一条,理由是节省空间、避免数据冗余。但查询时非常别扭——查A的好友列表时,既要查user_id = A的记录,又要查friend_id = A的记录,最后再UNION合并排序。毕设项目里,我建议直接存两条对称记录,查询时只按user_id查,代码简单、思路清晰、不会漏数据。代价仅仅是加好友时需要多插一条,删除时多删一条,这点开销对毕设项目完全不是问题。
2.4 在线状态别傻傻写进user表
有人会把在线状态直接做成sys_user表里的status字段,用户上线就UPDATE 1,下线就UPDATE 0。看着没毛病,但仔细想想就知道不行:用户心跳每30秒一次,每次心跳都要刷新在线状态,如果都打到数据库,这是极大的无意义压力;更严重的是,如果用户断网,没有走正常的关闭流程,数据库里的状态就会一直停在“在线”。
更合理的方案是:服务端内存里维护一个ConcurrentHashMap<Integer, WebSocketSession>来管理在线会话,同时用Redis的key-value记录在线状态,key是online:{userId},value是1,TTL设置为2分钟。用户每发一次心跳就把TTL刷新,超过2分钟没有心跳就自动过期,Redis里查不到就说明用户不在线,天然解决了断网场景。如果项目没引Redis,直接用内存里的Map判断在线状态也可以,只是报告里写Redis方案能体现多一些技术深度。
3. WebSocket接入与连接管理:这是整个项目的承重墙
3.1 为什么一定是WebSocket
有的同学会问,用HTTP轮询行不行?技术上当然行,早期很多网页版聊天工具就是这么干的。但体验和数据消耗都不太理想。
| 方式 | 实时性 | 客户端上行 | 服务器压力 | 适合场景 |
|---|---|---|---|---|
| HTTP短轮询 | 差,秒级延迟 | 正常HTTP | 高,大量空请求 | 定时刷新,不适合聊天 |
| HTTP长轮询 | 较好 | 正常HTTP | 连接挂起较多 | 早期IM方案 |
| SSE | 服务端单向实时推送 | 需要普通HTTP配合 | 较低 | 消息通知、行情推送 |
| WebSocket | 毫秒级,双向全双工 | 一条连接内自由发送 | 长连接占用但有状态 | 聊天、协同编辑、游戏 |
用生活化类比来讲:HTTP请求像打电话问完就挂断,想知道对方有没有新消息只能反复拨号;WebSocket相当于建立了一条专线,连接建立后双方随时可以说话,不用再反复拨号。
3.2 Spring Boot集成WebSocket:两种方式,我推荐Handler方式
Spring Boot集成WebSocket有两种主流方式。
方式一是@ServerEndpoint注解方式:
java复制@ServerEndpoint("/ws/{userId}")
@Component
public class ChatWebSocketEndpoint {
private static final Map<Integer, Session> onlineSessions = new ConcurrentHashMap<>();
@OnOpen
public void onOpen(Session session, @PathParam("userId") Integer userId) {
onlineSessions.put(userId, session);
}
@OnMessage
public void onMessage(String message, Session session) {
// 处理消息
}
@OnClose
public void onClose(Session session, @PathParam("userId") Integer userId) {
onlineSessions.remove(userId);
}
}
同时要注入一个ServerEndpointExporter:
java复制@Configuration
public class WebSocketConfig {
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}
方式二是实现WebSocketHandler接口:
java复制@Component
public class ChatWebSocketHandler extends TextWebSocketHandler {
private final Map<Integer, WebSocketSession> onlineSessions = new ConcurrentHashMap<>();
@Override
public
