☰
IM会话未读数与红点方案选型:从服务端聚合到混合模型的实战解析
2026/9/26 4:23:12 网站建设 项目流程

做IM开发的都清楚,会话未读数、红点、方案选型这几个关键词,写在需求文档里就一行话,真做起来却牵扯到消息状态、计数存储、推送通道、多端同步一整条链路。很多团队在未读数上返工,核心原因不是不会写代码,而是没把“未读数到底该由谁来算、红点数据源放哪、高并发下怎么聚合”这几个问题想明白。这几年我做IM项目,从客户端本地统计到服务端聚合再到混合模型全部趟了一遍,踩过的坑不少,也沉淀了一套相对稳定的做法。这篇文章把会话未读数和红点的方案选型、高并发IM场景下的服务端聚合细节,以及动态加载IM会话数据时网络异常(典型如失败时出现failed to fetch)该怎么容错一次性讲透。适合正在做IM客户端或服务端的开发同学,也适合刚接手IM技术选型的架构师参考。

1. 未读数与红点的本质:先搞清在数什么

1.1 未读数不是简单的累加,是消息状态机

在动手写方案之前,我建议先花一天时间把产品侧的未读数定义理清楚。未读数本质上是消息已读/未读状态在会话维度上的聚合视图。一条消息从产出到展示,会经历发送成功、服务端落库、推给接收方、接收方已读几个状态,未读数只在“消息处于未读状态”这个区间内才存在。很多方案做崩,就是因为把未读数当成一个独立计数器,而没有把它放到消息状态流转里看。

大部分产品的未读数至少分三层:

  • 会话维度未读数:某个单聊或群聊里有多少条没读的消息,显示在会话列表那条记录的右侧,是用户感知最强的未读入口。
  • 全局未读数:所有会话未读数的总和,常见用于应用启动页的桌面角标,或者IM主tab入口的红点数字。
  • 分类未读数:按消息类型拆开的,比如系统通知、群消息、私聊消息各自的红点,常见于消息中心分tab的数字展示。

这三层不是各自独立的。全局未读数可以设计成会话维度未读数的聚合结果,也可以独立维护。选型时最忌讳的是全局未读数让服务端启动时全量count,会话未读数却搞成客户端本地统计,两边数据源不一致,最后角标数字和会话列表红点对不上,产品验收直接翻车。

我自己的经验:未读数系统要在一开始就确定“唯一事实来源”到底落在哪一端。落在服务端,客户端的所有展示都围绕服务端下发的数据来做,客户端只负责展示和上报已读;落在客户端,就要接受多端不一致、换设备丢失未读的现实。后面所有方案选型都围绕这个决定展开,中途换数据源基本等于重写。

1.2 红点的分级与产品语义

红点也不只是“有没有未读”这么简单。成熟IM产品里,红点一般分四级:

层级场景展示形式数据源
应用角标系统桌面图标数字或小圆点全局未读数
主导航红点IM底tab入口数字/红点全局未读数
会话列表红点会话条目数字/红点会话未读数
会话内消息红点聊天页内新消息提示数字/红点/蓝色引导条本地增量消息

产品上的“红点”和“数字”其实是两套逻辑。红点只表达“有没有新的”这个布尔状态,数字则表达“有多少条”。很多产品经理会把“有未读就显示一个红点,没未读就消失”作为需求,但技术实现上全是数字统计的事。这里有一个很关键的产品约束要提前对齐:红点是否区分消息类型。比如群聊被@了要强提醒,系统通知要单独红点,这些都会让未读数方案从“一个计数器”升级成“多维度的计数集合”。

另外还有一个在方案设计阶段容易忽略的点:红点的消失逻辑不一定是“置为已读”。有的产品希望用户滑动删除会话后红点消失,有的希望清空聊天记录后未读数归零,有的则希望即使读了也保留一个“会话内有新内容”的记忆点。这些产品判断题直接决定服务端计数在哪些场景下被重置,建议在技术开发前拉着产品过一次完整状态流转,否则后面返工非常痛。

