尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

IM会话未读数与红点方案选型:存储、同步与最终一致性实践

发布时间:2026/9/27 1:12:10

资讯中心
01
ARTICLE

IM会话未读数与红点方案选型:存储、同步与最终一致性实践

IM会话未读数与红点方案选型:存储、同步与最终一致性实践
做IM的人迟早会遇到一个问题会话未读数不对了。小红点该亮不亮或者亮了消不掉用户骂完产品骂开发最后排查一圈发现是计数方案本身选错了。先说结论未读数和红点这块看起来只是“一个数字一个圆点”实际上牵扯到存储选型、同步协议、多端一致性、高并发削峰甚至客户端本地状态的容错。我前前后后做过两套IM系统的未读数模块也帮几个朋友排查过类似问题踩过的坑不算少。这篇把会话未读数和红点的方案选型从头到尾讲一遍从计数原理讲到线上问题排查适合正在做IM、或者准备从零搭消息模块的开发者参考。1. 先把问题拆清楚未读数和红点到底在解决什么问题1.1 一个数字背后其实有两条独立链路未读数系统看似只有一个维度但往里拆至少有两条链路写入链和展示链。写入链是指消息到达后系统如何把“某个会话多了一条未读”这件事记下来。消息可能是单聊也可能是几千人的大群可能一条消息只产生一次未读追加也可能一条群消息直接产生上万次追加。展示链则是指客户端App启动、从后台切回前台、或者正在聊天过程中如何拿到最新的未读数并刷新界面。这两条链的压力特征完全不一样写是突发型的高峰读是高频且分散的轮询。方案选型时必须分开考虑否则很容易出现“写的方案扛住了读的链路把DB打爆”这种尴尬局面。这里有个人容易被忽略的点未读数并不是一个简单累加器。用户的未读状态包含“每个会话各自的未读数”和“所有会话未读数之和”两层。总未读数通常决定App底部Tab上的红点会话未读数决定会话列表每个item后面的数字。而这两层之间必须保持一致你不可能出现Tab显示5条未读、点进去所有会话数字加起来是8的情况。这个一致性约束是后续所有方案设计的出发点。1.2 选型前必须回答的四个问题我建议任何团队在选型前先把下面四个问题拍死否则后面做技术方案就是纸上谈兵。第一量级是多少。日活十万和日活千万方案完全不同。十万日活时MySQL一张表加个索引可能就够了千万日活还硬扛DB那就是给自己埋雷。第二实时性要求多高。群里发消息离线用户的红点延迟几秒能不能接受如果能接受拉模式就够了如果不能必须考虑长连接推送。第三多端协同到什么程度。手机、PC、Web三端登录手机读了消息PC上的红点要不要立刻消失如果要就需要一个跨端的读同步协议复杂度直接上一个台阶。第四失败容忍度。未读数错了能不能自动纠正还是必须绝对精确我的经验是未读数允许短暂不准但必须最终收敛准确这里的关键词是“最终收敛”。我在第一套系统里就吃过这个亏产品只说了一句“要有未读数”开发直接上了Redis计数器结果没做对账线上跑到第三个月老用户的未读数开始乱跳最后花了两周做校准脚本才救回来。所以别急着写代码先把这四个问题和技术评审对齐。2. 存储与计数三种主流方案的取舍2.1 方案A纯数据库计数最简单粗暴的方案在im_conversation表上放一个unread字段消息到达时执行UPDATE ... SET unread unread 1 WHERE conv_id ?用户打开会话时执行UPDATE ... SET unread 0 WHERE conv_id ?。这个方案在数据量小、并发低的时候完全能跑我见过不少早期IM产品就是这么撑过第一年的。但它的瓶颈非常明确同一行的UPDATE是串行的行锁会在大群场景下成为瓶颈。一个2000人的群一条消息触发的2000次unread 1分布在不同的用户行上还好但如果所有人都盯着同一个热点会话比如直播弹幕聊天室那这个会话对应的行会被并发写锁打到请求排队。纯DB方案还有一个隐蔽问题UNREAD 1和“用户正在读消息”的UNREAD 0如果并发发生可能出现丢失更新。两个事务同时执行一个加一一个清零最后结果取决于锁的获取顺序很容易出现“明明读了数字还在涨”的诡异现象。我的判断是纯DB适合做历史数据存储和最终对账的依据不适合做实时计数的主力。如果团队刚起步、不想引入额外组件可以用它但必须接受一个现实——将来要扩容这块代码基本得重写。2.2 方案BRedis Hash计数器这是目前最主流的中型IM方案。核心数据结构是unread:{uid}这个Hashfield是会话IDvalue是未读数同时维护一个total_unread:{uid}的String作为总未读数兜底。消息到达时执行HINCRBY unread:{uid} {conv_id} 1和INCR total_unread:{uid}。Redis单实例的写能力通常在每秒10万次以上一次群消息引发上万次HINCRBY在Redis里也就是一两百毫秒的事完全能扛住。而且Hash结构天然支持HGETALL一次性取回某个用户所有会话的未读数客户端下拉刷新时一次命令搞定非常契合会话列表页面的数据需求。但Redis方案有几个必须提前设计的点。首先是持久化计数器数据在Redis里如果宕机且没开AOF未读数会全丢。我的做法是开AOF且appendfsync everysec即使丢也只丢一秒的计数增长可接受。其次是过期策略长期不活跃用户的unread:{uid}会一直占内存我习惯给每个Hash设置合理的TTL并周期性刷新避免冷数据积累。最后也是最关键的Redis方案同样存在计数漂移问题——崩溃、超时重试、并发清零都会导致Hash里的值和真实未读消息数对不上。所以Redis计数器只是“快计算”不是“准计算”。2.3 方案C基于消息序号的校准计算要解决“准”的问题很多团队会选择放弃增量计数改为记“已读位置”。具体做法是为每个会话记录用户已经读到的最后一条消息序号max_read_seq未读数 该会话当前最大消息序号 - 用户已读序号。因为消息序号是单调递增的、天然存在消息表里所以这个计算永远和真实消息数一致不存在漂移问题。这个方案最大的优点是准确而且天然支持多端同步——只要把max_read_seq同步到各个端每个端都能独立算出未读数。缺点是查询路径变长每次展示未读数都得知道“会话最大消息序号”如果消息表巨大就要有高效的索引或缓存支撑。所以成熟的IM系统往往是混合方案Redis计数器负责日常的高频读写消息序号负责定期校准和对账。这里顺便说一句很多面试里喜欢问“未读数怎么保证不丢”最稳妥的答案其实就是这个混合方案不能只依赖计数器必须让消息序号成为唯一事实来源。计数值丢了可以重算序号丢了可就真找不回来了。三种方案的对比如下方案实时读写性能准确性实现成本适用规模纯DB计数低行锁瓶颈并发下可能丢失更新最低日活十万以内Redis计数器高10万级QPS有漂移风险需对账中日活百万到千万序号校准计算依赖索引读较重绝对准确较高大厂IM标准做法3. 红点规则展示逻辑往往比计数更难3.1 红点的三个层级未读数方案定下来红点展示规则又是一层全新的复杂度。我一般把红点拆成三个层级App级红点、会话级红点、消息级灰点。App级红点是底部Tab上那个数字或小圆点逻辑最简单总未读数大于0就亮等于0就灭。会话级红点是会话列表每个item右侧的未读数字由每个会话的未读数决定。消息级灰点则是进入某个会话后某条消息前面的小灰点表示这条消息还没被读取。三个层级的触发条件、消除条件完全不一样产品需求里最容易扯皮的就是这些条件。层级展示位置触发条件消除条件App级红点底部Tab总未读数 0总未读数 0会话级红点会话列表item该会话未读数 0打开会话并清除消息级灰点会话内消息消息未被当前端读取消息被读取举个典型的例子用户点开一个会话读了一半就退出。此时会话级红点应该消失吗不同产品的答案不同。微信的做法是进入会话即清除该会话的未读而有些产品要求“真正滑动到底部”才算已读。这个看似产品决策的问题技术上的影响是如果采用“进入即清除”清除可以发生在客户端本地体验最好如果采用“滑动到底部才清除”服务端必须等待客户端上报一个精准的已读位置协议设计要复杂不少。所以我的建议是在技术评审阶段就把这个交互细节和产品对齐不要等技术方案做完再被推翻。3.2 清除时机与幂等为什么红点会“消不掉”红点“消不掉”是线上最常见的投诉根子几乎都在读操作和写操作的竞态上。假设用户正在会话A里读消息同时群里又进来一条新消息。如果客户端执行“清除未读”时直接用DEL或者SET 0而不是原子地“把当前值取出来再减掉”就会把新消息的未读也给清了。更常见的场景是用户在两台设备上同时操作手机清了未读PC端未读同步回来又覆盖了手机的状态于是红点刚灭又亮。解决思路有两个。第一个是“读时清除”用Lua脚本保证原子性先HGET取出会话未读数再HINCRBY减去该值、同时DECRBY总未读数整个过程在Redis服务端一次执行完不会插入新的增量。第二个是“上报已读序号”替代“清零”客户端把max_read_seq上报给服务端服务端用“当前最大序号 - 已读序号”重新计算未读数。第二种方案天然免疫竞态因为消息序号是只增不减的无论多少端同时上报最终结果都收敛到同一个值。3.3 多端一致性的几个坑多端同步是红点模块里最容易翻车的地方几个典型坑提前记住能省很多事。第一个坑重复上报。客户端网络超时后重试上报“已读”如果服务端没有做去重已读数会被错误地往前推进导致未读数被多减。解决方式是上报带上消息序号服务端判断是否重复。第二个坑离线期间的红点。用户一周没登录期间群里聊了500条登录时如果只同步“总未读数”不同步“各会话明细”会话列表就是一片空白。所以登录后的首次同步必须是全量摘要不能只推一个总数。第三个坑会话被删除。用户删除了一个会话服务端如果不清理该会话的未读计数总未读数里就残留了一堆永远看不到的未读红点永远消不掉。删除会话时必须联动删除对应的Hash field并同步递减总未读数。4. 同步策略未读数怎么高效到达客户端4.1 拉、推、混合三种模式的取舍未读数到达客户端的路径决定了整个系统的实时性表现和服务器压力。纯拉模式最简单客户端定时比如每30秒调用接口拉取总未读数和各会话未读数。优点是实现简单、失败可重试缺点是实时性差、轮询压力大。一个1000万日活的产品如果每30秒拉一次QPS就是300多再叠加登录、消息拉取等接口服务端压力很容易失控。纯推模式则由长连接把未读数变更事件实时推给客户端实时性最好但需要维护一套可靠的长连接系统而且推送丢失后客户端没有自愈手段。我的建议是混合模式这也是目前主流IM的通用做法客户端在线时通过长连接接收未读变更事件实时刷新同时保留一个低频的兜底拉取比如App从后台切到前台、断网重连后做全量校准。这个“实时靠推、异常靠拉”的组合既保证了体验又能容忍推送丢失。业界常说的“增量同步”就是这条思路的产物服务端为每个用户维护一个递增的sync_version把每次未读变化哪个会话、加了多少、当前总数追加到用户的同步流里客户端只需要带上自己上次的sync_version来拉取增量就可以在任何时刻重建完整的未读状态。4.2 增量同步协议的设计要点做增量同步协议我认为有三个设计要点必须把握。第一个是游标必须单调。sync_version只能递增不能回退否则客户端会漏掉中间状态。第二个是变化记录要可重放。一条记录最好包含“会话ID、本次变化量、操作类型、变更后的值”客户端重放时不能依赖上下文否则丢一条就全乱了。第三个是客户端必须做快照与增量结合。增量流不能无限增长服务端要定期把全量快照各个会话的未读数写入同步流客户端一旦发现自己落后的sync_version超过了快照点就直接拉快照重建而不是补成千上万条增量。这里有个我实际踩过的坑早期设计同步流时只存了“变化量”客户端断线一段时间后拉增量由于中间有些推送事件在长连接里丢了服务端也没留痕客户端的未读数就永久错位。后来改成“服务端为每个用户保留最近N天的变化流客户端只消费游标之后的记录”丢推送的问题才根除。记住未读数同步宁可多传重复数据也不能丢幂等消费是这一类系统的底线。4.3 failed to fetch 背后的同步接口与动态加载问题线上排查时经常会看到客户端报failed to fetch dynamically im这类错误字面意思是“动态加载IM资源失败”。很多团队第一反应是前端问题但我排查下来这类错误至少有三层原因。第一层是客户端在启动时动态拉取IM配置、会话摘要、未读数摘要的请求挂了。常见原因是客户端启动瞬间并发发起多个HTTP请求触发连接池或系统并发限制部分请求被系统直接拦截。这种场景服务端日志大概率看不到因为请求根本没到后端。处理方式是客户端做请求合并和优先级控制把未读数摘要和会话列表合并成一个接口避免启动风暴。第二层是服务端接口超时或限流。未读数摘要接口如果每次都实时聚合大量数据高并发下容易成为瓶颈必须用缓存或者预聚合结果。第三层是网络本身的抖动。移动端网络环境复杂弱网下长连接断开、HTTP请求失败都很正常重点不是消除失败而是让失败可恢复——客户端拿到失败响应后要立刻降级为展示本地缓存的上次未读数同时启动指数退避重试不能让用户看到未读数凭空消失或者界面卡死。这里我特别想强调一个原则未读数接口在任何情况下都不应该阻塞IM主流程。会话列表可以先渲染出来未读数以“异步慢慢补”的方式刷新。哪怕接口全挂了用户也能正常聊天只是红点暂时不亮而已。把未读数做成“尽力而为”的增强功能系统的健壮性会好非常多。5. 实操一套可落地的未读数服务设计5.1 数据模型与Redis Key设计直接给出一套我在生产环境验证过的设计方案读者可以参考着搭。Redis里主要用两类Keyunread:{uid}是Hash类型field为会话ID单聊用对方UID、群聊用群IDvalue为未读数TTL设为30天每次有读写操作时刷新TTLtotal_unread:{uid}是String类型记录所有会话未读数之和TTL同上。数据库侧保留一张user_conversation_unread表字段包括uid、conv_id、unread_count、last_read_seq、updated_at。这张表不参与实时读写只做定时对账和冷启动恢复。消息表保存conv_id、msg_seq提供(conv_id, msg_seq)组合索引作为“准计算”的消息序号来源。内存预算有个经验公式假设每个用户平均10个会话每对Key约占用500字节100万在线用户大约需要5GB左右的Redis内存。听起来不小但相比专门搭一套计数服务省事很多。如果连这个内存都嫌多可以只给活跃用户建计数器非活跃用户的未读数靠消息表查询兜底。5.2 核心流程发送、接收、清除的代码路径消息发送成功后的计数追加我建议走一条批处理路径。群消息尤其要注意不要循环逐条HINCRBY而是用Redis的Pipeline把这一条群消息对应的N次增量一次性提交能把网络往返从N次降到1次。伪代码如下// 群消息发送成功后给每个成员追加未读数 ListMember members groupService.getMembers(groupId); try (JedisPipeline pipeline redis.pipelined()) { for (Member m : members) { pipeline.hincrBy(unread: m.getUid(), groupId, 1); pipeline.incr(total_unread: m.getUid()); // 同时推进该用户的 sync_version用于增量同步 pipeline.incr(sync_version: m.getUid()); } pipeline.sync(); }用户打开会话时执行“读清除”一定要用下面这个Lua脚本保证原子性-- KEYS[1]: unread:{uid} -- KEYS[2]: total_unread:{uid} -- ARGV[1]: conv_id local current redis.call(HGET, KEYS[1], ARGV[1]) if current and tonumber(current) 0 then redis.call(HINCRBY, KEYS[1], ARGV[1], -tonumber(current)) redis.call(DECRBY, KEYS[2], tonumber(current)) end return current or 0这个脚本的核心思想是“取出当前值、清零、同步扣减总未读数”三步在一个原子操作里完成不会把清除之后新到的未读也误删。用户手滑连续点进点出会话也没关系第二次执行时current已经是0脚本幂等返回0。5.3 高并发压测结果与参数调优我在一个日活约200万的IM项目上压过这套方案单台Redis 8核16G消息峰值每秒约2万条其中群消息占总消息量40%。压测结论是Redis的CPU利用率大约在35%左右写入P99延迟2ms以内读取未读摘要HGETALL的P99在5ms以内。整体瓶颈根本不在Redis而在消息服务的发送链路上。参数调优方面有几个细节值得记下来。第一Redis的maxmemory-policy建议用allkeys-lru同时把未读数Key的TTL维护好防止内存被冷数据占满以后开始逐出热key。第二客户端拉取未读摘要的接口必须做本地缓存推荐缓存5秒一个下拉刷新高频场景能减少80%的后端请求。第三如果单个群非常大比如万人群一次群消息会触发上万次Redis写建议对超大群的写操作做异步化把“追加未读数”丢进消息队列慢慢消费不要让群聊首屏卡在未读数写入上。6. 常见问题与排查技巧实录6.1 未读数不对先分清“多了”还是“少了”线上未读数出问题第一步永远是分方向。方向错了排查就是白忙。未读数变多优先查重复计数消息是否被重复投递发送链路是否做了重试但没做幂等群消息是否在某些异常分支下被消费了两次未读数变少优先查清除逻辑读清除的Lua脚本是否被替换成了非原子操作多端同步时是否有端把已读序号错误上报成更大的值会话删除是否联动扣减了总数我通常会给排查团队一个口诀多了查写入少了查清除。还有一种常见情况是“总数对不上明细”。Tab上显示10会话列表加起来只有8。这种几乎都是因为某次原子性被破坏——要么总未读数在异常分支里没扣要么某个会话的field被误删。排查方法是写一个定时对账任务周期性扫描总未读数和HGETALL明细的差值把不一致的用户捞出来重算修复。对账任务不用跑得很频繁每小时跑一次就能修复绝大多数漂移。6.2 红点不消失与早消失的排查清单红点不消失我按下面这个顺序排查查客户端是否成功上报了已读或清除操作很多情况是客户端上报接口失败但没有重试服务端状态一直没更新查服务端的同步流是否消费成功客户端收到了清除事件但本地状态机因为事件顺序错乱而拒绝执行需要确认同步流的版本号单调性查是否存在“查询缓存”遮挡客户端UI层如果对未读数做了内存缓存服务端已经更新了UI没刷新也表现为红点不消失这是最容易忽略的纯前端问题。红点早消失则往往是清除条件过宽。比如进入会话就清除但用户其实没看消息或者某端上报已读时带错了会话ID把别的会话的未读也清了。排查时直接抓客户端的清除日志看上报的时间点和会话ID是否与用户行为吻合基本一抓一个准。6.3 监控、告警与验收指标最后给一套建议的监控指标都是我实际在用的Redis未读数Key数量与内存占用决定缓存层会不会被冷数据拖垮。HINCRBY和Lua脚本的P99耗时突增说明Redis出现了热key或大key。对账任务单轮修复的用户数与耗时如果对账用户数持续高位说明上线了新的漂移来源。客户端未读数摘要接口的失败率正常情况下应该低于0.5%高于这个值优先看服务端是否被限流、接口是否被打爆。同步流积压量增量事件生产和消费的速度差积压说明消费端处理不过来未读数会大面积延迟。验收指标上我给自己定的标准是未读数最终收敛误差小于0.1%红点从消息发送到客户端亮起的中位数延迟小于1秒P95小于3秒。能满足这三个数这套方案在绝大多数业务场景里就算合格了。最后说一点个人体会。未读数和红点这种功能是IM里最不起眼的一小块但它是用户每天打开App第一个感知到的东西。方案选型没有银弹小产品用DB计数能活中等规模用Redis计数器是主流要绝对准确就得引入消息序号校准。最核心的一条建议是先把“最终收敛”四个字刻在脑子里一切方案设计都围绕最终的收敛准确性来做而不是追求某个瞬时的精确。这样即使出了故障修复和补偿也会简单很多。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。