首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >IM分布式架构系列(17) 群里 999+ 未读 | 3种未读数一致性模型

IM分布式架构系列(17) 群里 999+ 未读 | 3种未读数一致性模型

原创
作者头像
拉丁解牛说技术
修改2026-06-28 01:05:50
修改2026-06-28 01:05:50
1070
举报

上学时候,很感慨普通人与学神的是80分到100分之间那20分差距,可以缩小,但是很难企及。而最近发现,与神的差距从一开始就是望尘莫及。

前几天看新闻,发现某AI科技公司董事长竟然是校友、是同学。原来以专业第一保送清华后,后来还去了伯克利读博深造。

从报道里,看到学神虽然多了一些岁月沉淀的成熟,白头发也有了,但依然是那个朴素无华、谦虚平和、对新知识有着无限热情的模样。


一、未读数为什么也是个难题

二、三种计数模型与一致性设计

三、某信的版本号增量同步模型

四、如何把未读数做稳


一、未读数为什么也是个难题

产品经理问:「会话列表那个红色数字,群里超过 99 就显示 99+,对吧?跟某信一样。」看起来就是个计数器吗,收一条加一,点进去清零。

而实际生产看到的问题现象多如牛毛:手机上明明全读了,平板打开还顶着 3 条未读;有人撤回了一条消息,未读数没跟着减;网络抖一下重连,红点从 5 跳到 8 又跳回 5。

未读数看着是个数字,本质是「某个用户在某个会话里还有多少条消息没看过」这件事的分布式一致性问题。它横跨客户端、接入层、存储,多端要对齐、弱网要自愈,还得扛住群消息的扇出量级。它不在消息收发的关键路径上,却是用户每天睁眼第一个看的东西——数字错了,用户立刻就不信任你的产品。

未读数依附在会话维度上。一条消息扇出到接收方、落到会话盒子里,未读数就在「落盒子」这一步累加;用户点开会话、上报已读,未读数在「已读推进」这一步清零。它的上游是消息扇出,下游是会话列表渲染和角标(badge)。

扇出时 +1,已读时清零

1.1 三个容易让未读数翻车的场景

场景一:多端不一致。

用户手机和平板同时在线。手机上点开了群,看完了,未读清零;平板这边如果没收到「已读」信号,红点还顶着。toB 办公场景里多端是常态——很多用户电脑挂着客户端、手机也开着。一个会话两个数字,用户会怀疑是不是漏消息了。

场景二:撤回、系统提示把数字算多了。

群里有人发了条消息,未读 +1,然后他撤回了。如果未读数只会加不会减,这个红点就永远多 1。再加上「XX 把 YY 移出了群聊」这类系统提示——它进了消息流,但不该计入未读。把这些不可计未读的消息算进去,用户点进会话翻到底也找不到那条「未读」的消息,红点却一直亮着。

场景三:弱网重连下的重复累加。

网络抖动,客户端断开重连,把离线期间的消息重新拉了一遍。如果未读是「收一条加一」的纯增量逻辑,重复拉取就会重复累加——红点从 5 跳到 8,用户没收到任何新消息,数字却变大了。

这三个场景的共同点:未读数不能是一个孤立的计数器,它是消息流、已读状态、多端同步三者交汇处的派生量,任何一处出问题,都会在这个用户每天盯着看的小红点上暴露出来。

二、三种计数模型与一致性设计

2.1 未读一致性核心要素

  • 多端一致:同一用户多个在线端,未读数最终要收敛到一致,且收敛延迟控制在秒级。
  • 幂等:同一条消息无论被推送 / 拉取几次,对未读数的贡献只有一次。弱网重连不能让数字虚高。
  • 可自愈:客户端本地态丢失(重装、清缓存、换设备)后,要能从服务端重建出正确的未读数。
  • 抗扇出:群消息一条要扇给上千人,未读数的更新不能成为扇出链路上的瓶颈。

2.2 模型一:客户端本地计算

最朴素的做法:服务端完全不管未读数,客户端收到一条新消息就本地 +1,用户点开会话就本地清零。服务端只负责把消息送达。

