diff --git a/hhs/Redis/01-安装与部署.md b/hhs/Redis/01-安装与部署.md
index d29cd8e..8ac41c9 100644
--- a/hhs/Redis/01-安装与部署.md
+++ b/hhs/Redis/01-安装与部署.md
@@ -66,6 +66,8 @@ redis-server --version # 确认版本
### Docker(推荐本地开发)
+将以下内容保存为 `docker-compose.yml`:
+
```yaml
version: "3.9"
services:
@@ -88,16 +90,40 @@ volumes:
redis-data:
```
+**启动、验证、连接一气呵成:**
+
+```bash
+# 1. 在 docker-compose.yml 同目录下启动(-d 后台运行)
+docker compose up -d
+
+# 2. 查看容器状态和端口映射
+docker compose ps
+# NAME STATUS PORTS
+# redis-dev Up 30s 0.0.0.0:6379->6379/tcp
+
+# 3. 通过容器内 redis-cli 验证连通性
+docker exec redis-dev redis-cli -a changeme ping
+# 输出: PONG
+
+# 4. 本地 redis-cli 连接(如果本机已安装)
+redis-cli -h 127.0.0.1 -p 6379 -a changeme
+```
+
> [!TIP]
-> - 通过 `${REDIS_PASSWORD:-changeme}` 引用环境变量,避免在仓库中硬编码真实密码。
+> - 通过 `${REDIS_PASSWORD:-changeme}` 引用环境变量,避免在仓库中硬编码真实密码。启动时可 `REDIS_PASSWORD=my-secret docker compose up -d` 覆盖默认值。
> - 开发环境建议限制 `maxmemory`,防止占满本机内存。
> - 生产环境不要使用 `redis:latest` 标签,锁定具体版本号以避免未知变更。
-> [!EXAMPLE] 如何快速连接带密码的 Redis?
+> [!QUESTION] 为什么用 `docker exec` 而不是直接 `redis-cli`?
+>
+> `docker exec` 不依赖本机是否安装了 redis-cli,任何装了 Docker 的机器都能直接执行——在 CI/CD 或同事的机器上尤其方便。而本机 `redis-cli` 的好处是支持命令历史补全和更丰富的交互体验。
+
+> [!EXAMPLE] 常用运维命令速查
> ```bash
-> export REDIS_PASSWORD="your-strong-pass"
-> redis-cli -a "$REDIS_PASSWORD" ping
-> # 输出: PONG
+> docker compose logs -f redis # 实时查看日志
+> docker compose stop redis # 停止容器(保留数据卷)
+> docker compose down -v # 停止并删除数据卷(慎用,清空数据)
+> docker exec redis-dev redis-cli -a changeme INFO memory # 查看内存使用
> ```
## 关键配置项
@@ -183,10 +209,10 @@ sysctl -p # 立即生效
```mermaid
flowchart LR
- A[客户端连接请求] -->|SYN| B[TCP 半连接队列\nsomaxconn]
- B -->|三次握手完成| C[TCP 全连接队列\ntcp-backlog]
- C --> D[Redis accept()]
- D --> E[分叉持久化\novercommit_memory]
+ A[客户端连接请求] -->|SYN| B["TCP 半连接队列
somaxconn"]
+ B -->|三次握手完成| C["TCP 全连接队列
tcp-backlog"]
+ C --> D[Redis accept]
+ D --> E["分叉持久化
overcommit_memory"]
style A fill:#e1f5fe
style C fill:#fff3e0
diff --git a/hhs/Redis/02-核心数据类型.md b/hhs/Redis/02-核心数据类型.md
index 9b8a0d4..42fc338 100644
--- a/hhs/Redis/02-核心数据类型.md
+++ b/hhs/Redis/02-核心数据类型.md
@@ -26,6 +26,22 @@ Redis 提供五种基础数据类型,用一句话总结各自的核心价值
>
> 了解编码不是为了手动管理它,而是帮你**预判性能和内存消耗**,避免踩坑(比如超大 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(字符串)
@@ -155,6 +171,8 @@ List 统一使用 **quicklist**,每个节点是一个 ziplist:
- `list-max-ziplist-size 5`:默认每个节点最多 4096 个元素
- `list-compress-depth 0`:默认不压缩首尾节点(方便头部操作)
+> 想深入了解? → [[02-核心数据类型/02-4-quicklist|quicklist 结构详解]]
+
## 四、Set(集合)
Set 就是**不重复的集合**——同一个元素加多少次都只保留一份。它的杀手锏是**集合运算**:交集、并集、差集。
@@ -186,18 +204,46 @@ SUNION tags:post1 tags:post2 # → go golang redis docker — 所有标签
### 深入:intset 编码
-当 Set 里全是整数时,Redis 会用 **intset**(有序整数数组)存储,内存极其紧凑。一旦加入非整数元素,自动切换为 **hashtable**。
+当 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 -->|"是"| I["intset, 有序数组, 内存紧凑"]
- Q -->|"否"| H["hashtable, O(1) 查找"]
+ 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 避免阻塞
@@ -294,6 +340,10 @@ Redis 在底层用不同的编码策略(ziplist、intset、skiplist 等)来*
## 关联笔记
+- [[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 风险
diff --git a/hhs/Redis/02-核心数据类型/02-1-ziplist与listpack.md b/hhs/Redis/02-核心数据类型/02-1-ziplist与listpack.md
new file mode 100644
index 0000000..23d09a1
--- /dev/null
+++ b/hhs/Redis/02-核心数据类型/02-1-ziplist与listpack.md
@@ -0,0 +1,240 @@
+---
+tags: [Redis, 底层数据结构, ziplist, listpack]
+create time: 2026-05-25 10:30
+author: hhs
+---
+
+# ziplist 与 listpack
+
+## 概述
+
+ziplist 是 Redis 为**节省内存**设计的一种紧凑型线性结构——把多个元素连续排列在一块内存里,省掉链表的指针开销。它是 Hash、List(quicklist 节点)、ZSet 在**小数据量**时的默认编码。
+
+但 ziplist 有一个臭名昭著的缺陷:**级联更新(cascade update)**。Redis 7.2 引入 listpack 彻底解决了这个问题。
+
+## 一、ziplist 的内存布局
+
+> [!question] 为什么要连续内存?
+> 传统链表每个节点独立分配内存,额外的 `next/prev` 指针各占 8 字节(64 位系统)。存 100 个短字符串,指针开销可能比数据本身还大。ziplist 的思路是:**把所有数据塞进一块连续内存,用紧凑的 header 描述每个元素的位置**。
+
+一个 ziplist 在内存中长这样:
+
+```mermaid
+flowchart LR
+ ZB["zlbytes, 4B, 整体字节数"] --> ZT["zltail, 4B, 尾节点偏移"]
+ ZT --> ZL["zllen, 2B, 节点数量"]
+ ZL --> E1["entry 1"]
+ E1 --> E2["entry 2"]
+ E2 --> EN["entry N"]
+ EN --> ZE["zlend, 0xFF, 结束标记"]
+
+ classDef header fill:#e1f5fe,stroke:#2196f3
+ classDef entry fill:#fff3e0,stroke:#ff9800
+ classDef tail fill:#fce4ec,stroke:#e91e63
+ class ZB,ZT,ZL header
+ class E1,E2,EN entry
+ class ZE tail
+```
+
+| 字段 | 大小 | 作用 |
+|------|------|------|
+| `zlbytes` | 4 字节 | 整个 ziplist 占用的总字节数(含自身) |
+| `zltail` | 4 字节 | 最后一个 entry 的偏移量(支持从尾部快速遍历) |
+| `zllen` | 2 字节 | entry 节点数量(超过 65535 时需遍历计数) |
+| `entry` | 变长 | 实际数据,逐个紧密排列 |
+| `zlend` | 1 字节 | 固定值 `0xFF`,标记结束 |
+
+## 二、entry 的内部结构
+
+> [!question] 既然没有指针,怎么知道每个 entry 的边界?
+> 答案是:每个 entry 用 `prevlen` + `encoding` 告诉 Redis "上一个元素多长"和"我自己是什么类型、多长"。解析时从头往后依次读,就知道每个 entry 在哪里。
+
+每个 entry 由三部分组成:
+
+```mermaid
+flowchart LR
+ PL["prevlen, 1 或 5B"] --> EN["encoding, 1, 2 或 5B"]
+ EN --> DA["data, 实际内容"]
+
+ classDef field fill:#e8f5e9,stroke:#4caf50
+ class PL,EN,DA field
+```
+
+| 字段 | 说明 |
+|------|------|
+| `prevlen` | 前一个 entry 的长度。≤ 253 字节用 1 字节存;> 253 字节用 5 字节存(第 1 字节标记 `0xFE`,后 4 字节存长度) |
+| `encoding` | 标识 data 的类型和长度——整数还是字符串?多长? |
+| `data` | 实际存储的内容 |
+
+### encoding 的 bit 编码
+
+> [!question] encoding 字段是怎么用 1~5 个字节同时表达"类型"和"长度"的?
+> 核心思路:**用高位 bit 组合区分类型**,剩余 bit 存长度或数值。这让 Redis 解析时只需读第一个字节就能判断后续格式。
+
+**字符串类型**——高 2 位为 `00`/`01`/`10`:
+
+| 编码格式 | 长度 bit 数 | 最大长度 |
+|---------|------------|---------|
+| `00xxxxxx` | 6 bit | 63 字节 |
+| `01xxxxxx xxxxxxxx` | 14 bit | 16383 字节 |
+| `10xxxxxx` + 4 字节 | 32 bit | 2³²-1 字节 |
+
+**整数类型**——高 4 位为 `11xx`:
+
+| 编码 | 含义 |
+|------|------|
+| `11000000` | int16,后跟 2 字节有符号整数 |
+| `11010000` | int32,后跟 4 字节有符号整数 |
+| `11010001` | int64,后跟 8 字节有符号整数 |
+| `11110001` | int24,后跟 3 字节有符号整数 |
+| `1111xxxx` (0001~1101) | 立即数 0~12,**data 部分为空** |
+
+> [!tip] 为什么存整数不直接用字符串?
+> 把 `"12345"` 当字符串存需要 5 字节 + encoding;转成 int16 只要 2 字节 + 1 字节 encoding。小整数的紧凑编码是 ziplist 省内存的另一大功臣。
+
+> [!warning] prevlen 是级联更新的根源
+> 注意到没有?`prevlen` 只有 **1 字节**(≤ 253)或 **5 字节**(> 253)两种取值。当一个 entry 从 ≤ 253 变成 > 253 时,它的下一个 entry 的 `prevlen` 必须从 1 字节扩展到 5 字节——**而这可能导致更后面的 entry 也跟着扩展**,形成连锁反应。
+
+## 三、级联更新(Cascade Update)
+
+这是 ziplist 最大的性能隐患。我们用一个例子说明:
+
+### 触发条件
+
+1. 插入一个新 entry(或现有 entry 增长),导致下一个 entry 的 `prevlen` 从 1 字节扩展到 5 字节
+2. 这 4 字节的膨胀可能让该 entry 总长超过 253,从而导致**再下一个** entry 的 `prevlen` 也要扩展
+3. 如此链式传播,最坏情况下**所有后续 entry 都要重新分配**
+
+### 最坏情况复杂度
+
+> [!danger] O(N²) 的代价
+> 假设 ziplist 有 N 个 entry,每个都刚好在 253 字节边界。一次插入触发 N 次 `memmove`,每次 O(N)——总计 O(N²)。
+>
+> 实际中很少触发最坏情况(需要连续的 253 边界 entry),但只要 ziplist 够大,风险就不可忽视。这就是为什么 Redis 给 ziplist 设了阈值——超过就切到更安全的结构。
+
+### 代码验证
+
+```go
+rdb.Del(ctx, "test:ziplist")
+
+// 1. 小 Hash → ziplist/listpack 编码,内存紧凑
+for i := 0; i < 100; i++ {
+ rdb.HSet(ctx, "test:ziplist", fmt.Sprintf("f%d", i), "short")
+}
+enc, _ := rdb.ObjectEncoding(ctx, "test:ziplist").Result()
+mem1, _ := rdb.MemoryUsage(ctx, "test:ziplist").Result()
+fmt.Printf("编码: %s, 内存: %d bytes\n", enc, mem1)
+// Redis 6: encoding=ziplist, Redis 7+: encoding=listpack
+
+// 2. 加入一个超长 value → 触发编码切换到 hashtable
+rdb.HSet(ctx, "test:ziplist", "big_field", strings.Repeat("x", 65))
+enc2, _ := rdb.ObjectEncoding(ctx, "test:ziplist").Result()
+mem2, _ := rdb.MemoryUsage(ctx, "test:ziplist").Result()
+fmt.Printf("编码: %s, 内存: %d bytes\n", enc2, mem2)
+// enc2=hashtable, 内存明显增大——65 > hash-max-ziplist-value(64)
+```
+
+> [!question] 如何观察级联更新的延迟影响?
+> 级联更新是内部实现,客户端无法直接观察。但你可以间接感知:当 ziplist 编码的 Hash/ZSet 接近阈值时,`HSET` / `ZADD` 的 **p99 延迟**可能出现毛刺——因为某次写入恰好触发了多级 `prevlen` 扩展。这也是 Redis 设置阈值提前切换的原因之一。
+>
+> 在 Redis 7+ 使用 listpack 后,这类毛刺基本消失。
+
+## 四、listpack:ziplist 的继任者
+
+> [!question] 如果没有 prevlen,怎么知道前一个 entry 的边界?
+> listpack 的答案是:**不需要知道前一个 entry 的长度**。每个 entry 末尾存一个 `element-total-len`(简称 backlen),记录**自己**的总长度。正向遍历时,读完当前 entry 的 encoding + data,再读 backlen 跳到下一个 entry;**修改任意 entry 只影响它自己的 backlen,不波及邻居**。
+
+### listpack 的整体布局
+
+```mermaid
+flowchart LR
+ LT["total-bytes, 4B"] --> LN["num-elements, 2B"]
+ LN --> E1["entry 1"]
+ E1 --> E2["entry 2"]
+ E2 --> EN["entry N"]
+ EN --> EOL["EOF, 0xFF"]
+
+ classDef header fill:#e1f5fe,stroke:#2196f3
+ classDef entry fill:#e8f5e9,stroke:#4caf50
+ classDef tail fill:#fce4ec,stroke:#e91e63
+ class LT,LN header
+ class E1,E2,EN entry
+ class EOL tail
+```
+
+和 ziplist 的整体结构几乎一致(`total-bytes` / `num-elements` / `EOF`),差别全在 entry 内部。
+
+### entry 内部:backlen 的变长编码
+
+```mermaid
+flowchart LR
+ EN["encoding"] --> DA["data"]
+ DA --> BL["backlen, 1~5B, 自己的总长度"]
+
+ classDef field fill:#e8f5e9,stroke:#4caf50
+ class EN,DA,BL field
+```
+
+`backlen` 采用类似 protobuf varint 的变长编码——**高位 bit 为 1 表示后续字节仍属于 backlen**,为 0 则结束。大多数 entry 总长 ≤ 127 字节,backlen 只需 1 字节,和 ziplist 的 1 字节 `prevlen` 一样紧凑。
+
+> [!tip] backlen 为什么能消灭级联更新?
+> ziplist 中 A 增长 → B 的 `prevlen` 膨胀 → B 总长变化 → C 的 `prevlen` 也得改……链式传播。
+>
+> listpack 中 A 增长 → A 的 `backlen` 可能变大,但 B 的 `backlen` **只记录 B 自己的长度,和 A 无关**。B 不需要做任何修改,C 也不需要。传播链**在 A 处就断了**。
+
+Redis 7.0+,Hash、ZSet、Stream、List(quicklist 节点)的紧凑编码全部切换为 listpack。
+
+### 核心区别
+
+```mermaid
+flowchart TB
+ subgraph ziplist_entry["ziplist entry"]
+ direction LR
+ ZP["prevlen, 记录前一个 entry 长度"]
+ ZE["encoding"]
+ ZD["data"]
+ end
+ subgraph listpack_entry["listpack entry"]
+ direction LR
+ LP["encoding"]
+ LD["data"]
+ LE["element-total-len, 记录自己总长"]
+ end
+
+ classDef old fill:#ffebee,stroke:#f44336
+ classDef new fill:#e8f5e9,stroke:#4caf50
+ class ZP,ZE,ZD old
+ class LP,LD,LE new
+```
+
+| 对比 | ziplist | listpack |
+|------|---------|----------|
+| entry 间依赖 | `prevlen` 引用前一个 entry | 无,各 entry 独立 |
+| 级联更新 | 会触发 | **不会触发** |
+| 空间开销 | `prevlen` 1~5 字节 | `element-total-len` 1~5 字节(相当) |
+| 倒序遍历 | 依赖 `prevlen` 逐个跳 | 从尾部向前,读 `element-total-len` 跳 |
+| 引入版本 | Redis 早期 | Redis 3.2 (实验), 7.2 (正式替代) |
+
+> [!tip] 你现在不需要关心版本差异
+> 理解"listpack = 无级联更新的 ziplist"就够了。Redis 的配置参数名(如 `hash-max-ziplist-entries`)在 7.2+ 仍然生效,只是底层实现换成了 listpack。
+
+## 五、为什么 Redis 还保留阈值切换?
+
+> [!insight] 紧凑结构不是万能的
+> ziplist/listpack 的查找是 O(N)——要从头遍历到目标 entry。当元素少时,这块连续内存对 CPU cache 非常友好,遍历也很快。但元素一多,O(N) 的代价就压不住了,必须切到 hashtable(O(1))或 skiplist(O(logN))。
+
+各类型的切换阈值:
+
+| 数据类型 | 配置项 | 默认值 | 切换到 |
+|---------|--------|--------|-------|
+| Hash | `hash-max-ziplist-entries` | 512 | hashtable |
+| Hash | `hash-max-ziplist-value` | 64 字节 | hashtable |
+| ZSet | `zset-max-ziplist-entries` | 128 | skiplist+hashtable |
+| ZSet | `zset-max-ziplist-value` | 64 字节 | skiplist+hashtable |
+| List | `list-max-ziplist-size` | -2 (每个节点 8KB) | quicklist 新节点 |
+
+## 关联笔记
+
+- [[hhs/Redis/02-核心数据类型]] — 五种数据类型的编码切换全景
+- [[hhs/Redis/02-核心数据类型/02-2-skiplist]] — ZSet 的另一种核心结构
+- [[hhs/Redis/08-SortedSet精解]] — ZSet 的高级用法与内存优化
diff --git a/hhs/Redis/02-核心数据类型/02-2-skiplist.md b/hhs/Redis/02-核心数据类型/02-2-skiplist.md
new file mode 100644
index 0000000..f0c19fa
--- /dev/null
+++ b/hhs/Redis/02-核心数据类型/02-2-skiplist.md
@@ -0,0 +1,520 @@
+---
+tags: [Redis, 底层数据结构, skiplist, ZSet]
+create time: 2026-05-25 10:30
+author: hhs
+---
+
+# skiplist(跳表)
+
+## 概述
+
+skiplist(跳表)是 Redis ZSet 的核心排序结构。它用**多层索引**把有序链表的查找从 O(N) 优化到 O(logN),实现简单、性能稳定——是 Redis 选择它而不是红黑树的原因。
+
+## 一、从有序链表到跳表
+
+> [!question] 为什么不直接用有序链表?
+> 有序链表查找要从头逐个遍历,O(N)。100 万个元素,最坏要走 100 万步。
+
+### 加一层索引
+
+如果我们每两个节点提取一个"索引指针",查找时先在索引层跳着走,跳过目标了再降下去——**步数直接减半**。
+
+再多加几层索引呢?每层跳过上一层的节点,查找路径像"跳台阶"一样逐层逼近:
+
+```mermaid
+flowchart LR
+ subgraph Level3["Level 3"]
+ direction LR
+ H3["header"] --> A3["1"]
+ A3 --> N3["NIL"]
+ end
+ subgraph Level2["Level 2"]
+ direction LR
+ H2["header"] --> A2["1"]
+ A2 --> D2["4"]
+ D2 --> G2["7"]
+ G2 --> N2["NIL"]
+ end
+ subgraph Level1["Level 1"]
+ direction LR
+ H1["header"] --> A1["1"]
+ A1 --> B1["2"]
+ B1 --> D1["4"]
+ D1 --> E1["5"]
+ E1 --> G1["7"]
+ G1 --> N1["NIL"]
+ end
+
+ classDef node fill:#e1f5fe,stroke:#2196f3
+ classDef nil fill:#fafafa,stroke:#ccc
+ class A3,D2,G2,A1,B1,D1,E1,G1 node
+ class N3,N2,N1 nil
+```
+
+> [!tip] 查找 5 的过程
+> 1. **Level 3**:从 header 到 1,下一个是 NIL(跳过头了)
+> 2. 降到 **Level 2**:从 1 到 4,下一个 7 超了
+> 3. 降到 **Level 1**:从 4 到 5,**找到了**!
+>
+> 总共只走了 3 步,而不是遍历全部 7 个节点。
+
+### 层数怎么定?
+
+> [!question] 如果每层固定间隔,不就退化成多级索引的数组了吗?
+> 跳表的精髓在于**随机化**。每个节点插入时,抛硬币决定要不要"长高一层"——概率各 50%。这样既不会出现极端退化,又不需要像平衡树那样做复杂的旋转操作。
+
+Redis 的实现中,节点最大层数限制为 **32 层**,晋升概率为 **0.25**(每 4 次有 1 次升一层)。数学期望上,一个 N 个元素的跳表平均层数约为 `log_{1/p}(N)`——对百万级数据,约 5~6 层。
+
+> [!info] 为什么是 O(logN)?
+> 每一层大约是上一层节点数的 `p` 倍(p=0.25),所以从高到低逐层下降时,每一层平均淘汰掉 `(1-p)` 比例的候选节点。
+>
+> 搜索路径长度 ≈ 每层检查 1 个节点 × 层数 = `log_{1/p}(N)`。以 p=0.25 为例:
+> - N = 1,000 → 约 5 层
+> - N = 1,000,000 → 约 10 层
+>
+> 与平衡二叉树的 O(log₂N) 同阶,但跳表不需要旋转,缓存局部性也更好(底层链表是连续遍历的)。
+
+## 二、Redis 中 skiplist 的节点结构
+
+Redis 的 skiplist 节点比教科书版多了几个字段:
+
+```mermaid
+flowchart TB
+ subgraph Node["skiplist node"]
+ direction TB
+ EL["ele, SDS 字符串, 成员名"]
+ SC["score, float64, 分数"]
+ BW["backward, 指向前一个节点"]
+ subgraph Levels["level[], 可变长度数组"]
+ direction LR
+ L1["forward + span, Level 1"]
+ L2["forward + span, Level 2"]
+ L3["forward + span, Level N"]
+ end
+ end
+
+ classDef field fill:#e8f5e9,stroke:#4caf50
+ classDef level fill:#fff3e0,stroke:#ff9800
+ class EL,SC,BW field
+ class L1,L2,L3 level
+```
+
+| 字段 | 类型 | 作用 |
+|------|------|------|
+| `ele` | SDS (简单动态字符串) | 成员名称,如 `"player:42"` |
+| `score` | `double` | 排序分数 |
+| `backward` | 指针 | **上一个节点**的指针(支持 `ZREVRANGE` 反向遍历) |
+| `level[i].forward` | 指针 | 第 i 层指向的下一个节点 |
+| `level[i].span` | `uint32` | 该层的 forward 跨越了多少个节点(用于计算 rank) |
+
+对应的 Redis 源码定义(`server.h`):
+
+```c
+// 单层索引
+typedef struct zskiplistLevel {
+ struct zskiplistNode *forward; // 指向下一个节点
+ unsigned long span; // 跨越的节点数,用于计算 rank
+} zskiplistLevel;
+
+// 跳表节点
+typedef struct zskiplistNode {
+ sds ele; // 成员名(SDS 字符串)
+ double score; // 排序分数
+ struct zskiplistNode *backward;// 指向前一个节点(反向遍历)
+ zskiplistLevel level[]; // 柔性数组,层数随机分配
+} zskiplistNode;
+
+// 跳表本身
+typedef struct zskiplist {
+ struct zskiplistNode *header, *tail; // 头尾哨兵节点
+ unsigned long length; // 节点总数
+ int level; // 当前最高层数
+} zskiplist;
+```
+
+### 三个结构体的层级关系
+
+> [!question] 这三个结构体分别对应什么?
+> 它们是严格的**包含关系**——`zskiplist` 管理整个跳表,包含很多 `zskiplistNode`,每个节点又包含若干 `zskiplistLevel`。
+
+```mermaid
+flowchart TB
+ ZS["zskiplist - 跳表本身 - 全局管理者"]
+ ZS -->|"header"| N1["zskiplistNode - 哨兵头节点"]
+ ZS -->|"tail"| N2["zskiplistNode - 最后一个节点"]
+ ZS -->|"length"| L["节点总数"]
+ ZS -->|"level"| ML["当前最高层数"]
+
+ N1 --> L3["zskiplistLevel - Level 3 的指针槽"]
+ N1 --> L2["zskiplistLevel - Level 2 的指针槽"]
+ N1 --> L1["zskiplistLevel - Level 1 的指针槽"]
+
+ classDef table fill:#fff3e0,stroke:#ff9800
+ classDef node fill:#e1f5fe,stroke:#2196f3
+ classDef lev fill:#e8f5e9,stroke:#4caf50
+ class ZS table
+ class N1,N2 node
+ class L1,L2,L3,L,ML lev
+```
+
+| 结构体 | 类比 | 职责 |
+|--------|------|------|
+| `zskiplistLevel` | 公交站牌上"下一站 XX,距离 3 站" | 最小积木,一个指针槽:**去哪 + 跨多远**,必须依附在节点上 |
+| `zskiplistNode` | 多层立交桥的一个出口 | 一个节点纵向跨越多层,每层有一个 `zskiplistLevel`;存储数据(`ele`、`score`) |
+| `zskiplist` | 整条公交线路的管理站 | 全局管理:头尾哨兵、节点总数、当前最高层数;不存数据,只做调度 |
+
+> [!tip] 一句话记住
+> **`zskiplist`** 管理跳表 → 包含 **N 个 `zskiplistNode`**(节点)→ 每个节点包含 **N 个 `zskiplistLevel`**(层指针槽)
+
+> [!question] 柔性数组 `level[]` 是什么?
+> 这是 C 语言的"柔性数组"写法——节点分配时按实际层数动态分配内存,不会为每个节点都预留 32 层的空间。一个只有 1 层的节点,`level[]` 只占 1 个 `zskiplistLevel` 的内存。这就是跳表比"固定多层数组"省空间的关键。
+
+> [!question] `forward` 和 `span` 分别是什么?
+> - **`forward`**:当前层的"跳转指针",告诉你**从这个节点出发,在这一层往后走,下一个节点是谁**。每一层都是一个独立的链表,`forward` 就是链表的 `next` 指针——只不过高层的 `forward` 跳得远(跳过中间节点),低层的 `forward` 跳得近。
+> - **`span`**:记录这个 `forward` 指针**跳过了多少个底层节点**。当你执行 `ZRANK` 查某个成员的排名时,只需沿着查找路径把沿途的 `span` 加起来——**不需要遍历整个链表**。
+>
+> 教科书跳表一般没有 `span` 和 `backward`,它们是 Redis 为支持排名和反向遍历而加的。
+
+`forward` 和 `span` 的配合示意:
+
+```mermaid
+flowchart LR
+ subgraph L2["Level 2"]
+ direction LR
+ H2["header"] -->|"span=3"| C2["score=3.0"]
+ C2 -->|"span=2"| E2["score=5.0"]
+ E2 -->|"span=1"| NIL2["NIL"]
+ end
+ subgraph L1["Level 1"]
+ direction LR
+ H1["header"] -->|"span=1"| A1["score=1.0"]
+ A1 -->|"span=1"| B1["score=2.0"]
+ B1 -->|"span=1"| C1["score=3.0"]
+ C1 -->|"span=1"| D1["score=4.0"]
+ D1 -->|"span=1"| E1["score=5.0"]
+ E1 -->|"span=1"| NIL1["NIL"]
+ end
+
+ classDef node fill:#e1f5fe,stroke:#2196f3
+ classDef nil fill:#fafafa,stroke:#ccc
+ class A1,B1,C1,D1,E1,C2,E2 node
+ class H1,H2,NIL1,NIL2 nil
+```
+
+> [!tip] 用上图举例
+> - Level 2 的 header → score=3.0:`forward` 指向 score=3.0 的节点,`span=3` 表示**跳过了底层的 3 个节点**(1.0、2.0、3.0)
+> - 查找 score=5.0 的排名时:Level 2 跳到 score=3.0(`rank += 3`),再降到 Level 1 走两步到 score=5.0(`rank += 2`),**总排名 = 5**,全程只走了 3 步
+>
+> 简单类比:**`forward` = 出口指示牌(下一站去哪),`span` = 里程数(跨过几站)**
+
+### 查找过程(带 span)
+
+```mermaid
+flowchart LR
+ H["header"] -->|"span=1"| N1["score=1.0, rank 计算起点"]
+ N1 -->|"span=3"| N4["score=4.0"]
+ N4 -->|"span=2"| N6["score=6.0"]
+ N6 -->|"span=1"| NIL["NIL"]
+
+ classDef node fill:#e1f5fe,stroke:#2196f3
+ classDef nil fill:#fafafa,stroke:#ccc
+ class N1,N4,N6 node
+ class H,NIL nil
+```
+
+查找 `score=4.0` 的节点:从 header 出发,Level 2 的 `span=1`(到 score=1.0),Level 1 的 `span=3`(到 score=4.0),`rank = 1 + 3 = 4`。
+
+### 简化搜索实现
+
+把上面的文字描述翻译成代码,核心逻辑只有几行:
+
+```go
+// rank 记录沿途经过的节点数,用于计算排名
+func (zsl *skiplist) getRank(score float64, ele string) uint64 {
+ var rank uint64
+ node := zsl.header
+
+ // 从最高层往最低层走
+ for i := zsl.level - 1; i >= 0; i-- {
+ // 在当前层尽量往前跳,直到下一个节点的 score 超过目标
+ for node.level[i].forward != nil &&
+ (node.level[i].forward.score < score ||
+ (node.level[i].forward.score == score &&
+ node.level[i].forward.ele < ele)) {
+ rank += node.level[i].span
+ node = node.level[i].forward
+ }
+ }
+
+ node = node.level[0].forward // 降到 Level 1,检查是否命中
+ if node != nil && node.score == score && node.ele == ele {
+ return rank
+ }
+ return 0 // 未找到
+}
+```
+
+> [!tip] 读代码的两个关键点
+> 1. **外层循环**:逐层下降,每一层只做"尽量往前跳"这一个动作——这就是"跳台阶"。
+> 2. **rank 累加**:跳过一个节点就加一次 `span`,降到 Level 1 时 rank 已经是精确排名,**不需要再逐个数**。
+
+### 随机层数生成(randomLevel)
+
+> [!question] 每个节点的层数怎么来的?
+> 不是预先算好的,而是**插入时随机决定**。Redis 的策略很直白:从 Level 1 开始,每次有 `p=0.25` 的概率升一层,直到 32 层上限。
+
+```go
+const (
+ ZSKIPLIST_MAXLEVEL = 32
+ ZSKIPLIST_P = 0.25
+)
+
+func randomLevel() int {
+ level := 1
+ // 每次以 25% 概率升一层,直到触顶
+ for level < ZSKIPLIST_MAXLEVEL && rand.Float64() < ZSKIPLIST_P {
+ level++
+ }
+ return level
+}
+```
+
+> [!info] 为什么是 0.25 而不是 0.5?
+> p=0.5 是教科书标准值,但 p=0.25 意味着**平均每 4 次才有 1 次升层**,高层数的节点更稀疏。好处是:
+> - 每个节点的平均指针数更少(`1/(1-p) = 1.33` vs p=0.5 的 2),**更省内存**
+> - 高层索引更"跨距"更大,虽然每层能排除的候选少一点,但层数也更少
+> - 总体搜索效率差异很小,Redis 选择了**内存更优**的方案
+
+层数分布(概率):
+
+| 层级 | 概率 | 含义 |
+|------|------|------|
+| 1 | 75% | 大多数节点只有 1 层 |
+| 2 | 18.75% | 约 1/5 的节点有 2 层 |
+| 3 | ~4.7% | 约 1/20 的节点有 3 层 |
+| k | `0.75 × 0.25^(k-1)` | 指数衰减 |
+
+### 插入过程
+
+> [!question] 插入一个新节点,要改哪些指针?
+> 核心思路:**找到每一层的"前驱节点",然后逐层缝入新节点**。这就像拉链——先定位每一层的缺口,再把新节点的指针串进去。
+
+```mermaid
+flowchart TB
+ subgraph Before["插入前: 寻找每层前驱"]
+ direction LR
+ UL3["update Level 3, header"] --> UL2["update Level 2, node1"]
+ UL2 --> UL1["update Level 1, node4"]
+ end
+ subgraph After["插入后: 逐层缝入"]
+ direction LR
+ NL3["new node, Level 3"] -->|"forward"| FL3["header.forward"]
+ NL2["new node, Level 2"] -->|"forward"| FL2["node1.forward"]
+ NL1["new node, Level 1"] -->|"forward"| FL1["node4.forward"]
+ end
+
+ Before -->|"逐层修改 forward 指针"| After
+
+ classDef update fill:#fff3e0,stroke:#ff9800
+ classDef newnode fill:#e8f5e9,stroke:#4caf50
+ class UL3,UL2,UL1 update
+ class NL3,NL2,NL1 newnode
+```
+
+核心代码(省略 span 计算细节,聚焦指针操作):
+
+```go
+func (zsl *skiplist) insert(score float64, ele string) {
+ // 1. 从最高层往下搜索,记录每层"最后一个比新节点小的节点"
+ update := make([]*zskiplistNode, ZSKIPLIST_MAXLEVEL)
+ node := zsl.header
+ for i := zsl.level - 1; i >= 0; i-- {
+ for node.level[i].forward != nil &&
+ node.level[i].forward.score < score {
+ node = node.level[i].forward
+ }
+ update[i] = node // 第 i 层的"前驱"
+ }
+
+ // 2. 随机生成新节点的层数
+ level := randomLevel()
+ if level > zsl.level {
+ // 新层数超过了当前最高层,初始化 header 的高层指针
+ for i := zsl.level; i < level; i++ {
+ update[i] = zsl.header
+ }
+ zsl.level = level
+ }
+
+ // 3. 创建新节点
+ newNode := newZskiplistNode(level, score, ele)
+
+ // 4. 逐层缝入:修改 forward 指针,像拉链一样
+ for i := 0; i < level; i++ {
+ newNode.level[i].forward = update[i].level[i].forward
+ update[i].level[i].forward = newNode
+ }
+
+ // 5. 设置 backward(双向链表的前驱指针)
+ newNode.backward = update[0]
+ if newNode.level[0].forward != nil {
+ newNode.level[0].forward.backward = newNode
+ }
+ zsl.length++
+}
+```
+
+> [!tip] 插入的核心逻辑
+> - **时间复杂度**:O(logN)——和查找一样,大部分时间花在"找前驱"上
+> - **指针修改**:只有新节点涉及的那几层需要改 forward,**不影响其他层**
+> - **不需要旋转**:对比红黑树插入后可能触发的多次旋转+重着色,跳表的插入操作"一气呵成"
+
+### 删除过程
+
+删除和插入是对称操作:同样先找前驱,然后**反向拆链**。
+
+```go
+func (zsl *skiplist) delete(score float64, ele string) {
+ // 1. 同样记录每层前驱
+ update := make([]*zskiplistNode, ZSKIPLIST_MAXLEVEL)
+ node := zsl.header
+ for i := zsl.level - 1; i >= 0; i-- {
+ for node.level[i].forward != nil &&
+ (node.level[i].forward.score < score ||
+ (node.level[i].forward.score == score &&
+ node.level[i].forward.ele < ele)) {
+ node = node.level[i].forward
+ }
+ update[i] = node
+ }
+
+ // 2. 定位到目标节点(Level 1 的下一个)
+ target := update[0].level[0].forward
+ if target == nil || target.score != score || target.ele != ele {
+ return // 未找到
+ }
+
+ // 3. 逐层拆链:把 target 从每一层的链表中摘除
+ for i := 0; i < zsl.level; i++ {
+ if update[i].level[i].forward != target {
+ break // 这层没有 target(层数高于 target 的实际层数)
+ }
+ update[i].level[i].forward = target.level[i].forward
+ }
+
+ // 4. 更新 backward 指针
+ if target.level[0].forward != nil {
+ target.level[0].forward.backward = target.backward
+ } else {
+ zsl.tail = target.backward
+ }
+
+ // 5. 如果删除后最高层变空,降低跳表层数
+ for zsl.level > 1 && zsl.header.level[zsl.level-1].forward == nil {
+ zsl.level--
+ }
+ zsl.length--
+}
+```
+
+> [!summary] 增删查的复杂度
+> | 操作 | 时间复杂度 | 核心步骤 |
+> |------|-----------|----------|
+> | 查找 | O(logN) | 逐层下降 + 当层前跳 |
+> | 插入 | O(logN) | 找前驱 + 逐层缝入 |
+> | 删除 | O(logN) | 找前驱 + 逐层拆链 |
+>
+> 三者的"骨架"完全一样——都是**先定位前驱节点**,差别只在最后一步:查找是比对,插入是接链,删除是断链。
+
+## 三、为什么 Redis 选跳表而不是红黑树?
+
+这是经典面试题,也是理解 Redis 设计哲学的关键:
+
+| 维度 | skiplist | 红黑树 |
+|------|----------|--------|
+| 实现复杂度 | 简单,插入只需调整指针 | 复杂,需要旋转 + 重着色 |
+| 范围查询 | **天然支持**:从起点沿 Level 1 链表走到终点 | 需要中序遍历,实现复杂 |
+| 并发友好 | 局部调整,锁粒度小 | 旋转影响大范围节点 |
+| 内存布局 | 每个节点独立分配 + 级联式指针 | 同样,但多了颜色位和父指针 |
+| 查找性能 | O(logN) **期望值** | O(logN) **最坏保证** |
+
+> [!insight] 关键差异在范围查询
+> ZSet 最高频的操作是 `ZRANGE`——"给我 score 在 100~200 之间的所有成员"。跳表只需定位到 100,然后沿 Level 1 链表往右走直到 200,**天然有序、天然支持**。红黑树做范围查询要写额外的中序遍历代码,还要处理边界条件。
+>
+> 这就是 Redis 作者 antirez 说的:"跳表足够好,而且实现简单。"
+
+## 四、ZSet 为什么需要 skiplist + hashtable 两套结构?
+
+> [!question] 一个数据类型配两套索引,不浪费吗?
+> 它们分工明确:skiplist 负责**按 score 排序和范围查询**,hashtable 负责**按 member 名字 O(1) 定位**。没有 hashtable,`ZSCORE` 命令就要在 skiplist 上 O(logN) 查找;没有 skiplist,`ZRANGE` 就要全量扫描。
+
+```mermaid
+flowchart LR
+ K["ZSet key"] --> SL["skiplist, 按 score 排序, 范围查询 O(logN)"]
+ K --> HT["dict, 按 member 查找, O(1)"]
+
+ SL --> N1["member A, score 100"]
+ SL --> N2["member B, score 200"]
+ SL --> N3["member C, score 300"]
+
+ HT --> H1["member A -> score 100"]
+ HT --> H2["member B -> score 200"]
+ HT --> H3["member C -> score 300"]
+
+ classDef struct fill:#fff3e0,stroke:#ff9800
+ classDef data fill:#e8f5e9,stroke:#4caf50
+ class SL,HT struct
+ class N1,N2,N3,H1,H2,H3 data
+```
+
+**代价**:每个元素存了两份索引(skiplist 节点 + hashtable entry),内存开销比单一结构大。但这是**用空间换时间**的经典取舍——ZSet 的操作种类太多(排序、排名、范围查询、精确查找),一套结构很难同时满足。
+
+### 内存估算
+
+> [!tip] 生产环境参考
+> 一个 ZSet 元素的大致内存占用:
+> - skiplist 节点:约 24 字节(forward 指针数组) + member SDS + score(8B)
+> - hashtable entry:约 24 字节(dictEntry) + member 指针 + value(score)
+> - 平均下来每个元素约 **500 字节**(取决于 member 名长度)
+>
+> 100 万个元素 ≈ 500MB。大 ZSet 要考虑分片。
+
+## 五、编码切换阈值
+
+ZSet 同样有 ziplist/listpack → skiplist 的自动切换:
+
+| 配置项 | 默认值 | 说明 |
+|--------|--------|------|
+| `zset-max-ziplist-entries` | 128 | 元素数超过 128 → 切 skiplist |
+| `zset-max-ziplist-value` | 64 字节 | 任意 member 长度超过 64B → 切 skiplist |
+
+```go
+// 1. 小 ZSet → ziplist/listpack 编码
+for i := 0; i < 100; i++ {
+ rdb.ZAdd(ctx, "test:zset", redis.Z{Score: float64(i), Member: fmt.Sprintf("m%d", i)})
+}
+// OBJECT ENCODING test:zset → "ziplist" (Redis 7.2+ 显示 "listpack")
+
+// 2. 加入超长 member → 触发编码切换
+rdb.ZAdd(ctx, "test:zset", redis.Z{Score: 9999, Member: strings.Repeat("x", 100)})
+// OBJECT ENCODING test:zset → "skiplist"
+```
+
+## 六、面试速记
+
+> [!summary] 高频问答速查
+> | 问题 | 关键回答 |
+> |------|----------|
+> | 跳表和红黑树怎么选? | 跳表实现简单,范围查询天然支持(沿底层链表走),并发锁粒度更小 |
+> | ZSet 为什么同时用跳表和 hashtable? | 跳表管排序/范围查询,hashtable 管 O(1) 精确查找 `ZSCORE` |
+> | Redis 跳表的晋升概率是多少? | p=0.25(1/4 概率升一层),最大 32 层 |
+> | `span` 字段的作用? | 记录指针跨越的节点数,支持 O(logN) 计算排名 `ZRANK` |
+> | 插入/删除的复杂度? | O(logN),核心都是"先找每层前驱",然后缝入/拆链 |
+> | ZSet 什么时候从 ziplist 切到 skiplist? | 元素数 > 128 或任意 member 长度 > 64 字节 |
+> | 跳表的空间复杂度? | O(N),每个元素约 1/(1-p) 个指针,p=0.25 时平均 1.33 个 |
+
+## 关联笔记
+
+- [[hhs/Redis/02-核心数据类型]] — 五种数据类型的编码切换全景
+- [[hhs/Redis/02-核心数据类型/02-1-ziplist与listpack]] — skiplist 的"前任"ziplist 详解
+- [[hhs/Redis/08-SortedSet精解]] — ZSet 的高级用法与性能优化
diff --git a/hhs/Redis/02-核心数据类型/02-3-hashtable.md b/hhs/Redis/02-核心数据类型/02-3-hashtable.md
new file mode 100644
index 0000000..edcd47f
--- /dev/null
+++ b/hhs/Redis/02-核心数据类型/02-3-hashtable.md
@@ -0,0 +1,331 @@
+---
+tags: [Redis, 底层数据结构, hashtable, dict, rehash]
+create time: 2026-05-25 11:00
+author: hhs
+---
+
+# hashtable(字典 / 哈希表)
+
+## 概述
+
+hashtable 是 Redis 中**使用最广泛**的底层结构——Hash、Set、ZSet(skiplist 模式下的 member 索引)都依赖它实现 O(1) 的精确查找。
+
+但 Redis 的 hashtable 不是你在教科书里学的那种"满了就扩、缩了就缩"的简单版本。它支持**渐进式 rehash**:扩容时不一次性迁移所有 bucket,而是分摊到每次 CRUD 操作中,避免一次性阻塞。
+
+## 一、dict 的整体结构
+
+> [!question] 为什么需要两个哈希表?
+> 答案是:**rehash 的时候,新旧表要共存**。平时只用 `ht[0]`,扩容时分配 `ht[1]`,逐步把数据从 0 搬到 1,搬完后交换、释放旧表。
+
+```mermaid
+flowchart TB
+ D["dict"] --> HT0["ht[0], 正在使用的哈希表"]
+ D --> HT1["ht[1], rehash 时的临时表"]
+ D --> RH["rehashidx, 当前 rehash 到哪个 bucket"]
+
+ HT0 --> B0_0["bucket 0, dictEntry 链表"]
+ HT0 --> B0_1["bucket 1"]
+ HT0 --> B0_N["bucket N"]
+
+ HT1 --> B1_0["bucket 0"]
+ HT1 --> B1_1["bucket 1"]
+ HT1 --> B1_M["bucket M"]
+
+ classDef dict fill:#e1f5fe,stroke:#2196f3
+ classDef table fill:#fff3e0,stroke:#ff9800
+ classDef bucket fill:#e8f5e9,stroke:#4caf50
+ class D,RH dict
+ class HT0,HT1 table
+ class B0_0,B0_1,B0_N,B1_0,B1_1,B1_M bucket
+```
+
+### dict 与 dictht 结构
+
+```go
+// 简化的 dict 结构
+type dict struct {
+ ht [2]*dictht // 两张哈希表,rehash 时交替使用
+ rehashidx int64 // rehash 进度,-1 表示未在 rehash
+ pauserehash int16 // > 0 时暂停 rehash(如遍历期间)
+}
+
+type dictht struct {
+ table []*dictEntry // bucket 数组
+ size uint64 // bucket 总数(总是 2 的幂)
+ sizemask uint64 // size - 1,用于位运算取模
+ used uint64 // 已存储的 entry 数量
+}
+```
+
+> [!tip] `sizemask` 的妙用
+> 因为 `size` 始终是 2 的幂,所以 `hash & sizemask` 等价于 `hash % size`,但位运算比取模快得多。这是所有高性能哈希表的经典技巧。
+
+### dictEntry 结构
+
+每个 bucket 是一个**单向链表**,节点是 `dictEntry`:
+
+```c
+// Redis 源码中的 dictEntry(来自 src/dict.h)
+typedef struct dictEntry {
+ void *key; // SDS 键
+ union {
+ void *val; // 指针:用于 Hash/Set 存储的值
+ uint64_t u64; // 整数:用于 ZSet 的 score 缓存
+ int64_t s64;
+ double d;
+ } v;
+ struct dictEntry *next; // 链表法解决哈希冲突
+} dictEntry;
+```
+
+> [!tip] 为什么值用 `union` 而不是 `void *`?
+> 如果全部用 `void *`,即使是整数 score 也要先 `malloc` 一块内存存整数、再把指针存进来——多一次堆分配、多 8 字节指针。`union` 直接把整数嵌在结构体里,省掉了这次分配,对 ZSet 这种高频读写场景有明显收益。
+
+> [!question] 为什么 Redis 用链表法而不开放寻址?
+> 开放寻址在负载因子较高时性能急剧下降(聚集效应)。链表法实现简单,且 Redis 通过渐进式 rehash 控制负载因子,链表长度通常很短(理想情况 ≤ 1)。
+
+## 二、哈希函数与冲突处理
+
+Redis 的哈希函数经历了演进:
+
+| 版本 | 哈希函数 | 特点 |
+|------|---------|------|
+| < 7.0 | MurmurHash2 (32/64 位) | 速度快,分布均匀 |
+| 7.0+ | SipHash-1-2 | 防 HashDoS 攻击(有序碰撞),安全性更强 |
+
+### key → bucket 的映射过程
+
+从一个 key 到最终落哪个 bucket,一共两步:
+
+```go
+// 伪代码:key → bucket index
+hash := siphash(key) // 1. 计算哈希值
+idx := hash & ht.sizemask // 2. 位运算取模,等价于 hash % size
+entry := ht.table[idx] // 拿到链表头,遍历查找
+```
+
+> [!question] 为什么 bucket 数量必须是 2 的幂?
+> 只有当 `size = 2^n` 时,`hash & (size - 1)` 才等价于 `hash % size`。位运算 & 比 % 快 5~10 倍,对每个 key 的每次访问都要用到,累计效果非常显著。Redis 在扩容时总是把新表大小设为**第一个 ≥ 2×used 的 2 的幂**。
+
+> [!warning] HashDoS 攻击
+> 攻击者构造大量具有相同哈希值的 key,让 hashtable 退化为链表,O(1) 变 O(N)。SipHash 是加密级哈希函数,攻击者无法预测输出,从根源上防御了这类攻击。Redis 7.0 默认启用 SipHash。
+
+### 负载因子与 rehash 触发条件
+
+```
+负载因子 = ht[0].used / ht[0].size
+```
+
+| 条件 | 触发 | 说明 |
+|------|------|------|
+| 没有 BGSAVE/BGREWRITEAOF 时,负载因子 ≥ 1 | **扩容** | 正常扩容阈值 |
+| 正在执行 BGSAVE/BGREWRITEAOF 时,负载因子 ≥ 5 | **扩容** | 提高阈值,避免 rehash 与 fork 竞争内存 |
+| 负载因子 < 0.1 | **缩容** | 内存空闲太多,释放回系统 |
+
+> [!question] 为什么 BGSAVE 期间要放宽扩容阈值?
+> `BGSAVE` 需要 `fork()` 子进程,操作系统使用 **Copy-on-Write**——fork 后如果父进程修改内存页,OS 会复制一份。如果此时大规模 rehash,大量内存页被修改,内存占用可能瞬间翻倍。提高扩容阈值可以减少这种情况。
+
+## 三、渐进式 Rehash(核心机制)
+
+> [!question] 一次性 rehash 有什么问题?
+> 假设 hashtable 有 1000 万个 key。一次性迁移意味着在某个瞬间,CPU 大量时间花在 rehash 上,Redis 主线程被阻塞——对于缓存场景,这可能意味着数百毫秒的延迟抖动。
+
+### 过程详解
+
+```mermaid
+flowchart TB
+ S1["1. 分配 ht[1]"] --> S2["2. rehashidx = 0, 开始渐进式迁移"]
+ S2 --> S3["3. 每次 CRUD 操作时, 迁移 ht[0] 上 rehashidx 指向的整个 bucket 到 ht[1]"]
+ S3 --> S4["4. rehashidx++"]
+ S4 --> S5{"所有 bucket 已迁移?"}
+ S5 -->|"否"| S3
+ S5 -->|"是"| S6["5. 释放 ht[0], ht[1] 变成 ht[0], 分配新空 ht[1]"]
+
+ classDef step fill:#e1f5fe,stroke:#2196f3
+ classDef check fill:#fff3e0,stroke:#ff9800
+ class S1,S2,S3,S4,S6 step
+ class S5 check
+```
+
+### rehash 期间的读写规则
+
+| 操作 | 行为 |
+|------|------|
+| **查找 (GET)** | 先查 `ht[0]`,没找到再查 `ht[1]` |
+| **插入 (SET)** | **只插入 `ht[1]`**(确保 `ht[0]` 只减不增) |
+| **删除 (DEL)** | 两张表都删 |
+
+用伪代码理解 lookup 在 rehash 期间的行为:
+
+```go
+func (d *dict) lookup(key string) *dictEntry {
+ // 如果正在 rehash,先推进一步(顺手搬一个 bucket)
+ if d.isRehashing() {
+ d.rehashStep()
+ }
+
+ // 先查 ht[0]
+ hash := siphash(key)
+ idx := hash & d.ht[0].sizemask
+ for e := d.ht[0].table[idx]; e != nil; e = e.next {
+ if e.key == key { return e }
+ }
+
+ // ht[0] 没找到且正在 rehash,再查 ht[1]
+ if d.isRehashing() {
+ idx = hash & d.ht[1].sizemask
+ for e := d.ht[1].table[idx]; e != nil; e = e.next {
+ if e.key == key { return e }
+ }
+ }
+ return nil // 两张表都没有
+}
+```
+
+> [!insight] "顺手" rehash
+> 注意 `rehashStep()` 是**嵌在 CRUD 里的**——每次操作"顺手"搬一个 bucket,用户无感知。这就是"渐进式"的精髓:不做大动作,小步快跑。
+
+> [!question] 哪些操作会触发 rehashStep?
+> 并非只有写操作。任何访问 dict 的命令都会"顺手"推进一步:
+> - **读**:`GET`、`HGET`、`SISMEMBER`、`ZSCORE` 等——查找时触发 `_dictRehashStep()`
+> - **写**:`SET`、`HSET`、`SADD`、`ZADD` 等——插入/删除时触发
+> - **主动触发**:`SCAN`、迭代器内部也会推进 rehash
+>
+> 所以高 QPS 的线上环境,rehash 通常很快完成。但**低 QPS 的场景**(比如只有定时任务写入),单靠 CRUD 触发不够——这时候 `serverCron` 的定时兜底就至关重要了。
+
+> [!tip] 周期性辅助迁移
+> Redis 的**时间事件**(serverCron,默认每 100ms)会执行 `incrementallyRehash()`,在空闲时也推进 rehash,避免大量只读操作时 rehash 进度停滞。
+>
+> 如果 rehash 持续很长时间没推进,可能是 QPS 太低、操作不够频繁——这种情况 serverCron 兜底。
+
+## 四、与持久化的交互
+
+> [!caution] rehash 期间做 RDB 快照
+> RDB 序列化时,会**同时遍历** `ht[0]` 和 `ht[1]`(如果正在 rehash)。这保证了快照的完整性,但序列化时间会略长。
+>
+> AOF 不受影响——每次写命令记录的是逻辑操作(`SET key value`),rehash 是纯内部行为。
+
+## 五、Redis 7.0+ 的 dict 优化:listpack 编码
+
+> [!question] hashtable 已经够快了,为什么还要优化?
+> 问题不在速度,在**内存**。每个 `dictEntry` 至少占 24 字节(key 指针 + value 指针 + next 指针),加上 SDS 和 redisObject,小 Hash 存 100 个短字段,元数据开销可能比数据本身还大。
+
+Redis 7.0 引入了 **listpack 编码的 dict**(`dictType` 支持嵌入式存储):小 hashtable 的 bucket 内部直接用 listpack 存储,省掉了 `dictEntry` 的堆分配。编码切换仍然由 `hash-max-ziplist-entries` / `hash-max-ziplist-value` 控制。
+
+### 两种编码对比
+
+```mermaid
+flowchart LR
+ subgraph HT["hashtable 编码(大 Hash)"]
+ direction TB
+ BK["bucket 数组"]
+ BK --> E1["dictEntry → SDS key, robj value, next ptr"]
+ BK --> E2["dictEntry → SDS key, robj value, next ptr"]
+ BK --> E3["dictEntry → ..."]
+ end
+
+ subgraph LP["listpack 编码(小 Hash)"]
+ direction TB
+ LPDATA["连续内存块"]
+ LPDATA --> L1["entry: len + field1 + value1"]
+ LPDATA --> L2["entry: len + field2 + value2"]
+ LPDATA --> L3["entry: ... + END byte"]
+ end
+
+ classDef htStyle fill:#ffebee,stroke:#f44336
+ classDef lpStyle fill:#e8f5e9,stroke:#4caf50
+ class BK,E1,E2,E3 htStyle
+ class LPDATA,L1,L2,L3 lpStyle
+```
+
+| 维度 | listpack 编码 | hashtable 编码 |
+|------|:-------------:|:-------------:|
+| 内存 | 极少(连续内存,无指针开销) | 较高(每 entry 至少 24 字节指针) |
+| 查找 | O(N) 线性扫描 | O(1) 哈希查找 |
+| 适用 | field 少、value 短的小 Hash | field 多或 value 较长的大 Hash |
+
+> [!tip] 什么时候切换?
+> `HSET myhash field value` 执行时,Redis 检查当前 Hash 的 field 数量和 value 长度。超过阈值(默认 128 个 field 或 64 字节 value,由 `hash-max-listpack-entries` / `hash-max-listpack-value` 控制)自动将 listpack 转换为 hashtable,**不可逆**。
+
+> [!question] 切换过程会不会阻塞?
+> 会,但通常可以忽略。`hashTypeConvertListpack()` 内部流程是:
+> 1. 创建一个临时 dict(hashtable 编码)
+> 2. **遍历** listpack 的所有 field-value 对,逐个插入 dict
+> 3. 释放 listpack 内存,用 dict 替换
+>
+> 这是一次性的 O(N) 操作。因为触发条件是 field ≤ 128(默认),所以转换代价很小——128 次插入在微秒级完成。但如果调大了 `hash-max-listpack-entries`(比如设成 10000),切换时的短暂阻塞就需要注意了。
+>
+> **关键点**:这个转换是**单向的、不可逆的**。即使你删除字段让数量降到 128 以下,编码也不会回退到 listpack。这是 Redis 对"频繁转换"的简化处理——避免在阈值附近反复翻转。
+
+## 六、各类型使用 hashtable 的方式
+
+| 数据类型 | hashtable 的角色 | 特殊之处 |
+|---------|-----------------|---------|
+| **Hash** | 唯一存储结构(大 Hash 时) | field→value 直接映射 |
+| **Set** | 唯一存储结构(含非整数元素时) | member→NULL,只用 key |
+| **ZSet** | skiplist 的"辅助索引" | member→score,配合 skiplist 实现 O(1) 定位 |
+
+> [!insight] 为什么 Set 用 hashtable 而不是直接存数组?
+> Set 的核心操作是 `SISMEMBER`(判断元素是否存在)和 `SADD/SREM`(增删)。hashtable 的 O(1) 查找完美匹配。如果用数组,每次 `SISMEMBER` 都要 O(N) 遍历——对大集合不可接受。
+
+## 七、Big Key 与实践要点
+
+hashtable 虽然快,但使用不当会成为性能杀手。一个 key 对应的 hashtable 特别大时("Big Key"),会引发一系列连锁问题。
+
+### 什么是 Big Key?
+
+| 类型 | Big Key 的定义 | 影响 |
+|------|---------------|------|
+| Hash/Set | field 数量达**数十万** | `HGETALL` / `SMEMBERS` 一次返回大量数据,阻塞主线程 |
+| ZSet | member 数量极大 | `ZRANGE 0 -1` 同理 |
+
+> [!warning] Big Key 的真实危害
+> 1. **读放大**:`HGETALL` / `SMEMBERS` 一次遍历整个 hashtable,大 key 可能耗时数百毫秒。
+> 2. **删除阻塞**:释放百万级 `dictEntry` 需要遍历所有 bucket 和链表,`DEL` 命令可能阻塞数秒。Redis 4.0+ 的 `UNLINK` 异步释放缓解了这个问题。
+> 3. **rehash 卡顿**:单个 bucket 链表过长时,一次 rehashStep 迁移该 bucket 耗时高。
+> 4. **内存不均**:集群模式下,大 key 所在 slot 可能成为热点节点。
+
+### 预防与应对
+
+```mermaid
+flowchart LR
+ A["Big Key 问题"] --> B["拆分: 按 hash 取模分桶"]
+ A --> C["压缩: 缩短 field/value"]
+ A --> D["异步删除: UNLINK 替代 DEL"]
+ A --> E["监控: redis-cli --bigkeys"]
+
+ classDef problem fill:#ffebee,stroke:#f44336
+ classDef solution fill:#e8f5e9,stroke:#4caf50
+ class A problem
+ class B,C,D,E solution
+```
+
+> [!tip] 拆分示例
+> 一个存储用户行为的 Hash 有 100 万个 field?拆成 `user:actions:0` ~ `user:actions:15` 共 16 个 Hash,按 `crc32(user_id) % 16` 分桶。单个 Hash 缩小到 ~6 万 field,rehash 和遍历的开销都在可控范围。
+
+### 线上诊断
+
+发现 Big Key 不能靠猜,需要工具实锤:
+
+| 工具 | 用法 | 特点 |
+|------|------|------|
+| `redis-cli --bigkeys` | 扫描全库,按类型统计最大 key | 简单直接,但会遍历所有 key,**建议从节点执行** |
+| `redis-cli --memkeys` | 按内存占用排序 | 比 bigkeys 更精准,显示实际字节数 |
+| `MEMORY USAGE key` | 查看单个 key 的内存占用 | 精确到字节,包含 dictEntry 开销 |
+| `DEBUG OBJECT key` | 查看编码方式和序列化长度 | 顺便确认编码是否符合预期 |
+| `SLOWLOG GET` | 慢查询日志 | Big Key 操作通常会出现在慢日志里 |
+
+> [!warning] 生产环境慎用 SCAN 类工具
+> `--bigkeys` 和 `--memkeys` 本质上是全库 SCAN,虽然用的是增量迭代,但在 key 数量极大(亿级)时仍会增加从节点的负载压力。建议在**从节点**、**低峰期**执行。
+
+## 八、总结
+
+> [!summary] 一句话带走
+> Redis hashtable 的核心设计哲学是**"不阻塞"**:用渐进式 rehash 把大动作拆成小步骤,用 2 的幂 bucket + 位运算保证单步足够快,用 SipHash 防住外部攻击。理解了 rehash 机制,就理解了 Redis 为什么能在百万 QPS 下依然保持亚毫秒级延迟。
+
+## 关联笔记
+
+- [[hhs/Redis/02-核心数据类型]] — 五种数据类型的编码切换全景
+- [[hhs/Redis/02-核心数据类型/02-1-ziplist与listpack]] — hashtable 的"紧凑替代品"
+- [[hhs/Redis/04-RDB持久化]] — rehash 期间的快照行为
+- [[hhs/Redis/07-集群方案]] — 集群 slot 迁移与 hashtable 的关系
diff --git a/hhs/Redis/02-核心数据类型/02-4-quicklist.md b/hhs/Redis/02-核心数据类型/02-4-quicklist.md
new file mode 100644
index 0000000..33f63ea
--- /dev/null
+++ b/hhs/Redis/02-核心数据类型/02-4-quicklist.md
@@ -0,0 +1,342 @@
+---
+tags: [Redis, 底层数据结构, quicklist, List, 链表, ziplist]
+create time: 2026-05-25 19:11
+author: hhs
+---
+
+# quicklist(快速列表)
+
+## 概述
+
+quicklist 是 Redis List 类型的**统一底层编码**——它是一个**双向链表**,但每个节点不是一个单独的元素,而是一个**ziplist(或 listpack)**。
+
+简单理解:quicklist = 链表的骨架 + 紧凑块的填充。它既保留了链表的插入/删除灵活性,又通过打包多个元素到连续内存中,大幅降低了指针开销。
+
+> [!tip] 快速定位
+> - 如果你是 **List 类型用户**:底层就是 quicklist,无需关心——但了解它能帮你做出更好的配置调优。
+> - 如果你在做**技术面试准备**:重点掌握"为什么需要 quicklist"(链表 vs 数组的折中)和"listpack 如何消除级联更新"。
+> - 如果你在做**性能优化**:重点关注 `list-max-ziplist-size` 和 `list-compress-depth` 两个配置参数。
+
+## 一、为什么需要 quicklist?
+
+> [!question] 普通链表有什么问题?
+> 考虑存 10000 个元素:普通双向链表需要 10000 个节点,每个节点有 `prev` 和 `next` 两个指针(64 位系统各占 8 字节),光是指针就吃掉 **160KB**——而数据本身可能才几十 KB。
+
+Redis 设计者想要的是一种"中间态":
+
+| 结构 | 内存效率 | 随机访问 | 插入/删除 |
+|------|:-------:|:-------:|:--------:|
+| 普通双向链表 | ❌ 指针开销大 | ❌ O(N) | ✅ O(1) |
+| ziplist(纯数组) | ✅ 极紧凑 | ❌ O(N) | ❌ 可能级联更新 |
+| **quicklist** | ✅ 折中方案 | ❌ O(N) | ✅ 局部影响 |
+
+quicklist 的核心思想:**把"散装"的链表节点打包成"整箱"的 ziplist**——既省掉了大部分指针,又让每个 ziplist 足够小,即使触发级联更新,影响范围也有限。
+
+> [!insight] 一个形象的比喻
+> 普通链表像一排散装零食,每个都要单独包装;quicklist 像把零食分装进几个密封袋,袋与袋之间用链子连起来。袋子太大不好管理(级联更新风险),袋子太小又失去了打包的意义。quicklist 的配置参数就是帮你找到"合适的袋子大小"。
+
+## 二、整体结构
+
+```mermaid
+flowchart TB
+ QL["quicklist"] --> HEAD["head, 指向第一个节点"]
+ QL --> TAIL["tail, 指向最后一个节点"]
+ QL --> LEN["len, 节点数量"]
+ QL --> FILL["fill, 每个节点的最大元素数"]
+
+ HEAD --> N1["quicklistNode 1"]
+ N1 --> ZP1["ziplist / listpack"]
+ N1 --> NEXT1["next"]
+
+ N1 --> N2["quicklistNode 2"]
+ N2 --> ZP2["ziplist / listpack"]
+ N2 --> NEXT2["next"]
+
+ N2 --> N3["quicklistNode N"]
+ N3 --> ZP3["ziplist / listpack"]
+
+ classDef ql fill:#e1f5fe,stroke:#2196f3
+ classDef node fill:#fff3e0,stroke:#ff9800
+ classDef zp fill:#e8f5e9,stroke:#4caf50
+ class QL,HEAD,TAIL,LEN,FILL ql
+ class N1,N2,N3,NEXT1,NEXT2 node
+ class ZP1,ZP2,ZP3 zp
+```
+
+### 核心结构体
+
+```go
+// quicklist 整体
+type quicklist struct {
+ head *quicklistNode // 头节点
+ tail *quicklistNode // 尾节点
+ len uint64 // 节点数量(不是元素数量!)
+ fill int32 // 每个节点的元素上限(来自 list-max-ziplist-size)
+ compress uint16 // LZF 压缩深度(来自 list-compress-depth)
+}
+
+// 每个节点
+type quicklistNode struct {
+ prev *quicklistNode
+ next *quicklistNode
+ entry *ziplist // Redis 7.2+ 改为 listpack
+ sz uint32 // entry 指向的 ziplist 总字节大小
+ count uint16 // 当前 ziplist 中的元素数量
+ encoding uint16 // 编码方式:原生 or LZF 压缩
+ container uint16 // 存储容器:ziplist or listpack
+}
+```
+
+> [!question] `len` 是节点数还是元素数?
+> `quicklist.len` 是**节点数**(即 ziplist 块的数量),不是 List 中元素的总数。要获取元素总数,需要把所有节点的 `count` 加起来。
+
+## 三、配置参数详解
+
+### list-max-ziplist-size
+
+控制每个 quicklistNode 中 ziplist 的大小。注意:这个参数**同时支持正数和负数**,含义不同:
+
+| 值 | 含义 | 示例 |
+|----|------|------|
+| `-5` | 每个 ziplist 最大 **64 KB** | 大元素场景 |
+| `-4` | 每个 ziplist 最大 **32 KB** | 默认推荐 |
+| `-3` | 每个 ziplist 最大 **16 KB** | — |
+| `-2` | 每个 ziplist 最大 **8 KB** | 默认值 |
+| `-1` | 每个 ziplist 最大 **4 KB** | — |
+| `正数 N` | 每个 ziplist 最多 **N 个元素** | 精确控制元素数 |
+
+> [!question] 为什么有正数和负数两种模式?
+> 正数按**元素个数**限制,适合元素大小相对均匀的场景;负数按**字节数**限制,适合元素大小差异大的场景。默认 `-2`(8KB)是一个平衡点——每个节点占用的空间不大,内存分配友好,且元素数量足够多来摊薄指针开销。
+
+### list-compress-depth
+
+控制从头尾开始的**多少个节点不压缩**,中间的节点使用 **LZF 算法压缩**:
+
+| 值 | 含义 |
+|----|------|
+| `0` | 默认,不压缩任何节点 |
+| `1` | 头尾各 1 个节点不压缩,其余压缩 |
+| `2` | 头尾各 2 个节点不压缩,其余压缩 |
+
+```mermaid
+flowchart LR
+ subgraph D0["compress-depth = 0"]
+ direction LR
+ A0["节点1, 未压缩"] --> B0["节点2, 未压缩"] --> C0["节点3, 未压缩"] --> D0N["节点4, 未压缩"]
+ end
+
+ subgraph D1["compress-depth = 1"]
+ direction LR
+ A1["节点1, 未压缩"] --> B1["节点2, LZF 压缩"] --> C1["节点3, LZF 压缩"] --> D1N["节点4, 未压缩"]
+ end
+
+ classDef plain fill:#e8f5e9,stroke:#4caf50
+ classDef compressed fill:#ffebee,stroke:#f44336
+ class A0,B0,C0,D0N,A1,D1N plain
+ class B1,C1 compressed
+```
+
+> [!tip] 什么时候开启压缩?
+> List 元素较大(如存储 JSON 消息体)且**访问集中在头尾**(典型的队列场景)时,开启 `list-compress-depth 1` 或 `2` 可以显著节省内存。中间的节点被 LZF 压缩后,取到时再解压,对冷数据的访问多一次解压开销,但对热数据(头尾)完全无影响。
+>
+> 如果你的 List 经常做 `LRANGE` 遍历中间部分,就**不要开压缩**——解压开销会抵消收益。
+
+### 两个参数如何配合?
+
+```go
+// Redis 启动时的默认配置
+list-max-ziplist-size -2 // 每个节点最大 8KB
+list-compress-depth 0 // 不压缩
+
+// 消息队列场景(大消息、头尾操作为主)
+list-max-ziplist-size -5 // 每个节点最大 64KB(消息体大)
+list-compress-depth 1 // 压缩中间节点,节省内存
+```
+
+## 四、核心操作
+
+### 插入(LPUSH / RPUSH)
+
+以 `LPUSH` 从头部插入为例:
+
+```mermaid
+flowchart TB
+ S1["LPUSH key value"] --> S2{"head 节点的 ziplist 未满?"}
+ S2 -->|"是"| S3["直接在 head 的 ziplist 头部插入元素"]
+ S2 -->|"否"| S4["新建 quicklistNode, 创建新 ziplist, 插入元素, 设为新 head"]
+
+ classDef step fill:#e1f5fe,stroke:#2196f3
+ classDef check fill:#fff3e0,stroke:#ff9800
+ class S1,S3,S4 step
+ class S2 check
+```
+
+> [!question] 插入会导致级联更新吗?
+> 会,但**影响范围被限制在单个 ziplist 内部**。即使 ziplist 的级联更新把一个节点"撑大"了,它最多导致这一个节点重新分配内存,不会波及其他节点。这就是 quicklist 比纯 ziplist 更适合大数据量的根本原因。
+
+### 随机访问(LINDEX)
+
+`LINDEX key 500` 需要找到 List 中第 500 个元素:
+
+```mermaid
+flowchart TB
+ S1["LINDEX key 500"] --> S2["从 head 开始遍历 quicklistNode"]
+ S2 --> S3{"目标元素在当前节点内?"}
+ S3 -->|"否"| S4["跳过整个节点, count 累加"]
+ S4 --> S3
+ S3 -->|"是"| S5["在节点的 ziplist 内做偏移定位, 取出元素"]
+
+ classDef step fill:#e1f5fe,stroke:#2196f3
+ classDef check fill:#fff3e0,stroke:#ff9800
+ class S1,S2,S4,S5 step
+ class S3 check
+```
+
+> [!caution] LINDEX 是 O(N)
+> 即使 quicklistNode 的数量不多,定位到具体节点后还需要在 ziplist 内做 O(K) 的线性偏移(K 是节点内的元素数)。所以 `LINDEX` 整体仍然是 O(N)。如果你需要频繁按索引随机访问 List,说明你可能**选错了数据结构**——考虑用 Hash 或 ZSet。
+
+### 范围查询(LRANGE)
+
+`LRANGE key 0 99` 取前 100 个元素:
+
+```go
+// 伪代码:LRANGE 的执行逻辑
+func (ql *quicklist) lrange(start, stop int) []any {
+ node := ql.head
+ offset := start
+ result := make([]any, 0, stop-start+1)
+
+ // 1. 跳到包含 start 的节点
+ for node != nil && offset >= node.count {
+ offset -= node.count
+ node = node.next
+ }
+
+ // 2. 从该节点开始,逐节点取元素直到满足 stop
+ for node != nil && len(result) <= stop-start {
+ // 在当前节点的 ziplist 中从 offset 开始取
+ fetched := ziplistRange(node.entry, offset, ...)
+ result = append(result, fetched...)
+ offset = 0 // 后续节点从头开始取
+ node = node.next
+ }
+ return result
+}
+```
+
+> [!tip] LRANGE 的效率优化
+> 虽然理论复杂度是 O(N),但 quicklist 的连续内存布局对 **CPU 缓存友好**——遍历单个 ziplist 时,数据在内存中连续排列,cache miss 很少。相比普通链表的"跳来跳去",实际速度快得多。
+
+### 从两端弹出(LPOP / RPOP)
+
+```mermaid
+flowchart TB
+ S1["LPOP key"] --> S2{"head 的 ziplist 有多个元素?"}
+ S2 -->|"是"| S3["移除并返回 ziplist 的第一个元素"]
+ S2 -->|"否, 只剩一个"| S4["返回元素, 释放整个 quicklistNode, head 指向 next"]
+
+ classDef step fill:#e1f5fe,stroke:#2196f3
+ classDef check fill:#fff3e0,stroke:#ff9800
+ class S1,S3,S4 step
+ class S2 check
+```
+
+> [!question] 为什么不直接用 ziplist 做 List?
+> 如果整个 List 只用一个 ziplist,插入/删除导致的**级联更新**会影响整个列表。而且当 List 很大时,ziplist 需要大块连续内存,**内存分配失败的概率增大**。quicklist 把大块拆成小块,每块独立分配,既降低了碎片化风险,又把级联更新限制在单个节点内。
+
+## 五、操作复杂度速查
+
+| 操作 | 命令 | 时间复杂度 | 说明 |
+|------|------|:---------:|------|
+| 头部插入 | `LPUSH` | O(1) | ziplist 未满时直接插入;满则新建节点 |
+| 尾部插入 | `RPUSH` | O(1) | 同上 |
+| 头部弹出 | `LPOP` | O(1) | ziplist 未空时直接移除;空则释放节点 |
+| 尾部弹出 | `RPOP` | O(1) | 同上 |
+| 按索引访问 | `LINDEX` | O(N) | 需遍历节点 + 节点内偏移 |
+| 范围查询 | `LRANGE` | O(S+N) | S 为偏移跳过量,N 为返回数量 |
+| 获取长度 | `LLEN` | O(1) | 直接读取 quicklist 的长度元数据 |
+
+> [!caution] 避免滥用 LINDEX / LRANGE 大范围扫描
+> quicklist 的强项是**队列语义**(头尾操作 O(1))。如果你发现 `LINDEX` 或大范围 `LRANGE` 成为瓶颈,大概率是数据结构选型的问题——考虑换用 Hash(按 key 直接 O(1) 查)或 ZSet(按 score 范围 O(logN) 查)。
+
+## 六、LZF 压缩机制
+
+当 `list-compress-depth > 0` 时,中间节点的 ziplist 会被 LZF 压缩:
+
+```mermaid
+flowchart LR
+ subgraph READ["访问压缩节点"]
+ direction TB
+ R1["读取压缩节点"] --> R2{"encoding == LZF?"}
+ R2 -->|"是"| R3["解压 ziplist, 还原到内存"]
+ R3 --> R4["在解压后的 ziplist 中定位元素"]
+ R2 -->|"否"| R4
+ end
+
+ subgraph WRITE["写入压缩节点"]
+ direction TB
+ W1["写入压缩节点"] --> W2["先解压, 修改 ziplist"]
+ W2 --> W3{"节点不在首尾, 且 compress-depth 允许?"}
+ W3 -->|"是"| W4["重新 LZF 压缩"]
+ W3 -->|"否"| W5["保持原样"]
+ end
+
+ classDef check fill:#fff3e0,stroke:#ff9800
+ classDef step fill:#e1f5fe,stroke:#2196f3
+ class R2,W3 check
+ class R1,R3,R4,W1,W2,W4,W5 step
+```
+
+> [!insight] 压缩的收益有多大?
+> LZF 对文本型数据(如 JSON 消息)的压缩率通常在 **50%~70%**。一个存储 1000 条消息的 List,中间节点压缩后内存占用可以降低一半以上。代价是访问中间节点时的解压开销——但对于队列场景(只操作头尾),这个开销为零。
+
+## 七、Redis 7.0+ 的演进
+
+Redis 7.2 开始,quicklist 的节点内部从 **ziplist** 迁移到 **listpack**。结构体中的 `container` 字段标识当前使用的是哪种:
+
+| container 值 | 存储格式 | 说明 |
+|:-----------:|---------|------|
+| 2 | ziplist | 旧格式,仍兼容 |
+| 3 | listpack | 新格式,去掉了 `prevlen`,彻底消除级联更新 |
+
+> [!question] 迁移是自动的吗?
+> 是的。当一个 quicklistNode 被修改(插入/删除元素)时,Redis 会检查并将其转换为 listpack 格式。未修改的节点保持原样。整个过程对用户完全透明。
+
+### ziplist vs listpack 节点对比
+
+```mermaid
+flowchart LR
+ subgraph ZP["ziplist 节点"]
+ direction TB
+ Z1["prevlen, 1 或 5 字节"]
+ Z1 --> Z2["encoding + len"]
+ Z2 --> Z3["data"]
+ Z3 --> Z4["prevlen, 1 或 5 字节"]
+ Z4 --> Z5["..."]
+ end
+
+ subgraph LP["listpack 节点"]
+ direction TB
+ L1["encoding + len"]
+ L1 --> L2["data"]
+ L2 --> L3["backlen, 只记录自身长度"]
+ L3 --> L4["encoding + len"]
+ L4 --> L5["..."]
+ end
+
+ classDef zpStyle fill:#ffebee,stroke:#f44336
+ classDef lpStyle fill:#e8f5e9,stroke:#4caf50
+ class Z1,Z2,Z3,Z4,Z5 zpStyle
+ class L1,L2,L3,L4,L5 lpStyle
+```
+
+> [!tip] 级联更新是怎么消失的?
+> ziplist 的 `prevlen` 记录前一个节点的长度——前一个节点变大时,`prevlen` 可能从 1 字节变 5 字节,引发后续节点连锁反应。listpack 用 `backlen` 只记录**自身**的长度,修改任何节点都不会影响其他节点,级联更新从结构上被消除了。
+>
+> 详见 [[02-1-ziplist与listpack]]
+
+## 关联笔记
+
+- [[02-核心数据类型]] — 五种数据类型的编码切换全景
+- [[02-1-ziplist与listpack]] — quicklist 节点内部的紧凑存储结构
+- [[02-3-hashtable]] — Redis 最常用的 O(1) 查找结构
diff --git a/hhs/Redis/03-基本命令速查.md b/hhs/Redis/03-基本命令速查.md
index 2c4db29..7590fbe 100644
--- a/hhs/Redis/03-基本命令速查.md
+++ b/hhs/Redis/03-基本命令速查.md
@@ -1,5 +1,5 @@
---
-tags: [Redis, 缓存, 命令]
+tags: [Redis, 缓存, 命令, Stream, PubSub, Lua]
create time: 2026-05-15 18:12
---
@@ -7,27 +7,35 @@ create time: 2026-05-15 18:12
## 概述
-按使用频率排序的常用命令,标注时间复杂度、典型场景和避坑点。完整命令参考官方文档。
+本文按数据类型分类梳理 Redis 常用命令,标注**时间复杂度**、典型场景和生产避坑点。覆盖 String / Hash / List / Set / ZSet 五大基础类型,以及 Pub/Sub、Stream、Geo、Lua 脚本等进阶能力。每条命令配有 Go(go-redis)代码示例,方便直接复制使用。
-### 数据类型选型速查
+> [!NOTE] 怎么用这份速查表?
+> - 日常开发直接 Ctrl+F 搜命令名
+> - 遇到新业务需求先看数据类型选型流程图,再查对应章节
+> - 每个章节末尾的 callout 是"老手踩过的坑",建议通读一遍
+
+## 数据类型选型速查
遇到业务需求时,按以下流程选择合适的 Redis 数据结构:
```mermaid
flowchart TD
- A["需要存什么?"] --> B{"单值 / 键值对?"}
- B -- 是 --> C["String
缓存, 计数器, 分布式锁"]
- B -- 否 --> D{"多个字段 / 对象?"}
- D -- 是 --> E["Hash
用户信息, 配置, 表单数据"]
- D -- 否 --> F{"需要排序 / 排名?"}
- F -- 是 --> G["ZSet
排行榜, 延迟队列, 范围查询"]
- F -- 否 --> H{"需要去重 / 集合运算?"}
- H -- 是 --> I["Set
标签, 共同关注, UV 统计"]
- H -- 否 --> J{"需要队列 / 栈?"}
- J -- 是 --> K["List
消息队列, 最新列表, 栈"]
- J -- 否 --> L["Pub/Sub
广播, 实时通知"]
+ start["需要存什么?"] --> q1{"单值 / 键值对?"}
+ q1 -- 是 --> s1["String
缓存, 计数器, 分布式锁"]
+ q1 -- 否 --> q2{"多个字段 / 对象?"}
+ q2 -- 是 --> s2["Hash
用户信息, 配置, 表单数据"]
+ q2 -- 否 --> q3{"需要排序 / 排名?"}
+ q3 -- 是 --> s3["ZSet
排行榜, 延迟队列, 范围查询"]
+ q3 -- 否 --> q4{"需要去重 / 集合运算?"}
+ q4 -- 是 --> s4["Set
标签, 共同关注, UV 统计"]
+ q4 -- 否 --> q5{"需要队列 / 栈?"}
+ q5 -- 是 --> s5["List
消息队列, 最新列表, 栈"]
+ q5 -- 否 --> s6["Pub/Sub 或 Stream
广播通知 / 可靠消息队列"]
```
+> [!TIP] Stream 也是数据类型
+> Redis 5.0 新增的 Stream 适合**需要持久化和消费确认**的消息场景。选型时如果 Pub/Sub 的 "fire-and-forget" 不满足需求,直接上 Stream(详见下方 Stream 章节)。
+
## String — 字符串操作
| 命令 | 返回值 | 复杂度 | 说明 |
@@ -302,14 +310,14 @@ redis-cli --pipeline <<< $'SET k1 v1\nSET k2 v2\nSET k3 v3'
sequenceDiagram
participant C as "客户端"
participant S as "Redis 服务端"
- C->>S: MULTI(开启事务)
+ C->>S: "MULTI 开启事务"
S-->>C: OK
- C->>S: SET acc:A 900
- S-->>C: QUEUED(排队,不立即执行)
- C->>S: SET acc:B 1100
+ C->>S: "SET acc:A 900"
+ S-->>C: "QUEUED 排队,不立即执行"
+ C->>S: "SET acc:B 1100"
S-->>C: QUEUED
- C->>S: EXEC(提交)
- S-->>C: [OK, OK](依次执行,一次性返回结果)
+ C->>S: "EXEC 提交"
+ S-->>C: "[OK, OK] 依次执行,一次性返回"
```
> [!QUESTION] "QUEUED" 是什么意思?
@@ -344,9 +352,9 @@ sequenceDiagram
A->>S: WATCH stock:1001
A->>S: MULTI
A->>S: DECR stock:1001
- B->>S: SET stock:1001 999(被其他客户端修改)
+ B->>S: "SET stock:1001 999 被其他客户端修改"
A->>S: EXEC
- S-->>A: nil(事务被取消,因为 stock:1001 已变)
+ S-->>A: "nil 事务被取消,stock:1001 已变"
```
> [!NOTE] WATCH 的使用范式
@@ -402,6 +410,64 @@ fmt.Printf("delivered to %d subscribers\n", receivers)
> - **Pub/Sub**:实时广播,不关心历史,用完即丢(适合通知、缓存失效广播)
> - **Stream**:持久化消息队列,支持消费组、ACK、回溯(适合业务事件、任务队列)
> - 简单规则:**需要"至少一次"保证就用 Stream,否则 Pub/Sub 足够**
+> - 两者命令对比详见 [[hhs/Redis/15-Stream]]
+
+## Stream — 消息队列
+
+Redis 5.0 引入的日志型数据结构,弥补了 Pub/Sub 不持久化的缺陷。底层用 Radix Tree + Listpack 实现,支持消费组(Consumer Group)和消息确认(ACK),可胜任轻量级任务队列。
+
+| 命令 | 返回值 | 复杂度 | 说明 |
+|------|--------|--------|------|
+| `XADD key [MAXLEN ~ n] ID field value [field value ...]` | entry ID | O(1) | 追加消息,`*` 自动生成 ID(时间戳-序号) |
+| `XLEN key` | length | O(1) | 消息总数 |
+| `XRANGE key start end [COUNT n]` | entries | O(N) | 按 ID 范围正序读取(`-`/`+` 表示最小/最大) |
+| `XREVRANGE key end start [COUNT n]` | entries | O(N) | 按 ID 范围倒序读取 |
+| `XREAD [COUNT n] [BLOCK ms] STREAMS key [key ...] ID [ID ...]` | entries | O(N) | 读取消息,`$` 表示仅新消息,BLOCK 阻塞等待 |
+| `XGROUP CREATE key groupname ID [MKSTREAM]` | OK | O(1) | 创建消费者组,`$` 只消费新消息,`0` 从头消费 |
+| `XREADGROUP GROUP group consumer [COUNT n] [BLOCK ms] [NOACK] STREAMS key [key ...] ID` | entries | O(N) | 组内消费,`>` 表示未分配的新消息 |
+| `XACK key group ID [ID ...]` | acked count | O(N) | 确认消费,释放消息 |
+| `XDEL key ID [ID ...]` | deleted count | O(N) | 删除指定消息 |
+| `XTRIM key MAXLEN [~] n` | trimmed count | O(N) | 裁剪流长度,`~` 近似裁剪(性能更好) |
+| `XPENDING key group [start end count] [consumer]` | pending info | O(N) | 查看未确认(pending)消息 |
+| `XCLAIM key group consumer min-idle-time ID [ID ...]` | entries | O(N) | 认领超时未 ACK 的消息(故障转移) |
+| `XINFO STREAM key` | stream info | O(1) | 查看流元信息 |
+| `XINFO GROUPS key` | group info | O(N) | 查看所有消费者组 |
+
+```go
+// 生产者——追加消息
+id, _ := rdb.XAdd(ctx, &redis.XAddArgs{
+ Stream: "order:events",
+ MaxLen: 10000, // 近似裁剪,控制内存
+ Approx: true,
+ Values: map[string]interface{}{"orderId": "1001", "status": "paid"},
+}).Result()
+fmt.Println("entry ID:", id)
+
+// 消费者组——组内竞争消费
+msgs, _ := rdb.XReadGroup(ctx, &redis.XReadGroupArgs{
+ Group: "order-consumers",
+ Consumer: "worker-1",
+ Streams: []string{"order:events", ">"},
+ Count: 10,
+ Block: 5 * time.Second,
+}).Result()
+
+for _, stream := range msgs {
+ for _, msg := range stream.Messages {
+ // 处理消息...
+ rdb.XAck(ctx, "order:events", "order-consumers", msg.ID) // 确认消费
+ }
+}
+```
+
+> [!WARNING] 消息丢失的两个陷阱
+> 1. **生产端**:`XADD` 返回后消息已写入内存,但若 Redis 崩溃且未配置 AOF,消息会丢失。对可靠性要求高的场景请开启 `appendfsync everysec`
+> 2. **消费端**:`XREADGROUP` 读取后消息进入 Pending List(PEL),必须显式 `XACK` 才算消费完成。忘记 ACK 会导致消息堆积在 PEL 中,需要用 `XPENDING` + `XCLAIM` 清理
+
+> [!TIP] MAXLEN vs MINID
+> - `XADD key MAXLEN ~ 10000`:限制流最大约 10000 条(近似裁剪,性能好)
+> - `XADD key MINID ~ 1685000000000-0`:限制最小消息 ID(按时间裁剪,7.0+)
+> - 两者都建议加 `~` 做近似裁剪,避免精确裁剪带来的 O(N) 阻塞
## Geo — 地理位置
@@ -482,6 +548,10 @@ result, _ := script.Run(ctx, rdb, []string{"stock:sku:1001"}, 1).Int()
## 关联笔记
-- [[hhs/Redis/02-核心数据类型]] — 每种类型的底层编码原理
-- [[hhs/Redis/05-AOF持久化]] — 持久化策略对性能的影响
-- [[hhs/Redis/09-高级特性]] — Pipeline / Lua / 事务详解
+- [[02-核心数据类型]] — 每种类型的底层编码原理(ziplist → hashtable 切换机制)
+- [[02-核心数据类型/02-2-skiplist]] — ZSet 底层跳表实现,理解 ZRANGE 为什么是 O(log N+M)
+- [[04-RDB持久化]] / [[05-AOF持久化]] — 持久化策略对命令性能的影响
+- [[08-SortedSet精解]] — ZSet 进阶玩法:排行榜、延迟队列、优先级队列
+- [[09-高级特性]] — Pipeline / Lua / 事务深入解析
+- [[15-Stream]] — Stream 完整指南:Consumer Group 模式、消息确认、故障转移
+- [[16-GEO]] — Geo 底层原理与围栏检测实战
diff --git a/hhs/Redis/09-高级特性.md b/hhs/Redis/09-高级特性.md
index ec2f082..6ae2680 100644
--- a/hhs/Redis/09-高级特性.md
+++ b/hhs/Redis/09-高级特性.md
@@ -59,6 +59,24 @@ EXEC
# → [OK, 124] — 两个都成功了
```
+```mermaid
+sequenceDiagram
+ participant C as "Client"
+ participant S as "Redis Server"
+ C->>S: MULTI
+ S-->>C: OK
+ C->>S: SET k v
+ S-->>C: QUEUED
+ C->>S: INCR counter
+ S-->>C: QUEUED
+ C->>S: EXEC
+ Note over S: 依次执行所有排队命令
+ S-->>C: [OK, 1]
+```
+
+> [!QUESTION] 既然 MULTI 能批量执行,为什么还需要 Pipeline?
+> 关键区别在于**排队阶段是否立即返回结果**。MULTI 的 QUEUED 只是"记下来",命令要等 EXEC 才真正执行——这意味着你**无法根据前一条的结果决定后续命令**。而 Lua 脚本能做"读-判断-写",Pipeline 则纯粹为减少 RTT。三者各有分工,切勿混淆。
+
> [!WARNING] Redis 事务 ≠ 数据库事务
> - 没有 ROLLBACK / ACID 保证
> - 不能捕获异常后回滚
@@ -179,18 +197,17 @@ EVALSHA numkeys args... # 只传 SHA1,省带宽
### Lua 沙箱限制
```lua
--- 不可用:非确定性函数(传统 Redis)
-math.random() -- ❌ 无法预测结果
-os.date() -- ❌ 依赖系统时间
-time.time() -- ❌ Lua 标准库不存在
+-- ❌ 不可用:非确定性函数(传统 Redis < 7.2)
+math.random() -- 无法预测结果
+os.date() -- 依赖系统时间
--- ✅ 推荐做法:在客户端生成,通过 ARGV 传入
+-- ✅ 推荐做法:在客户端生成随机值,通过 ARGV 传入
-- local token = client.generateUUID()
-- redis.call('set', KEYS[1], ARGV[1]) -- 带 token 写入
+```
> [!NOTE] Redis 7.2+ 更新
> Redis 7.2 起开放了部分确定性数学函数:`math.random`(需自行 set seed)、`math.max`、`math.min`、`math.log`。但不建议在状态机类脚本中使用随机性。
-```
> [!QUESTION] 为什么 Lua 脚本不能有随机函数?
> Lua 脚本在主线程中同步执行,如果引入随机性或时间依赖,会导致两个严重问题:
@@ -410,6 +427,40 @@ for msg := range ch {
> - **从节点不会推送**:默认只在写入节点生效,哨兵/集群切换后需重新订阅
> - **建议**:只订阅你需要的事件类型(如 `KElx`),而非 `KExa` 通配一切
+## 六、选型指南——如何选择?
+
+面对一个需求,到底该用事务、Lua、Pipeline 还是 Stream?以下是决策流程:
+
+```mermaid
+flowchart TD
+ A["需求场景"] --> B{"批量命令?"}
+ B -->|"否"| C["单条命令即可"]
+ B -->|"是"| D{"需要原子性?"}
+ D -->|"否"| E["Pipeline"]
+ D -->|"是"| F{"读-判断-写?"}
+ F -->|"是"| G["Lua 脚本"]
+ F -->|"否"| H{"支持 Cluster?"}
+ H -->|"是"| G
+ H -->|"否"| I["MULTI / EXEC"]
+ A --> J{"消息传递?"}
+ J -->|"可靠投递/任务队列"| K["Stream"]
+ J -->|"实时广播/通知"| L["Pub/Sub"]
+```
+
+| 需求 | 推荐方案 | 理由 |
+|------|---------|------|
+| 批量写入、减少 RTT | Pipeline | 无原子性要求,纯性能优化 |
+| 读-判断-写原子操作 | Lua 脚本 | 逻辑原子性,一气呵成 |
+| 简单批量 + 单机 | MULTI / EXEC | 轻量,无需写 Lua |
+| 任务队列、事件溯源 | Stream | 持久化 + Consumer Group |
+| 实时通知、WebSocket 广播 | Pub/Sub | 无状态、低延迟 |
+
+> [!TIP] 一句话总结
+> - **Pipeline**:省网络,不保证顺序
+> - **MULTI**:保证顺序,不保证逻辑原子
+> - **Lua**:真正的原子性,但阻塞主线程(脚本要短小精悍)
+> - **Stream**:需要消息可靠投递时的首选
+
## 关联笔记
- [[hhs/Redis/06-主从与哨兵]] — Sentinel 故障检测(也是基于 PING/PONG 心跳)