Files
cs-note/hhs/Redis/02-核心数据类型.md
T
2026-05-25 20:51:57 +08:00

350 lines
16 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
author: hhs
---
# Redis 核心数据类型与底层编码
## 概述
> [!tip] 先别急着背编码,想想你在业务里会怎么用
> 你用 Redis,第一件事不是去记 `ziplist` 和 `hashtable` 的区别——而是搞清楚:**什么时候该用哪种数据类型?**
Redis 提供五种基础数据类型,用一句话总结各自的核心价值:
| 类型 | 一句话定位 | 最典型的场景 |
|------|-----------|-------------|
| **String** | 万能小抽屉 | 缓存、计数器、分布式锁 |
| **Hash** | 一个对象的多个字段 | 用户信息、配置项 |
| **List** | 有序队列 | 消息队列、最新动态 |
| **Set** | 不重复的袋子 | 标签、点赞、共同好友 |
| **ZSet** | 带权重的排行榜 | 排行榜、延迟任务 |
> [!question] 那"底层编码"又是什么?
> 你在业务层只管用 `String`、`Hash` 这些类型——但 Redis 在底层会**自动选择最省内存的存储方式**。比如同样是 `String`,存数字 `42` 只占几个字节,存一段长 JSON 可能几百字节。这就是"编码"在起作用。
>
> 了解编码不是为了手动管理它,而是帮你**预判性能和内存消耗**,避免踩坑(比如超大 Hash 的 `HGETALL` 阻塞)。
### 前置概念:底层编码结构速览
> [!tip] 这一节是"词典",不用现在全记住
> 下面六个概念会在后续各节的「深入」部分反复出现。先混个脸熟,遇到时回来查即可。
| 结构 | 一句话定义 | 用在哪里 |
|------|-----------|---------|
| **ziplist** | 连续内存的紧凑列表,省空间但查找 O(N) | Hash / List(quicklist 节点) / ZSet(小数据量) |
| **listpack** | ziplist 的继任者,去掉了 prevlen 彻底解决级联更新 | Redis 7.2+ 替代 ziplist |
| **hashtable** | 标准哈希表,查找 O(1) | Hash / Set / ZSet 的编码之一 |
| **intset** | 有序整数数组,纯整数集合时使用 | Set(全为整数时) |
| **quicklist** | 双向链表,每个节点是一个 ziplist/listpack | List 的统一编码 |
| **skiplist** | 多层索引的有序链表,范围查询 O(logN) | ZSet(大数据量时) |
> 想深入了解? [[02-核心数据类型/02-1-ziplist与listpack|ziplist 与 listpack]] · [[02-核心数据类型/02-2-skiplist|skiplist]] · [[02-核心数据类型/02-3-hashtable|hashtable]] · [[02-核心数据类型/02-4-quicklist|quicklist]]
我们先从最简单的 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) | 短文本、小型 JSON |
| `raw` | > 44 字节 | 大对象 JSON / Base64 |
> [!question] embstr 和 raw 有什么区别?
> 简单理解:`embstr` 是"打包好的快递"——key 和 value 的内存**连在一起分配**,创建销毁都只要一次操作,效率高。`raw` 是"分开打包"——支持值变大后扩容。超过 44 字节时,`embstr` 无法原地扩展,只能切换成 `raw`。
>
> 你不需要手动管理这些——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"] -->|"entries > 512 or value >= 64B"| B["hashtable"]
style A fill:#e1f5fe
style B fill:#fff3e0
```
阈值由两个独立配置控制(注意是**两个参数**,不是一个):
- `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)性能问题。
### String vs Hash:怎么选?
| 维度 | String(JSON) | Hash(字段级) |
|------|----------------|----------------|
| 读取 | ✅ `GET user:1` 一行搞定 | `HGETALL user:1` 需合并 |
| 局部更新 | ❌ 读 → 改 → 写整个 JSON | ✅ `HSET user:1 email b@x.com` |
| 内存占用 | 较高(序列化开销) | 较低(ziplist 省 ~40%) |
| 适用场景 | 小数据、读多写少 | 中等大小、频繁部分更新 |
> [!caution] 警惕大 Hash
> `HGETALL` 在 hashtable 编码下是 O(N),字段数很大时会阻塞 Redis。超过几千个字段,考虑拆成多个 key 或改回 String(JSON)。
## 三、List(列表)
List 就是**有序的队列**——从一头进,从另一头出。
```go
// 发布新消息到 Feed 流(从左边插入最新消息)
rdb.LPush(ctx, "news-feed", jsonMessage)
// 修剪到最近 1000 条,防止列表无限增长
rdb.LTrim(ctx, "news-feed", 0, 999)
// 取出最近 10 条展示给用户
items, _ := rdb.LRange(ctx, "news-feed", 0, 9).Result()
```
> [!question] LPUSH + LTRIM 为什么是黄金搭档?
> `LPUSH` 总是把最新消息插到最前面,`LTRIM` 把超出的旧消息砍掉。两者配合就是一个**固定长度的环形缓冲区**——Feed 流、日志轮转的标配模式。
### 典型场景
| 场景 | 命令 | 说明 |
|------|------|------|
| 消息队列 | `LPUSH` + `BRPOP` | 单消费者阻塞式消费 |
| 最新 N 条记录 | `LPUSH` + `LTRIM` + `LRANGE` | Feed 流、操作日志 |
| 延迟队列 | 用 ZSet 更合适 | List 本身不支持按时间排序 |
### 深入:quicklist 结构
> [!question] 为什么不用普通链表?
> 普通双向链表每个节点都有 16 字节的指针开销。quicklist 把多个元素**打包进一个紧凑块**(ziplist),大幅减少指针浪费——就像把散装零食装进密封袋,省空间又好管理。
```mermaid
flowchart LR
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`:默认不压缩首尾节点(方便头部操作)
> 想深入了解? → [[02-核心数据类型/02-4-quicklist|quicklist 结构详解]]
## 四、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**(整数集合)存储。它的本质是一块**连续的、有序的整数数组**,支持二分查找(O(logN)),内存极其紧凑——没有指针、没有哈希桶,每个元素只占它自身的字节数。
> [!question] intset 比 hashtable 省多少?
> 存 1000 个整数:hashtable 每个 `dictEntry` 至少 24 字节(key 指针 + value 指针 + next),总计 ~24KB+。intset 用 `int32` 存储只需 4KB——**省了 80%**。
**intset 内存布局**:
```mermaid
flowchart LR
EN["encoding, 4B, 16/32/64 位"] --> LN["length, 4B, 元素个数"]
LN --> C1["contents, 有序排列的整数数组"]
classDef header fill:#e1f5fe,stroke:#2196f3
classDef data fill:#fff3e0,stroke:#ff9800
class EN,LN header
class C1 data
```
**自动升级机制**:当插入一个比当前编码更大的整数时,intset 会**升级**——把所有元素扩展到更宽的类型(如 `int16` → `int32`),然后插入新元素。这是单向的,**不会自动降级**。
| 配置项 | 默认值 | 说明 |
|--------|--------|------|
| `set-max-intset-entries` | 512 | 元素数超过 512 → 切换为 hashtable |
> [!caution] intset 一旦遇到非整数就"永久"切换
> 加入一个字符串后,整个 Set 切成 hashtable,即使后来删掉了那个字符串,**也不会切回 intset**。所以如果你的 Set 明确只存整数(如用户 ID 集合),别手滑加字符串。
```mermaid
flowchart LR
E["Set 添加元素"] --> Q{"全部是整数?"}
Q -->|"是"| Q2{"元素数 <= 512?"}
Q2 -->|"是"| I["intset, 有序数组, 内存极紧凑"]
Q2 -->|"否"| H["hashtable, O(1) 查找"]
Q -->|"否"| H
classDef encoding fill:#e1f5fe,stroke:#2196f3
classDef encoding2 fill:#fff3e0,stroke:#ff9800
classDef check fill:#e8f5e9,stroke:#4caf50
class I encoding
class H encoding2
class Q,Q2 check
```
> [!tip] SSCAN 避免阻塞
> `SSCAN` 可以增量迭代大集合,避免单次命令阻塞 Redis——和 Hash 的 `HSCAN` 同理。大集合千万别用 `SMEMBERS` 一次全捞。
## 五、ZSet / Sorted Set(有序集合)
ZSet 是 Redis 中**最强大**的类型——每个元素都有一个分数(score),天然有序。排行榜、延迟任务、带权重的队列,都是它的主场。
```bash
# 添加成员和分数
ZADD leaderboard 100 "player1" 200 "player2" 150 "player3"
# 取 Top 10(按分数从高到低)
ZREVRANGE leaderboard 0 9 WITHSCORES
# 查某个玩家的排名和分数
ZREVRANK leaderboard "player3" # → 2(第 3 名)
ZSCORE leaderboard "player2" # → 200
# 按分数范围查询(Redis 6.2+ 新语法,替代 ZRANGEBYSCORE)
ZRANGE leaderboard 0 150 BYSCORE
```
### 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)
}
```
> [!warning] 命令迁移提示
> Redis 6.2 将 `ZRANGEBYSCORE` / `ZREVRANGEBYSCORE` / `ZREVRANGE` 统一合并为 `ZRANGE` 的参数形式(`BYSCORE` / `BYLEX` / `REV`)。旧命令仍可用但已弃用,新代码直接用 `ZRANGE ... BYSCORE`。
### ZSet vs List 做消息队列
| 维度 | List (BRPOP) | ZSet (BZPOPMIN) |
|------|-------------|-----------------|
| FIFO 顺序 | ✅ 天然保证 | ⚠️ 依赖 score 设置 |
| 延迟执行 | ❌ 不支持 | ✅ score = 执行时间戳 |
| 重排优先级 | ❌ 需重建列表 | ✅ 改 score 即可 |
| 复杂度 | O(log(N/M)) | O(log N) |
> [!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。
## 快速对照表
| 数据类型 | 编码 | 最佳场景 | 时间复杂度 |
|---------|------|---------|-----------|
| 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、skiplist 等)来**自动优化内存和性能**——你不需要手动管理,但理解它能帮你预判行为、避免踩坑。
> [!summary] 选型速查
> - **缓存对象** → Hash(字段多、常部分更新时)或 String(JSON 整体读写时)
> - **计数 / 锁 / Token** → String(int 编码最省内存)
> - **Feed 流 / 队列** → List(LPUSH + LTRIM 黄金搭档)
> - **标签 / 去重 / 关系运算** → Set(交集并集差集一步到位)
> - **排行榜 / 延时任务 / 带权重** → ZSet(score 是一切的基础)
## 关联笔记
- [[02-核心数据类型/02-1-ziplist与listpack]] — 底层编码:ziplist 内存布局与级联更新
- [[02-核心数据类型/02-2-skiplist]] — 底层编码:skiplist 原理与 ZSet 双索引设计
- [[02-核心数据类型/02-3-hashtable]] — 底层编码:dict 结构与渐进式 rehash
- [[02-核心数据类型/02-4-quicklist]] — 底层编码:quicklist 结构与 LZF 压缩
- [[hhs/Redis/08-SortedSet精解]] — ZSet 的高级玩法
- [[hhs/Redis/03-基本命令速查]] — 常用命令速查表
- [[hhs/Redis/07-集群方案]] — 集群环境下的大 Key 风险