2. Redis 核心数据结构回顾:缓存场景下的选型原则
在 Redis 缓存实战中,选错数据结构往往导致性能劣化或代码复杂度飙升。本章从缓存场景出发,逐一回顾五种核心数据结构的特性、适用边界与避坑要点,并给出可操作的选型决策路径。
2.1 数据结构全景对比
下表从 存储形态、读写复杂度、典型缓存场景 三个维度对齐五种结构,便于快速定位候选类型。
| 数据结构 | 底层编码 | 单 Key 容量 | 读写时间复杂度 | 典型缓存场景 |
|---|---|---|---|---|
| String | int / embstr / raw | 512 MB | O(1) 读写 | Session、计数器、分布式锁、对象 JSON 缓存 |
| Hash | ziplist / hashtable | 2^32-1 个 field | O(1) 单 field 读写 | 对象属性缓存、用户资料、购物车 |
| List | quicklist | 2^32-1 个元素 | O(1) 头尾操作 / O(N) 索引 | 消息队列、时间线、最新 N 条 |
| Set | intset / hashtable | 2^32-1 个元素 | O(1) 增删查 / O(N) 交并差 | 标签系统、去重、好友关系 |
| Sorted Set | ziplist / skiplist | 2^32-1 个元素 | O(log N) 增删查 / O(log N) 范围 | 排行榜、延时队列、滑动窗口限流 |
选型第一原则: 不要用 String 存对象全部字段(序列化/反序列化开销大),优先用 Hash 做对象缓存;不要用 List 做随机访问,优先用 Sorted Set 做有序范围查询。
2.2 String:最通用但最容易被误用
String 是 Redis 最基础的结构,支持 SET、GET、INCR、DECR、APPEND 等原子操作。在缓存场景中,String 适合存储 序列化后的完整对象 或 单一数值。
- 适用场景:Session Token、API 限流计数器、分布式锁(SETNX)、热点数据全量缓存。
- 避坑点:大 Value(超过 10 KB)会导致网络传输和内存碎片;频繁修改部分字段时,反序列化再序列化开销大,应改用 Hash。
- 内存优化:整数用 int 编码,短字符串用 embstr(≤ 44 字节),长字符串用 raw。
# 正确用法:缓存序列化对象
SET user:1001 '{"name":"alice","age":30}' EX 3600
# 错误用法:频繁修改对象内单个字段
GET user:1001 → 反序列化 → 修改 → 序列化 → SET user:1001
# 应改用 HSET user:1001 age 31
2.3 Hash:对象缓存的正确姿势
Hash 将对象的多个字段存储在同一个 Key 下,支持对单个字段的 O(1) 读写,避免 String 的序列化全量读写问题。
- 适用场景:用户资料、商品详情、配置项、购物车(用户 ID 为 Key,商品 ID 为 field,数量为 value)。
- 避坑点:field 数量过多(超过 512 个)或单个 field 的 value 过大时,ziplist 会升级为 hashtable,内存和性能均下降;建议控制 field 数在 1000 以内。
- 批量操作:使用
HMGET/HMSET减少网络往返。
# 用户资料缓存
HSET user:1001 name "alice" age 30 city "beijing"
HGET user:1001 name
HMGET user:1001 name age
2.4 List:队列与时间线
List 基于双向链表(quicklist),支持 LPUSH / RPUSH / LPOP / RPOP 等头尾操作,时间复杂度 O(1)。
- 适用场景:消息队列(生产者 LPUSH,消费者 RPOP)、最新 N 条动态(LTRIM 截断)、栈结构(LPUSH + LPOP)。
- 避坑点:
LINDEX/LSET随机访问是 O(N),大数据量下性能差;List 不提供去重能力,重复消息需业务层处理。 - 阻塞版本:
BRPOP/BLPOP实现消费者等待,减少空轮询。
# 最新 100 条动态
LPUSH feed:user:1001 "post:2001"
LTRIM feed:user:1001 0 99
# 阻塞队列消费
BRPOP queue:task 0
2.5 Set:去重与集合运算
Set 基于哈希表或整数集合,支持 O(1) 的增删查,以及 SINTER、SUNION、SDIFF 等集合运算。
- 适用场景:用户标签、关注/粉丝集合、抽奖去重、IP 黑名单。
- 避坑点:
SMEMBERS返回所有元素,大集合(超过 5000 个)会阻塞 Redis;改用SSCAN分批迭代。 - 内存优化:元素全为整数且数量较少时,使用 intset 编码,内存极省。
# 用户标签交集:同时拥有 "tech" 和 "redis" 标签的用户
SINTER tag:tech tag:redis
# 抽奖去重
SADD lottery:2025 "user:1001"
SPOP lottery:2025 1
2.6 Sorted Set:排行榜与范围查询
Sorted Set 通过跳表 + 哈希表实现,每个元素关联一个 double 类型的 score,支持按 score 排序和范围查询。
- 适用场景:实时排行榜、延时队列(score 为时间戳)、滑动窗口限流、自动补全。
- 避坑点:score 精度问题(double 在 2^53 以上会丢失精度);
ZRANGE/ZREVRANGE默认按字典序,需指定WITHSCORES获取分数。 - 性能:跳表插入/删除 O(log N),范围查询 O(log N + M),适合百万级数据。
# 游戏排行榜
ZADD leaderboard 1000 "player:1001"
ZADD leaderboard 950 "player:1002"
ZREVRANGE leaderboard 0 9 WITHSCORES
# 延时队列:score 为执行时间戳
ZADD delay:queue 1700000000 "task:001"
ZRANGEBYSCORE delay:queue 0 1700000000 LIMIT 0 10
2.7 选型决策流程
下图总结了从业务需求到数据结构的决策路径,帮助你在设计缓存时快速排除错误选项。
2.8 内存与性能权衡速查
下表列出不同数据结构的 内存编码转换阈值 和 性能拐点,帮助你在容量规划时做出预判。
| 数据结构 | 编码类型 | 触发升级条件 | 性能拐点 | 建议上限 |
|---|---|---|---|---|
| Hash | ziplist → hashtable | field 数 > 512 或 value 长度 > 64 字节 | field 数 > 1000 时内存膨胀 2~3 倍 | field ≤ 500 |
| List | quicklist(固定) | 无编码转换 | 元素数 > 10 万时 LINDEX 变慢 | 元素 ≤ 5 万 |
| Set | intset → hashtable | 元素数 > 512 或含非整数 | 元素数 > 5000 时 SMEMBERS 阻塞 | 元素 ≤ 2000 |
| Sorted Set | ziplist → skiplist | 元素数 > 128 或元素长度 > 64 字节 | 元素数 > 1 万时 ZRANGE 延迟上升 | 元素 ≤ 5000 |
重要提醒: 编码转换是 不可逆 的。一旦从 ziplist/intset 升级为 hashtable/skiplist,即使后续删除元素使数量低于阈值,也不会自动降级。设计缓存 Key 时应预留足够的容量余量,避免触发升级后内存持续增长。
2.9 实战选型检查清单
在每次设计缓存结构前,对照以下清单快速验证选型是否正确:
- 读写模式:是读多写少还是写多读少?Hash 适合读多写少,String 适合写多读少。
- 字段粒度:是否需要单独读写对象内的某个字段?是则用 Hash,否则用 String。
- 有序需求:是否需要按时间或分数排序?是则用 Sorted Set,否则用 List。
- 去重需求:是否需要自动去重?是则用 Set,否则用 List。
- 过期策略:是否需要为每个字段单独设置 TTL?Redis 只支持 Key 级别过期,Hash 内字段无法单独过期。
- 原子操作:是否需要 INCR/DECR 原子增减?仅 String 支持。
案例: 设计一个“用户最近浏览商品”缓存。需求:保存最近 50 个商品 ID,按浏览时间倒序,可去重。选型分析:需要有序 + 去重 → Sorted Set,以时间戳为 score,商品 ID 为 member,每次浏览时 ZADD,ZREVRANGE 0 49 获取最新列表。若用 List 则需额外去重逻辑,若用 Set 则无法排序。