3. Redis作为特征存储:数据结构选型、特征缓存策略、高并发下的性能调优
Redis 在特征存储里有多常见?我这么说吧,我经手的几个推荐系统项目,线上特征服务几乎都离不开它。说白了,它就是个内存里的高速键值店,读写都在纳秒级。但用好它,可不是随便 set/get 就完事了。
这一章,咱们就聊聊怎么把 Redis 真正当成特征存储来用。数据结构怎么选?缓存策略怎么定?高并发下怎么扛得住?嗯,这些都是我在实战里踩过的坑,今天一并倒出来。
核心观点:Redis 作为特征存储,选型的关键不是“哪个数据结构快”,而是“哪个数据结构最贴合你的特征访问模式”。选对了,事半功倍;选错了,天天加班。
3.1 Redis 数据结构选型:特征场景下的最佳匹配
Redis 有 5 种基本数据结构,外加一些高级结构。但用在特征存储里,常用的其实就那几种。我习惯先问自己一个问题:我的特征是怎么存、怎么读的?
3.1.1 String:适合单值特征
最简单的场景——用户年龄、物品价格、模型分数。这些特征就是一个 key 对应一个 value。用 String 最直接。
# 存储用户年龄特征
SET user:age:1001 28
# 读取
GET user:age:1001
# 带过期时间
SETEX user:age:1001 3600 28
但要注意,String 的 value 最大 512MB。我见过有人把整个用户画像序列化成一个 JSON 塞进去,结果单个 key 几十 MB,读写都慢。这其实是个坑——String 适合小特征,别往里塞大对象。
3.1.2 Hash:适合多字段特征
一个用户有几十个特征字段怎么办?用 String 存多个 key?那查询一次就得打几十个请求,延迟受不了。
Hash 就是干这个的。一个 key 对应一个用户,每个 field 对应一个特征字段。一次 hgetall 就能拿到全部特征。
# 存储用户多维度特征
HSET user:profile:1001 age 28 gender male city beijing
HSET user:profile:1001 ltv 12500.50 level vip3
# 读取全部特征
HGETALL user:profile:1001
# 读取部分特征
HMGET user:profile:1001 age ltv
我在项目中遇到过一个问题:Hash 的 field 数量太多(比如超过 1000 个),hgetall 的耗时就会明显上升。这时候可以考虑拆分成多个 Hash,或者用下面的结构。
3.1.3 Sorted Set:适合排序特征
有些特征天然带排序需求——比如用户最近浏览的商品 ID 列表、热门物品的实时排名。Sorted Set 的 score 就是天然的排序字段。
# 存储用户最近浏览的商品(时间戳作为score)
ZADD user:recent:1001 1699000000 item_1001
ZADD user:recent:1001 1699000100 item_1002
# 获取最近浏览的10个商品
ZREVRANGE user:recent:1001 0 9 WITHSCORES
# 清理过期浏览记录(保留最近100条)
ZREMRANGEBYRANK user:recent:1001 0 -101
你想想看,如果用 List 做这个事,每次插入都得自己维护顺序,还得手动清理。Sorted Set 一条命令搞定,省心多了。
3.1.4 选型对比表
| 数据结构 | 适用特征场景 | 读写模式 | 内存效率 | 我的建议 |
|---|---|---|---|---|
| String | 单值特征、小对象 | 单 key 读写 | 高 | 适合简单特征,别存大 JSON |
| Hash | 多字段特征、用户画像 | 批量读写 field | 中 | field 数控制在 500 以内 |
| Sorted Set | 排序特征、时间序列 | 按 score 范围查询 | 中低 | 适合 TopK 场景 |
| Set | 标签特征、去重集合 | 成员判断、交集并集 | 中 | 适合黑白名单 |
| List | 队列特征、FIFO 场景 | 两端操作 | 高 | 特征存储里用得少 |
小技巧:选型时先画特征访问模式图。如果 80% 的查询都是“一次取一个用户的所有特征”,Hash 就是首选。如果特征是“查单个字段”,String 更省内存。
3.2 特征缓存策略:怎么让 Redis 不成为瓶颈
Redis 再快,也扛不住无脑的缓存策略。我见过最典型的场景:特征过期时间设得太短,导致缓存雪崩;或者设得太长,特征更新不及时,模型效果变差。
3.2.1 过期策略:TTL 怎么设?
特征是有时效性的。用户年龄可能一天变一次,但用户实时行为特征可能几秒就过期。我习惯按特征类型分层设 TTL:
- 静态特征(性别、注册时间):TTL 设 24-48 小时,甚至不设过期,靠主动更新
- 准静态特征(用户等级、LTV):TTL 设 1-6 小时
- 动态特征(实时点击、浏览):TTL 设 5-30 分钟
- 实时特征(模型分数、推荐结果):TTL 设 1-5 分钟
我曾经犯过一个错:所有特征统一设 1 小时过期。结果凌晨流量低谷时,大量特征同时过期,数据库被打爆了。后来改成随机 TTL,在基准值上 ±20% 抖动,雪崩问题就解决了。
# 随机TTL示例
base_ttl = 3600 # 1小时
jitter = random.randint(-600, 600) # ±10分钟
ttl = base_ttl + jitter
SETEX user:profile:1001 ttl serialized_data
3.2.2 淘汰策略:内存满了怎么办?
Redis 内存是有限的。特征数据量一大,就得考虑淘汰策略。我推荐两种:
- allkeys-lru:最近最少使用淘汰。适合特征访问有明显热度的场景。大部分特征存了但很少读,LRU 能自动把冷特征踢出去。
- volatile-ttl:优先淘汰即将过期的 key。适合特征都有明确 TTL 的场景。
我个人习惯用 allkeys-lru。为什么呢?因为特征访问的 80/20 法则很明显——20% 的热点特征扛了 80% 的流量。LRU 天然保护热点,冷特征淘汰了也不心疼。
注意:别用 noeviction。内存满了直接写失败,线上特征服务会返回空值,模型推理结果直接崩掉。我见过有人因为这个被 oncall 叫醒三次。
3.2.3 缓存穿透、击穿、雪崩
这三个问题在特征存储里特别常见。我简单说下我的应对方案:
| 问题 | 现象 | 我的解法 |
|---|---|---|
| 缓存穿透 | 查询不存在的 key,每次都穿透到 DB | 布隆过滤器 + 空值缓存(TTL 设短一点) |
| 缓存击穿 | 热点 key 过期,大量请求同时打到 DB | 互斥锁(SETNX)或永不过期 + 异步更新 |
| 缓存雪崩 | 大量 key 同时过期,DB 被打爆 | 随机 TTL + 多级缓存(本地缓存兜底) |
嗯,这里要注意:布隆过滤器虽然能挡住大部分穿透,但会有误判。我一般把误判率控制在 1% 以内,再加一层空值缓存兜底,基本就稳了。
3.3 高并发下的性能调优
特征服务的高并发场景,我经历过单机 QPS 10 万+ 的挑战。Redis 本身能扛,但客户端和网络层面容易出问题。
3.3.1 连接池:别让连接成为瓶颈
每次请求都新建 Redis 连接?那 QPS 一上去,TCP 连接数就爆炸了。必须用连接池。
# Python示例:合理配置连接池
import redis
pool = redis.ConnectionPool(
host='localhost',
port=6379,
max_connections=50, # 最大连接数
socket_connect_timeout=2, # 连接超时
socket_timeout=2, # 读写超时
retry_on_timeout=True, # 超时重试
health_check_interval=30 # 健康检查间隔
)
r = redis.Redis(connection_pool=pool)
我建议 max_connections 设为核心数的 2-4 倍。设太大反而不好,因为 Redis 是单线程的,连接多了上下文切换开销也大。
3.3.2 Pipeline:批量操作省网络
特征服务经常要一次查几十个特征。如果一个个 get,网络往返时间(RTT)就是大头。Pipeline 可以把多个命令打包,一次网络往返搞定。
# 批量读取特征
pipe = r.pipeline()
pipe.get('user:age:1001')
pipe.get('user:gender:1001')
pipe.get('user:level:1001')
results = pipe.execute() # 一次网络往返
我在项目中测过,100 个特征的批量读取,用 Pipeline 比逐个 get 快了 10 倍以上。但要注意,Pipeline 里的命令太多会阻塞 Redis,建议控制在 100-200 个以内。
3.3.3 读写分离:主从架构
特征服务通常是读多写少。我习惯用一主多从的架构:写操作走主节点,读操作走从节点。这样主节点专心处理写入,从节点分担读取压力。
但有个坑:Redis 主从复制是异步的。写入主节点后,从节点可能还没同步。如果特征对一致性要求高(比如实时反作弊),就得读主节点。
我的经验:特征存储一般允许秒级不一致。所以读从节点完全 OK。如果业务要求强一致,那就用 Redisson 的读写锁,但性能会下降 30% 左右。
3.3.4 内存优化:省内存就是省成本
Redis 是内存数据库,内存就是钱。我见过一个项目,特征数据量 100GB,Redis 集群用了 20 台 64GB 的机器,每月成本好几万。优化后降到 10 台,省了一半。
几个省内存的技巧:
- 用 Hash 代替 String:存多个字段时,Hash 的内存效率比多个 String 高 30-50%
- 开启压缩:Redis 6.0+ 支持 zstd 压缩,特征数据能压缩 2-5 倍
- 用整数编码:如果特征值都是整数,Redis 内部会用 intset 编码,省内存
- 控制 key 长度:key 名别太长,比如用 user:1001 代替 user:profile:user_id_1001
# 开启压缩(redis.conf)
activedefrag yes
jemalloc-bg-thread yes
# 特征值压缩示例(应用层)
import zlib
compressed = zlib.compress(feature_value.encode())
r.set(f'user:feat:{user_id}', compressed)
3.4 本章小结
Redis 作为特征存储,说简单也简单,说复杂也复杂。选对数据结构、定好缓存策略、做好高并发调优,它就能成为你特征服务的利器。
我个人最深的体会是:别把 Redis 当黑盒用。理解它的内存模型、网络模型、线程模型,你才能做出正确的 trade-off。比如,Hash 虽然省内存,但 hgetall 在大 field 数下会阻塞;Pipeline 虽然快,但命令太多会占满 Redis 的响应缓冲区。
嗯,这些细节,都是在线上一点一点磨出来的。下一节咱们聊聊更进阶的话题——特征存储的分布式方案,看看 Redis Cluster 和 RedisShard 怎么选。