好处很直接:服务端零负担,没有任何未读相关的存储和计算,扇出链路不受影响。早期很多 toC IM 走的就是这条路——消息能可靠送达 + 客户端能做增量同步,未读自然就能算出来。但它的代价在 toB 多端场景里被放大:

  • 多端各算各的,天然不一致。服务端没有一个权威数字去对齐,端 A 已读,端 B 不知道,红点不会消。
  • 重装即归零。客户端本地态一丢未读数就没了,用户换手机、清了缓存所有红点清空——哪怕实际上还有几百条没读。
  • 幂等全靠客户端自觉。重复拉取要靠客户端按消息 ID 去重才能不重复累加,逻辑下沉到每个端各自实现,容易出 bug。
代码语言:javascript
复制
on_receive(msg):
    if msg.id in seen_ids: // 关键:客户端自己去重,否则重连会重复累加
        return
    seen_ids.add(msg.id)
    conversation[msg.conv_id].unread += 1

结论:纯客户端计算适合单端、或对多端一致性要求很低的场景。toB 办公 IM 多端是刚需,这个模型不够用。

2.3 模型二:服务端存计数器

把未读数当成服务端的一个权威计数器:每个用户的每个会话存一个 unreadCnt。消息扇出落到接收方会话盒子时 unreadCnt + 1,已读时清零。客户端的数字以服务端为准。

代码语言:javascript
复制
// 扇出落盒子时
on_fanout(user_id, conv_id, msg):
    box = get_box(user_id, conv_id)
    box.unread += 1 // 服务端权威累加
    box.last_msg_seq = msg.seq

// 已读时
on_read(user_id, conv_id):
    box = get_box(user_id, conv_id)
    box.unread = 0                   // 清零
    broadcast_read_changed(user_id, conv_id)   // 通知其他端

服务端权威解决了多端一致和可自愈:任何端重装后向服务端拉一次就拿到正确的未读数;点击端已读后服务端清零,再广播一个「已读变更」事件给其他端,其他端跟着清零。但「+1 这个动作本身」藏着两个坑:

  • +1 不幂等。同一条消息因为重试、重连被处理两次,计数器就多加一次。要做幂等,得在累加时带上消息序号去重,或者干脆不用 +1,改成「按序号算差值」——这就引出了模型三。
  • 群场景下的写放大。一条群消息扇给 1000 人,就是 1000 次 unreadCnt + 1 的写操作。如果计数器存在需要落盘的存储里,扇出链路上凭空多出上千次写,量级一上来就是瓶颈。

服务端计数器是 toB IM 的合理基座,但 +1 计数器很难同时做对幂等和性能,真正能落地的往往是下面的混合模型。

2.4 模型三:已读水位 + 计数器的混合模型

这是目前在中等规模 toB IM 里我们倾向的做法——核心是把「计数」和「已读边界」拆成两个东西:

  • 已读水位(read watermark):记录用户在这个会话里「读到哪个序号 / 哪个时间点」,是一个只升不降的单调量
  • 未读计数器:一个加速用的派生缓存,让会话列表不用每次都去算差值。

未读数的真值,本质是「会话最大序号 - 已读水位」之间还有多少条可计未读消息;计数器只是把这个差值缓存下来,避免每次渲染列表都回算。

代码语言:javascript
复制
// 已读推进:水位只升不降
on_read(user_id, conv_id, read_seq):
    box = get_box(user_id, conv_id)
    if read_seq > box.read_watermark: // 关键:只接受前进,丢弃回退请求
        box.read_watermark = read_seq
        box.unread = 0
        broadcast_read_changed(user_id, conv_id, read_seq)

// 重建未读数(重装 / 对账时用)
def recompute_unread(user_id, conv_id):
    max_seq = get_conversation_max_seq(conv_id)
    watermark = box.read_watermark
    return count_countable_msgs(conv_id, watermark, max_seq)   // 区间内可计未读的条数

水位的「只升不降」是关键——它让整个流程对乱序和重放天然免疫。无论已读请求因弱网乱序到达、还是重连后重复上报,水位只接受比当前大的值,小的直接丢弃。这就把「幂等」从「靠客户端去重」变成了「靠单调水位兜底」,可靠性高了一个量级。

计数器和水位的关系是「缓存 - 真值」:计数器用于日常快速读取,水位用于异常时重建。计数器算错了不要紧,拿水位回算一次就能纠正。

跨端同步走的是另一条线:已读推进后,服务端不仅清自己这条盒子的计数,还会算出同一用户的另一个端、把那个端的未读也清掉,再发一个「已读变更」通知经消息队列路由到在线的其他端。这样手机读了,平板的红点也跟着消。