2. 方案选型的关键技术点:计数算在哪,怎么同步

2.1 客户端本地统计与服务端聚合的取舍

未读数到底在哪算,这是整个选型的分水岭。三种主流做法各有明确适用边界。

客户端本地统计:消息离线拉取到本地,客户端建一张消息表,按会话维度查询未读。优点是IM SDK集成方不需要依赖服务端额外接口,适合那种围绕单机数据库的小型工具类App,或者SDK内嵌场景。但缺点在多端和重度使用时暴露很明显——手机没收到的消息根本不算未读,A手机读了B手机不知道,群成员几百人每人本地算一遍,设备一换全归零。

服务端聚合计数:服务端在消息入库时维护每个用户每个会话的未读计数,接收方已读后减去。这是主流IM产品采用的方式,因为只有服务端能看到全局消息流向,多端一致性只能靠它解决。缺点是计数器是热点写,尤其群聊里一条消息要批量为N个成员各加一次未读,高并发IM场景下压力非常大。

混合模型:服务端下发“未读基线”,客户端本地做增量维护。比如客户端启动时拉一次服务端统计作为基线,后续新消息通过长连接推送到达,本地在这个基线上做加减。这种方式兼顾了实时性和服务端压力,但前提是客户端本地状态必须可靠,且所有已读操作最终都要回传服务端,否则基线迟早对不上。

结论很直接:没有多端诉求、数据量可控的小项目,客户端本地统计最简单;只要存在多端登录、换设备、群聊重度场景,就必须走服务端聚合。我当时选混合模型,核心原因是纯服务端聚合的实时推送在弱网下容易断,本地增量兜底能避免几次重连后未读数突然跳变。

2.2 同步模式:全量拉取、增量拉取、事件通知

未读数从服务端到客户端,同步方式决定实时性和流量成本。这里没有“最佳方案”,只有“适合当前业务形态的方案”。

  • 全量拉取:进入会话列表或App启动时,调用一次接口把未读数全部拉回来。优点是实现最简单,缺点是每次tab切换都要拉,流量和响应时间都难看,弱网下稍微一卡红点就闪不出来。
  • 增量拉取:基于增量游标,只拉差值。比如客户端记录上次拉到的未读版本号,这次只请求版本号之后的变化。流量小,但实时性依然依赖客户端主动触发。
  • 事件通知+主动拉取:新消息到达时,长连接通道只推一条轻量事件给客户端,客户端发现“有未读变化”后再调接口拿最新未读数。实时性好,服务端事件推送压力不大,缺点是客户端每次都要做额外请求,触发频率高时要小心流量和接口压力。

我实际用的是“事件通知+增量拉取”的组合,不是每一条新消息都推未读数字,而是推{会话ID, 最后一条消息seq},客户端拿本地会话的最后seq对比,不一致就去拉增量消息和未读基线。高并发下推送通道只传小体积通知,不会因为未读计数更新而狂发消息体。

这里要特别提醒:一旦选择事件通知模式,客户端必须在本地有一套可靠的事件去重和状态合并逻辑。长连接通道不保证消息顺序和送达次数,同一个事件可能推两次,也可能推得顺序错乱,如果客户端直接拿事件里的数字去覆盖本地显示,很容易出现未读数先涨后跌或者来回闪的情况。

2.3 存储与计数架构:从Redis到落库

服务端未读计数最常见的落地方式是Redis。最朴素的方案是每个用户一个哈希表:unread:{userId},field是会话ID,value是未读数。每条消息入库时执行一次HINCRBY,已读回执时执行一次赋值清零或递减,查询时直接HGETALL。这个方案的读写复杂度都是O(1),Redis单实例轻松扛住几十万级在线用户。

但高并发IM场景下有两个坑必须先想清楚。

热点会话写放大。一个千人群产生一条消息,按朴素方案要对群里每个在线成员各写一次计数,写放大就是群成员数倍。我的应对办法是不追求每条消息实时更新所有成员未读数,而是把“成员未读计数”拆成“群消息游标+个人已读游标”两个值——群未读数=群最新消息seq - 个人已读游标,不用每条消息都触发批量写。这个方案强烈推荐给有大群的业务,省掉的写放大是量级级别的收益。

