IM聊天、站内信、公告通知,几乎每个带消息模块的系统都绕不开已读未读这个需求。产品经理要的是会话列表上的未读红点数字,点进去之后具体哪条消息读过、哪条没读也要能区分,甚至还要支持多端同步已读状态。如果直接在MySQL里给每条消息加一个is_read字段,用户量一上来,更新和查询的压力会瞬间把数据库拖垮。Redis凭借内存存储和高性能命令,成了这类场景的主流选择,但选哪种数据结构、怎么设计key,直接决定了方案能不能扛住量。

一、先想清楚业务粒度:未读数和已读回执是两回事
很多团队一上来就纠结用什么数据结构,其实第一步应该是拆需求。已读未读在产品层面通常有两层含义:第一层是未读数,也就是会话列表上那个红色数字,只关心有多少条没读;第二层是已读回执,需要精确知道用户读到了哪一条,或者具体读过哪些消息,比如企业IM单聊里对方的已读状态。
这两层需求的数据规模差别巨大。未读数只需要一个计数器,一个用户一个会话一个key就够;而已读回执要记录的是用户与消息的笛卡尔积关系,如果系统有千万用户、每人每天几百条消息,存全量明细的成本会非常高。所以实践中常见的做法是分层:未读数用轻量的计数方案,已读明细只在强需求场景才落地,而且通常只存最后已读消息ID这种游标,而不是逐条存储。
另外一个容易被忽略的点是多端同步。用户手机上读了消息,网页端的红点也应该消失,这意味着已读状态必须存储在服务端共享介质里,而不能只存在客户端本地。这也是Redis方案比纯前端方案更受青睐的原因之一。
二、方案一:String计数器实现会话未读数
最简单直接的方案是用Redis的String结构存未读数,key设计成unread:{userId}:{conversationId},新消息到达时INCR,用户点开会话阅读时清零或减去相应数量。这个方案实现成本最低,读写都是O(1),性能毫无压力。
# 新消息到达,未读数加一 INCR unread:1001:conv:2001 # 用户进入会话,未读数清零 SET unread:1001:conv:2001 0 # 拉取会话列表时批量读取未读数 MGET unread:1001:conv:2001 unread:1001:conv:2002 unread:1001:conv:2003
这个方案的短板也很明显:它只能回答有几条没读,回答不了具体哪几条没读。如果产品要求进入会话后逐条标灰已读消息,计数器就不够用了。另外并发场景下要小心,用户正在阅读的同时又有新消息进来,如果先INCR再SET 0,会把新到的消息也清掉。稳妥的做法是把清零改成DECRBY,服务端根据阅读时上报的最后一条消息ID计算增量扣减,或者用Lua脚本把读取和清零做成原子操作。
内存方面String计数器非常省,一个key大概几十字节,百万级用户乘以几十个会话,总量也在GB以内,完全可接受。对于只做红点数字的产品,这个方案性价比最高。
三、方案二:Set集合存储已读消息明细
当需要精确到每条消息的已读状态时,Set是一个直观的选择。key设计成read:{userId}:{conversationId},成员是消息ID,用户读过某条消息就SADD,判断是否已读用SISMEMBER,统计已读条数用SCARD。
# 用户1001阅读了消息30001、30002、30003 SADD read:1001:conv:2001 30001 30002 30003 # 判断单条消息是否已读,返回1表示已读 SISMEMBER read:1001:conv:2001 30004 # 统计该会话已读消息总数 SCARD read:1001:conv:2001
Set方案的优点是语义清晰、命令简单,支持任意分布的消息ID,不要求ID连续。缺点是内存开销与已读消息数成正比,每个成员大约要占用几十到上百字节,如果用户长期不清理、消息ID又是雪花算法生成的64位数字,存储成本会持续膨胀。补救办法是配合过期策略:会话内消息超过一定时间后统一视为已读,直接删除整个key重建;或者只在活跃会话维护Set,冷数据回源数据库。
还有一个查询效率问题。客户端拉取一页20条消息,要判断每条的已读状态,如果逐条SISMEMBER就是20次网络往返。可以把命令打包成pipeline一次发出,也可以改用SMISMEMBER(Redis 6.2及以上版本支持)一次判断多个成员,把往返次数压到一次,页面渲染速度会有明显改善。
四、方案三:Bitmap位图,用空间换性能的杀手锏
如果消息ID是自增整数,Bitmap几乎是已读未读场景的最优解。原理很简单:把消息ID直接当作偏移量,某个位置为1表示已读,为0表示未读。SETBIT和GETBIT两条命令就能完成核心读写,理解成本几乎为零。
# 用户1001阅读了消息ID为8的消息,对应位置置为1 SETBIT readbitmap:1001:conv:2001 8 1 # 查询消息ID为8的已读状态 GETBIT readbitmap:1001:conv:2001 8 # 统计该会话已读总数 BITCOUNT readbitmap:1001:conv:2001 # 一次判断偏移量8、15、16三条消息的已读状态 BITFIELD readbitmap:1001:conv:2001 GET u1 8 GET u1 15 GET u1 16
Bitmap最大的优势是内存极其节省:1亿条消息的已读状态理论上只需约12MB,这是Set方案完全无法比拟的。配合BITCOUNT可以很快拿到已读数,未读数等于会话总消息数减去已读数,计算也很轻。对于消息量巨大的社群或直播间弹幕场景,位图几乎是唯一能撑住的方案。
但它有硬性前提:偏移量必须是整数且最好连续密集。如果用的是雪花ID这类超长不连续的数字,直接拿它当偏移量会造成Bitmap中间大量空洞,内存反而爆炸。解决办法是维护一个消息ID到序号的映射,先把长ID转成会话内自增序号再写入Bitmap,代价是多一次映射查询。此外Bitmap只能表达已读和未读两种状态,如果将来要扩展成已读、已读并回执、撤回不可见等多态,位图就得一个状态一张图,复杂度随之上升。
五、方案选型与组合落地建议
三种方案没有绝对优劣,实际系统往往是组合使用。一个常见的落地架构是:会话列表的红点数字用String计数器维护,进入会话后的逐条已读标记用Bitmap或Set存储,两者在用户触发阅读时通过一次Lua脚本同时更新,保证口径一致。发送方查看对方是否已读时,再从明细数据反查即可。
| 维度 | String计数器 | Set集合 | Bitmap |
|---|---|---|---|
| 表达能力 | 仅未读数 | 任意消息ID粒度 | 整数ID粒度 |
| 内存占用 | 极低 | 随已读量增长 | 极低但依赖ID密度 |
| 多态扩展 | 差 | 好 | 差 |
| 适用场景 | 红点数字 | ID不连续、需多态 | 海量消息、自增ID |
最后提醒几个工程细节:一是Redis里的已读状态建议定位为热数据缓存,定期落库归档,避免Redis故障后已读记录全部丢失;二是已读上报接口要做幂等,客户端重试重复上报不能导致计数错乱;三是大群聊场景不要给每个成员都存一份明细,改为只存每条消息的未读者Bitmap或直接退化成已读数统计,否则存储会被群成员数乘消息数放大到不可控。把粒度、规模、扩展性这三个问题想清楚,再套用上面的方案组合,已读未读这个需求就不难落地了。