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 选型决策流程

下图总结了从业务需求到数据结构的决策路径,帮助你在设计缓存时快速排除错误选项。

Redis 缓存数据结构选型决策树 缓存需求 需要什么操作? 单个值/计数器 String 对象多字段 Hash 有序/队列 List 去重/集合运算? Set 按分数排序? Sorted Set 仅头尾操作? List 选型口诀:单值用 String,对象用 Hash,队列用 List,去重用 Set,排序用 Sorted Set 避免用 String 存对象、用 List 做随机访问、用 Set 做排序

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 实战选型检查清单

在每次设计缓存结构前,对照以下清单快速验证选型是否正确:

  1. 读写模式:是读多写少还是写多读少?Hash 适合读多写少,String 适合写多读少。
  2. 字段粒度:是否需要单独读写对象内的某个字段?是则用 Hash,否则用 String。
  3. 有序需求:是否需要按时间或分数排序?是则用 Sorted Set,否则用 List。
  4. 去重需求:是否需要自动去重?是则用 Set,否则用 List。
  5. 过期策略:是否需要为每个字段单独设置 TTL?Redis 只支持 Key 级别过期,Hash 内字段无法单独过期。
  6. 原子操作:是否需要 INCR/DECR 原子增减?仅 String 支持。
案例: 设计一个“用户最近浏览商品”缓存。需求:保存最近 50 个商品 ID,按浏览时间倒序,可去重。选型分析:需要有序 + 去重 → Sorted Set,以时间戳为 score,商品 ID 为 member,每次浏览时 ZADD,ZREVRANGE 0 49 获取最新列表。若用 List 则需额外去重逻辑,若用 Set 则无法排序。