Redis数据可靠性。未读数本质上只是“展示用状态”,丢一部分不算灾难,但Redis崩溃恢复时,方案里必须定义清楚“丢失后怎么办”。最稳妥是允许短暂归零,客户端用本地“有新消息”的红点兜底;最怕的是半恢复状态,数据不一致且客户端毫不知情。我的做法是未读数异步定期落库快照,Redis重启后从快照补齐,客户端启动时也可以用基线拉取纠正。

至于数据库层,不建议直接拿离线消息表count(*)统计未读。未读本质上是高频读状态,离线消息表是高频写表,把两者耦合在同一张表上,锁竞争和慢查询早晚会拖垮核心链路。如果离线消息表已经存在,最多只在冷备和异步校验时用一下。

3. 高并发IM下的服务端聚合与客户端落地

3.1 高并发下的服务端聚合策略

高并发IM对未读数最直接的冲击是两条链路:一是新消息进入后触发计数变化的链路,二是客户端查询未读数的链路。两条链路同时高负载,服务端必须先拆分再优化。

新消息链路方面,消息本身先经过接入层、消息存储,再到未读计数器。未读计数器最怕的是每条消息同步等待Redis写完成才返回,这样消息发送的端到端时延被拉长。我采用的做法是计数写入异步化:消息落库成功之后立刻给发送方回ack,未读计数扔进一个内存队列批量合并,每200毫秒或攒够100条再批量提交给Redis。消息发送方感知不到这200毫秒,但Redis的写量直接少了一个数量级。

查询链路方面,会话列表页面一打开通常要拿几百个会话的未读数。逐个HGET显然不行,直接用HGETALL把整个哈希拉下来,在客户端内存里自己Mapping。如果用户会话特别多,哈希很大,建议按会话类型拆分,比如私聊一个哈希、群聊一个哈希、系统通知一个哈希,避免大key拖垮Redis。

还有一个在线/离线分流策略。在线用户的消息推送到端上后,未读数理论上可以不等服务端计数,客户端本地先加展示,服务端计数异步补齐;离线用户的消息则必须进离线存储,等服务端计数的完整链路。把在线和离线的未读流程分开处理,能显著降低实时计数对服务端的压力。这个分流策略在高并发IM里尤其关键,因为在线用户通常是活跃峰值,但他们的未读计数反而是最不需要精确实时的。

3.2 客户端未读数状态机与本地持久化

客户端一定要有清晰的未读数状态机。我在工程里定义的状态是:未初始化(NO_DATA)、有基线(SYNCED)、本地脏数据(DIRTY)、等待确认(PENDING)。每个状态决定哪些展示元素可用:

  • 未初始化:启动阶段,未读接口还没返回,这个阶段不要显示“0”,建议显示一个占位灰条或干脆不显示红点数,只显示会话列表骨架。你永远不知道用户会对一个错误的“0”有多敏感。
  • 有基线:展示服务端拉下来的未读数,正常渲染数字红点。
  • 本地脏数据:长连接推送的本地增量和服务端基线不同步,这个阶段以本地增量为准临时展示,同时后台起同步任务纠正。
  • 等待确认:用户已读操作执行完,本地清零但服务端还没回ack,乐观展示清零,但不要覆盖服务端基线,等到ack之后才真正提交。

本地持久化方面,客户端要有一张会话列表缓存表,字段至少包括:conv_id, last_read_seq, unread_count, last_msg_time。每次渲染先从这里取,服务端基线到达或长连接增量到达时更新。用SQLite就够,但索引一定要建对:(unread_count desc, last_msg_time desc)的联合索引是会话列表排序的刚需,没有它列表一长就只能全表扫描排序。

这里分享一个很实际的经验:未读数不要直接存一个纯数字,而是存“已读游标+最新消息seq”,每次计算未读数=seq - last_read_seq。数字会被清零逻辑覆盖,但游标天然支持增量纠正,两个客户端各自上报已读,服务端只要维护最大的游标即可,不用处理“减错了”的纠纷。

