
你经常打交道的3个人的平均值,就是你
拒绝几乎所有应酬和无效社交
把时间花在阅读、思考、写作和少数几段深度合作上
只和"长期博弈、高声誉、聪明、有能量"的人交往
一、群已读功能的由来
1.1 群已读在 IM 链路中的位置
1.2 已读状态背后的三个隐藏问题
二、群已读的存储与一致性
2.1 群已读的可验证设计目标
2.2 已读状态的存储模型选择
2.3 群已读的扇出账单与服务端合并
2.4 并发写覆盖与最终一致
2.5 跨端漫游的已读水位
2.6 群已读的端到端骨架
三、大厂如何设计
3.1 某钉 DTIM 的读扩散混合与已读合并
3.2 企某信的 referid 链与异步化
3.3 某信不做已读的产品决策
3.4 三家方案的横向对比
四、如何优化提升
4.1 已读模型的硬决策
4.2 群规模触发的方案切换
4.3 已读风暴的削峰兜底
4.4 已读状态的可观测
产品经理:"老板让加个已读功能,跟某钉、某书一样。" 我想:不就是多存一个状态字段吗,两天搞定。
结果一周后,800 人部门群一条开会提醒消息,10 秒内消息中台 CPU 打到 90%、Redis 大量超时、聊天列表延迟 5 秒才更新。复盘才发现:800 人同时点开消息,服务端瞬间要处理 800 次状态写、800 次发送方"已读人数+1"、再把状态同步给 800 个客户端——这是 N 平方量级的扇出。
一个看似"加个字段"的需求,背后藏着写扩散后的状态一致性、热点写覆盖、跨端同步、推/拉取舍等一连串问题。已读回执是 toB IM 真正的复杂度试金石——单聊已读容易,群已读才考验架构。中等规模 toB IM 项目,已读这块一般都被迭代改造过 2~3 轮:第一版先能跑、第二版要能扛千人群、第三版才能勉强支撑频繁交互的万人项目群。
已读回执的本质是消息的状态机迁移——已发送 → 已送达 → 已读——但这台状态机的"输入"分散在消息的每个接收方手里,"输出"要广播给所有相关人(至少是发送方),他是一条与原消息流并行的反向状态通道。

图 1. 群已读的反向状态通道。原消息走的是群消息扩散"一对多扇出",已读走的是"多对一聚合 + 一对多通知"。
实践过程中绕不开三个问题:
已读这个功能不只是"读"——它每一次状态变更都触发一连串后台操作。下面三个故障形态在 toB IM 群里反复出现:
故障形态 | 表现 | 根因 |
|---|---|---|
已读"卡住" | 别人明显已读,但发送方页面仍显示"未读" | 已读通知走推送通道丢了;发送方没有兜底拉取 |
红点数不一致 | 手机点开消息红点消失了,切到 PC 红点还在 | 已读状态没跨端同步;或者跨端 readTime 不单调 |
群已读统计错 | 200 人群里点开"已读详情",显示 178/200,但实际全员已读 | 并发写覆盖,状态丢失;或者退群成员未排除 |
这三种问题的共同后果是:用户对"已读"这个语义失去信任——一旦如此,整个 toB 场景的核心承诺:可靠性("老板能确认员工看过通知")就gg了。
从产品体验和技术架构设计上至少达到以下四个目标:
主流的三种存储模型,对应不同规模下的取舍:
模型 | 存储结构 | 适合规模 | 代价 |
|---|---|---|---|
每条消息 N 个状态 | msg_acks(msgid, uid, if_read) 每条消息 N 行 | 小群(≤ 50 人)、低频 | 千人群成本不可控 |
每条消息 1 行聚合 | receipt(msgid, readArray[]) 已读 uid 列表存数组 | 中大群(百人到千人) | 数组高频更新有并发问题 |
每用户 1 个水位 | user_conv_read(uid, gid, last_ack_msgid) 每人一个游标 | 任何规模、不需"具体谁读了哪条" | 拿不到"某条消息的已读详情" |
水位模型最省,是单聊和 toC 群聊的主流——last_ack_msgid 之前的消息都默认已读,O(用户数) 而非 O(消息数 × 用户数)。但它丢失了"某条消息谁读了谁没读"的精度——对 toB 场景中"老板必须看到这条公告谁没读"的需求是死穴。
中大群通常落在中间方案:每条消息一行聚合状态(典型字段如 readArray,已读用户的 uid 集合)。读时一次返回,写时把 uid 加入集合。它占用约 8 字节 × 已读人数 / 条,千人全读约 8KB,可接受。代价是数组的并发写,高并发下会丢更新。
存储模型选定后,下一道坎是流量。一条群消息的已读生命周期里到底发生几次"事"?算一下 800 人群的账单(假设 80% 在线、全部点开过这条消息):
1 条群消息发出
→ 服务端写 800 条副本(写扩散,已在系列 01 算过)
→ 客户端逐条 ACK 已读 (800 次上行请求)
→ 服务端更新发送方的 readArray (并发 800 次写同一行)
→ 已读变更通知给发送方 (800 次推送)
→ 发送方端口"已读人数"实时跳变 (800 次 UI 刷新)一条消息至少 800 次后端写 + 800 次状态通知——这是消息扇出之外的"第二轮风暴"。群里 5 个用户同时发 5 条消息,就是 5 × 800 = 4000 次集中爆发。
朴素解法是客户端逐条 ACK,简单但流量最大;进一步是服务端按时间窗合并 ACK,能压扇出但发送方"已读跳数"延迟到合并窗口长度。我经历过的项目最终用两层合并——客户端 + 服务端各做一层:
// 客户端合并:批量上报多条已读
on_user_open_conversation(conv):
unread_msg_ids = get_unread(conv)
if len(unread_msg_ids) > 1:
send_batch_ack(conv, unread_msg_ids) // 一次请求带多条
else:
send_single_ack(conv, unread_msg_ids[0])
// 服务端合并:相同发送方的多个接收方已读聚合
on_receipt_batch_arrive(receipts):
group_by_sender = group(receipts, key=r.sender_uid)
for sender, batch in group_by_sender:
merge_update(sender, batch) // 一次更新发送方的多条 readArray经验上能把扇出量压低到原来几分之一。

