
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长连接和离线推送事件驱动增量更新就是天然的选择如果通信链路只有轮询那服务端计算 定时拉快照反而更稳妥。先想清楚你手里的基础设施再谈选型这个顺序别颠倒了。我个人做大并发场景的体会是未读数这种功能单看每个技术点都不难难的是把所有状态汇总在一起时还能保持条理清晰。状态模型想清楚了代码只是体力活。