IM 未读数架构设计:从客户端计算到水位混合模型的演进

0 阅读6分钟

产品经理甩过来一句:「会话列表那个红色数字,群里超过 99 就显示 99+,对吧?」然后你下意识就说了「好」。直到上了线,bug 单像雪花一样飞来——手机读了平板红点还在、撤回消息数字不降、网络抖一下未读从 5 跳到 8——你才发现,那个看起来人畜无害的小红点,居然是个分布式一致性问题。

说白了,未读数就是一个「某个用户在某个会话里还有多少条消息没看」的问题,但它横跨客户端、接入层、存储三端,是多端对齐、弱网自愈、群消息扇出三条线交汇的产物。它不在消息收发的关键路径上,却是用户每天睁眼第一个看的东西——数字错了,用户立刻就不信任你的产品。

今天咱们就掰开揉碎聊一聊,IM 未读数背后那三种主流实现模型,以及它们各自的坑。

图片

最直觉的做法:服务端只管发消息,客户端收到一条就本地 +1,点进去就本地清零。

好处很明显——服务端零负担,啥也不用存,扇出链路干干净净。早期很多 toC 的 IM 就是这么干的,消息能可靠送达、客户端能做增量同步,未读数自然就算出来了。

但它的问题也一目了然。

多端各算各的,天然不一致。 手机读了 100 条,平板那边毫不知情,红点还顶着。服务端没有一个权威数字可以去对齐,端 A 已读、端 B 不知道——这就是多端不一致的根因。

重装即归零。 换手机、清缓存,所有红点全部清空——哪怕实际上还有几百条没读。用户体验直接崩掉。

幂等全靠客户端自觉。 网络抖动重连,把离线消息重新拉一遍,如果客户端不去重,未读数就重复累加了。逻辑下沉到每个端自己实现,任何一个端出了 bug,数字就虚高。

结论很明确:纯客户端计算,只适合单端场景。 toB 办公 IM 多端是刚需,这条路走不通。

模型二:服务端存计数器——权威了,但扛不住群

把未读数挪到服务端:每个用户的每个会话存一个 unreadCnt。消息落到会话盒子时加一,用户点进去时清零。客户端不自己做主,以服务端为准。

这个模型解决了多端一致和可自愈的问题——任何端重装后拉一次服务端,就能拿到正确的未读数。手机已读后服务端广播给平板,平板也跟着清零。

但「+1 这个动作本身」藏着两个大坑。

第一,+1 不幂等。 同一条消息因为重试、重连被处理了两次,计数器就多加一次。要解决这个问题,得在累加时带上消息序号去重,不然计数器会越加越离谱。

第二,群场景的写放大。 一条群消息扇给 1000 人,就是 1000 次 unreadCnt + 1 的写操作。如果计数器存在需要落盘的存储里,扇出链路凭空多出上千次写,量级一大就是瓶颈。

所以,裸的 +1 计数器很难同时做对幂等和性能,真正能落地的往往是下面的混合模型。

图片

模型三:已读水位 + 计数器混合模型——目前最稳的解法

这才是目前中等规模 toB IM 里普遍倾向的做法。核心是把「计数」和「已读边界」拆成两样东西:

  • • 已读水位(read watermark):记录用户在这个会话里「读到哪个序号」,是一个只升不降的单调量。

  • • 未读计数器:一个加速用的派生缓存,让会话列表不用每次都去算差值。

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

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

计数器和水位的关系就是「缓存 vs 真值」。计数器算错了不要紧,拿水位回算一次就能纠正。

容易被忽略的细节:不可计未读消息

混合模型里有个特别容易翻车的点:不是所有进消息流的都该计未读。

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

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

落地中的几个血泪教训

跨端清零的乱序陷阱。 手机已读 → 服务端清零 → 广播给平板,这条路走消息队列,而消息队列不保证有序。如果「手机已读到 seq 100」的通知比「平板已读到 seq 80」晚到,简单覆盖就会让水位从 100 退回到 80,红点诡异复活。解法?不用「清零」语义,用「声明式水位对齐」——只有 N 比本地大才前进,小的丢弃。

大群未读降级。 大群里真去精确算「1247 条未读」,既扛不住写放大,用户其实也不关心精确值。设一个 999+ 的上限,超了就标记「爆了」。但注意:@我、被引用这类要单独精确计数——用户对「有人 @ 我」极度敏感,这条不能降级。

可观测性。 未读数错了,用户不会报「未读数 bug」,他会说「我怎么有条消息没看到」。最好的指标是计数器和水位的偏差值——偏差长期为 0 说明逻辑健康,偏差突增往往是撤回或编辑操作引入了修正项漏算。

写在最后

未读数这个功能,看着人畜无害,真做起来处处是分布式系统的经典难题——一致性、幂等、跨端同步、写放大。你选了哪种模型,取决于你的场景:单端 toC 可以用客户端计算,小群 toB 可以用服务端计数器,中等规模多端 toB 那还是水位 + 计数器的混合模型最稳。

你在项目里处理过未读数吗?踩过什么坑?评论区聊聊。