点赞和收藏是内容平台、社交应用中最常见的互动功能,数据量随用户规模线性增长。Redis凭借内存读写和多数据类型支持,常被用来承载这类高并发、低延迟的计数与状态存储。但选错数据结构会导致内存暴涨、命令复杂度升高,甚至影响线上稳定性。本文从实际操作层面拆解Set、Hash、BitMap三种核心方案,分析各自优劣,并讨论与数据库同步时需要注意的问题。

使用Set集合实现基础点赞与收藏
Set是Redis中最直观的点赞存储方案。对于每一条内容,用一个Set保存所有点赞用户的ID,key形如like:article:1001,成员为user:10001这样的用户标识。点赞操作使用SADD,取消点赞使用SREM,判断是否点赞使用SISMEMBER,统计总数使用SCARD。收藏功能完全同理,只需把key前缀换成favorite即可。这种设计最大的好处是简单,开发人员一看就懂,配合Redis的集合运算还能轻松实现“共同点赞”、“我点赞的人还赞了哪些内容”等关系查询。
不过Set的缺点也很明显:内存占用偏高。Redis的Set在成员数量较少时会使用紧凑的整数集合(intset)编码,当成员数量超过set-max-intset-entries(默认512)后转为哈希表编码,每个成员要额外存储指针和哈希节点,内存膨胀明显。假设一篇文章有10万点赞用户,每个用户ID平均25字节,仅这一篇文章的点赞Set就可能占用数MB内存。对于千万级内容平台,整体内存开销会非常惊人。因此Set更适合用户量不大、内容数量有限的中小型应用,或者作为过渡方案快速上线。
代码示例:
# 用户10001点赞文章2001 SADD like:article:2001 user:10001 # 用户10002点赞同一篇文章 SADD like:article:2001 user:10002 # 检查用户10001是否点赞 SISMEMBER like:article:2001 user:10001 # 返回 1 表示已点赞 # 统计点赞总数 SCARD like:article:2001 # 返回 2 # 取消点赞 SREM like:article:2001 user:10001
使用Hash结构存储扩展属性
当业务不仅需要知道“谁点了赞”,还要记录点赞时间、取消时间或者用户的其他状态时,Hash就比Set更合适。一种常见做法是把内容ID作为Hash的key,比如like:article:2001,Hash内部的field为用户ID,value为点赞发生的时间戳或一个JSON字符串。这样一条点赞记录就包含了完整的上下文信息,后续产品分析、用户行为追踪都很方便。Redis的HSET、HGET、HEXISTS、HLEN等命令可以直接操作。
Hash的另一个优势是可以利用HINCRBY对单个用户做计数,也可以使用HSCAN分批遍历所有点赞用户,避免像Set那样一次性SMEMBERS可能阻塞Redis。但Hash的value字段会消耗额外内存,如果value只是简单的1或0,相比Set会多出不少开销。如果value存储了时间戳等有意义的数据,那么这部分开销是值得的。在实际项目中,我们常把点赞和收藏合并到一个Hash里,例如interact:article:2001包含两个field:like:user:10001和favorite:user:10001,value分别为对应的时间戳。这样一次HGETALL就能取回用户对某内容的所有互动状态,减少网络往返。
代码示例:
# 用户10001在时间戳1712345678点赞文章2001 HSET like:article:2001 user:10001 1712345678 # 判断是否点赞 HEXISTS like:article:2001 user:10001 # 返回 1 # 获取点赞时间 HGET like:article:2001 user:10001 # 返回 "1712345678" # 批量获取多个用户点赞状态(使用HMGET) HMGET like:article:2001 user:10001 user:10002 user:10003 # 返回 ["1712345678", nil, nil]
使用BitMap极致压缩内存
如果业务强调海量用户下的内存控制,并且用户ID是连续的正整数(或者能通过外部映射表转换为连续ID),BitMap是最佳选择。BitMap本质上是字符串,每一位代表一个用户,1表示已点赞,0表示未点赞。例如key为like:article:2001,offset为用户ID(如10001),使用SETBIT设置位,GETBIT读取位,BITCOUNT统计总点赞数。这种方案的内存占用极小:1亿用户只需要约12.5MB,与Set相比减少了数十倍。
BitMap的局限也很明确。首先用户ID必须连续,如果系统中用户ID是UUID或随机字符串,需要额外维护一张“用户ID到连续整数”的映射表,这又会引入一致性维护成本。其次BitMap无法存储额外信息,点赞时间、取消原因等数据只能另存他处。此外,BitMap的读写虽然都是O(1),但BITCOUNT在非常大的字符串上执行时可能耗时较长,需要注意在Redis实例上避免频繁大范围统计。实际使用中,BitMap常配合Redis的BITOP做多个内容的集合运算,比如计算多个文章的共同点赞用户,这在推荐场景中非常有用。
代码示例:
# 假设用户ID为10001,设置该用户点赞位为1 SETBIT like:article:2001 10001 1 # 读取该用户是否点赞 GETBIT like:article:2001 10001 # 返回 1 # 统计该文章总点赞数 BITCOUNT like:article:2001 # 返回 1 # 批量设置多个用户点赞(使用BITFIELD或多次SETBIT) SETBIT like:article:2001 10002 1 SETBIT like:article:2001 10003 1
混合方案与数据一致性处理
真实业务中很少只用单一结构从头走到尾。比较常见的策略是:核心状态用Set或Hash存储以保证灵活性和可读性,同时维护一份BitMap用于高效统计或去重计算;或者将点赞和收藏分开处理,点赞用BitMap节省内存,收藏用Set因为收藏行为通常伴随列表展示需求。还有一种做法是Redis只存近期热点数据,冷数据定期归档到关系型数据库或对象存储,降低内存压力。无论哪种混合方式,都要提前规划好key的命名规范、过期策略以及数据迁移脚本。
数据一致性是缓存场景的老大难。点赞操作典型的流程是:先写数据库,再更新Redis;或者先更新Redis,再异步落库。前者可能因为数据库成功但Redis更新失败导致缓存不一致,后者可能因为Redis成功但消息丢失导致数据丢失。解决思路通常包括:使用Redis事务(MULTI/EXEC)或Lua脚本保证多个Redis命令原子执行;数据库更新采用可靠消息队列保证最终一致;定期对账任务扫描数据库和Redis的差异并修复。对于点赞这种允许短暂不一致的场景,优先保证可用性,通过过期时间加补偿机制即可满足大多数需求。
最后要提醒的是,所有涉及用户ID的存储都要注意隐私和合规,避免将敏感信息直接作为key或field暴露。设计数据结构时预留版本号或命名空间,方便后续平滑升级。选型时先用小规模数据压测不同方案的实际内存占用和命令耗时,再结合业务增长预期做决定,比盲目套用网上方案要可靠得多。