图 2. 已读 ACK 的两级合并。客户端攒"一次进会话的多条",服务端攒"同发送方的多个接收方"。
接下来是解决并发写覆盖问题。把已读人列表 readArray 存成一个数组字段,每次已读时"读出来 → 加 uid → 写回去",并发场景下会经典丢更新:
初始 readArray = [a]
线程 P 读到 [a],加 b → 写回 [a, b]
线程 Q 读到 [a],加 c → 写回 [a, c]
最终 [a, c] (b 丢了)千人群里同一发送方的 readArray 被几百个接收方并发更新,覆盖几乎必然发生。处理这类问题有三种解法:
方案 | 思路 | 优势 | 代价 |
|---|---|---|---|
分布式锁 | 更新前抢 Redis 锁 | 强一致 | 高频场景锁等待严重;锁是单点故障 |
乐观锁 / 版本号 | CAS 写入,冲突重试 | 无单点 | 高并发下冲突率高、重试链路长 |
MQ 串行化 | 同 sender 的请求 HASH 到一个 partition | 简单可靠,天然合并 | 单 partition 是吞吐上限 |
MQ 串行化是大多数主流 toB IM 的选择:与已有消息总线复用、同 sender 的请求落同一 partition, 天然串行且消费时还能就近合并、失败重试由 MQ 兜底。配套必须有幂等键 (senderUid, msgId, readerUid) 三元组判重——它保证同一用户对同一消息的重复 ACK 被识别为同一逻辑事件。失了它,重试会让 readArray 出现重复 uid 或状态计数偏高。
在设计体验上,要求用户手机点开消息后,PC 端的红点应立即消失。这个实现的关键是已读状态独立于设备——服务端维护一个会话级已读水位(per-user per-conversation),各端读它,不读自己本地的状态:
read_time_log:
PK = (userId, conversationId)
fields = (readTime, triggerEndpoint)
// 更新时只升不降
on_read_event(userId, convId, newReadTime, endpoint):
existing = read_time_log.get(userId, convId)
if existing == null or newReadTime > existing.readTime:
read_time_log.upsert(userId, convId, newReadTime, endpoint)
push_read_changed_to(userId, exclude=endpoint) // 跨端通知"只升不降"是反直觉但必要的约束。已读 ACK 在跨端、跨网络环境下到达顺序不确定——5 分钟前手机上的 ACK 可能晚于 1 分钟前 PC 端的 ACK 到达。若允许 readTime 回退,新到的旧 ACK 会把已经推进的水位抹回去,用户最不解的体验是"我明明读过了,红点怎么又出现了"——这条规则就是为了堵住这个。
跨端通知用 ReadChanged 事件走主消息通道下发,等同于一条不可见的控制消息:
字段 | 含义 |
|---|---|
userId | 发生已读的用户 |
conversationId | 涉及会话 |
readTime | 新的已读水位 |
triggerEndpoint | 触发已读的端(手机/PC/Web) |
excludeEndpoint | 不需通知的端(自己) |
接收端拿到 ReadChanged 后本地校验 readTime > local.readTime 才更新,再次给水位单调性兜底。
把上面五个设计点串起来,整体流程设计:

