
先说结论
面试官问“百万在线 IM 的已读未读怎么设计”,不要上来就说给每条消息加一个 read_status 字段。
这个功能表面是在问消息状态,实际考的是三件事:
- 数据怎么存,才不会随着消息数和成员数膨胀到不可控。
- 未读数怎么计算,才不会每次都扫消息表。
- 群聊和高频上报怎么处理,才扛得住百万在线用户。
更稳的回答是:已读未读不要按“每条消息一个状态”建模,而要按“用户在会话里读到哪里”建模。也就是给每个用户在每个会话中维护一个已读游标,游标通常是会话内递增的消息序号 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_seq、leave_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这样统计“读到某条消息之后的人数”就不需要扫全量成员。但这个能力仍然应该按需触发,不要在每次消息列表渲染时对每条消息都实时计算。

百万在线下的高频读写链路
只把数据模型设计对还不够。百万在线场景下,已读上报会非常频繁,必须控制写入路径。
比较稳的链路是:
- 客户端合并上报:用户快速划过一屏消息时,不要每条消息都上报。客户端只上报一段时间内最大的
read_seq。 - 接入层校验:服务端校验用户是否属于会话,
seq是否合法,防止伪造会话或越权上报。 - Redis 扛高频:最新游标先写 Redis,使用
max语义保证幂等和单调前进。 - 异步批量落库:通过队列或定时任务把游标批量刷回数据库,降低数据库写入压力。
- 长连接推送:单聊可以把对方已读位置实时推给发送方;群聊则按产品规则推摘要或在打开回执详情时再拉取。
这套链路可以概括成三句话:客户端合并降上报,Redis 扛高频,异步落库保持久,长连接保实时。

读扩散还是写扩散
已读未读设计里还有一个常见追问:到底是写扩散还是读扩散?
逐条记录每个用户读没读,本质上就是强写扩散。发送一条群消息,提前写出所有成员状态,读取时简单,但写入和存储成本高。
已读游标模型更接近读时计算。写入时只更新用户自己的阅读位置;展示某条消息的回执时,再根据游标统计。这会把成本从“每条消息都写很多状态”改成“真正需要展示回执时再算”。
实际系统一般不会极端选择一边,而是按场景折中:
- 单聊:游标足够简单,直接用。
- 普通群:维护成员游标,回执按需统计。
- 超大群:不做精确到人的回执,只做个人阅读位置同步。
- 高频热点消息:可以缓存回执统计结果,设置短 TTL,避免重复计算。
面试时可以这样答
如果面试官问“百万在线 IM 已读未读怎么设计”,可以按这个顺序回答:
第一,先点破核心矛盾:
这个问题不能按每条消息存状态设计,尤其群聊会出现消息数乘成员数的存储膨胀。核心应该是记录用户在会话里的已读游标。
第二,讲单聊:
单聊里每条消息有会话内递增
seq,每个用户在会话里维护一个read_seq。未读数等于latest_seq - read_seq;判断对方是否读了我某条消息,只要比较对方read_seq是否大于等于这条消息的seq。
第三,讲群聊:
群聊要分产品形态。大群可以只做“我读到哪”的多端同步,不做精确到人的回执;需要回执的群,也不要按消息和成员存状态,而是每个成员维护一个已读游标。统计某条消息的已读人数时,用消息
seq和成员游标比较,必要时用 Redis sorted set 优化统计。
第四,补性能落地:
已读上报是高频操作,客户端要合并上报最新位置,服务端用 Redis 扛住高频游标更新,再异步批量落库。单聊或小群的已读变化通过长连接实时推送,群回执详情按需拉取或缓存统计结果。
容易答错的点
不要说“给消息表加一个状态字段”。消息是否已读不是消息自己的全局属性,而是某个用户相对于某个会话的阅读进度。
不要忽略群成员边界。统计未读人数时,要考虑加入时间、退群时间、禁用成员、机器人账号和发送者是否计入回执。
不要让客户端直接决定最终状态。客户端只能上报“我看到的位置”,服务端必须校验会话权限和消息序号,并用单调更新防止旧上报覆盖新游标。
不要把 Redis 当唯一存储。Redis 适合扛高频读写,但数据库仍然要保存可恢复的游标状态,防止缓存丢失后用户未读数大面积错乱。
不要对每条群消息实时计算回执。回执详情通常是低频入口,应该按需计算、缓存或限制群规模,而不是在消息列表里持续做重统计。
最后记住这一句
IM 已读未读的核心不是“状态字段”,而是“阅读位置”。用游标替代逐条状态,单聊算得快,群聊按产品形态取舍,再用客户端合并、Redis、异步落库和长连接把高频链路兜住,才是能落地的百万在线设计。
资料说明
本文基于用户提供的视频文案和内容分析整理,未引用需要实时核验的外部数据。
评论(0)