IM会话未读数和红点方案选型,这个话题我琢磨了挺久。凡是做过即时通讯(IM)客户端或服务端的同学,基本都绕不过这一关。未读数看起来是个简单的东西——无非就是数字加减、红点显隐,但真正落地的时候,你会发现在不同场景下它的复杂程度完全不是一个量级。
最近我把这块从零梳理了一遍,也调研对比了几种常见做法。这篇就结合我自己的工程实践,聊聊会话未读数、红点展示从方案选型到落地实现的核心思路和踩坑记录。
1. 未读数系统的本质:它不是一个数字,而是一致性问题
先说清楚一个容易被误解的点:未读数表面上是“某个会话有多少条没读的消息”,但站在系统角度,它真正的难点是多端一致、实时同步、过期失效、状态归并这四个问题的叠加。
- 多端一致:用户在手机已读了一条消息,PC端和网页端的未读数也必须同步消失。
- 实时同步:新消息到达的瞬间,列表页的红点/数字要立刻变化,不能等用户下拉刷新。
- 过期失效:会话被删除、消息被撤回、或者用户批量已读之后,旧未读数不能再出现。
- 状态归并:一个会话的未读状态可能来自多个消息来源,需要按会话聚合。
如果你只是写个Demo,用本地变量自增自减就够了。但一旦上了生产环境、接了多端、要面对重连补拉、离线推送这种场景,未读数就变成了全局分布式状态的一部分。这个定位想清楚,选型才有方向。
我见过不少团队一开始只把未读数当成客户端本地变量处理,结果做到后面多端同步时,只能推倒重来。建议最早先把“未读数 = 服务端状态 + 本地修正”这个模型定下来,后面所有方案都围绕它展开。
2. 未读数存储的三种主流选型与我的取舍
2.1 纯客户端本地维护
这是最轻量的一种方案。客户端维护每个会话的未读计数变量,收到消息时加一,进入会话时清零,本地已读上报时归档。优点是完全不依赖服务端,开发速度快。缺点是换设备、清缓存、重装App之后未读数丢失,而且多端场景下无法合并不同设备的已读事件。
这种方案适合什么场景呢?内部工具类IM、项目原型、或者对未读数精确度没有要求的业务。但生产级即时通讯里,它只能作为降级预案存在。
2.2 服务端维护全局未读数
服务端在消息发送和已读上报时维护每个会话的未读数,客户端通过拉取或推送获取结果。这个方案的一致性有保障,但需要设计好两个接口:拉取未读数总览、上报已读状态。它的复杂点在于消息是逐条产生的,但未读数状态是批量聚合的,所以服务端需要做归并存储。
我最终采用的是这种方案。具体来说:服务端在写入新消息时,对接收方的会话未读数原子自增;用户已读某条消息时,按“已读位置”更新会话的未读计数。未读数在数据库中以“接收方 + 会话ID”为维度存一份,查询时走缓存聚合。
这里有个很好的工程简化:不存“哪些消息是未读的”,而是存“用户读到了哪一条”,即last_read_msg_id,未读数 = 会话最新消息ID - last_read_msg_id 的差值。当然这中间要排除自己发送的消息、系统通知等不需要计入未读的Id,需要做偏移修正。
2.3 本地缓存 + 服务端快照混合方案
严格来说,这不是独立的第三种方案,而是前两者的结合。客户端在本地维护一份会话未读快照,服务端只负责下发初始值和关键变更事件。优点是离线启动时列表也能秒开,缺点是状态源变多,容易出现本地和服务端不一致。
我实际落地时就是混合结构:首次启动拉服务端全量快照,之后常规变更是通过事件驱动增量更新,本地再维护一个修正层。比如收到服务端的新消息事件时,本地先乐观加一,得到“预测未读数”,等服务端下发最新快照后覆盖修正。
这种做法的核心收益是交互上几乎无延迟,数字变化立刻反馈,最终一致性交给服务端校准。下面这张表可以更直观看出三种方案的差异:
| 方案 | 一致性 | 实时性 | 开发成本 | 适合场景 |
|---|---|---|---|---|
| 纯本地维护 | 差 | 好 | 低 | 原型、工具类IM |
| 服务端全局维护 | 好 | 取决于推送链路 | 中 | 生产级IM |
| 本地缓存+服务端快照 | 好 | 好 | 中高 | 高并发、多端IM |
我个人建议是:如果你从零起步,直接奔着服务端全局维护方案去,再在客户端做一层本地缓存优化体验。
3. 红点的状态机设计:显示、消失、闪烁都要有“因”
未读数解决了,红点就在数字之外的另一套状态体系了。红点不只是“有未读”和“没未读”二值,它至少包含这些状态:隐藏、显示静态红点、显示数字、数字封顶(99+)、下发已读状态后清除。这套状态之间切换,我建议用状态机统一管理,避免if-else鸡飞狗跳。
3.1 红点状态枚举划分
我在项目里抽象了这样一个红点状态枚举:
| 状态 | 含义 | 触发场景 |
|---|---|---|
| HIDDEN | 不显示任何未读标记 | 无未读消息 / 会话折叠 |
| DOT | 仅显示小红点 | 未读数为0但有系统通知 |
| NUMBER | 显示具体数字 | 正常有未读消息 |
| LIMIT | 显示99+ | 未读数超上限 |
| MUTED_DOT | 免打扰但有未读 | 群聊消息免打扰 |
| PENDING | 服务端未下发最终值 | 刚发消息尚未收到ack |
引入PENDING状态是有现实意义的。IM收发消息存在网络延迟,用户在发送消息后立刻看列表,未读数可能因为消息尚未送达而短暂“错误减少”,这看起来像Bug。所以发送过程中我会让它停留在PENDING状态,收到服务端回执(ack)后转为NUMBER或HIDDEN,用状态机的“占位”换来视觉稳定。
3.2 状态流转的事件驱动
红点状态的流转尽量走事件驱动,不要轮询。触发事件包括:新消息到达、会话已读、批量已读、会话删除、免打扰开关切换、撤回消息等。
我用了类似 reducer 的方式管理:所有事件统一进入一个状态处理函数,根据当前状态和事件类型计算下一步状态。这比分布式地在页面各处改计数要安全得多。
一个典型的例子:某群聊免打扰开启后,新消息到达时红点不应直接显示数字,而是显示一个“灰色小点”或完全不显示。这个规则在状态机里非常容易表达,但如果你在收到消息回调里直接unreadCount += 1然后刷新UI,就很容易上下文缺失、状态错乱。
4. 实时性与性能的博弈:消息驱动的增量更新
未读数方案选型里最容易翻车的是实时性设计。如果每次收到消息都全量拉取一次未读数,列表页切回来的时候大概会卡到不想用。更合理的做法是增量更新 + 事件合并。
4.1 事件增量更新未读数
新消息到达时,服务端下发的不是完整的会话列表,而是单个事件:
{ "type": "message.new", "sessionId": "session_1024", "messageId": "msg_88012", "receivedAt": 1714680000, "needUnread": true }客户端拿到这个事件后,在本地对对应会话的未读数加一。这个操作要快,通常用一个Map维护sessionId -> unreadCount,事件到达时直接更新内存缓存,再通过订阅通知UI刷新列表对应行。UI层面只更新当前会话那一行,不做全局刷新。实测这个方案在高并发消息场景下表现很平稳,不会有滚动卡顿。
4.2 合并高频事件
在群聊轰炸或大量推送场景下,同一会话可能在几百毫秒内连续来十几条消息,此时如果每条事件都触发UI刷新,性能就有压力。我给这类高频场景加了合并策略:
- 维护一个短时窗口(比如300ms),同一会话的未读事件合并为一次UI刷新。
- 合并时数字直接加事件条数,而不是一条条推给UI。
这个思路在电商客服、直播群聊这类场景特别管用。实际操作中,我用了一个独立于UI订阅的小型debounce调度器,只对高频会话生效。
4.3 首屏加载与回前台刷新
未读数在列表页首屏加载时,要走一次轻量拉取,拿到所有会话的未读数快照。用户从后台切回前台时,需要判断是否重新拉取。我的策略是“回到前台超过30秒则自动拉一次”,保证红点状态不会被系统挂起期间的离线消息拖太久。
这类刷新接口返回的结构大概长这样:
{ "sessions": [ { "sessionId": "1024", "unreadCount": 5, "type": "NUMBER" }, { "sessionId": "2048", "unreadCount": 0, "type": "DOT" }, { "sessionId": "3072", "unreadCount": 156, "type": "LIMIT" } ], "totalUnread": 161, "syncTimestamp": 1714680100 }其中totalUnread是所有会话未读数汇总,有时候用户想知道的是“总共还剩多少条没读”。但这个汇总值的实时性一般,我一般不把它作为精确数字展示,只用于角标红点显示。
5. 已读上报与多端同步:最容易被低估的复杂度
未读数选型最后能不能抗住工程验证,就看已读上报和多端同步这一层。
5.1 已读上报的时机
我在客户端选择了离开会话页和切后台时上报已读这两个节点。为什么要避开逐条消息上报?因为用户在会话页连续读消息时,逐条上报会频繁触发服务端写操作,属于不必要的性能开销。
具体上报协议一般是这样的:
{ "type": "read.session", "sessionId": "1024", "lastReadMessageId": "msg_88012", "timestamp": 1714682000 }服务端收到后做两件事:更新last_read_msg_id,并计算新的未读数。这个更新以“消息ID位置”为准,能天然处理并发乱序。
5.2 多端同步的“已读回执”
你在手机A上看了某群的聊天,手机B上的未读数也应当同步清零。这不能靠B主动拉取(除非用户手动下拉刷新),因为关闭App的B端是收不到推送的。真正保底的是服务端在收到已读上报后,向该用户的其他在线终端下发一个“已读同步事件”:
{ "type": "session.read.sync", "sessionId": "1024", "lastReadMessageId": "msg_88012" }其他端收到后立即更新本地未读数。至于离线端,等它下次登录时拉取快照时自然矫正过来。
5.3 未读数不会因为已读上报变成负数
已读上报和消息到达事件存在时序竞争。我实际遇到过的情况是:一条消息还没落入本地,用户就先点了已读,此时服务端如果直接unreadCount = max(0, unreadCount - n)就会有丢状态的风险。必须按lastReadMessageId的位置全局计算,而不是在客户端维护的计数上做减法。这也是为什么我一直强调服务端计算是源,客户端修正只是中间态。
6. 常见坑位盘点:选了方案不等于万事大吉
整个方案走下来,有几个坑我印象特别深。
6.1 无效消息计入未读数
系统通知、自己发送的消息、群聊中@他人的消息,这些不该计入未读数,但很容易在消息事件驱动时不小心算进去。我在事件源处做了标记:只有needUnread = true的消息才驱动未读增加,同时在服务端计算时排除了消息发送者本人。这个细节看起来简单,但漏掉后会出现“未读数永远差几条”的诡异问题。
6.2 封顶值必须统一
不同端对封顶值的设定如果不一致,就会出现手机显示“99+”,PC显示“100”,然后用户截图对比质疑你数据不一致。我统一约定:所有未读数超过99的会话统一显示99+,实际值仍然存在后端不返回封顶前的原始值,客户端只负责展示。总未读数角标也做了同样封顶,但阈值单独配置为99。
6.3 免打扰会话的红点规则
免打扰会话的意思是不打扰用户,但未读事务仍然要积累。我采取的是:免打扰群聊的新消息不播报通知,但在列表里保留未读数累计,等到用户进入会话页后会一次性已读,列表状态从MUTED_DOT直接转为HIDDEN。这里也需要状态机支持跨会话转移,不能在进入会话后只清单个会话的红点。
6.4 批量已读的合并上报
用户在会话列表左滑“全部已读”时,本质上是连续触发了多个read.session事件。如果逐条上报,服务端要处理多轮计算,还可能触发多次同步推送。我在这一层加了批量接口,一次上报包含多个会话的lastReadMessageId数组,服务端批量处理,再统一推一次同步事件。
7. 从选型到落地:我给你的务实建议
我知道很多人看这类文章想看一份现成的推荐方案。结合我自己的项目,我最终落地的是这一套组合:
- 服务端维护全局未读数,用
last_read_msg_id方式计算,数据库原子自增/更新。 - 客户端首屏拉取未读快照,后续靠事件增量更新,UI只刷新受影响行。
- 红点状态用状态机统一管控,特殊状态(免打扰、封顶、待确认)显式建模。
- 已读上报走“离开会话”和“切后台”两节点,批量上报统一走聚合接口。
- 多端同步依靠服务端下行
session.read.sync事件,离线端靠下次登录拉取矫正。
这套方案既保证了实时性,也控制了工程复杂度。你如果只是做到“显示未读数”这层,不需要上完整状态机;但如果要做成分层红点、免打扰、多端一致,那状态机和服务端计算模型早晚是绕不开的。
另外一点务实建议:方案选型时不要贪图“最先进”,要看你团队的通信链路和消息模型。如果你已经有完善的WebSocket长连接和离线推送,事件驱动增量更新就是天然的选择;如果通信链路只有轮询,那服务端计算 + 定时拉快照反而更稳妥。先想清楚你手里的基础设施,再谈选型,这个顺序别颠倒了。
我个人做大并发场景的体会是:未读数这种功能,单看每个技术点都不难,难的是把所有状态汇总在一起时还能保持条理清晰。状态模型想清楚了,代码只是体力活。