This repository has been archived on 2026-05-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
all-in-kingsoft/hhs/Redis/02-核心数据类型.md
T
2026-05-17 00:06:11 +08:00

324 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 风险