3.3 多端同步与已读游标的一致性

多端登录是未读数方案最容易翻车的地方。手机读了、电脑没读,到底算读了没有?产品上通常定义“任一端已读即已读”,技术实现就必须有全局唯一已读游标。

服务端为每个用户每个会话维护read_seq,客户端上报“我已读到某条消息seq”,服务端只做最大值合并。新消息seq大于read_seq才算未读。所有端查询未读数都基于这个游标计算,天然一致。这个设计比加减计数法更抗并发:不用处理“同时上报清零”的竞态,服务端逻辑就是个简单比较。

客户端上报已读时不要逐条上报,建议做批量已读上报:客户端维护一个待上报队列,每500毫秒或攒够20条会话的变化,统一发一个批量请求。这样既减少服务端请求量,也让服务端合并读游标的压力小很多。批量接口设计上可以带{conv_id, read_seq}数组,返回ack或失败重试标识。

多端场景下还要处理“本地已读但服务端未确认”的窗口期。手机端用户读了一条消息,未读数本地清零,但网络请求还没到服务端,此时电脑端同步到一个“未读+1”,用户会困惑。这是最终一致性窗口,我建议客户端在已读上报pending期间不要向用户展示新增未读的实时红点闪动,等ack慢的时候就合并到下一次增量里一起处理,避免闪红点造成的焦虑感。

4. 常见问题与排坑实战

4.1 未读数回跳:本地清零了又被服务端拉回来

这是我在多个项目里遇到的最高频问题。用户看完会话列表,明明已读清零,结果返回再进来又出现一条未读,来回折腾,体验非常差。

根因几乎都是“清零操作丢失”:客户端已读之后,本地清零成功了,但上报服务端请求在弱网下超时或失败,服务端计数没变。下次拉基线,服务端还是原来的未读数,客户端只能把旧值展示出来,看起来就是回跳。

解决方案分三层:

  • 客户端清零后,已读上报要进入pending队列,失败重试三次,重试也失败就保留待上报状态,不要默默丢弃。
  • 服务端收到已读上报时以read_seq为准,而不是简单执行“这个会话清零”。这样即使上报延迟,旧的上报也不会把新消息的未读错误清零。
  • 客户端本地存储里要区分“已确认清零”和“乐观清零”,只有前者才允许覆盖服务端基线。如果发现服务端基线比本地乐观清零的值大,直接采用基线值并打一个同步异常日志,方便排查。

4.2 应用角标和会话列表红点对不上

全局未读数显示在App桌面角标,会话列表红点显示在会话条目上,两边数字不一致,产品通常第一眼就能发现。

问题出在数据源不一致:角标走服务端总计数接口,会话列表走本地缓存。我统一改成了服务端下发的未读基线同时包含全局和会话两个维度,客户端启动拉一次基线,长连接推送的每个事件都同步更新全局计数和对应会话计数。本地展示层直接从同一份内存快照取值,杜绝双写。

另外一个容易忽略的是iOS角标需要系统权限和本地推送配合。角标数字更新的时机不能依赖前端定时器,而是在进后台、切回前台、收到推送这三个关键节点显式更新;否则用户切回桌面的瞬间角标永远是旧数字。Android端相对自由一些,但国产ROM对后台限制很严,角标更新同样要走在App生命周期关键节点上,而不是全局定时刷。

4.3 动态加载IM会话数据时网络异常的处理

动态加载IM会话数据出现failed to fetch,是移动端和Web端都常遇到的问题。这里不单指会话列表首屏的未读数拉取,也包括IM SDK动态初始化、按需加载某个会话的历史消息、降级到H5页签时的动态脚本拉取。表现是未读数取不到、红点渲染异常,但根因往往不只一个。

