IM 系统里的群聊已读怎么做?
阅读说明与事实边界:先把题目问完整
“群聊已读”看起来只是消息旁边显示一个数字,真正设计时至少包含四个不同问题:
- 接收方怎样上报“我读到了哪一条”?
- 发送方怎样看到“这条消息有多少人已读”?
- 点开已读详情时,怎样分页查看已读和未读成员?
- 万人大群里是否还提供逐人名单,还是只提供计数或不提供回执?
如果不先确认产品口径,很容易一上来设计 message_id + user_id + read_time 回执表。这个模型在十人群里能用,但一个一万人群一天产生十万条消息,理论组合已经达到十亿级,绝大多数记录还没有独立业务价值。
本文设计一个工作群聊场景:
- 普通群最多 500 人,显示已读人数,并可分页查看名单;
- 大群最多 2 万人,只展示已读人数,不主动广播完整名单;
- 用户打开会话且消息真正进入可视区域后,客户端上报最大已读序号;
- 多端登录时,以所有设备上报的最大进度为准;
- 离线、弱网和重复上报不能让进度倒退;
- 已送达、已读、回复是三种不同事实,系统不把它们混为一谈。
这是一份设计基线。群规模、消息速率和延迟目标需要在实际产品中通过容量测试确认。
一、项目基础:先定义“已读”的业务语义
1.1 什么动作算已读
可选口径有三种:
| 口径 | 优点 | 问题 |
|---|---|---|
| 收到推送就算已读 | 实现简单 | 用户可能根本没有打开会话,只能叫“已送达” |
| 打开会话就算全部已读 | 上报次数少 | 长列表中尚未进入视口的消息也被误算 |
| 消息进入可视区域后推进游标 | 语义较准确 | 客户端要处理滚动、前后台切换和批量合并 |
本文采用第三种。客户端不逐条发回执,而是上报:
conversation_id: g_9001
read_to_seq: 1289
device_id: d_72
client_event_id: 01J...
含义是:该用户在这个会话中已经读到序号 1289。它不表示用户逐字阅读,也不表示理解或同意,只表示客户端按照约定确认消息已经进入阅读范围。
1.2 先区分四种状态
系统可以分别记录“服务端已受理”“设备已送达”“客户端已读”和“用户已响应”。没有设备确认时不能说已送达,没有阅读上报时不能说已读,更不能把已读等同于业务响应。
1.3 群聊已读要守住的不变量
- 同一用户在同一会话的
read_seq只能增大,不能倒退。 read_seq不能超过该用户当前可见的最大消息序号。- 用户只能上报自己有权限访问的群和消息范围。
- 重复上报、乱序上报和多设备并发不能产生多次副作用。
- 一条消息的已读人数不能大于它发送时的可读成员数。
- 退群、被移除和新入群不能悄悄改写历史消息的统计口径。
最后一条最容易被忽略。今天群里有 100 人,消息发出时有 80 人;明天加入 20 人,历史消息的分母不应该从 79 个接收者变成 99 个接收者。
二、为什么不为每条消息插入一条回执
设一个群有 M 个成员,一天产生 N 条消息。逐消息回执模型最坏需要 M × N 条关系记录,而且每个用户阅读一屏消息会产生几十次写入。
游标模型只保存每个成员在每个会话中的最大阅读进度,存储量接近“群成员关系数”,而不是“群成员数乘消息数”。如果用户的 read_seq 是 1289,就可以推导出该用户已经读过所有对他可见且序号不大于 1289 的消息。
游标不是无条件成立。它依赖一个重要前提:会话内消息拥有单调递增序号,并且系统能定义用户可见区间。如果存在仅部分成员可见的消息、密聊消息或复杂权限变化,就要引入可见性过滤,不能简单用序号差计算未读数。
三、核心数据模型
3.1 会话内序号
message_id 可以是全局唯一 ID,但不能直接承担会话内顺序。消息服务为每个会话分配单调递增的 msg_seq:
g_9001: 1287, 1288, 1289, 1290 ...
序号分配只要求单个会话内有序,不要求整个系统全局有序。热点大群可以由固定分片负责生成序号,普通群则按 conversation_id 路由到对应消息分片。
3.2 关系模型
关键字段解释:
join_seq:成员从哪个会话序号开始可见;新成员默认不计入更早消息的未读分母。leave_seq:成员退出前最后可见到哪个序号,用于保持历史口径。readable_member_count:消息发送时可读成员数快照。普通群可以精确保存;大群可按产品口径只保存接收者数量,不保存完整快照名单。version:用于条件更新,防止并发上报覆盖更大的阅读进度。
发送者通常不计入接收方已读分母。机器人、被禁用账号是否计入,要由产品规则明确。
四、业务闭环:一条消息从发送到显示“12 人已读”
4.1 总体架构
各组件的责任要收紧:
- 消息服务负责消息事实和会话序号,不负责猜测用户是否已读。
- 阅读进度服务负责校验并推进游标。
- Redis 阅读索引用于快速计数和分页,不是唯一真相源。
- 回执聚合服务负责合并通知,避免一个群成员读一次就向所有人广播一次。
4.2 正常时序
数据库更新可以使用类似下面的条件:
UPDATE im_read_cursor
SET read_seq = :new_seq,
read_at = :read_at,
version = version + 1
WHERE conversation_id = :conversation_id
AND user_id = :user_id
AND read_seq < :new_seq;
如果更新行数为零,不代表请求失败,可能是另一个设备已经把游标推进得更远。服务应查询或返回 max(old_seq, new_seq),而不是把较小值覆盖回去。
4.3 客户端为什么要合并上报
用户快速滚动 100 条消息时,客户端不应发 100 个请求。可以按以下条件触发:
- 最大可见序号比上次确认值前进了一段;
- 停止滚动一小段时间;
- 页面进入后台或会话即将关闭;
- 服务端要求同步进度。
合并窗口是体验和写放大的取舍。窗口太短,服务端写入过多;窗口太长,发送者看到的已读数明显滞后。具体数值应由客户端埋点和压测决定,不能从示例直接抄到生产。
五、核心技术实现:怎样查询一条消息的已读人数
5.1 普通群:按成员游标计算
在 Redis 中,为每个普通群维护一个有序集合:
key: im:read:{conversation_id}
member: user_id
score: read_seq
查询消息 1289 的已读人数,本质上是统计 score 大于等于 1289 的合格接收者。名单分页也可以从这个索引读取,然后再关联成员昵称和头像。
但不能直接把 ZCOUNT 当最终答案。还要排除发送者、发送后才入群的人,以及消息发送时没有可见权限的人。普通群可以在群成员变化时维护有效成员集合,或在查询时结合 join_seq/leave_seq 过滤。
5.2 大群:不把每次阅读变成全群广播
大群真正危险的通常不是保存一个游标,而是通知扩散。两万人群里一万人推进阅读进度,如果每次都把新计数广播给两万人,就会产生巨大的无效扇出。
可采用三层控制:
- 阅读事件仍然按用户游标合并,保证源头不逐消息上报。
- 聚合服务按
conversation_id + message_range在短窗口内合并计数变化。 - 只向正在查看相关消息详情的发送者或订阅者推送,不向全群广播每个变化。
大群默认只显示计数,不提供实时完整名单。需要名单时按需分页查询,并设置权限、频率限制和结果缓存。产品如果要求万人群每条消息都实时展示精确名单,需要明确接受更高的存储和计算成本。
5.3 已读数是否必须实时精确
对协作群,发送者往往需要的是“还有谁没读”,可以允许秒级最终一致;对公告确认、风控通知等有审计要求的场景,单纯聊天游标不够,应该另建“确认任务”:每个接收者主动确认,留下独立审计记录。
这两个业务不能为了复用一个“已读”概念而混在一起:
| 场景 | 合适模型 |
|---|---|
| 日常聊天已读提示 | 会话阅读游标 |
| 全员必须确认的制度通知 | 独立确认任务和确认明细 |
| 设备推送送达率 | 设备送达回执 |
| 用户执行某项操作 | 业务操作记录 |
六、成员进退群如何保持历史口径
6.1 新成员加入
新成员加入时记录 join_seq = conversation.latest_seq + 1。默认情况下,他不属于历史消息的接收者,因此历史消息不应突然多出一个“未读”。如果产品允许查看最近若干条历史消息,也不等于把他加入历史回执分母;“能看历史”和“当时是接收者”是两个概念。
6.2 成员退出
退群时记录 leave_seq,不要直接物理删除成员关系。否则查询历史消息时无法知道该成员在消息发送时是否属于群。
6.3 分母快照与成员版本
同一个用户多次进出群时,简单的一行 group_member 不足以表达多个有效区间。可保留成员历史表,或者为每次成员周期生成独立关系 ID。消息保存 member_version 和接收者计数快照,避免后来成员变化改写历史分母。
七、撤回、删除和不可见消息怎么办
7.1 撤回不回退游标
消息撤回后,用户的 read_seq 不应回退。游标表达的是阅读进度,不是当前可见消息数量。撤回状态记录在消息本身,客户端同步时显示撤回占位。
7.2 “最新序号减已读序号”未必等于未读条数
如果序列里包含仅管理员可见消息、撤回消息、系统控制消息,直接用 latest_seq - read_seq 会高估未读数。常见选择有:
- 产品允许近似,把它定义成“未同步进度”而不是严格消息条数;
- 为不同可见性通道分配独立子序列;
- 查询消息索引,计算用户可见且未读的消息数;
- 对特殊消息不占普通聊天序号。
选哪一种取决于产品是否需要精确角标。不能一边允许大量个性化可见消息,一边承诺用一个整数相减得到精确未读数。
八、可靠性设计:重复、乱序、多端和宕机
8.1 多端乱序
手机先上报 1289,电脑因网络延迟后上报 1284。数据库条件更新会拒绝倒退;响应把服务端当前值返回给电脑,电脑据此修正本地状态。
8.2 服务端成功,响应丢失
客户端没有收到响应会重试同一个或更大的游标。由于更新是单调且幂等的,不会产生错误。client_event_id 可用于排查和限时去重,但正确性不应只依赖短期去重缓存。
8.3 数据库已更新,Redis 更新失败
数据库游标是真相源。Redis 更新失败时:
- 写入结构化失败日志和重试事件;
- 查询结果允许短暂滞后;
- 后台按游标变更日志或数据库增量重建索引;
- 对审计型查询直接回源或提示统计同步中。
8.4 先写 Redis 再异步落库是否可行
可以获得更低写延迟,但确认语义必须说清楚。若服务返回成功后 Redis 故障导致数据尚未持久化,阅读进度可能回退。若产品允许客户端再次上报并最终修复,可以采用;若“已读”承担合规审计,则需要在返回成功前获得可靠持久化证据。
九、接口边界与安全
9.1 上报接口
POST /im/conversations/{conversationId}/read-cursor
服务端从登录态取得 user_id,不能接受客户端任意填写用户。校验内容包括:
- 用户当前或历史上是否具有该序号的读取权限;
read_to_seq是否存在且不超过允许上限;- 请求体和连接身份是否一致;
- 上报速率是否异常;
- 设备时间只用于诊断,事实时间使用服务端时间。
9.2 查询接口
GET /im/messages/{messageId}/read-receipts?status=read&cursor=...
只允许消息发送者、群管理员或具备相应权限的角色查询。大群名单要分页和限流,避免接口被用来批量枚举群成员的在线和阅读行为。
9.3 隐私边界
群聊已读属于行为数据。系统需要明确保存期限、可见角色、是否允许关闭回执,以及导出和审计规则。不能因为技术上能算出每个人的阅读时间,就默认所有群成员都能查看。
十、缓存、分片与热点群
10.1 分片键
消息和游标通常按 conversation_id 路由,便于会话内有序和群维度统计。但超级大群会成为单分片热点,需要单独识别:
- 将消息写入与阅读游标写入拆成不同分片集群;
- 阅读事件可以再按
conversation_id + user_id hash分散持久化写; - 聚合统计按会话汇总,异步合并各分片结果;
- 超级群使用独立容量池,避免拖累普通群。
10.2 缓存失效
Redis ZSET 丢失不是业务事实丢失,但会使计数和名单查询变慢。重建要有版本水位:先记录重建开始时的事件位点,扫描持久游标建立基线,再回放位点之后的增量,最后原子切换索引版本,避免边扫描边写造成遗漏。
10.3 背压
回执事件积压时,应优先保证游标持久化,再降低统计推送频率。不能为了让已读数字更实时,挤占消息发送和接收的核心资源。
十一、可观测、对账和恢复
11.1 核心指标
| 类别 | 指标 | 用途 |
|---|---|---|
| 接入 | 阅读上报 QPS、合并比例、非法序号数 | 判断客户端写放大和异常请求 |
| 存储 | 游标更新延迟、条件更新未命中率 | 识别乱序、多端和数据库瓶颈 |
| 索引 | Redis 更新失败、重建进度、回源比例 | 判断统计是否可信 |
| 事件 | 回执队列积压、最老事件时间 | 判断发送端看到的滞后 |
| 查询 | 已读人数查询 P95/P99、名单分页失败率 | 观察用户体验 |
| 不变量 | 已读人数大于接收人数、游标超过可见序号 | 直接发现数据错误 |
11.2 对账任务
抽样或分片扫描以下关系:
- 持久游标与 Redis score 是否一致;
- 游标是否落在成员可见区间内;
- 消息的已读计数是否超过
readable_member_count; - 离群成员的历史周期是否完整;
- 回执事件位点是否长时间停滞。
修复动作必须幂等。索引错了可以从真相源重建;成员历史缺失则需要审计和人工确认,不能凭当前成员表猜历史。
十二、怎么测试,而不是只画架构图
12.1 正确性用例
- 同一设备重复上报相同序号,游标不变;
- 两台设备乱序上报,最终游标取最大值;
- 非群成员伪造会话 ID,上报被拒绝;
- 新成员加入后,历史消息分母不变化;
- 成员退出后,发送于退出前的消息仍能查询历史回执;
- 撤回消息不导致游标回退;
- Redis 更新失败后,重建能恢复正确计数。
12.2 并发与容量测试
压测至少拆开四条链路:上报写入、游标持久化、回执事件聚合、已读详情查询。不要只测一个空接口的总 QPS。
容量模型需要输入:
峰值在线人数
每秒进入会话的人数
客户端平均合并窗口
普通群与大群占比
活跃消息发送者订阅回执的比例
单次上报、事件和索引的平均字节数
压测报告应注明环境、数据分布、群规模分布、是否包含数据库和 Redis、P95/P99 延迟、错误率以及积压恢复时间。没有执行压测时只能写“设计目标”,不能写“系统已支持”。
12.3 故障演练
- Redis 集群短时不可用,验证游标不丢且统计可恢复;
- 回执队列暂停消费,验证消息主链路不受拖累;
- 单个热点群流量突增,验证隔离和降级;
- 数据库主从切换,验证客户端重试不会使游标倒退;
- 聚合服务重启,验证重复事件不会重复扩大计数。
十三、方案取舍
| 方案 | 适合场景 | 主要代价 |
|---|---|---|
| 每消息每用户回执表 | 小群、少量关键消息、强审计 | 写入和存储随成员数乘消息数增长 |
| 会话阅读游标 | 连续消息流、日常群聊 | 对个性化可见消息和历史成员处理更复杂 |
| Redis ZSET 阅读索引 | 普通群实时计数和名单 | 需要持久真相源和重建机制 |
| 大群只显示聚合计数 | 万人大群 | 无法实时提供完整精确名单 |
| 独立确认任务 | 公告签收、合规确认 | 这是另一套业务对象,操作成本更高 |
成熟的回答不是说“用 Redis 就行”,而是说明为什么用游标压缩写入,为什么普通群和大群的产品能力不同,以及统计索引丢失后如何从事实数据恢复。
十四、面试时怎样讲
14.1 两分钟主回答
先定义已读:消息真正进入客户端阅读范围后,上报会话内最大 read_seq。服务端为每个用户和会话保存单调递增游标,通过条件更新解决重复、乱序和多端并发。消息发送时保存可读人数或成员版本,避免进退群改写历史分母。普通群用 Redis ZSET 按成员的 read_seq 做计数和分页,数据库游标是真相源;大群只推聚合计数,并对通知做合并和按需订阅,避免全群扩散。Redis 或事件链路失败时允许统计短暂滞后,通过增量事件和对账重建,但消息收发主链路不能被回执拖垮。
14.2 面试官可能继续追问
- 为什么不用布隆过滤器?它不能列出成员,也不适合表达单调阅读进度。
- Redis ZSET 是真相源吗?不是,除非产品明确接受故障后的进度丢失并依赖客户端修复。
- 大群怎么查一条消息的精确已读名单?按需分页查询分片游标并过滤成员有效区间,代价高,因此产品通常降级能力。
- 消息撤回会不会让未读数减一?取决于产品口径,但阅读游标不能回退。
- 多设备谁覆盖谁?取最大游标,不使用最后写入覆盖。
- 新成员能看历史消息,算不算历史未读?能看与当时是接收者要分开定义。
- 回执队列积压怎么办?扩大合并窗口、减少推送,优先保证游标持久化和消息主链路。
- 怎样证明没有读人数超过群成员数?建立不变量指标和离线对账,而不是只看接口报错。
14.3 不能越界的项目口径
如果个人项目只实现了单聊已读或简单群游标,应明确说“本文是可演进方案”。没有万群压测、成员历史和索引重建证据,就不能声称自己已经支撑万人大群。面试中可以讲清设计推演和验证计划,但不能把设计目标说成生产成果。
总结
群聊已读的核心不是回执表,而是三个选择:
- 用会话内有序序号和单调阅读游标压缩写入;
- 用成员有效区间和发送时口径守住历史统计;
- 把普通群的精确能力与大群的聚合能力分开,控制通知扩散。
数据库或可靠日志保存阅读事实,Redis 保存可重建的查询索引;重复、乱序和多端通过取最大值收敛;缓存失败、事件积压和热点群通过降级、重建和对账恢复。把这些业务边界讲清楚,才算真正回答了“IM 系统里的群聊已读怎么做”。