324 lines
10 KiB
Markdown
324 lines
10 KiB
Markdown
---
|
||
tags: [Redis, 缓存, 数据结构]
|
||
create time: 2026-05-15 18:11
|
||
---
|
||
|
||
# Redis 核心数据类型与底层编码
|
||
|
||
## 概述
|
||
|
||
> [!QUESTION] 同一个 `SET` 命令,有时占内存几字节,有时几百字节——是 Bug 吗?
|
||
> 不是。Redis 会根据值的特征**自动选择最省内存的内部表示**。这就是你要理解的"编码"概念。
|
||
|
||
Redis 有五种基础数据类型:**String、Hash、List、Set、ZSet**。但每种类型在不同场景下会切换不同的**内部编码**——理解这一点比单纯记忆命令更重要,因为它直接决定了操作的时间复杂度和内存消耗。
|
||
|
||
---
|
||
|
||
```mermaid
|
||
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)
|
||
|
||
```go
|
||
// 设置过期时间(原子操作)
|
||
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(哈希表)
|
||
|
||
### 内部编码切换
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
A["ziplist"] -->|"超出阈值: 节点数>5 或值≥64B"| B["hashtable"]
|
||
|
||
style A fill:#e1f5fe
|
||
style B fill:#fff3e0
|
||
```
|
||
|
||
阈值由两个配置控制:
|
||
- `hash-max-ziplist-size 5`:节点数 > 5 或某个字段长度 ≥ 64B → 切 hashtable
|
||
- `hash-max-ziplist-value 64`:单个值 > 64B → 切 hashtable
|
||
|
||
### 适用场景
|
||
|
||
| 场景 | 命令 | 说明 |
|
||
|------|------|------|
|
||
| 用户信息缓存 | `HSET user:1 name alice age 25` | 只更新需要改的字段 |
|
||
| 表单数据存储 | `HMSET` / `HGETALL` | 替代 String 存整个 JSON |
|
||
|
||
### String vs Hash 选型
|
||
|
||
> [!QUESTION] 为什么更新一个字段要读写整个 JSON?
|
||
> String 存的是完整序列化字符串,改任何内容都要重新序列化写入。Hash 是结构化的键值对,只修改目标字段即可。
|
||
|
||
```mermaid
|
||
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 紧凑块中,大幅减少指针浪费。
|
||
|
||
```mermaid
|
||
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 灵活 |
|
||
|
||
```go
|
||
// 发布新消息
|
||
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 切换
|
||
|
||
```mermaid
|
||
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
|
||
```
|
||
|
||
### 集合操作(唯一能力)
|
||
|
||
```bash
|
||
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),但无序。两者互补,代价是每个元素存了两份索引。
|
||
|
||
```mermaid
|
||
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 中**最复杂**的数据结构,代价也是最高的——每个元素存在两份索引。
|
||
|
||
### 关键命令
|
||
|
||
```bash
|
||
# 添加(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 示例
|
||
|
||
```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 的数据类型本质上是一套**编码策略系统**。理解这个系统的关键在于三个原则:
|
||
|
||
1. **小数据用紧凑编码** — ziplist / intset / embstr 牺牲灵活性换取极致内存效率
|
||
2. **大数据切高性能结构** — hashtable / skiplist / raw 以空间换时间,操作复杂度可控
|
||
3. **切换是自动且平滑的** — 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 风险
|