300 lines
12 KiB
Markdown
300 lines
12 KiB
Markdown
|
|
---
|
|||
|
|
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` 阻塞)。
|
|||
|
|
|
|||
|
|
我们先从最简单的 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`:默认不压缩首尾节点(方便头部操作)
|
|||
|
|
|
|||
|
|
## 四、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
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> [!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 是一切的基础)
|
|||
|
|
|
|||
|
|
## 关联笔记
|
|||
|
|
|
|||
|
|
- [[hhs/Redis/08-SortedSet精解]] — ZSet 的高级玩法
|
|||
|
|
- [[hhs/Redis/03-基本命令速查]] — 常用命令速查表
|
|||
|
|
- [[hhs/Redis/07-集群方案]] — 集群环境下的大 Key 风险
|