2. Redis 核心数据结构:底层实现与适用场景
Redis 之所以能成为缓存领域的基石,不仅因为其纯内存操作,更在于它提供了五种核心数据结构——String、List、Set、ZSet、Hash。每种结构都对应着不同的底层编码与适用场景。理解这些差异,是设计缓存契约、划定领域边界的前提。
核心认知: Redis 的每种数据结构在内部并非只有一种实现方式。为了平衡内存与性能,Redis 会根据存储数据的大小、元素数量等条件,动态切换底层编码(如
int、embstr、ziplist、skiplist 等)。这种设计哲学贯穿整个课程。
2.1 String:最基础的缓存单元
String 是 Redis 中最简单的数据类型,但也是使用最频繁的。它本质上是一个动态字符串,最大容量为 512 MB。
底层实现
- int(整数编码): 当存储的字符串可以被解析为 64 位有符号整数时,Redis 直接使用
long类型存储,节省空间且运算极快。 - embstr(嵌入式字符串): 当字符串长度小于等于 44 字节时,Redis 使用
embstr编码。它将redisObject和sdshdr结构体分配在同一块连续内存中,减少内存碎片,提升缓存命中率。 - raw(原始字符串): 当字符串长度大于 44 字节时,使用
raw编码。此时redisObject和sdshdr分两次分配内存,灵活性更高。
适用场景
| 场景 | 说明 | 示例命令 |
|---|---|---|
| 缓存对象 JSON | 将序列化后的对象(如用户信息、配置)直接缓存 | SET user:1001 '{"name":"Alice"}' |
| 计数器 | 利用 INCR/DECR 实现原子自增,如访问次数、库存 |
INCR page:view:home |
| 分布式锁 | 通过 SETNX 实现互斥锁,配合过期时间防止死锁 |
SETNX lock:order 1 |
| Session 共享 | 在微服务架构中,将用户 Session 以 String 形式集中存储 | SETEX session:token 3600 "data" |
# 示例:使用 String 实现计数器
> SET article:read:42 0
OK
> INCR article:read:42
(integer) 1
> GET article:read:42
"1"
2.2 List:有序的消息队列
List 是一个双向链表,支持在头部和尾部快速插入、删除元素。它天然适合用作消息队列或时间线存储。
底层实现
- ziplist(压缩列表): 当列表元素数量较少(默认 < 512 个)且每个元素长度较小(默认 < 64 字节)时,使用
ziplist。它将所有元素连续存储,节省内存。 - linkedlist(双向链表): 当不满足
ziplist条件时,转换为linkedlist。每个节点独立分配内存,支持高效的头部/尾部操作。 - quicklist(快速列表,Redis 3.2+): 结合了
ziplist和linkedlist的优点。它将链表分段,每段内部使用ziplist压缩存储,既节省内存又保持操作效率。
适用场景
| 场景 | 说明 | 示例命令 |
|---|---|---|
| 消息队列 | 生产者 LPUSH,消费者 RPOP 或 BRPOP(阻塞读取) |
LPUSH msg:queue "task1" |
| 最新动态/时间线 | 使用 LTRIM 保留最近 N 条记录,如用户最新发帖 |
LPUSH user:timeline "post1" |
| 栈结构 | 利用 LPUSH + LPOP 实现后进先出(LIFO) |
LPUSH stack "a" |
# 示例:使用 List 实现简单消息队列
> LPUSH task:queue "send_email"
(integer) 1
> LPUSH task:queue "generate_report"
(integer) 2
> RPOP task:queue
"send_email"
2.3 Set:去重与集合运算
Set 是一个无序、不重复的字符串集合。它支持高效的集合运算(交集、并集、差集),非常适合标签、关注关系等场景。
底层实现
- intset(整数集合): 当集合中所有元素都是整数且元素数量较少(默认 < 512 个)时,使用
intset。它使用有序数组存储,支持二分查找,内存紧凑。 - hashtable(哈希表): 当不满足
intset条件时,转换为hashtable。使用字典结构,查找、插入、删除的时间复杂度均为 O(1)。
适用场景
| 场景 | 说明 | 示例命令 |
|---|---|---|
| 标签系统 | 为文章或商品打标签,通过 SINTER 查找共同标签 |
SADD article:1:tags "redis" "cache" |
| 去重统计 | 记录独立访客 IP,利用 Set 自动去重 | SADD unique:visitors "192.168.1.1" |
| 关注/粉丝关系 | 使用 SUNION 或 SDIFF 实现推荐关注 |
SADD user:1001:follow "user:1002" |
# 示例:使用 Set 计算共同好友
> SADD user:1:friends "user:2" "user:3"
(integer) 2
> SADD user:4:friends "user:2" "user:5"
(integer) 2
> SINTER user:1:friends user:4:friends
1) "user:2"
2.4 ZSet:带权重的有序集合
ZSet(Sorted Set)在 Set 的基础上为每个元素关联了一个 score(分数),元素按 score 从小到大排序。它是实现排行榜、延迟队列等场景的核心数据结构。
底层实现
- ziplist(压缩列表): 当元素数量较少(默认 < 128 个)且每个元素长度较小时,使用
ziplist。元素按 score 顺序存储,查找时使用二分法。 - skiplist + hashtable(跳表 + 哈希表): 当不满足
ziplist条件时,使用skiplist作为有序结构,hashtable作为索引结构。跳表支持 O(log N) 的插入、删除和范围查询,哈希表则保证元素到 score 的快速映射。
适用场景
| 场景 | 说明 | 示例命令 |
|---|---|---|
| 排行榜 | 按玩家分数排序,使用 ZRANK 获取排名 |
ZADD leaderboard 100 "player1" |
| 延迟队列 | 将任务执行时间作为 score,使用 ZRANGEBYSCORE 获取到期任务 |
ZADD delay:queue 1700000000 "task1" |
| 滑动窗口限流 | 将时间戳作为 score,统计窗口内的请求数 | ZADD rate:limit:api 1700000000 "req1" |
# 示例:使用 ZSet 实现排行榜
> ZADD game:score 100 "Alice"
(integer) 1
> ZADD game:score 200 "Bob"
(integer) 1
> ZADD game:score 150 "Charlie"
(integer) 1
> ZRANGE game:score 0 -1 WITHSCORES
1) "Alice"
2) "100"
3) "Charlie"
4) "150"
5) "Bob"
6) "200"
2.5 Hash:对象字段的紧凑存储
Hash 是一个键值对集合,特别适合存储对象。与将整个对象序列化为 String 不同,Hash 允许对单个字段进行读写,节省带宽并支持部分更新。
底层实现
- ziplist(压缩列表): 当字段数量较少(默认 < 512 个)且每个字段和值的长度较小时,使用
ziplist。它将所有键值对按顺序存储,内存利用率极高。 - hashtable(哈希表): 当不满足
ziplist条件时,转换为hashtable。使用字典结构,支持 O(1) 的字段读写。
适用场景
| 场景 | 说明 | 示例命令 |
|---|---|---|
| 用户信息缓存 | 存储用户对象的多个字段,如姓名、年龄、邮箱 | HSET user:1001 name "Alice" age 30 |
| 购物车 | 以用户 ID 为 key,商品 ID 为 field,数量为 value | HSET cart:1001 item:2001 2 |
| 配置管理 | 存储应用配置,支持按字段单独更新 | HSET app:config theme "dark" |
# 示例:使用 Hash 存储用户信息
> HSET user:1001 name "Alice" age 30 email "alice@example.com"
(integer) 3
> HGET user:1001 name
"Alice"
> HGETALL user:1001
1) "name"
2) "Alice"
3) "age"
4) "30"
5) "email"
6) "alice@example.com"
2.6 数据结构选择决策树
在实际缓存契约设计中,选择哪种数据结构往往取决于访问模式和数据关系。以下决策树可以帮助你快速判断:
2.7 底层编码对比总结
下表汇总了五种数据结构的底层编码及其切换条件,帮助你在设计缓存时预判内存与性能表现:
| 数据结构 | 底层编码 | 切换条件 | 时间复杂度 | 内存效率 |
|---|---|---|---|---|
| String | int / embstr / raw | 长度 ≤ 44 字节 → embstr;否则 raw | O(1) | 极高(int 编码) |
| List | ziplist / linkedlist / quicklist | 元素 < 512 且长度 < 64B → ziplist | O(1) 头尾操作 | 高(ziplist) |
| Set | intset / hashtable | 元素 < 512 且全为整数 → intset | O(1) | 高(intset) |
| ZSet | ziplist / skiplist+hashtable | 元素 < 128 且长度 < 64B → ziplist | O(log N) | 中(skiplist) |
| Hash | ziplist / hashtable | 字段 < 512 且长度 < 64B → ziplist | O(1) | 高(ziplist) |
避坑指南: 不要盲目使用
ZSet 替代 List 或 Set。虽然 ZSet 功能强大,但其底层跳表结构在插入和删除时涉及多层指针调整,性能开销远大于 List 的头尾操作。只有在确实需要按 score 排序或范围查询时,才选择 ZSet。
2.8 实战:缓存契约中的数据结构选择
假设我们正在设计一个文章缓存系统,需要缓存文章详情、阅读量、标签、排行榜和评论列表。根据上述知识,我们可以做出如下契约设计:
| 缓存需求 | 推荐数据结构 | Key 设计 | 理由 |
|---|---|---|---|
| 文章详情 | Hash | article:{id} |
支持按字段更新(如标题、内容),节省带宽 |
| 阅读量 | String | article:read:{id} |
原子自增,高性能计数器 |
| 文章标签 | Set | article:tags:{id} |
自动去重,支持交集运算(推荐相关文章) |
| 热门文章排行榜 | ZSet | hot:articles |
按阅读量排序,支持范围查询 |
| 最新评论 | List | article:comments:{id} |
LPUSH 新评论,LTRIM 保留最近 N 条 |
通过这种契约设计,我们不仅明确了每个缓存键的职责边界,还充分利用了 Redis 各数据结构的底层优势,实现了内存效率与访问性能的最佳平衡。