From b2b947f9f45c5a104236d2edaa61593c188e9d44 Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Thu, 21 May 2026 23:50:11 +0800 Subject: [PATCH] vault backup: 2026-05-21 23:50:11 --- hhs/Redis/02-核心数据类型.md | 392 ++++++++++++++++------------------- hhs/Redis/03-基本命令速查.md | 120 ++++++++++- hhs/Redis/04-RDB持久化.md | 63 +++++- 3 files changed, 363 insertions(+), 212 deletions(-) diff --git a/hhs/Redis/02-核心数据类型.md b/hhs/Redis/02-核心数据类型.md index 55d3d27..9b8a0d4 100644 --- a/hhs/Redis/02-核心数据类型.md +++ b/hhs/Redis/02-核心数据类型.md @@ -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 是一切的基础) ## 关联笔记 diff --git a/hhs/Redis/03-基本命令速查.md b/hhs/Redis/03-基本命令速查.md index a3142bd..8e43de9 100644 --- a/hhs/Redis/03-基本命令速查.md +++ b/hhs/Redis/03-基本命令速查.md @@ -198,14 +198,16 @@ students := rdb.ZRangeByScore(ctx, "score:classA", redis.ZRangeBy{ | `FLUSHDB` | OK | O(N) | 清空当前库 | | `FLUSHALL` | OK | O(N) | 清空所有库 | | `TYPE key` | string/hash/list... | O(1) | 查看数据类型 | +| `EXISTS key [key ...]` | existing count | O(N) | 判断 key 是否存在,支持批量 | +| `OBJECT ENCODING key` | encoding name | O(1) | 查看底层编码(排查性能利器) | +| `UNLINK key [key ...]` | deleted count | O(N) | 异步删除,不阻塞主线程 ✅ | | `MOVE key db` | 1 or 0 | O(1) | 移动到其他数据库(集群不支持) | | `RENAME key newkey` | OK | O(1) | 重命名(不管类型) | | `RANDOMKEY` | key / nil | O(1) | 随机返回一个 key(集群不支持) | ```go -// 优雅删除大 key——避免 DEL 阻塞主线程 -keys := []string{"large:key1", "large:key2"} -rdb.Del(ctx, keys...).Err() // 小批量 DEL +// 优雅删除大 key——首选 UNLINK(异步删除,不阻塞主线程) +rdb.Unlink(ctx, "large:key1", "large:key2") // 或者用 SSCAN/HSCAN 分批删除字段 iter := rdb.HScan(ctx, "big:hash", 0, "", 100).Iterator() @@ -214,6 +216,9 @@ for iter.Next(ctx) { } ``` +> [!TIP] DEL vs UNLINK +> `DEL` 是同步删除,大 key(如百万元素的 Set)会阻塞主线程。`UNLINK`(4.0+)在后台线程异步释放内存,**生产环境优先用 UNLINK**。 + > [!WARNING] TTL 的陷阱 > - `TTL` 返回 `-1` 表示 key 存在但无过期时间,`-2` 表示 key 不存在 > - Redis 的过期键删除是**惰性删除 + 定期删除**混合策略,不能保证设置 EXPIRE 后立即清理 @@ -297,6 +302,115 @@ EXEC # 原子提交 > [!WARNING] Redis 事务不是"数据库事务" > Redis 事务只保证**不中断**(没有回滚),中间执行的命令如果出错,后续命令仍会继续。如果需要"出错就回滚"的行为,请使用 Lua 脚本。 +## Pub/Sub — 发布订阅 + +Pub/Sub 是 Redis 内置的消息广播模型:发布者向 channel 发消息,所有订阅者**实时**收到。注意它是 **fire-and-forget**——不持久化、不确认、离线消息会丢失。 + +| 命令 | 返回值 | 复杂度 | 说明 | +|------|--------|--------|------| +| `SUBSCRIBE channel [channel ...]` | messages | O(N) | 订阅一个或多个 channel | +| `UNSUBSCRIBE channel [channel ...]` | OK | O(N) | 取消订阅 | +| `PUBLISH channel message` | receivers count | O(N+M) | 发布消息,N=channel 订阅者数,M=pattern 匹配数 | +| `PSUBSCRIBE pattern [pattern ...]` | messages | O(N) | 按 glob 模式订阅(如 `news.*`) | +| `PUNSUBSCRIBE pattern [pattern ...]` | OK | O(N) | 取消模式订阅 | +| `PUBSUB CHANNELS [pattern]` | channels | O(N) | 列出活跃 channel | +| `PUBSUB NUMSUB [channel ...]` | channel-count pairs | O(N) | 查询各 channel 订阅者数量 | + +```go +// 订阅端——监听消息 +sub := rdb.Subscribe(ctx, "order:events") +ch := sub.Channel() +for msg := range ch { + fmt.Printf("channel=%s payload=%s\n", msg.Channel, msg.Payload) +} + +// 发布端——广播事件 +receivers, _ := rdb.Publish(ctx, "order:events", `{"orderId":"1001","status":"paid"}`).Result() +fmt.Printf("delivered to %d subscribers\n", receivers) +``` + +> [!QUESTION] Pub/Sub vs Stream,怎么选? +> - **Pub/Sub**:实时广播,不关心历史,用完即丢(适合通知、缓存失效广播) +> - **Stream**:持久化消息队列,支持消费组、ACK、回溯(适合业务事件、任务队列) +> - 简单规则:**需要"至少一次"保证就用 Stream,否则 Pub/Sub 足够** + +## Geo — 地理位置 + +Redis 3.2+ 引入的地理位置类型,底层用 **ZSet** 实现(GeoHash 编码为 score),支持范围查询和距离计算。 + +| 命令 | 返回值 | 复杂度 | 说明 | +|------|--------|--------|------| +| `GEOADD key longitude latitude member [lon lat member ...]` | added count | O(log N) | 添加地理位置 | +| `GEOPOS key member [member ...]` | coordinates | O(N) | 获取坐标 | +| `GEODIST key member1 member2 [m\|km\|ft\|mi]` | distance | O(1) | 两点间距离 | +| `GEORADIUS key lon lat radius m\|km\|ft\|mi [WITHCOORD] [WITHDIST] [ASC\|DESC] [COUNT n]` | members | O(N+log N) | 以坐标为中心搜索 ⚠️ 6.2+ 已废弃,用 GEOSEARCH | +| `GEORADIUSBYMEMBER key member radius m\|km\|ft\|mi` | members | O(N+log N) | 以成员为中心搜索 ⚠️ 6.2+ 已废弃 | +| `GEOSEARCH key FROMLONLAT lon lat BYRADIUS radius m\|km [ASC\|DESC] [COUNT n] [WITHCOORD] [WITHDIST]` | members | O(N+log N) | ✅ 6.2+ 推荐,统一搜索命令 | +| `GEOSEARCH key FROMMEMBER member BYRADIUS radius m\|km` | members | O(N+log N) | 以成员为中心搜索 | +| `GEOSEARCHSTORE dst src ...` | stored count | O(N+log N) | 搜索结果存入新 key(6.2+) | +| `GEOHASH key member [member ...]` | geohash strings | O(N) | 返回 GeoHash 编码字符串 | + +```go +// 门店入库——添加地理位置 +rdb.GeoAdd(ctx, "stores:shanghai", + &redis.GeoLocation{Name: "store-001", Longitude: 121.4737, Latitude: 31.2304}, + &redis.GeoLocation{Name: "store-002", Longitude: 121.4897, Latitude: 31.2397}, +) + +// 附近 3km 的门店——GEOSEARCH +stores, _ := rdb.GeoSearchLocation(ctx, "stores:shanghai", &redis.GeoSearchLocationQuery{ + GeoSearchQuery: redis.GeoSearchQuery{ + Longitude: 121.4800, + Latitude: 31.2350, + Radius: 3, + RadiusUnit: "km", + Sort: "ASC", + Count: 10, + }, + WithCoord: true, + WithDist: true, +}).Result() +``` + +> [!TIP] GEO 底层就是 ZSet +> 可以用 `ZRANGE`、`ZREM` 等 ZSet 命令操作 Geo key。`GEODIST` 计算的是球面距离(Haversine 公式),精度在 ~0.5% 以内。 + +## Lua 脚本速查 + +Redis 2.6+ 内置 Lua 解释器,脚本在服务端**原子执行**,是实现 CAS、限流器、排行榜原子操作的利器。 + +| 命令 | 返回值 | 说明 | +|------|--------|------| +| `EVAL "script" numkeys key [key ...] arg [arg ...]` | script result | 执行 Lua 脚本 | +| `EVALSHA sha1 numkeys key [key ...] arg [arg ...]` | script result | 按 SHA1 执行(脚本已缓存) | +| `SCRIPT LOAD "script"` | SHA1 | 缓存脚本,返回 SHA1 | +| `SCRIPT EXISTS sha1 [sha1 ...]` | 1 or 0 array | 检查脚本是否已缓存 | +| `SCRIPT FLUSH` | OK | 清空脚本缓存 | +| `SCRIPT KILL` | OK | 终止正在执行的脚本(只限无写操作时) | + +```go +// 原子扣减库存——Lua 实现 check-and-set +script := redis.NewScript(` + local stock = tonumber(redis.call('GET', KEYS[1])) + if stock and stock >= tonumber(ARGV[1]) then + redis.call('DECRBY', KEYS[1], ARGV[1]) + return 1 + end + return 0 +`) +result, _ := script.Run(ctx, rdb, []string{"stock:sku:1001"}, 1).Int() +// result == 1 表示扣减成功,0 表示库存不足 +``` + +> [!WARNING] Lua 脚本的限制 +> - 脚本执行期间**阻塞整个 Redis**,严禁写耗时逻辑(循环上限建议 < 5000 次迭代) +> - 脚本中不能访问外部网络或文件系统 +> - 集群模式下所有 KEYS 必须在同一个 slot(用 `{hash_tag}` 保证) +> - `SCRIPT KILL` 只能终止**未执行写操作**的脚本;已写数据的脚本只能等它跑完或重启 Redis + +> [!NOTE] EVAL vs EVALSHA +> `EVAL` 每次发送完整脚本源码,`EVALSHA` 只发送 40 字节的 SHA1 摘要。高并发场景**务必用 EVALSHA + SCRIPT LOAD**,减少网络开销。go-redis 的 `NewScript` 会自动处理 EVALSHA 失败后回退 EVAL。 + ## 关联笔记 - [[hhs/Redis/02-核心数据类型]] — 每种类型的底层编码原理 diff --git a/hhs/Redis/04-RDB持久化.md b/hhs/Redis/04-RDB持久化.md index fe7a078..f9edd7b 100644 --- a/hhs/Redis/04-RDB持久化.md +++ b/hhs/Redis/04-RDB持久化.md @@ -107,6 +107,34 @@ flowchart TD E1 & E2 ==> "Footer" ``` +### 各段含义 + +| 段落 | 内容 | 说明 | +|------|------|------| +| **Header** | Magic + Version + 校验 | `REDIS` 魔数标识文件类型;版本号用于兼容性判断;CRC64 用于完整性校验 | +| **DB Selector** | 数据库编号 | 默认 16 个 DB(0-15),只序列化非空 DB | +| **Key-Value Pairs** | 类型 + 编码 + 数据 | 每个 key 携带类型标记(String/List/Hash/ZSet…),底层编码直接序列化,因此文件极紧凑 | +| **EOF** | `0xFF` 终止符 | 标识数据段结束 | +| **Footer** | CRC64 校验和 | 8 字节,启动加载时校验文件完整性 | + +> [!QUESTION] 为什么 RDB 文件比 AOF 小很多? +> 因为 RDB 直接序列化底层数据结构(如 ziplist、intset),而非记录产生这些数据的命令字符串。一个 Hash 用 ziplist 存储可能只有几十字节,但生成它的 HSET 命令可能有数百字节。 + +## 关键配置参数 + +```conf +# ---- RDB 核心参数 ---- +dbfilename dump.rdb # 快照文件名 +dir /var/lib/redis # 工作目录,RDB/AOF 文件存放位置 +rdbcompression yes # 是否使用 LZF 压缩字符串(推荐开启,CPU vs 磁盘权衡) +rdbchecksum yes # 是否在文件末尾写入 CRC64 校验(推荐开启) +rdb-save-incremental-fsync yes # 写入磁盘时增量 fsync,避免一次性大量 I/O 阻塞 +rdb-del-sync-files no # 无盘复制时,是否删除用于同步的临时 RDB(Redis 7+) +``` + +> [!TIP] rdbcompression 的取舍 +> 开启压缩可以显著减少磁盘占用(通常压缩 40%-60%),代价是写入时多消耗一点 CPU。对于绝大多数场景,**保持 yes** 是最优解。只有在 CPU 极度敏感、磁盘充裕的特殊场景下才考虑关闭。 + ## Fork 过程详解 ```mermaid @@ -132,6 +160,26 @@ sequenceDiagram > [!WARNING] COW 内存消耗 > fork 后主进程若持续写入大值,会导致 COW 副本膨胀。例如你有一个 1GB 的值被频繁修改,fork 后每次写都可能多拷贝一页(4KB)。高峰期建议关闭自动 RDB,仅依赖 AOF。 +### 深入理解 COW(Copy-On-Write) + +fork 后父子进程共享同一份物理内存页(只读),只有当某一方**写入**时,内核才会真正复制该页——这就是"写时复制"。 + +```mermaid +flowchart TD + F["Fork 完成"] --> S["父子进程共享
所有物理内存页"] + S --> W{"主进程收到
写请求?"} + W -->|"否"| R["子进程继续读取
(零拷贝, 极快)"] + W -->|"是"| C["内核复制该内存页
(Copy-On-Write)"] + C --> N["主进程写入副本
子进程仍读原页"] + R & N --> D["子进程完成序列化"] + D --> DONE["RDB 文件生成"] +``` + +> [!QUESTION] 为什么 Redis 占用内存会翻倍? +> 这是面试高频题。答案是:**不会翻倍,但最坏情况接近翻倍。** fork 本身不复制内存,只有**主进程实际写入的页面**才会被 COW。如果 BGSAVE 期间主进程几乎不写入,额外内存开销几乎为零。但若全量写入,就会逐步逼近 2 倍。 +> +> **运维经验**:保持系统 `vm.overcommit_memory=1`(Linux 默认),否则 fork 可能因内核认为内存不足而失败。 + ### TTL / 过期键处理 > [!QUESTION] RDB 快照会保存已过期的 key 吗? @@ -206,6 +254,19 @@ flowchart LR > [!TIP] 混合持久化几乎是无脑开启的配置——启动快 + 不丢数据。 +## 最佳实践 + +> [!SUMMARY] 核心原则:RDB 是备份手段,不是实时数据保障 + +1. **不要只依赖 RDB**:生产环境推荐 `AOF + RDB` 混合持久化,AOF 保证数据安全性,RDB 保证快速恢复 +2. **合理设置触发频率**:默认三档(900s/300s/60s)适合大部分场景;写入密集型业务可适当提高频率,但注意 fork 开销 +3. **监控 fork 耗时**:`INFO persistence` 中的 `rdb_last_bgsave_time_sec`,超过 1s 就需要关注 +4. **备份策略**:定期将 `.rdb` 文件拷贝到异地存储(S3/OSS),建议保留多个历史版本 +5. **磁盘选择**:RDB 文件写入对磁盘 I/O 有要求,建议使用 SSD 并配合 `rdb-save-incremental-fsync` + +> [!WARNING] 大 Key 是 RDB 的天敌 +> 一个 100MB 的 String key,序列化时会导致子进程写入耗时剧增,同时 COW 也可能产生大量页面复制。发现大 Key 后应优先拆分。 + ## 运维要点 ```bash @@ -225,4 +286,4 @@ tar czf redis-backup.tar.gz dump.rdb - [[hhs/Redis/05-AOF持久化]] — AOF 持久化机制 - [[hhs/Redis/06-主从与哨兵]] — Sentinel 故障转移流程 -- [[hhs/Redis/09-运维调优]] — 备份策略与大 Key 治理 +- [[hhs/Redis/11-运维与性能调优]] — 备份策略与大 Key 治理