521 lines
20 KiB
Markdown
521 lines
20 KiB
Markdown
---
|
||
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 的高级用法与性能优化
|