2.5 不可计未读消息与反向修正

混合模型里有个容易被忽略、但用户极其敏感的细节:不是所有进消息流的东西都该计未读

撤回消息、「XX 加入群聊」这类系统提示、被编辑过的消息——它们在序号上占了位置,却不该让红点 +1。如果按「最大序号 - 水位」粗算未读数,这些消息就会被算进去,用户点进会话翻到底也找不到那条「未读」,红点却一直亮——这是投诉率很高的一类 bug。

工程上常见的处理是给计数器配一个反向修正项。落盒子时只对「可计未读」的消息类型累加;遇到撤回这类事件,则记一笔修正量去抵消之前的累加。最终展示的未读数是「累加量 - 修正量」。

代码语言:javascript
复制
on_message_event(user_id, conv_id, event):
    box = get_box(user_id, conv_id)
    if event.type in COUNTABLE:           // 普通文本 / 图片等
        box.unread += 1
    elif event.type == RECALL:            // 撤回:抵消之前那次累加
        box.recall_offset += 1            // 反向修正项
    // 系统提示等不可计类型:既不加 unread 也不加修正
    display_unread = box.unread - box.recall_offset

这个修正项看着土,但它解决了一个真实痛点:消息流的「物理条数」和用户感知的「未读条数」从来不是一回事。哪些消息计未读、哪些不计本质是个产品定义问题,但实现上必须在服务端有个地方把这笔账记清楚,否则多端各自判断又会不一致。

2.6 三种模型的全貌对照

把三种模型放在四个关键要素下进行对比,选型逻辑就清晰了:

模型

多端一致

可自愈

幂等

抗扇出

适用场景

客户端本地计算

✗ 各算各的

✗ 重装即丢

△ 靠客户端去重

✓ 服务端零负担

单端 / toC 弱一致

服务端裸计数器

✓ 权威数字

✓ 可重拉

✗ +1 不幂等

△ 群场景写放大

单聊 / 小群

水位 + 计数器混合

✓ + 跨端通知

✓ 水位重建

✓ 单调水位兜底

△ 仍需治理大群

中大规模 toB IM

混合模型不是「最优解」,但对于我们中小IM toB 场景多端约束是最合适的——成本只是要额外维护一份水位、一套跨端通知、一个修正项。

图混合模型下单个会话未读数的状态流转

三、某信的版本号增量同步模型

未读数这件事,公开资料里讲得最透、且和上面三种模型形成鲜明对照的,是某信。某信走的是一条偏客户端计算的路线,但它用一套极其讲究的「每用户版本号 + 增量同步」机制,把客户端计算的可靠性做到了极致——这恰好是模型一的「重武器版」。

3.1 seqsvr 与每用户版本号

某信团队公开分享过,它在立项之初就确立了用数据版本号实现终端与后台的增量同步这条主线。服务端为每一份需要和客户端同步的数据(包括聊天消息)赋予一个唯一、递增的序列号作为版本号;客户端同步时带上自己已有的最大版本号,后台据此算出需要下发的增量数据。

产生这些版本号的,是某信内部叫 seqsvr 的序列号生成器——按公开数据,它每天承载万亿级调用,单次申请序列号平时耗时 1ms、99.9% 小于 3ms。关键设计是每个用户独立一段序列号空间(per-user sequence),而不是全局一个递增序列,从而避开了全局发号器的互斥瓶颈。

在这套机制下,未读数不需要服务端单独存一个计数器:客户端拿着本地版本号去同步,把增量消息拉回来,未读数就是本地按规则算出来的。序列号既是同步的版本号,也是算未读的依据。

维度

详情

优势

每用户独立序列避开全局发号瓶颈,扛得住海量级别; 增量同步天然省流量、抗重复(带版本号去重); 未读、消息同步、多端漫游复用同一套序列号,机制统一。

代价

未读数本质在客户端算,多端各算各的,写扩散下多端独立计算几乎必然出现不一致; 客户端逻辑复杂,去重、计数、不可计类型判断都压在端上; 服务端缺少一个权威未读数,运营侧难做对账。

3.2 客户端算未读的代价

这套方案放在某信的体量和 toC 定位下是合理的:海量用户、写扩散、弱化服务端会话概念,把未读计算下沉到端上,服务端就省掉了海量计数器的存储和写放大。代价是多端未读一致性——在写扩散模型下,未读数由各端独立计算,缺少一个服务端权威值去对齐,多端之间出现短暂甚至持续的不一致几乎是结构性的必然。对 toC 来说,偶尔的多端未读不齐是可以接受的体验瑕疵。

