Redis 在推荐系统中的角色:特征缓存、排序队列和去重集合
一、三种角色一个 Redis:推荐系统对数据结构的依赖超出预期
Redis 在大多数后端架构里扮演的角色是缓存——存一下 Session,放一下热点数据。但在推荐系统中,Redis 的定位远不止缓存,它是贯穿整个推荐链路的核心数据枢纽。
推荐系统一天要读写的数据类型非常多样:用户特征需要毫秒级查询(Hash)、推荐结果需要有序排列(Sorted Set)、用户曝光记录需要高效去重(Set / HyperLogLog)、实时特征需要原子累加(String / Hash)、候选池需要先进先出队列(List)。幸运的是,这些需求恰好落在 Redis 的核心数据结构能力范围内。一个集群扛三种角色,开发成本低、运维简单。
但风险也在这里——所有鸡蛋在一个篮子里。如果 Redis 集群挂了,推荐链路的三个环节同时停摆:特征查不到、曝光去重失败(重复推荐炸用户)、排序队列丢失(推荐顺序打乱)。所以 Redis 在这套架构里不是可有可无的缓存,而是强依赖的基础设施。基础设施不需要漂亮话,它需要高可用方案和降级策略。
二、特征缓存:热数据的内存布局和过期策略
用户特征是推荐系统里访问最频繁的数据。一个推荐请求可能涉及 1 个用户特征和 200 个物品特征,每次特征查询如果都落到下游存储(HBase/MySQL),延迟和负载都扛不住。缓存命中率必须做到 95% 以上。
用户特征用 Hash 存储,Key 是user:{uid}:features,Field 是特征名,Value 是特征值。这样做的好处是可以部分更新——用户点击了一个新品类,只更新click_category这个 Field,不用重写整个用户画像。
物品特征中,Embedding 向量是最特殊的一类。一个 128 维的 float32 向量 512 字节,用 String 存储用 Protobuf 序列化后存。读取时直接反序列化,避免 JSON 的解析开销。对于高频访问的 Top 10000 热门物品,Embedding 需要在应用层再做一层本地缓存(如 Go 的sync.Map或freecache),把网络 IO 降为内存读取。
过期策略上,用户特征 TTL 设为 30 分钟——因为用户实时行为通过 Flink 写到 Redis 后,30 分钟内必然会被推荐服务读取。超过 30 分钟没读到,说明这个用户不活跃,Key 自动删除腾内存。物品特征 TTL 更长,设为 24 小时——物品特征变化慢,但全量物品的 Embedding 向量更新一天一次就够了。
内存预算控制是特征缓存的关键约束。一个 DAU 100 万的推荐系统,活跃用户约 30 万,每个用户特征 Hash 约 2KB,总计 600MB。热门物品 10 万个,每个 Embedding 512 字节,总计 50MB。加上 Redis 自身开销(约 1.5 倍),总共约 1GB。再加上去重和排序的数据,总内存预算控制在 4GB 以内。如果超了,加机器,不要调低 TTL——TTL 太短会导致缓存命中率下跌,下游存储被打崩,后果比加机器贵得多。
三、曝光去重:Set 的正确用法和 HyperLogLog 的精度取舍
曝光去重是推荐系统里最容易被忽视,但出了问题影响最大的一个环节。用户一天可能请求推荐上百次,如果每次返回的内容里都混着重读的"老面孔",用户体验直接崩塌。
短期去重用 Set。Key 是exposed:{uid}:{date},Value 是物品 ID 集合,TTL 设 48 小时。每次精排前先 SMEMBERS 取出已曝光集合,过滤掉这些物品后再排序。每次下发推荐结果后用 SADD 追加。
但 Set 的问题是——如果用户一天看了 200 个物品,Set 里就有 200 个元素,没问题。但如果一个用户一天看了 5000 个物品(重度短视频用户),Set 的内存就会膨胀。这时候需要一种近似方案:只保留最近 N 次曝光。Set 配合 LTRIM 的思想:每次 SADD 后,检查 Set 大小,如果超过 500 个,随机删除一些 Key——不是标准的 Set 操作,但可以用 SPOP 随机弹出旧元素。
长期去重用 Bitmap。Key 是exposed_bitmap:{uid}:{iid},按月分片。每个物品 ID 映射到一个 bit 位,SetBit 标记已曝光,GetBit 判重。Bitmap 比 Set 省内存——10 万个物品的去重只需要 12.5KB(100000/8),而 Set 里 500 个字符串型的物品 ID 就要用 20KB 左右。代价是物品 ID 和 bit 位置的映射需要额外维护一张表。
UV 统计用 HyperLogLog。Key 是uv:{iid},每次曝光调用 PFADD。12KB 的空间可以统计数十亿 UV,误差控制在 0.81%。如果需要分时间段统计,用 BitMap 切分更合适——HyperLogLog 不支持取交集(无法算"同时看了视频 A 和 B 的用户数"),这是它的边界。
四、Redis 承载力临界点:什么时候该拆集群
Redis 单集群扛三种角色,在流量较小的阶段没什么问题。但当 QPS 突破 5 万,开始出现三类信号,说明该拆了:
信号一:特征缓存的 SET 操作和去重集合的 SADD 操作,CPU 时间被特征查询的 GET 操作抢占。特征查询是同步阻塞的(推荐请求等它返回),去重写入是异步的(可以延迟几百毫秒)。两者抢同一个 CPU,后果是特征查询延迟抖动。
信号二:排序队列的 ZADD 操作数量暴涨。一个推荐请求写入 1 个 Sorted Set Entry,10 万 QPS 就是每秒 10 万次 ZADD。如果和特征缓存的 HGET 放在同一个节点上,磁盘 IO 和 CPU 都会成为瓶颈。
信号三:集群内存超过单节点上限。Redis Cluster 可以横向扩展,但单个 Slot 的数据不能超过 64GB(受限 Redis 的内存管理)。一旦接近这个上限,迁移 Slot 的时间会达到分钟级,期间服务不可用。
拆分的标准方案是按职责拆集群:集群 A(特征缓存 + 排序队列,用 SSD Redis / RedisFlash 降低内存成本)、集群 B(去重集合,用小内存高 IOPS 的 Redis 节点)。两条链路在物理上隔离,性能互不干扰。架构层面需要业务服务兼容多集群配置:
type RedisClients struct { FeatureCache *redis.ClusterClient // 特征缓存 + 排序 DedupSet *redis.ClusterClient // 去重集合 }五、总结
Redis 在推荐系统中承担三种关键角色:特征缓存的 Hash/String(要求高命中率 + 低延迟)、排序队列的 Sorted Set/List(要求高吞吐写入)、去重集合的 Set/Bitmap/HyperLogLog(要求空间效率和近似精度)。
这三种角色的资源需求差异很大——特征缓存重延迟、排序队列重吞吐、去重集重空间。当单集群承载不了时,按职责拆分是必然选择。拆分后业务侧用多客户端分别连接不同集群,访问逻辑不变。
Redis 在推荐架构里不是"缓存"而是"强依赖基础设施"。从部署第一天起就要配置 Sentinel 哨兵或 Cluster 模式,确保主从切换在秒级内完成。特征查不到可以有兜底(返回默认值),去重失败可以让用户看到重复内容(体验差但服务不挂),排序队列丢失会自动重新生成——但 Redis 集群整体不可用超过 30 秒,推荐服务会全面降级。这不是技术选择,是可靠性底线。