10 KiB
tags, create time
| tags | create time | |||
|---|---|---|---|---|
|
2026-05-15 18:11 |
Redis 核心数据类型与底层编码
概述
[!QUESTION] 同一个
SET命令,有时占内存几字节,有时几百字节——是 Bug 吗? 不是。Redis 会根据值的特征自动选择最省内存的内部表示。这就是你要理解的"编码"概念。
Redis 有五种基础数据类型:String、Hash、List、Set、ZSet。但每种类型在不同场景下会切换不同的内部编码——理解这一点比单纯记忆命令更重要,因为它直接决定了操作的时间复杂度和内存消耗。
flowchart TD
subgraph "String"
S1["int — 整数"]
S2["embstr (≤44B) — 短字符串"]
S3["raw — 长字符串"]
end
subgraph "Hash"
H1["ziplist — 元素少且小"]
H2["hashtable — 超出阈值后切换"]
end
subgraph "List"
L1["quicklist (ziplist 节点 + 指针)"]
end
subgraph "Set"
ST1["intset — 全是整数"]
ST2["hashtable — 出现非整数"]
end
subgraph "ZSet"
Z1["ziplist — 元素少且差距小"]
Z2["skiplist + hashtable — 大规模数据"]
end
一、String(字符串)
三种内部编码
| 编码 | 触发条件 | 典型场景 |
|---|---|---|
int |
值可表示为长整型 | 计数器 (INCR) |
embstr |
≤ 44 字节(Redis 7 之前是 39B) | 短文本存储 |
raw |
> 44 字节 | 大对象 JSON / Base64 |
[!QUESTION] embstr 和 raw 的区别?
embstr:一块连续内存分配,创建/销毁只需一次 malloc/free,效率高raw:分开分配 SDS 结构体和 redisObject 头,支持扩容- 超过 44B 时 embstr 不可变,只能切 raw
代码示例(Go go-redis)
// 设置过期时间(原子操作)
rdb.Set(ctx, "token:abc", jwtPayload, 24*time.Hour)
// 原子递增计数器
rdb.Incr(ctx, "counter:api")
// MGET 批量获取
vals, _ := rdb.MGet(ctx, "user:1", "user:2", "user:3").Result()
[!INSIGHT] 编码切换是透明的 你不需要自己管理编码——
REDISOBJECT.encoding字段由 Redis 自动维护。用MEMORY USAGE key可以看到实际占用字节数,这是验证编码效果最直接的方式。
二、Hash(哈希表)
内部编码切换
flowchart LR
A["ziplist"] -->|"超出阈值: 节点数>5 或值≥64B"| B["hashtable"]
style A fill:#e1f5fe
style B fill:#fff3e0
阈值由两个配置控制:
hash-max-ziplist-size 5:节点数 > 5 或某个字段长度 ≥ 64B → 切 hashtablehash-max-ziplist-value 64:单个值 > 64B → 切 hashtable
适用场景
| 场景 | 命令 | 说明 |
|---|---|---|
| 用户信息缓存 | HSET user:1 name alice age 25 |
只更新需要改的字段 |
| 表单数据存储 | HMSET / HGETALL |
替代 String 存整个 JSON |
String vs Hash 选型
[!QUESTION] 为什么更新一个字段要读写整个 JSON? String 存的是完整序列化字符串,改任何内容都要重新序列化写入。Hash 是结构化的键值对,只修改目标字段即可。
quadrantChart
title "String vs Hash — 场景选择矩阵"
x-axis "简单操作" --> "复杂操作"
y-axis "内存紧凑" --> "灵活存取"
"String (JSON)": [0.3, 0.75]
"Hash (字段级)": [0.7, 0.85]
| 维度 | 方案 A — String(JSON) | 方案 B — Hash(字段级) |
|---|---|---|
| 读取 | GET user:1 ✅ 一行搞定 |
HGETALL user:1 需合并 |
| 局部更新 | ❌ 读 → 改 → 写整个 JSON | ✅ HSET user:1 email b@x.com |
| 内存占用 | 较高(重复 key、序列化开销) | 较低(ziplist 紧凑存储) |
| 适用规模 | 小数据、读多写少 | 中等大小、频繁部分更新 |
[!TIP] Hash 在 ziplist 编码下内存占用比单独 String 省 ~40%,适合字段数 < 100 的对象缓存。
方案 A — String(JSON 整体存取)
GET user:1 → {"name":"alice","age":25,"email":"a@x.com"}
✅ 读写简单 ✅ 序列化一致
❌ 更新一个字段要读写整个 JSON
方案 B — Hash(字段级操作)
HSET user:1 email b@x.com
✅ 局部更新,内存更紧凑
❌ HGETALL 不适合超大 hash
[!CAUTION] 警惕大 Hash
HGETALL在 hashtable 编码下是 O(N),hash 很大时会阻塞 Redis。如果字段数超过几千,考虑拆成多个 key 或改回 String(JSON)。
三、List(列表)
quicklist 结构
[!QUESTION] 为什么不用双向链表 + SDS,而要用 quicklist? 普通双向链表每个节点都有指针开销(16B)。quicklist 把多个元素打包进一个 ziplist 紧凑块中,大幅减少指针浪费。
flowchart LR
H["head"] <--> Z1["ziplist 节点 #1\n(≤ 4096 项)"]
Z1 <--> Z2["ziplist 节点 #2\n(≤ 4096 项)"]
Z2 <--> Z3["ziplist 节点 #N\n(≤ 4096 项)"]
Z3 <--> T["tail"]
classDef linkPoint fill:#e8f5e9,stroke:#4caf50
classDef znode fill:#fff3e0,stroke:#ff9800
class H,T linkPoint
class Z1,Z2,Z3 znode
List 统一使用 quicklist(压缩链表),每个节点是一个 ziplist:
list-max-ziplist-size 5:默认每个节点最多 4096 个元素list-compress-depth 0:默认不压缩首尾节点(方便头部操作)
典型场景
| 场景 | 命令 | 说明 |
|---|---|---|
| 消息队列 | LPUSH + BRPOP |
单消费者阻塞式消费 |
| 最新 N 条记录 | LRANGE 0 -1 |
配合 LTRIM 固定长度 |
| 延迟队列 | ZSet score = timestamp | 比 List 灵活 |
// 发布新消息
rdb.LPush(ctx, "news-feed", jsonMessage)
// 修剪到最近 1000 条
rdb.LTrim(ctx, "news-feed", 0, 999)
// 取出最近 10 条
items, _ := rdb.LRange(ctx, "news-feed", 0, 9).Result()
四、Set(集合)
intset → hashtable 切换
flowchart LR
E["Set 添加元素"] --> Q{"全部是\n整数?"}
Q -->|"是"| I["intset\n有序数组 · 内存紧凑"]
Q -->|"否 — 出现字符串"| H["hashtable\nO(1) 查找 · 集合运算"]
classDef encoding fill:#e1f5fe,stroke:#2196f3
classDef encoding2 fill:#fff3e0,stroke:#ff9800
class I encoding
class H encoding2
集合操作(唯一能力)
SADD tags:post1 go golang redis
SADD tags:post2 redis docker golang
SDIFF tags:post1 tags:post2 # go — post1 独有
SINTER tags:post1 tags:post2 # golang redis — 共有的
SUNION tags:post1 tags:post2 # go golang redis docker
SCARD tags:post1 # 3 — 元素个数
SISMEMBER tags:post1 go # 1 — 判断成员
应用场景
- 点赞/收藏列表:
SADD like:post:42 user:100 - 共同好友:
SINTER user:A:friends user:B:friends - 随机抽取:
SRANDMEMBER lottery 5(不删除)/SPOP lottery 5(删除)
[!TIP] Set 的 SSCAN 命令
SSCAN可以增量迭代大集合,避免单次命令阻塞 Redis——和 Hash 的HSCAN同理。
五、ZSet / Sorted Set(有序集合)
skiplist + hashtable 双结构
[!QUESTION] 为什么需要两套数据结构? skiplist 天然有序、支持高效范围查询,但按 member 查找要遍历;hashtable 查找 O(1),但无序。两者互补,代价是每个元素存了两份索引。
flowchart LR
Key["key: leaderboard"] --> SL["skiplist\n按 score 排序 · 范围查询 O(log N)"]
Key --> HT["hashtable\n按 member 查找 · O(1) 定位"]
classDef primary fill:#e8f5e9,stroke:#4caf50
classDef secondary fill:#fff3e0,stroke:#ff9800
class SL secondary
class HT secondary
这是 Redis 中最复杂的数据结构,代价也是最高的——每个元素存在两份索引。
关键命令
# 添加(member → score)
ZADD leaderboard 100 "player1" 200 "player2" 150 "player3"
# 正序排名 1~5
ZRANGE leaderboard 0 4 WITHSCORES
# 倒序 Top 10
ZREVRANGE leaderboard 0 9 WITHSCORES
# 获取分数
ZSCORE leaderboard "player2" # 200
# 获取排名(从 0 开始)
ZRANK leaderboard "player3" # 1
ZREVRANK leaderboard "player3" # 2
# 分数范围查询
ZRANGEBYSCORE leaderboard 0 150
# 移除指定范围
ZREMRANGEBYRANK leaderboard 0 2
Go 示例
// 排行榜加分(原子操作)
rdb.ZIncr(ctx, "leaderboard", redis.Z{
Score: 10.5,
Member: "player42",
})
// 获取 Top 10
members, _ := rdb.ZRevRangeWithScores(ctx, "leaderboard", 0, 9).Result()
for _, m := range members {
fmt.Printf("%s: %.1f\n", m.Member, m.Score)
}
ZSet vs List 做消息队列对比
| 维度 | List (BRPOP) | ZSet (BZPOPMIN) |
|---|---|---|
| FIFO 顺序 | ✅ 保证 | ⚠️ 依赖 score |
| 延迟执行 | ❌ 不支持 | ✅ score = 执行时间戳 |
| 重排优先级 | ❌ 需重建 | ✅ 改 score 即可 |
| 复杂度 | O(log(N/M)) | O(log N) |
[!WARNING] ZSet 性能陷阱 不要往同一个 ZSet 里塞超过百万级别的元素——skiplist 虽然 O(log N),但实际维护成本不小。百万级可以考虑按分片拆成多个 ZSet。
快速对照表
| 数据类型 | 编码 | 最佳场景 | 时间复杂度 |
|---|---|---|---|
| String | int / embstr / raw | 计数、缓存、分布式锁 | O(1) |
| Hash | ziplist / hashtable | 对象缓存(部分更新) | O(1)~O(N) |
| List | quicklist | FIFO 队列、feed流 | O(1)~O(N) |
| Set | intset / hashtable | 标签、去重、关系运算 | O(1) |
| ZSet | ziplist / skiplist+ht | 排行榜、延迟任务、权重 | O(log N) |
核心思想:内存与性能的权衡
Redis 的数据类型本质上是一套编码策略系统。理解这个系统的关键在于三个原则:
- 小数据用紧凑编码 — ziplist / intset / embstr 牺牲灵活性换取极致内存效率
- 大数据切高性能结构 — hashtable / skiplist / raw 以空间换时间,操作复杂度可控
- 切换是自动且平滑的 — Redis 在后台根据阈值决定何时升级编码,开发者只需关注业务语义
[!SUMMARY] 实战检查清单
- 缓存对象 → Hash(ziplist)或 String(JSON),字段多且常部分更新选 Hash
- 计数 / 锁 / Token → String(int 编码最省)
- Feed 流 / 队列 → List(quicklist)
- 标签 / 去重 / 集合运算 → Set(intset)
- 排行榜 / 延时任务 / 带权随机 → ZSet(skiplist)
关联笔记
- hhs/Redis/08-SortedSet精解 — ZSet 的高级玩法
- hhs/Redis/03-基本命令速查 — 常用命令速查表
- hhs/Redis/07-集群方案 — 集群环境下的大 Key 风险