百万在线 IM 已读未读怎么设计?

IM 已读未读的难点不在状态字段,而在海量消息下的存储膨胀、高频上报和群聊统计。核心做法是用已读游标替代逐条状态,并按单聊、大群、回执群分别设计。

百万在线 IM 已读未读怎么设计?

先说结论

面试官问“百万在线 IM 的已读未读怎么设计”,不要上来就说给每条消息加一个 read_status 字段。

这个功能表面是在问消息状态,实际考的是三件事:

  1. 数据怎么存,才不会随着消息数和成员数膨胀到不可控。
  2. 未读数怎么计算,才不会每次都扫消息表。
  3. 群聊和高频上报怎么处理,才扛得住百万在线用户。

更稳的回答是:已读未读不要按“每条消息一个状态”建模,而要按“用户在会话里读到哪里”建模。也就是给每个用户在每个会话中维护一个已读游标,游标通常是会话内递增的消息序号 seq

为什么不能给每条消息存状态

单聊里给消息加状态字段看起来还能跑,但一到群聊就会出问题。

假设一个 500 人群发出 1 条消息,如果要精确记录每个人读没读,这条消息就可能生成 500 条读状态记录。消息越多、群越多、成员越多,记录量会按“消息数乘成员数”快速膨胀。

更麻烦的是,已读状态不是低频数据。用户进入会话、划过消息列表、切换设备、回到前台,都可能触发已读位置变化。如果每次都更新数据库里的逐条状态,数据库会长期承受高频小写入,写放大很明显。

所以这道题的第一个关键点是识别模型错误:已读未读不是给每条消息打标签,而是记录用户阅读进度。

单聊:一个游标解决大部分问题

单聊最适合用游标模型。

每个会话里的消息都有一个递增序号:

  • 第 98 条消息:seq = 98
  • 第 99 条消息:seq = 99
  • 第 100 条消息:seq = 100
  • 第 101 条消息:seq = 101

用户 A 在这个会话里的已读游标是 read_seq = 100,就表示 A 已经读到第 100 条;seq <= 100 的消息对 A 来说都是已读,seq > 100 的消息都是未读。

未读数也不需要逐条统计:

unread_count = conversation_latest_seq - user_read_seq

如果要判断“我发出的某条消息对方是否已读”,也只需要看对方的游标:

peer_read_seq >= message.seq  => 对方已读
peer_read_seq <  message.seq  => 对方未读

这个设计把 N 条消息状态压缩成 1 条用户会话进度,存储和计算成本都降下来了。

推荐的数据模型

可以把数据拆成三类:

message
- conversation_id
- seq
- sender_id
- content
- created_at

conversation_member
- conversation_id
- user_id
- join_seq
- leave_seq
- role/status

conversation_read_cursor
- conversation_id
- user_id
- read_seq
- updated_at

其中 message.seq 是会话内单调递增序号。conversation_read_cursor.read_seq 是用户在会话里的已读位置。

这里要注意两个边界:

第一,已读上报必须是单调前进的。客户端可能因为多端、弱网、重试而上报旧位置,服务端更新时应该使用:

read_seq = max(old_read_seq, reported_read_seq)

第二,群成员统计不能把不该统计的人算进去。比如用户后加入群,不应该对加入前的历史消息产生未读;用户退群后,也不应该继续参与新消息统计。因此成员表里最好保留 join_seqleave_seq 或等价的成员有效区间。

群聊:先看产品到底要什么

群聊不能只给一个技术答案,要先区分产品形态。

大群:只同步“我读到哪”

很多大群、超大群不做精确到人的已读回执。产品上只需要知道“我在这个群读到哪里”,用于多端同步、红点、未读数和继续阅读。

这种情况下,每个用户在群里仍然维护一个自己的已读游标:

group_read_cursor(group_id, user_id, read_seq)

但不会展示“这条消息谁读了、谁没读”。这是用产品边界规避存储和统计爆炸。对于成员数很大的群,这通常是更现实的选择。

回执群:每个成员一个游标

如果产品要求像办公 IM 一样展示“几人已读、几人未读”,也不要为每条消息给每个人存一条状态。

仍然维护每个成员在群里的已读游标:

group_member_read_cursor(group_id, user_id, read_seq)

要统计某条消息 m 的已读人数:

read_count = count(member.read_seq >= m.seq)
unread_count = valid_member_count_at(m.seq) - read_count

对于 500 人级别的群,当用户点开某条消息的回执详情时,临时按成员游标统计通常是可以接受的。对于更大的群,如果还要求精确回执,就可以在 Redis 里维护一个按 read_seq 排序的集合:

