Spring Boot + WebSocket 群聊已读未读:从 Demo 到生产级架构设计与落地
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} -> Set<userId>问题非常明显:
- 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
- 消息发送
- 消息持久化
- 分配群内消息序号
seq
Read 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 为什么一定要“只增不减”
真实场
