3. Redis 数据结构与特征存储:String、Hash、Sorted Set、List 在特征存储中的应用
聊到特征存储,Redis 基本是绕不开的。我做了这么多年推荐系统,线上特征存取十有八九都用它。原因很简单——快。但怎么用、用哪种数据结构,这里头门道不少。
很多人一上来就全塞 String,图省事。结果线上延迟一高,查半天发现是序列化反序列化在作妖。嗯,今天我就把四种常用数据结构在特征场景下的用法,掰开了讲清楚。
3.1 String:最直接,但别滥用
String 是 Redis 最基础的数据结构。存特征时,通常把整个用户特征向量序列化成 JSON 或 Protobuf,然后直接 SET/GET。
我早期做新闻推荐时就这么干。一个用户的所有兴趣标签、点击历史、实时行为,全塞进一个 String。看起来简单,但问题很快暴露了。
- 痛点一: 只要更新一个标签,就得把整个大字符串读出来、反序列化、改完再写回去。这操作,说白了就是拿大炮打蚊子。
- 痛点二: 大 Key 问题。一个用户特征几 KB 还好,要是存了 embedding 向量,几百个用户挤在一个分片上,慢得你想哭。
那什么时候该用 String?我个人习惯是:存那些整体读写、不常更新的特征。比如用户画像里的性别、年龄区间、会员等级。这些特征变化慢,一次读取全部用上,String 最省事。
// 存用户基础画像
SET user:profile:1001 '{"gender":"male","age_group":"25-35","vip_level":3}'
// 读的时候直接拿
GET user:profile:1001
3.2 Hash:特征存储的「主力选手」
Hash 是我在项目中用得最多的结构。为什么?因为它天然适合存「一个实体的多个特征字段」。
你想想看,一个用户有几十个特征:年龄、性别、城市、兴趣标签、实时点击次数、近 30 天购买金额……如果用 Hash,每个特征就是一个 field,互不干扰。
我在做电商推荐时,用户特征表就是用 Hash 存的。更新某个特征时,只需要 HSET 一个 field,不用动其他字段。这在实时特征计算场景下太重要了——用户刚点了一个商品,我只需要更新「最近点击商品 ID」这一个 field,毫秒级完成。
// 初始化用户特征
HSET user:features:1001 age 28 gender male city beijing
HSET user:features:1001 recent_click item_12345
HSET user:features:1001 click_count_1h 15
// 只更新点击次数
HINCRBY user:features:1001 click_count_1h 1
// 读取所有特征
HGETALL user:features:1001
这里有个坑,我踩过。HGETALL 在 field 数量多的时候,返回的数据量很大。如果特征字段超过 50 个,建议按业务维度拆成多个 Hash,或者用 HMGET 只取需要的字段。
| 场景 | 推荐结构 | 原因 |
|---|---|---|
| 用户实时特征(< 50 字段) | Hash | 灵活更新,部分读取 |
| 物品静态属性 | Hash | 字段稳定,整体读取 |
| 用户画像(大字段) | Hash + 拆分 | 避免大 Key 和慢查询 |
3.3 Sorted Set:搞定「带权重的特征列表」
Sorted Set 在推荐系统里,最经典的应用就是存「用户最近交互的物品列表」。每个物品 ID 是 member,时间戳是 score,天然按时间排序。
我记得有一次做短视频推荐,需要实时获取用户最近看过的 50 个视频 ID,用来做去重和负反馈。用 Sorted Set 简直不要太爽。
// 用户看了视频 v_001,当前时间戳 1696000000
ZADD user:history:1001 1696000000 v_001
// 再看了 v_002
ZADD user:history:1001 1696000001 v_002
// 获取最近 50 条
ZREVRANGE user:history:1001 0 49
// 只保留最近 100 条,防止无限增长
ZREMRANGEBYRANK user:history:1001 0 -101
除了时间序列,Sorted Set 还能存特征权重。比如用户对物品类别的偏好分数,score 就是偏好值。每次用户有正向行为,ZINCRBY 加上几分,负向行为减几分。这在实时特征更新里非常实用。
3.4 List:队列场景的「老黄牛」
List 在特征存储里用得相对少,但特定场景下很顺手。比如存用户的实时行为序列——点击、曝光、购买,按时间顺序排列。
我习惯用 List 做「滑动窗口特征」。比如用户最近 100 次点击的商品 ID 列表,用 LPUSH 往左推,LTRIM 只保留前 100 个。这样每次读取时,LRANGE 0 99 就能拿到最新的行为序列。
// 用户点击了商品,推入列表左侧
LPUSH user:clicks:1001 item_12345
// 只保留最近 100 条
LTRIM user:clicks:1001 0 99
// 读取最近 20 条点击记录
LRANGE user:clicks:1001 0 19
不过要注意,List 不适合随机访问。如果你需要频繁读取中间某个位置的元素,那还是用 Sorted Set 或者 Hash 更合适。List 的优势在于「头尾操作快」,适合做队列或者栈。
3.5 四种结构怎么选?一张图看懂
下面这张图是我自己总结的选型逻辑,每次带新人我都直接甩给他看。
3.6 实战中的「组合拳」
真实线上系统,很少只用一种结构。我做过的一个实时特征服务,同时用了三种:
- Hash 存用户实时统计特征(点击次数、曝光次数、停留时长)
- Sorted Set 存用户最近交互的物品 ID 列表(用于去重和负反馈)
- String 存用户 embedding 向量(整体读写,不拆分)
这样各司其职,更新时互不干扰。读取特征时,用 pipeline 一次发三个命令,毫秒级拿到所有数据。
rec:user:1001:stats、rec:user:1001:history。这样排查问题时,一眼就能看出是哪个业务、哪个实体、哪类特征。
3.7 避坑指南
最后说几个我踩过的坑,希望你别再走一遍。
- 大 Key 问题: 一个 Hash 或者 Sorted Set 里塞了几万个 field,读写都慢。建议单个结构不超过 5000 个元素。超了,就拆分。
- 过期时间设置: 实时特征一定要设 TTL。我见过有人忘了设,结果 Redis 内存被打满,整个服务挂了。实时特征一般 1-24 小时过期就够了。
- 序列化选择: String 存 JSON 虽然方便,但解析慢。线上建议用 Protobuf 或者 MessagePack。我之前从 JSON 切到 Protobuf,读取耗时降了 40%。
- Pipeline 批量操作: 别一条一条发命令。一次 pipeline 打包 10-20 个命令,网络开销能省一大半。
嗯,Redis 数据结构这块,说白了就是「因地制宜」。没有银弹,只有最适合你业务场景的方案。多想想你的特征是怎么更新的、怎么读取的,选型自然就清晰了。