ZADD im:group:{group_id}:read_cursor read_seq user_id
ZCOUNT im:group:{group_id}:read_cursor message_seq +inf

这样统计“读到某条消息之后的人数”就不需要扫全量成员。但这个能力仍然应该按需触发,不要在每次消息列表渲染时对每条消息都实时计算。

群聊已读回执的两种产品形态

百万在线下的高频读写链路

只把数据模型设计对还不够。百万在线场景下,已读上报会非常频繁,必须控制写入路径。

比较稳的链路是:

  1. 客户端合并上报:用户快速划过一屏消息时,不要每条消息都上报。客户端只上报一段时间内最大的 read_seq
  2. 接入层校验:服务端校验用户是否属于会话,seq 是否合法,防止伪造会话或越权上报。
  3. Redis 扛高频:最新游标先写 Redis,使用 max 语义保证幂等和单调前进。
  4. 异步批量落库:通过队列或定时任务把游标批量刷回数据库,降低数据库写入压力。
  5. 长连接推送:单聊可以把对方已读位置实时推给发送方;群聊则按产品规则推摘要或在打开回执详情时再拉取。

这套链路可以概括成三句话:客户端合并降上报,Redis 扛高频,异步落库保持久,长连接保实时。

已读上报的高频读写链路

读扩散还是写扩散

已读未读设计里还有一个常见追问:到底是写扩散还是读扩散?

逐条记录每个用户读没读,本质上就是强写扩散。发送一条群消息,提前写出所有成员状态,读取时简单,但写入和存储成本高。

已读游标模型更接近读时计算。写入时只更新用户自己的阅读位置;展示某条消息的回执时,再根据游标统计。这会把成本从“每条消息都写很多状态”改成“真正需要展示回执时再算”。

实际系统一般不会极端选择一边,而是按场景折中:

  • 单聊:游标足够简单,直接用。
  • 普通群:维护成员游标,回执按需统计。
  • 超大群:不做精确到人的回执,只做个人阅读位置同步。
  • 高频热点消息:可以缓存回执统计结果,设置短 TTL,避免重复计算。

面试时可以这样答

如果面试官问“百万在线 IM 已读未读怎么设计”,可以按这个顺序回答:

第一,先点破核心矛盾:

这个问题不能按每条消息存状态设计,尤其群聊会出现消息数乘成员数的存储膨胀。核心应该是记录用户在会话里的已读游标。

第二,讲单聊:

单聊里每条消息有会话内递增 seq,每个用户在会话里维护一个 read_seq。未读数等于 latest_seq - read_seq;判断对方是否读了我某条消息,只要比较对方 read_seq 是否大于等于这条消息的 seq

第三,讲群聊:

群聊要分产品形态。大群可以只做“我读到哪”的多端同步,不做精确到人的回执;需要回执的群,也不要按消息和成员存状态,而是每个成员维护一个已读游标。统计某条消息的已读人数时,用消息 seq 和成员游标比较,必要时用 Redis sorted set 优化统计。

第四,补性能落地:

已读上报是高频操作,客户端要合并上报最新位置,服务端用 Redis 扛住高频游标更新,再异步批量落库。单聊或小群的已读变化通过长连接实时推送,群回执详情按需拉取或缓存统计结果。

容易答错的点

不要说“给消息表加一个状态字段”。消息是否已读不是消息自己的全局属性,而是某个用户相对于某个会话的阅读进度。

不要忽略群成员边界。统计未读人数时,要考虑加入时间、退群时间、禁用成员、机器人账号和发送者是否计入回执。

不要让客户端直接决定最终状态。客户端只能上报“我看到的位置”,服务端必须校验会话权限和消息序号,并用单调更新防止旧上报覆盖新游标。

不要把 Redis 当唯一存储。Redis 适合扛高频读写,但数据库仍然要保存可恢复的游标状态,防止缓存丢失后用户未读数大面积错乱。

不要对每条群消息实时计算回执。回执详情通常是低频入口,应该按需计算、缓存或限制群规模,而不是在消息列表里持续做重统计。

最后记住这一句

IM 已读未读的核心不是“状态字段”,而是“阅读位置”。用游标替代逐条状态,单聊算得快,群聊按产品形态取舍,再用客户端合并、Redis、异步落库和长连接把高频链路兜住,才是能落地的百万在线设计。

资料说明

本文基于用户提供的视频文案和内容分析整理,未引用需要实时核验的外部数据。

上一篇:HTTP/3 的 QUIC 基于 UDP,为什么仍然可靠?

评论

评论(0)

暂无评论