做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 方案B:Redis Hash计数器
这是目前最主流的中型IM方案。核心数据结构是unread:{uid}这个Hash,field是会话ID,value是未读数;同时维护一个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里主要用两类Key:unread:{uid}是Hash类型,field为会话ID(单聊用对方UID、群聊用群ID),value为未读数,TTL设为30天,每次有读写操作时刷新TTL;total_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次。伪代码如下:
// 群消息发送成功后,给每个成员追加未读数 List<Member> 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计数器是主流,要绝对准确就得引入消息序号校准。最核心的一条建议是:先把“最终收敛”四个字刻在脑子里,一切方案设计都围绕最终的收敛准确性来做,而不是追求某个瞬时的精确。这样即使出了故障,修复和补偿也会简单很多。