From 7e4671fe25ed3cc81fb7f5d986d1ae6e2b9c4cf6 Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Mon, 25 May 2026 20:51:57 +0800 Subject: [PATCH] vault backup: 2026-05-25 20:51:57 --- hhs/Redis/01-安装与部署.md | 44 +- hhs/Redis/02-核心数据类型.md | 56 +- .../02-核心数据类型/02-1-ziplist与listpack.md | 240 ++++++++ hhs/Redis/02-核心数据类型/02-2-skiplist.md | 520 ++++++++++++++++++ hhs/Redis/02-核心数据类型/02-3-hashtable.md | 331 +++++++++++ hhs/Redis/02-核心数据类型/02-4-quicklist.md | 342 ++++++++++++ hhs/Redis/03-基本命令速查.md | 120 +++- hhs/Redis/09-高级特性.md | 63 ++- 8 files changed, 1673 insertions(+), 43 deletions(-) create mode 100644 hhs/Redis/02-核心数据类型/02-1-ziplist与listpack.md create mode 100644 hhs/Redis/02-核心数据类型/02-2-skiplist.md create mode 100644 hhs/Redis/02-核心数据类型/02-3-hashtable.md create mode 100644 hhs/Redis/02-核心数据类型/02-4-quicklist.md 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 心跳)