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
小技巧: 如果特征值超过 10KB,建议考虑压缩或者拆分。我见过有人把整个用户行为序列塞进一个 String,结果单 Key 到了 2MB,线上直接阻塞。

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 加上几分,负向行为减几分。这在实时特征更新里非常实用。

注意: Sorted Set 的 score 是 double 类型,存时间戳没问题。但如果存分数,注意精度问题。我遇到过 score 累加多了出现精度丢失,导致排序不准。建议分数放大 1000 倍用整数存。

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 四种结构怎么选?一张图看懂

下面这张图是我自己总结的选型逻辑,每次带新人我都直接甩给他看。

Redis 特征存储选型决策图 特征数据来了 需要单独更新某个字段吗? 是 Hash 多字段特征存储 否 需要排序或权重? 是 Sorted Set 时间序列 / 权重排序 否 头尾操作频繁? 是 List 行为序列 / 队列 否 String 整体读写 / 小数据 选型原则:先看更新粒度 → 再看排序需求 → 最后看访问模式

3.6 实战中的「组合拳」

真实线上系统,很少只用一种结构。我做过的一个实时特征服务,同时用了三种:

  • Hash 存用户实时统计特征(点击次数、曝光次数、停留时长)
  • Sorted Set 存用户最近交互的物品 ID 列表(用于去重和负反馈)
  • String 存用户 embedding 向量(整体读写,不拆分)

这样各司其职,更新时互不干扰。读取特征时,用 pipeline 一次发三个命令,毫秒级拿到所有数据。

我的习惯: 特征 Key 的命名统一用「业务域:实体类型:实体ID:特征类型」。比如 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 数据结构这块,说白了就是「因地制宜」。没有银弹,只有最适合你业务场景的方案。多想想你的特征是怎么更新的、怎么读取的,选型自然就清晰了。