常见根因有三种:

  • 网络切换或弱网:设备刚连上网络,请求超时或连接被重置,fetch直接失败。
  • 服务端异常或接口超时:未读数接口在服务端高负载下没有快速失败,客户端等不到响应。
  • 资源加载时序问题:动态加载的JS脚本或资源包被CDN或网关拦截,导致IM模块初始化不完整,后续所有未读数请求全部失效。这种情况比较隐蔽,报错信息很像是网络问题,实际是模块没起来。

应对策略我总结为“三层降级+本地缓存兜底”:

  • 接口层容错:所有未读数请求设置超时(我一般设1500ms左右),失败后自动重试一次,重试也失败走降级。
  • 展示层降级:拿不到精确未读数时,不要显示“0”,也不要显示错误红点,可以显示一个灰色占位或“有新消息”的弱提示。用户至少知道有东西没加载出来,而不是被误导为一切已读。
  • 本地缓存兜底:客户端本地会话表里保存上一次成功的快照,动态加载失败就渲染旧数据,并打上“离线状态”标记;等网络恢复或SDK初始化完成后,用增量同步纠正。

还有一个容易踩的坑:Web端动态加载IM SDK脚本失败时,如果只做了资源加载的catch,忽略后续初始化完整性检查,会出现“JS加载成功但内部模块没注册完”的状态,未读数接口调用直接报内部错误而不是网络错误。我的做法是给SDK初始化加一个ready事件,动态加载完成且核心模块注册成功后再置为可交互,否则一律走降级逻辑。

4.4 大群消息未读数的特殊处理

大群是未读数方案的试金石。每个群成员都对一条消息做实时未读更新,服务端写放大太大,所以大群的未读计数设计必须和单聊、小群区分开。

在设计上,群消息不维护每个成员的未读计数器,而是维护“群最新消息seq”和“成员已读游标”两个值。群未读数=群最新seq - 成员read_seq。这种方式把写次数从“消息数×群成员数”降为“消息数×1”,在线成员的未读数展示完全由查询端计算。@消息单独给一个标记字段,不影响未读计数的整体数据结构。

对于全局未读数(App角标)想用群未读数求和的话,不要实时计算所有群的未读数,那样查询成本过高。更好的做法是:全局角标保持独立的计数器,只在群消息到达时批量累加一次;群成员退出会话、清空聊天记录时,单独做一次批量减法。这部分不做强一致,允许短暂偏差,靠客户端下次拉基线去修正。

5. 选型决策建议与经验收尾

写到这里基本把方案的关键节点都过了一遍。最后给一份决策清单,按业务场景快速锁定方案:

场景推荐方案不推荐
无多端、轻量工具App客户端本地统计服务端聚合(偏重)
标准IM、多端同步服务端基线+本地增量混合纯客户端统计
超大群、高活跃群seq游标+个人read_seq逐成员实时计数
Web/H5嵌入事件通知+本地缓存与动态加载容错全量拉取(弱网扛不住)

做技术选型时遵循一个原则:未读数本质是展示状态,可以最终一致,不要强一致。任何端上的数字看起来是对的,比技术上分毫不差更重要。为了一条未读多打几次服务端接口,用户感知不出来,但服务端压力是实打实的。所以我在团队里一直强调:未读数模块优先保证可用性和低延迟,其次才是精确性。

我在实际项目里最深的体会是,未读数模块的复杂度不在单点实现,而在跨端、跨网络的状态一致性。方案设计阶段多花两天把产品语义问清楚——红点分几级、已读定义是什么、群聊要不要特殊处理——后面能少返工一个月。如果你正在做IM选型,建议先从“服务端维护已读游标+客户端事件通知+本地缓存降级”这套组合切进去,它覆盖了95%业务的诉求,剩下的5%等真的遇到再优化也来得及。

还有一个容易遗漏的需求:用户手动清空聊天记录后,未读数和红点是否要一起消失?这个题目一定要提前找产品经理确认,技术方案上会直接决定是否需要在清空动作里联动服务端计数重置,等上线后再补,多半又要踩一次数据不一致的坑。这类细节和红点系统本身的容错降级放在一起考虑,整个方案才算是闭环。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询