导读:本期聚焦于又改需求创作的《Redis如何高效实现消息已读未读状态存储?三种方案对比详解》,敬请观看详情。消息系统里已读未读功能看似简单,背后却藏着不少设计取舍。未读数一条条数效率低,用Redis的Bitmap能不能扛住亿级消息量?会话级别与消息级别的已读回执又该怎么区分存储?本文围绕Redis展开,对比String计数器、Set集合、Bitmap位图三种方案的实现思路,给出读写命令示例与内存估算,分析各方案在实时性、内存占用、并发安全上的优缺点,并针对未读数红点、最后阅读位置、多端同步等典型场景给出落地建议,帮你选出最合适的存储方案。

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

Redis如何高效实现消息已读未读状态存储?三种方案对比详解

一、先想清楚业务粒度:未读数和已读回执是两回事

很多团队一上来就纠结用什么数据结构,其实第一步应该是拆需求。已读未读在产品层面通常有两层含义:第一层是未读数,也就是会话列表上那个红色数字,只关心有多少条没读;第二层是已读回执,需要精确知道用户读到了哪一条,或者具体读过哪些消息,比如企业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或直接退化成已读数统计,否则存储会被群成员数乘消息数放大到不可控。把粒度、规模、扩展性这三个问题想清楚,再套用上面的方案组合,已读未读这个需求就不难落地了。

Redis已读未读BitmapSet数据结构消息状态存储修改时间:2026-09-10 02:18:57

免责声明:已尽一切努力确保本网站所含信息的准确性。网站作品多为原创整理与精心创作,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们进行处理Email:chomcom@qq.com。
引用或转载本作品时,请注明当前出处:https://www.ipipp.com/html/0910/53747.html,基于非商业用途的前提下,欢迎转载或二创本作品。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。