vault backup: 2026-05-21 23:50:11

This commit is contained in:
hhs
2026-05-21 23:50:11 +08:00
parent c3779073e9
commit b2b947f9f4
3 changed files with 363 additions and 212 deletions
+184 -208
View File
@@ -1,266 +1,225 @@
---
tags: [Redis, 缓存, 数据结构]
create time: 2026-05-15 18:11
author: hhs
---
# Redis 核心数据类型与底层编码
## 概述
> [!QUESTION] 同一个 `SET` 命令,有时占内存几字节,有时几百字节——是 Bug 吗?
> 不是。Redis 会根据值的特征**自动选择最省内存的内部表示**。这就是你要理解的"编码"概念。
> [!tip] 先别急着背编码,想想你在业务里会怎么用
> 你用 Redis,第一件事不是去记 `ziplist` 和 `hashtable` 的区别——而是搞清楚:**什么时候该用哪种数据类型?**
Redis 有五种基础数据类型:**String、Hash、List、Set、ZSet**。但每种类型在不同场景下会切换不同的**内部编码**——理解这一点比单纯记忆命令更重要,因为它直接决定了操作的时间复杂度和内存消耗。
Redis 提供五种基础数据类型,用一句话总结各自的核心价值:
---
| 类型 | 一句话定位 | 最典型的场景 |
|------|-----------|-------------|
| **String** | 万能小抽屉 | 缓存、计数器、分布式锁 |
| **Hash** | 一个对象的多个字段 | 用户信息、配置项 |
| **List** | 有序队列 | 消息队列、最新动态 |
| **Set** | 不重复的袋子 | 标签、点赞、共同好友 |
| **ZSet** | 带权重的排行榜 | 排行榜、延迟任务 |
```mermaid
flowchart TD
subgraph "String"
S1["int — 整数"]
S2["embstr (≤44B) — 短字符串"]
S3["raw — 长字符串"]
end
> [!question] 那"底层编码"又是什么?
> 你在业务层只管用 `String`、`Hash` 这些类型——但 Redis 在底层会**自动选择最省内存的存储方式**。比如同样是 `String`,存数字 `42` 只占几个字节,存一段长 JSON 可能几百字节。这就是"编码"在起作用。
>
> 了解编码不是为了手动管理它,而是帮你**预判性能和内存消耗**,避免踩坑(比如超大 Hash 的 `HGETALL` 阻塞)。
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 开始,逐个击破。
## 一、String(字符串)
### 三种内部编码
String 是 Redis 最基础的类型——**一个 key 对应一个值**,就这么简单。
```go
// 存一个 Token,24 小时过期(原子操作)
rdb.Set(ctx, "token:abc", jwtPayload, 24*time.Hour)
// 原子递增:接口调用计数器,天然线程安全
rdb.Incr(ctx, "counter:api")
// MGET 批量获取:一次网络往返取多个 key,比循环 GET 快得多
vals, _ := rdb.MGet(ctx, "user:1", "user:2", "user:3").Result()
```
> [!question] 为什么 String 还能做计数器?
> `INCR` 是 Redis 的原子命令,底层直接操作整数值,不需要"先读再改再写"。100 个请求同时 `INCR` 也不会丢数据——这是它比你自己维护计数器靠谱得多的原因。
### 深入:三种内部编码
同样是 String,Redis 底层用了三种不同的存储方式:
| 编码 | 触发条件 | 典型场景 |
|------|---------|---------|
| `int` | 值可表示为长整型 | 计数器 (`INCR`) |
| `embstr` | ≤ 44 字节(Redis 7 之前是 39B) | 短文本存储 |
| `embstr` | ≤ 44 字节(Redis 7 之前是 39B) | 短文本、小型 JSON |
| `raw` | > 44 字节 | 大对象 JSON / Base64 |
> [!QUESTION] embstr 和 raw 的区别?
> - `embstr`:一块连续内存分配,创建/销毁只需一次 malloc/free,效率高
> - `raw`:分开分配 SDS 结构体和 redisObject 头,支持扩容
> - 超过 44B 时 embstr 不可变,只能切 raw
> [!question] embstr 和 raw 有什么区别?
> 简单理解:`embstr` 是"打包好的快递"——key 和 value 的内存**连在一起分配**,创建销毁都只要一次操作,效率高。`raw` 是"分开打包"——支持值变大后扩容。超过 44 字节时,`embstr` 无法原地扩展,只能切换成 `raw`。
>
> 你不需要手动管理这些——Redis 根据值的大小自动选择。用 `MEMORY USAGE key` 可以验证实际占用。
### 代码示例(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` 可以看到实际占用字节数,这是验证编码效果最直接的方式。
> [!insight] 一个常见的误解
> `"42"` 存进去是 `int` 编码,只占几个字节;`"hello world"` 是 `embstr`;一段长 JSON 是 `raw`。所以**同一个 `SET` 命令,不同值的内存差距可以是几十倍**——这不是 Bug,是编码策略在起作用。
## 二、Hash(哈希表)
### 内部编码切换
Hash 就是**一个 key 下面存多个字段**——和编程语言里的 Map/字典一模一样。
```bash
# 存一个用户对象,每个字段独立存取
HSET user:1 name alice age 25 email a@x.com
HGET user:1 name # → "alice"
HSET user:1 age 26 # 只改 age,其他字段不受影响
```
> [!question] 为什么不直接用 String 存 JSON?
> String 存 JSON,改一个字段要"读出整个 JSON → 在应用层改 → 写回去"。Hash 是结构化的,`HSET user:1 age 26` 一行搞定,**不需要碰其他字段**。这是 Hash 最大的优势。
### 深入:编码切换
```mermaid
flowchart LR
A["ziplist"] -->|"超出阈值: 节点数>5 或值≥64B"| B["hashtable"]
A["ziplist"] -->|"entries > 512 or value >= 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
阈值由两个独立配置控制(注意是**两个参数**,不是一个):
- `hash-max-ziplist-entries 512`:Hash 中 field 数量超过 512 → 切 hashtable
- `hash-max-ziplist-value 64`:任意一个 field-value 超过 64 字节 → 切 hashtable
### 适用场景
> [!TIP] 调参建议
> 在生产环境中,如果对象字段数稳定在 1000 以内且单值较短,可以适当调高 `entries` 到 1024 以充分利用 ziplist 的内存优势。反之,如果频繁出现大字段,建议降低 `entries` 让它尽早切到 hashtable,避免 ziplist 的级联更新(cascade update)性能问题。
| 场景 | 命令 | 说明 |
|------|------|------|
| 用户信息缓存 | `HSET user:1 name alice age 25` | 只更新需要改的字段 |
| 表单数据存储 | `HMSET` / `HGETALL` | 替代 String 存整个 JSON |
### String vs Hash:怎么选?
### 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` 需合并 |
| 维度 | String(JSON) | Hash(字段级) |
|------|----------------|----------------|
| 读取 | ✅ `GET user:1` 一行搞定 | `HGETALL user:1` 需合并 |
| 局部更新 | ❌ 读 → 改 → 写整个 JSON | ✅ `HSET user:1 email b@x.com` |
| 内存占用 | 较高(重复 key、序列化开销) | 较低(ziplist 紧凑存储) |
| 适用规模 | 小数据、读多写少 | 中等大小、频繁部分更新 |
| 内存占用 | 较高(序列化开销) | 较低(ziplist 省 ~40%) |
| 适用场景 | 小数据、读多写少 | 中等大小、频繁部分更新 |
> [!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)。
> [!caution] 警惕大 Hash
> `HGETALL` 在 hashtable 编码下是 O(N),字段数很大时会阻塞 Redis。超过几千个字段,考虑拆成多个 key 或改回 String(JSON)。
## 三、List(列表)
### quicklist 结构
List 就是**有序的队列**——从一头进,从另一头出。
> [!QUESTION] 为什么不用双向链表 + SDS,而要用 quicklist?
> 普通双向链表每个节点都有指针开销(16B)。quicklist 把多个元素打包进一个 ziplist 紧凑块中,大幅减少指针浪费。
```go
// 发布新消息到 Feed 流(从左边插入最新消息)
rdb.LPush(ctx, "news-feed", jsonMessage)
```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
// 修剪到最近 1000 条,防止列表无限增长
rdb.LTrim(ctx, "news-feed", 0, 999)
// 取出最近 10 条展示给用户
items, _ := rdb.LRange(ctx, "news-feed", 0, 9).Result()
```
List 统一使用 **quicklist**(压缩链表),每个节点是一个 ziplist:
- `list-max-ziplist-size 5`:默认每个节点最多 4096 个元素
- `list-compress-depth 0`:默认不压缩首尾节点(方便头部操作)
> [!question] LPUSH + LTRIM 为什么是黄金搭档?
> `LPUSH` 总是把最新消息插到最前面,`LTRIM` 把超出的旧消息砍掉。两者配合就是一个**固定长度的环形缓冲区**——Feed 流、日志轮转的标配模式。
### 典型场景
| 场景 | 命令 | 说明 |
|------|------|------|
| 消息队列 | `LPUSH` + `BRPOP` | 单消费者阻塞式消费 |
| 最新 N 条记录 | `LRANGE 0 -1` | 配合 `LTRIM` 固定长度 |
| 延迟队列 | ZSet score = timestamp | 比 List 灵活 |
| 最新 N 条记录 | `LPUSH` + `LTRIM` + `LRANGE` | Feed 流、操作日志 |
| 延迟队列 | 用 ZSet 更合适 | 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()
```
### 深入:quicklist 结构
## 四、Set(集合)
### intset → hashtable 切换
> [!question] 为什么不用普通链表?
> 普通双向链表每个节点都有 16 字节的指针开销。quicklist 把多个元素**打包进一个紧凑块**(ziplist),大幅减少指针浪费——就像把散装零食装进密封袋,省空间又好管理。
```mermaid
flowchart LR
E["Set 添加元素"] --> Q{"全部是\n整数?"}
Q -->|"是"| I["intset\n有序数组 · 内存紧凑"]
Q -->|"否 — 出现字符串"| H["hashtable\nO(1) 查找 · 集合运算"]
H["head"] <--> Z1["ziplist node 1, max 4096 items"]
Z1 <--> Z2["ziplist node 2, max 4096 items"]
Z2 <--> Z3["ziplist node N, max 4096 items"]
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`:默认不压缩首尾节点(方便头部操作)
## 四、Set(集合)
Set 就是**不重复的集合**——同一个元素加多少次都只保留一份。它的杀手锏是**集合运算**:交集、并集、差集。
```go
// 点赞:同一个用户只能赞一次,重复 SAdd 自动去重
rdb.SAdd(ctx, "like:post:42", "user:100")
// 判断是否已点赞
liked, _ := rdb.SIsMember(ctx, "like:post:42", "user:100").Result()
// 两个帖子的共同点赞者——一行代码搞定,不需要在应用层做交集
common, _ := rdb.SInter(ctx, "like:post:42", "like:post:99").Result()
```
### 集合运算示例
```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 — 所有标签
```
> [!tip] 集合运算是 Set 区别于其他类型的核心能力
> "共同好友"、"猜你喜欢"、"去重统计"——凡是涉及**两个集合之间的关系**,都该想到 Set。
### 深入:intset 编码
当 Set 里全是整数时,Redis 会用 **intset**(有序整数数组)存储,内存极其紧凑。一旦加入非整数元素,自动切换为 **hashtable**。
```mermaid
flowchart LR
E["Set 添加元素"] --> Q{"全部是整数?"}
Q -->|"是"| I["intset, 有序数组, 内存紧凑"]
Q -->|"否"| H["hashtable, O(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` 同理。
> [!tip] SSCAN 避免阻塞
> `SSCAN` 可以增量迭代大集合,避免单次命令阻塞 Redis——和 Hash 的 `HSCAN` 同理。大集合千万别用 `SMEMBERS` 一次全捞。
## 五、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 中**最复杂**的数据结构,代价也是最高的——每个元素存在两份索引。
### 关键命令
ZSet 是 Redis 中**最强大**的类型——每个元素都有一个分数(score),天然有序。排行榜、延迟任务、带权重的队列,都是它的主场。
```bash
# 添加(member → score)
# 添加成员和分数
ZADD leaderboard 100 "player1" 200 "player2" 150 "player3"
# 正序排名 1~5
ZRANGE leaderboard 0 4 WITHSCORES
# 倒序 Top 10
# 取 Top 10(按分数从高到低)
ZREVRANGE leaderboard 0 9 WITHSCORES
# 获取分数
ZSCORE leaderboard "player2" # 200
# 查某个玩家的排名和分数
ZREVRANK leaderboard "player3" # → 2(第 3 名)
ZSCORE leaderboard "player2" # → 200
# 获取排名(从 0 开始)
ZRANK leaderboard "player3" # 1
ZREVRANK leaderboard "player3" # 2
# 分数范围查询
ZRANGEBYSCORE leaderboard 0 150
# 移除指定范围
ZREMRANGEBYRANK leaderboard 0 2
# 按分数范围查询(Redis 6.2+ 新语法,替代 ZRANGEBYSCORE)
ZRANGE leaderboard 0 150 BYSCORE
```
### Go 示例
@@ -279,17 +238,38 @@ for _, m := range members {
}
```
### ZSet vs List 做消息队列对比
> [!warning] 命令迁移提示
> Redis 6.2 将 `ZRANGEBYSCORE` / `ZREVRANGEBYSCORE` / `ZREVRANGE` 统一合并为 `ZRANGE` 的参数形式(`BYSCORE` / `BYLEX` / `REV`)。旧命令仍可用但已弃用,新代码直接用 `ZRANGE ... BYSCORE`。
### ZSet vs List 做消息队列
| 维度 | List (BRPOP) | ZSet (BZPOPMIN) |
|------|-------------|-----------------|
| FIFO 顺序 | ✅ 保证 | ⚠️ 依赖 score |
| FIFO 顺序 | ✅ 天然保证 | ⚠️ 依赖 score 设置 |
| 延迟执行 | ❌ 不支持 | ✅ score = 执行时间戳 |
| 重排优先级 | ❌ 需重建 | ✅ 改 score 即可 |
| 重排优先级 | ❌ 需重建列表 | ✅ 改 score 即可 |
| 复杂度 | O(log(N/M)) | O(log N) |
> [!WARNING] ZSet 性能陷阱
> 不要往同一个 ZSet 里塞超过百万级别的元素——skiplist 虽然 O(log N),但实际维护成本不小。百万级可以考虑按分片拆成多个 ZSet。
> [!tip] 什么时候用 List、什么时候用 ZSet?
> 简单 FIFO 队列用 List;需要延迟执行、优先级排序、按时间范围查询的场景,用 ZSet。
### 深入:为什么需要 skiplist + hashtable 两套结构?
```mermaid
flowchart LR
Key["key: leaderboard"] --> SL["skiplist, 按 score 排序, 范围查询 O(log N)"]
Key --> HT["hashtable, 按 member 查找, O(1) 定位"]
classDef primary fill:#e8f5e9,stroke:#4caf50
classDef secondary fill:#fff3e0,stroke:#ff9800
class SL secondary
class HT secondary
```
skiplist 天然有序,擅长范围查询("给我分数在 100~200 之间的所有成员"),但按 member 名字查找要遍历;hashtable 查找 O(1),但无序。两者互补,代价是**每个元素存了两份索引**——这是 Redis 中内存开销最大的数据结构。
> [!warning] ZSet 性能陷阱
> 不要往同一个 ZSet 里塞超过百万级别的元素——skiplist 虽然 O(log N),但百万级的维护成本不小。可以按分片拆成多个 ZSet。
## 快速对照表
@@ -301,20 +281,16 @@ for _, m := range members {
| Set | intset / hashtable | 标签、去重、关系运算 | O(1) |
| ZSet | ziplist / skiplist+ht | 排行榜、延迟任务、权重 | O(log N) |
## 核心思想:内存与性能的权衡
## 一句话总结
Redis 的数据类型本质上是一套**编码策略系统**。理解这个系统的关键在于三个原则:
Redis 在底层用不同的编码策略(ziplist、intset、skiplist 等)来**自动优化内存和性能**——你不需要手动管理,但理解它能帮你预判行为、避免踩坑。
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)
> [!summary] 选型速查
> - **缓存对象** → Hash(字段多、常部分更新时)或 String(JSON 整体读写时)
> - **计数 / 锁 / Token** → String(int 编码最省内存)
> - **Feed 流 / 队列** → List(LPUSH + LTRIM 黄金搭档)
> - **标签 / 去重 / 关系运算** → Set(交集并集差集一步到位)
> - **排行榜 / 延时任务 / 带权重** → ZSet(score 是一切的基础)
## 关联笔记