Spring Boot + WebSocket 群聊已读未读:从 Demo 到生产级架构设计与落地一、前言:已读未读不是功能点,而是一套高并发状态系统“群聊已读未读”看起来只是聊天界面底部的一行字:18 人已读237 人未读我是否已经看过最后一条消息但一旦进入真实生产环境,这个问题很快就会从“前端展示逻辑”升级为“高并发状态同步系统”:一个 3000 人企业群,1 秒内可能有数百次已读推进用户同时在线于 Web、iOS、Android、桌面端,多端状态必须一致WebSocket 连接分布在多个 Pod 上,推送不能依赖单机内存已读状态需要低延迟可见,但又不能把数据库写爆扩缩容、重连、消息乱序、重复投递、热点群,都会把简单方案打穿所以,这个问题本质上不是“如何记录谁读了哪条消息”,而是:如何在分布式系统中,以可扩展、可观测、可恢复的方式维护“用户阅读进度”这一核心状态。本文会把这件事从原理、架构、工程、代码、案例五个层面完整讲透,并给出一套可落地的 Spring Boot + WebSocket + Redis + Kafka 生产方案。二、业务语义先讲清:到底什么叫“已读”在群聊系统里,“已读”有三种常见业务语义,它们不能混为一谈。2.1 消息级已读定义:用户是否读过某一条具体消息。适合场景:小群需要查看“哪些人读了这条消息”合规审计、任务确认、流程审批类 IM问题:如果每条消息都存已读用户列表,数据量会随“消息数 x 群成员数”爆炸增长2.2 会话级已读定义:用户在某个群聊里,已经读到哪一条消息。典型表示:(groupId, userId) - lastReadMsgSeq这是绝大多数生产 IM 的核心模型,因为:写入复杂度低查询未读数高效存储规模随“用户数 x 群数”增长,而不是随“消息数 x 用户数”增长2.3 视图级已读定义:不是“消息被拉到了客户端”就算已读,而是“消息进入了用户可视区域或用户进入了会话页面”才算已读。这会影响:客户端上报时机回执语义是否可信多端状态推进的规则生产里建议明确采用:“客户端已可见 + 服务端幂等推进游标”的定义。三、先避坑:为什么很多方案一开始就设计错了3.1 错误方案一:每条消息存一个已读用户集合数据模型:message_read_users:{msgId} - SetuserId问题非常明显:1000 人群,1 天 10 万条消息,理论上可能产生 1 亿条关系Redis Set 和数据库关联表都会迅速膨胀查询“某人未读多少条”反而更慢这个模型只适合:成员规模小审批确认场景明确需要消息级已读明细3.2 错误方案二:直接用latestMsgId - lastReadMsgId这个思路在 Demo 里常见,但生产里并不总是成立,因为会遇到:消息撤回审核删除分库分表后 ID 不连续不同分区消息序号并非全局自增所以生产里不要把“数据库主键 ID”直接当未读计数依据。正确做法是:为群内消息维护独立的单调递增messageSeq已读推进也基于messageSeq计数优先用 seq 差值,精确校准用补偿逻辑3.3 错误方案三:已读上报直接同步写数据库如果每次滚动列表都同步更新 MySQL/TiDB:数据库写放大严重峰值流量下 RT 抖动明显热门群会形成热点行更新生产思路应该是:热路径先进 Redis异步写 Kafka消费端批量落库通过幂等更新保证最终一致四、核心建模:群聊已读未读的正确抽象4.1 关键对象我们先把领域模型抽象清楚。Conversation表示群聊会话。Message表示群里的消息实体,除了数据库主键外,还应有群内递增序号seq。ReadCursor表示某用户在某群中已经读到的最大消息序号。4.2 推荐核心关系Conversation: 1 ---- n Message Conversation: 1 ---- n ReadCursor ReadCursor: (conversationId, userId) - lastReadSeq4.3 为什么“游标法”是主流生产方案游标法的核心不是记录“谁读了哪条”,而是记录“每个人读到了哪里”。例如:群 G 当前最新消息 seq = 1050 用户 U 的 lastReadSeq = 1043 则 U 的未读区间 = (1043, 1050]它的优势非常稳定:写入是 O(1)更新天然幂等,可用max(old, new)合并用户维度查询非常快便于多端同步数据量可控这也是生产架构能成立的根基。五、生产级目标:这套系统到底要扛什么一个可以上线的大规模群聊已读系统,至少要满足下面几类目标。5.1 性能目标已读推进 RT:P99 100ms已读通知可见延迟:P99 300ms单群瞬时已读推进:每秒数百到数千次单节点长连接:1 万到 5 万,取决于协议栈和机器规格5.2 一致性目标同一用户多端已读游标不回退已读事件允许重复,但最终状态不能错短时间允许最终一致,不要求分布式强一致事务5.3 工程目标支持水平扩容支持热点群隔离支持故障恢复与数据回补支持链路追踪、指标告警、死信补偿六、整体架构:从单机聊天室升级为分布式状态系统6.1 推荐架构分层客户端层 Web / iOS / Android / PC | 接入层 API Gateway / Ingress 鉴权、限流、WebSocket Upgrade、灰度路由 | 连接层 Push Gateway 负责 WebSocket/STOMP 连接管理、订阅关系、实时下行 | 业务层 Chat Service Read Service Conversation Service | 事件层 Kafka 消息发送事件、已读推进事件、成员变更事件 | 存储层 Redis Cluster TiDB / MySQL 分库分表 对象存储 / 冷归档6.2 各服务职责Push Gateway维护长连接处理 STOMP 订阅和下行推送不承担核心业务状态持久化Chat Service消息发送消息持久化分配群内消息序号seqRead Service接收已读上报幂等推进lastReadSeq产出已读事件维护 Redis 热状态Conversation Service查询会话列表计算未读数汇总已读快照6.3 为什么把“连接层”和“业务层”拆开很多项目一开始把 WebSocket、消息发送、已读处理全部放在同一个 Spring Boot 服务里,初期没问题,规模上来后会出现三个硬伤:长连接占用线程、内存和 FD,影响普通业务接口稳定性WebSocket 节点扩缩容频繁,和业务服务发布节奏不一致推送流量和业务读写流量耦合,难以独立扩容所以生产里建议至少逻辑拆分成:连接网关消息业务已读业务七、已读链路设计:从客户端上报到多端同步的完整流程7.1 基础流程1. 用户打开群聊或滚动消息列表 2. 客户端计算当前可见最大 messageSeq 3. 通过 STOMP 发送 read receipt 4. Push Gateway 鉴权并转发给 Read Service 5. Read Service 用 Redis Lua 脚本执行“只增不减”更新 6. 更新成功后发送 Kafka ReadCursorAdvancedEvent 7. Push Gateway 订阅事件,向同用户其他端和群内在线成员推送增量通知 8. 消费者批量落库,形成可恢复状态7.2 为什么一定要“只增不减”真实场