但 toB 办公场景的天平不一样。员工电脑 + 手机多端常驻是常态,「手机读了平板红点还在」会被当成产品 bug 反复投诉;而 toB 的用户和会话量级,比某信小好几个数量级,服务端存一份权威未读数完全扛得住。这就是为什么同一个问题,某信选客户端计算,而中等规模 toB IM 更倾向服务端权威的混合模型——不是谁的方案更好,是用户量级和一致性诉求,让我们各自选了不同的方向。

四、如何把未读数做稳

对于我们的落地混合模型,其实还有几个真正决定成败的细节要认真处理。

4.1 跨端清零的乱序陷阱

混合模型最容易翻车的地方,是跨端已读通知的乱序。手机已读 → 服务端清零 → 广播给平板,这条链路走消息队列,而消息队列不保证严格有序。如果「手机已读到 seq 100」的通知,比之前「平板已读到 seq 80」的通知晚到,简单覆盖就会让水位从 100 退回 80,红点诡异复活。

解法还是回到水位的「只升不降」:跨端同步的不是「清零」这个动作,而是「已读到 seq N」这个水位值。对端收到后,只有 N 比本地水位大才前进,小的丢弃。把同步语义从「命令式的清零」改成「声明式的水位对齐」,乱序就自然免疫了——一开始我们同步的是 unread=0 这个结果,踩了坑后才改成同步水位本身。

4.2 用水位做兜底对账

计数器会算错——重试、并发、修正项漏算,都可能让 unreadCnt 和真实未读数偏离。光靠计数器自己,错了就一直错,用户只能手动「点进会话再退出」来纠正。

更稳的做法是把水位当成对账基准:计数器是快路径,水位 + 会话最大序号是慢路径真值。在几个时机触发一次回算——客户端冷启动拉会话列表时、收到已读变更通知时、或服务端定时巡检时——用「区间内可计未读条数」校正计数器。这样即便快路径偶尔算错,也会被慢路径在秒级内拉回来,相当于给未读数加了一条最终一致的自愈回路。

4.3 大群未读的降级

回到标题那个 999+。大群里一条消息扇给上千人,就是上千次未读累加;真要精确显示「1247 条未读」,既扛不住写放大,用户也不关心精确值——他只想知道「有很多没看」。

工程上常见的降级是设一个上限,超过就显示「999+」,内部不再精确累加,只标记「超阈值」。这不是偷懒,是顺着产品语义做的优化:用户对大数字的精度不敏感,99+ 和 1247 对他是同一个意思——「这群我得抽空看看」。把成本省在用户根本感知不到的地方,是大群场景最划算的取舍。但 @我、被引用这类强提醒要单独精确计数——用户对「有没有人 @我」极度敏感,这条不能降级。

4.4 可观测:未读数的对账指标

未读数错了,用户不会报「未读数 bug」,他会说「我怎么有条消息没看到」或者「红点点不掉」。这类问题极难复现,因为它依赖具体的多端时序和弱网过程。

值得长期埋的指标有三类:清零失败率(已读上报后多久未读仍非零)、多端不一致检出率(同一用户多端未读数采样比对的偏差比例)、计数器与水位回算偏差(快路径和慢路径的差值分布)。第三个最有用——偏差长期为 0 说明计数逻辑健康;偏差突增,往往是某次撤回 / 编辑改动引入了修正项漏算。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、未读数为什么也是个难题
    • 1.1 三个容易让未读数翻车的场景
  • 二、三种计数模型与一致性设计
    • 2.1 未读一致性核心要素
    • 2.2 模型一:客户端本地计算
    • 2.3 模型二:服务端存计数器
    • 2.4 模型三:已读水位 + 计数器的混合模型
    • 2.5 不可计未读消息与反向修正
    • 2.6 三种模型的全貌对照
  • 三、某信的版本号增量同步模型
    • 3.1 seqsvr 与每用户版本号
    • 3.2 客户端算未读的代价
  • 四、如何把未读数做稳
    • 4.1 跨端清零的乱序陷阱
    • 4.2 用水位做兜底对账
    • 4.3 大群未读的降级
    • 4.4 可观测:未读数的对账指标
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档