图 3. 群已读的端到端骨架。水位和聚合并行更新:水位管"用户端红点"、聚合管"发送方已读详情",两条通道各自最终一致。
这张图反映一个核心判断:已读不是一个状态、是两个——给接收方看的(水位、跨端同步)和给发送方看的(聚合、谁读了)。
某钉 DTIM 的存储模型是读扩散 + 写扩散混合——普通消息走读扩散(conversation_message 表),个性化状态(如删除状态)走写扩散(message_inbox 表,原文以删除状态为例;已读是否走 message_inbox ),最终用户视角合并。大群里用户都在线时瞬时会有大量已读请求,每个都处理会产生 M × N 的扩散(M 消息条数、N 群成员数),万人群下不可接受。
他们的解法分两层:客户端把一个会话的多次已读合并发送,服务端再按消息合并处理(公开资料示例为"1 分钟合并 1 次")。在消息多次更新场景下,公开分享资料显示整体扩散量同比减少 96%。跨端一致性上,已读事件通过同步服务的 FIFO 位点流统一下推,自增 id 串行写入用户的事件队列。
优势 | 代价 |
|---|---|
客户端 + 服务端两层合并能把已读风暴压两个数量级;读扩散减少存储;位点流统一承载已读事件 | 1 分钟合并窗口让发送方的"已读跳数"非实时;位点是用户维度,超大账号要做热点拆分;架构整体复杂 |
企某信走纯写扩散,已读状态用一个有意思的模式——通过 referid 链把"原消息"和"已读状态消息"串起来。接收方已读时,在自己消息流插入新消息(msgid=b2,referid=b1);同时在发送方消息流插入(msgid=a2,referid=a1)把读者加进"已读列表"。已读状态走原本的消息同步通道,客户端按 seq 增量拉就能拿到。
写覆盖问题(多接收方并发更新发送方的已读列表)用MQ 串行化解决——同发送方的请求 HASH 到一个 partition、就近合并。性能上做两点:接收方同步写入(用户立即看到"已读成功")、发送方异步写入(靠重试达到最终一致);多条已读合并处理,相关研发公开分享资料:整体写入量节省 44%。
优势 | 代价 |
|---|---|
复用消息通道,前后端协议改动小;referid 链可扩展到撤回 / 编辑等场景;MQ 串行化解决覆盖写 | LevelDB 落盘要 merge 重复状态消息;接收方和发送方两套消息流的一致性需异步重试兜底 |
某信作为熟人社交,长期不做已读回执——这是一个产品决策反推架构决策的经典案例。技术上某信不缺实现已读的能力,但产品定位是"保护用户隐私"——已读状态会让用户失去"假装没看到"的社交退路。这件事反过来影响了它的消息存储模型:每条消息只在每个用户视角下有"是否已读"的本地状态、不向其他用户暴露、不需要服务端聚合,因此整套消息存储能保持极简(per-user 消息流 + 单调 seq,没有 readArray 这种聚合字段)。
优势 | 代价 |
|---|---|
消息模型极简,无聚合存储 / 无 M×N 扩散 / 无写覆盖问题;隐私体验在 toC 场景是核心卖点 | toB 场景完全不可用——办公场景对"消息已读确认"是刚需;某信另起企某信做这事,技术栈分裂 |
已读这个功能要不要做,本质是产品定位题不是技术题。如果业务是 toC 熟人社交、加这个功能反而伤体验,不做就是最正确的架构。
维度 | 某钉 DTIM | 企某信 | 某信 |
|---|---|---|---|
存储模型 | 读扩散 + 写扩散混合 | 写扩散 + referid 链 | 写扩散,无聚合 |
已读详情 | 支持,按消息聚合 | 支持,按消息聚合 | 不支持(产品决策) |
跨端同步 | 位点流统一推送 | 消息流增量拉取 | 用户自己端内同步 |
风暴合并 | 客户端 + 服务端两级 | 客户端 + MQ 串行化合并 | 不需要 |
写覆盖解决 | 1 分钟服务端合并窗口 | MQ 串行化 | 不存在该问题 |
公开效果 | 扩散量减少 96% | 写入量节省 44% | — |
共性是:所有做已读的厂家都不靠"每次都同步落库"——必须有客户端合并、服务端合并、MQ 串行化中的至少两层削峰。分歧在存储模型和同步通道:读扩散混合更省存储但模型复杂,纯写扩散 + referid 链改造小但 LevelDB 要 merge。
做已读之前先回答三个问题:是否需要"具体谁读了哪条"、群规模上限是多少、是否支持多端。三个问题决定模型:只要"谁没读"是产品入口(强 toB 场景),必须聚合存储;群规模超 500 人,必须两层合并;支持多端,必须独立水位表。这三件事都对的话,从 day 1 就分两套存储:水位表(接收方红点)+ 聚合表(发送方详情)。
群成员1000人是个粗略分水岭:
我的经验里项目群最常被问的不是"谁读了"而是"还有几个人没读"——这个差异允许把详情查询做成弱实时(30 秒缓存)。假如"详情查询要实时",这里的强一致是过度设计。
合并永远不是免费的,要为合并失败留兜底:
readTime 的回退尝试(虽然被"只升不降"挡掉了),频次突增意味着客户端有 bug 或时钟问题。已读故障比消息丢失更难排查——消息丢失用户会立刻投诉,已读异常往往几小时后才发现"红点不对"。最值得加的三个指标:
有了这三个指标监控,已读问题能从被动接受"用户截图反馈",变成"大盘告警先于用户发现",搬砖小工能更从容排查问题。
已读回执功能天然接受"已读状态可以慢但不能错"——因为比起延迟 30 秒看到已读,发送方更不能接受"明明全员已读却显示 788/800"这种bug。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。