
简介这是一套基于SpringBoot的毕业设计级在线聊天系统源码面向计算机类专业学生如计科、人工智能、通信工程等开展课程设计、期末大作业及毕设开发也适合Java后端与移动端初学者进阶实践。系统采用前后端分离架构整合Netty实现实时通信前端基于MUIH5Plus适配手机端含登录注册、通讯录、朋友圈、扫一扫等完整功能模块后台涵盖分布式文件存储FastDFS、Nginx负载均衡及多模块微服务协同huxin-netty、huxin-mybatis等。资源共290个文件含73个Java核心业务类、74个编译后class文件、27个XML配置与映射文件、20个HTML页面及配套CSS/JS资源包体仅1.35MB轻量易部署。已有148人学习下载提供可直接运行的调试通过代码、清晰分层的模块目录结构及典型聊天场景下的Handler、Utils、FileUtils等关键实现类便于理解高并发IM系统的设计逻辑与工程落地细节。1. 为什么一个 Spring Boot 聊天系统毕业答辩时总被问“消息不丢吗”“并发撑得住吗”“离线消息怎么存”这不是一个只拼前端炫酷动画、后端搭个 Controller 就能交差的“玩具项目”。当你把“基于 Spring Boot 的在线聊天系统”写进毕设标题评审老师盯的从来不是登录页有没有圆角阴影而是WebSocket 连接断了重连时用户刚发的那条“在吗”会不会石沉大海100 人同时刷屏消息顺序会不会乱成一锅粥凌晨三点用户下线早上上线时收到的 27 条未读是靠内存轮询还是数据库兜底这些问题背后是真实系统必须面对的可靠性、一致性与可扩展性三座大山。本篇不讲“Spring Boot 多简单”而是带你从零手撕一个能跑通、能压测、能解释清楚每行关键代码为什么这么写的聊天系统骨架——它用 Spring Boot 作底座但真正值钱的是对长连接生命周期、消息投递语义、存储分层策略的落地选择。适合正在写毕设、想避开“功能能点开细节一问就懵”陷阱的开发者也适合想快速验证一个轻量级实时通信方案是否可行的工程实践者。2. 用 WebSocket STOMP 搭建可靠长连接通道为什么不用纯 WebSocket 原生 API2.1 选型依据STOMP 是什么它比裸 WebSocket 解决了哪三个硬伤纯 WebSocket 协议本身只定义了“双向字节流通道”不规定消息格式、订阅机制、错误重试或会话管理。这意味着你得自己设计消息体是 JSON 还是 Protobuf字段叫from还是senderId用户 A 订阅“群聊#123”B 发消息时服务端怎么知道该推给谁靠 HashMap 缓存 Session宕机就全丢。网络抖动导致连接断开前端重连后如何让服务端知道“这是同一个用户别清空他的订阅关系”STOMPSimple Text Oriented Messaging Protocol是一个运行在 WebSocket 之上的应用层子协议它把上述问题标准化了定义了CONNECT/SUBSCRIBE/SEND/DISCONNECT等标准帧frame客户端和服务端只需按规范解析支持 Topic广播和 Queue点对点两种消息模型天然适配“群聊”和“私聊”场景内置receipt机制可要求服务端对关键操作如 SEND返回确认为消息可靠性提供基础。提示Spring Boot 对 STOMP 的支持封装在spring-boot-starter-websocket中它底层仍用 WebSocket 传输但帮你屏蔽了帧解析、心跳维持、Session 绑定等黑匣子逻辑。这不是“多一层抽象就慢”而是用标准换来的可维护性。2.2 最小可运行配置5 行 Java 代码开启 STOMP 端点Configuration EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { Override public void configureMessageBroker(MessageBrokerRegistry config) { // 1. 启用 /topic 前缀的广播消息群聊、系统通知 config.enableSimpleBroker(/topic); // 2. 设置 /user 前缀用于点对点消息私聊需配合 Security 配置 config.setUserDestinationPrefix(/user); // 3. 客户端发送消息的目标地址前缀如 /app/sendMsg config.setApplicationDestinationPrefixes(/app); } Override public void registerStompEndpoints(StompEndpointRegistry registry) { // 4. 暴露 WebSocket 端点路径允许跨域毕设本地调试必备 registry.addEndpoint(/ws).setAllowedOrigins(*).withSockJS(); // 5. SockJS 是降级方案当浏览器不支持 WebSocket 时自动切到 HTTP 长轮询 } }参数说明与踩坑点enableSimpleBroker(/topic)启用内存级消息代理适合单机开发。注意它不持久化、不集群同步——如果项目后期要水平扩展这里必须换成 Redis 或 RabbitMQ 代理见第 5 章setUserDestinationPrefix(/user)这是实现“用户 A 发消息给用户 B”的关键。Spring 会自动将/user/1001/private这类地址解析为“发给 userId1001 的私有队列”前提是用户登录后已通过SimpMessagingTemplate关联了 SessionwithSockJS()毕设阶段强烈建议保留。很多学校机房禁 WebSocket但允许 HTTPSockJS 能无缝 fallback避免调试时“页面白屏却找不到原因”。2.3 前端连接与订阅用 stompjs 库 3 步完成握手// 1. 创建 Stomp 客户端使用 SockJS 作为传输层 const socket new SockJS(/ws); const stompClient Stomp.over(socket); // 2. 连接并处理认证假设后端用 JWTtoken 存 localStorage stompClient.connect( { Authorization: Bearer localStorage.getItem(token) }, () { console.log(WebSocket 连接成功); // 3. 订阅两个关键频道群聊/topic/group/1和私聊/user/queue/private stompClient.subscribe(/topic/group/1, onGroupMessage); stompClient.subscribe(/user/queue/private, onPrivateMessage); }, (error) console.error(连接失败:, error) ); function onGroupMessage(message) { const data JSON.parse(message.body); // 渲染群聊消息注意 data.senderId, data.content 字段由后端统一约定 } function onPrivateMessage(message) { const data JSON.parse(message.body); // 渲染私聊消息data.fromId 即发送方 ID }逻辑说明前端订阅/user/queue/private是一种“逻辑队列”实际消息由后端根据当前用户 ID 动态路由。Spring 会自动将SimpMessagingTemplate.convertAndSendToUser(1001, /queue/private, msg)发送的消息精准投递给 userId1001 的所有在线连接。这比前端自己维护“我该监听哪个 URL”更安全、更解耦。3. 消息投递的三种语义如何让“已发送”真正等于“对方已收到”3.1 “最多一次”At-Most-Once默认行为也是毕设最容易翻车的坑Spring STOMP 默认采用“fire-and-forget”模式服务端收到SEND帧转发给订阅者然后立即返回RECEIPT帧如果客户端要求了。但这个 RECEIPT 只代表“服务端已入队”不代表“客户端已渲染”。如果用户网络瞬间中断消息就丢了且无任何补偿。血泪经验某高校毕设演示时导师故意拔网线学生发现“最后一条消息消失”当场无法解释。根源就是没区分“服务端投递成功”和“客户端接收成功”。3.2 实现“至少一次”At-Least-Once客户端 ACK 服务端重发队列核心思路客户端收到消息后主动发送一个ACK帧给服务端服务端收到才认为投递成功若超时未收到 ACK则从重发队列中再次推送。服务端关键代码消息发送与 ACK 监听Component public class ChatMessageService { Autowired private SimpMessagingTemplate messagingTemplate; Autowired private RedisTemplateString, Object redisTemplate; // 用 Redis 存重发队列 // 发送消息时生成唯一 messageId并存入 Redis有效期 2 分钟 public void sendMessageToUser(String userId, ChatMessage message) { String messageId UUID.randomUUID().toString(); message.setMessageId(messageId); // 先存重发队列keyuserId:retry, value{messageId, message, timestamp} String retryKey user: userId :retry; redisTemplate.opsForHash().put(retryKey, messageId, message); redisTemplate.expire(retryKey, 2, TimeUnit.MINUTES); // 再通过 STOMP 发送 messagingTemplate.convertAndSendToUser(userId, /queue/private, message); } // 监听客户端发来的 ACK需前端在 onMessage 后调用 stompClient.ack(frame) MessageMapping(/ack) public void handleAck(Payload AckMessage ack) { String retryKey user: ack.getUserId() :retry; // 从 Redis 删除对应 messageId表示投递成功 redisTemplate.opsForHash().delete(retryKey, ack.getMessageId()); } }前端 ACK 触发逻辑function onPrivateMessage(message) { const data JSON.parse(message.body); renderMessage(data); // 渲染消息 // 关键渲染完成后立即发送 ACK 帧 stompClient.ack(message); // stompjs 自动构造 ACK 帧 }参数说明Redis TTL2分钟足够覆盖绝大多数网络抖动又避免重发队列无限膨胀ACK 帧必须在渲染后触发确保用户真正“看到”消息才确认而非仅仅收到字节流此方案成本低、改造小毕设答辩时能清晰画出“发送→接收→ACK→删除”流程图比空谈“消息可靠性”有力得多。3.3 “恰好一次”Exactly-Once毕设不强求但要知道它的代价理论上可通过分布式事务如 Seata 消息去重表message_id 唯一索引实现。但代价是每条消息增加 2 次数据库写入事务日志 去重表引入强依赖外部中间件毕设环境部署复杂度陡增对于 90% 的毕设场景“至少一次”已满足需求用户最多看到重复消息但不会丢失。提示答辩时若被问及可坦诚回答“我们采用 At-Least-Once配合前端去重如检查 message.id 是否已存在 DOM在可用性与复杂度间做了权衡。若需 Exactly-Once需引入 RocketMQ 事务消息或 Kafka 幂等 Producer这超出当前项目范围。”4. 消息存储分层设计内存缓存 MySQL 持久化 Redis 热数据加速4.1 为什么不能只用 MySQL—— 毕设常见性能误判很多同学一上来就EntityJPA所有消息直写数据库。结果测试发现100 人在线时群聊消息每秒写入 50 条MySQLINSERT成瓶颈查询“最近 20 条历史”需要ORDER BY create_time DESC LIMIT 20随着数据量增长create_time索引失效查询变慢更致命的是消息是典型的“写多读少”场景但 MySQL 的 B 树索引对高频写入并不友好。正确做法是分层热数据放内存/Redis冷数据落盘查询走缓存穿透保护。4.2 三层存储结构与数据流向层级技术选型存储内容生命周期读写频率L1内存缓存ConcurrentHashMap当前在线用户的 Session 映射、未 ACK 消息队列JVM 生命周期高频读写连接管理L2RedisSorted SetZSET每个群聊/私聊的最新 500 条消息scoretimestamp7 天自动过期高频读中频写L3MySQLInnoDB表全量消息归档含 sender_id, receiver_id, content, status永久低频写中频查后台管理关键设计点Redis ZSET 的score用毫秒时间戳member存 JSON 序列化的消息对象天然支持按时间倒序取 TOP NMySQL 表必须建联合索引INDEX idx_user_time (receiver_id, create_time)支撑“查某用户所有消息并分页”绝不允许 Redis 和 MySQL 数据不一致所有写操作必须先写 MySQL主库再更新 RedisCache-Aside 模式。4.3 消息写入的原子化封装一个方法搞定三写Service Transactional // 确保 MySQL 写入成功才继续 public class MessagePersistenceService { Autowired private JdbcTemplate jdbcTemplate; Autowired private RedisTemplateString, Object redisTemplate; public void saveAndCacheMessage(ChatMessage message) { // 1. 写 MySQL主库 String sql INSERT INTO chat_message (sender_id, receiver_id, content, create_time) VALUES (?, ?, ?, ?); jdbcTemplate.update(sql, message.getSenderId(), message.getReceiverId(), message.getContent(), message.getCreateTime() ); // 2. 写 Redis ZSET群聊消息存入 /group/{id}私聊存入 /private/{userId} String redisKey message.isGroupMessage() ? group: message.getGroupId() : private: message.getReceiverId(); // ZADD key score memberscore 为毫秒时间戳 redisTemplate.opsForZSet().add(redisKey, message.toJsonString(), message.getCreateTime().getTime() ); redisTemplate.expire(redisKey, 7, TimeUnit.DAYS); // 7天过期 // 3. 可选异步清理 L1 内存中的临时队列如未 ACK 列表 if (message.isUnack()) { asyncClearUnackQueue(message.getMessageId()); } } }参数说明Transactional是底线MySQL 写失败整个方法回滚Redis 不会脏写redisTemplate.expire(...)必须显式设置否则 ZSET 永不过期Redis 内存爆炸私聊消息的 Redis Key 设计为private:{userId}意味着每个用户有自己的消息池查询时直接ZREVRANGE private:1001 0 19即可拿到最新 20 条O(log N) 复杂度。5. 避坑指南毕设调试中最常遇到的 4 个“玄学”问题与根治方案5.1 现象前端连接 WebSocket 成功但stompClient.subscribe()后收不到任何消息原因后端configureMessageBroker()中未调用config.setApplicationDestinationPrefixes(/app)导致客户端stompClient.send(/app/sendMsg, ...)发送的消息服务端根本没注册对应的MessageMapping方法或MessageMapping方法参数未加Payload注解Spring 无法反序列化 JSON更隐蔽的是EnableWebSocketMessageBroker注解缺失整个 STOMP 配置类被 Spring 忽略。解决检查WebSocketConfig类是否被Configuration修饰且EnableWebSocketMessageBroker存在在MessageMapping方法上加ResponseStatus(HttpStatus.OK)用 Postman 模拟发送POST /app/sendMsg看是否 200开启 Spring DEBUG 日志logging.level.org.springframework.web.socketDEBUG观察DEBUG o.s.w.s.m.StompSubProtocolHandler - Processing SUBSCRIBE frame是否出现。5.2 现象用户 A 给用户 B 发私聊B 收到消息但消息体里senderId是 null原因前端发送SEND帧时JSON body 中senderId字段名与后端ChatMessage实体类的JsonProperty注解不一致如前端传fromId后端期望senderId或ChatMessage类缺少无参构造函数Jackson 反序列化失败静默创建空对象。解决统一前后端字段命名推荐全部小驼峰senderId,receiverIdChatMessage类必须有public ChatMessage() {}在MessageMapping方法参数前加Valid配合NotNull注解让校验失败时抛出MethodArgumentNotValidException便于定位。5.3 现象本地调试一切正常部署到学校服务器后 WebSocket 连接 404原因学校服务器 Nginx/Apache 未配置 WebSocket 代理HTTP 请求被正常转发但 WebSocket 升级请求Upgrade: websocket被拦截或转成普通 HTTP或服务器防火墙关闭了 WebSocket 默认端口80/443 之外的端口。解决Nginx 配置必须包含location /ws { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; # 关键透传 Upgrade 头 proxy_set_header Connection upgrade; # 关键透传 Connection 头 proxy_set_header Host $host; }毕设部署优先走 80/443 端口避免额外开防火墙用curl -i -N -H Connection: Upgrade -H Upgrade: websocket http://your-server/ws测试升级头是否透传。5.4 现象MySQL 消息表数据越来越多SELECT * FROM chat_message WHERE receiver_id ? ORDER BY create_time DESC LIMIT 20查询变慢原因receiver_id字段未建索引全表扫描即使有索引当receiver_id区分度低如大量消息发给系统用户 0MySQL 优化器可能放弃索引改用 filesort。解决必建联合索引ALTER TABLE chat_message ADD INDEX idx_receiver_time (receiver_id, create_time);查询时强制使用索引仅调试用SELECT * FROM chat_message FORCE INDEX (idx_receiver_time) WHERE receiver_id ? ORDER BY create_time DESC LIMIT 20;生产环境考虑分区按create_time月分区避免单表过大。6. 毕设答辩加分技巧用 JMeter 做 3 分钟压测证明你的系统不是“纸糊的”6.1 为什么压测是答辩“王炸”—— 导师最怕学生说不清边界很多同学答辩时被问“能支持多少人”回答“应该可以吧”“没试过”。而如果你能打开 JMeter展示一张“100 并发用户下WebSocket 连接成功率 100%消息平均延迟 200ms”的截图导师立刻明白你不仅写了代码还思考了它的工程边界。6.2 三步搭建最小压测脚本无需写代码Step 1录制 WebSocket 流量启动 JMeter → 添加线程组 → 添加WebSocket Open ConnectionSamplerServer Name/IP 填localhostPort 填8080Path 填/ws添加WebSocket Single Write SamplerPayload Type 选Text内容填{type:CONNECT,headers:{}}运行用浏览器访问聊天页面JMeter 自动捕获SUBSCRIBE和SEND帧。Step 2参数化用户与消息在线程组下添加CSV Data Set ConfigFilename:users.csv内容1001,1002,1003,...Variable Names:userId将WebSocket Single Write Sampler的 Payload 改为{type:SEND,destination:/app/sendMsg,body:{\senderId\:\${userId}\,\receiverId\:\1002\,\content\:\Hello from ${userId}\}}Step 3执行并分析结果线程数设为100Ramp-Up Period10秒模拟 10 秒内 100 人接入添加View Results Tree和Summary Report运行后重点关注Connect Time应稳定在 50ms 内证明连接池配置合理LatencyWebSocket 消息端到端延迟 300ms 为优秀Error %必须为 0证明无连接泄漏、内存溢出。我的习惯毕设答辩前夜用 JMeter 跑 3 轮不同并发50/100/200截图保存Summary Report答辩时直接投屏。当导师问“稳定性如何”我不说“理论上”而是说“这是 100 并发下的实测数据错误率 0平均延迟 186ms。如果需要更高并发我会将 Redis 代理升级为集群并增加 WebSocket 连接数限制。” —— 这种回答比背十页原理管用。希望帮到你。本文还有配套的精品资源点击获取