Files
cs-note/hhs/Redis/02-核心数据类型/02-2-skiplist.md
T
2026-05-25 20:51:57 +08:00

20 KiB
Raw Blame History

tags, create time, author
tags create time author
Redis
底层数据结构
skiplist
ZSet
2026-05-25 10:30 hhs

skiplist(跳表)

概述

skiplist(跳表)是 Redis ZSet 的核心排序结构。它用多层索引把有序链表的查找从 O(N) 优化到 O(logN),实现简单、性能稳定——是 Redis 选择它而不是红黑树的原因。

一、从有序链表到跳表

[!question] 为什么不直接用有序链表? 有序链表查找要从头逐个遍历,O(N)。100 万个元素,最坏要走 100 万步。

加一层索引

如果我们每两个节点提取一个"索引指针",查找时先在索引层跳着走,跳过目标了再降下去——步数直接减半。

再多加几层索引呢?每层跳过上一层的节点,查找路径像"跳台阶"一样逐层逼近:

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 节点比教科书版多了几个字段:

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):

// 单层索引
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。

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 的配合示意:

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)

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。

简化搜索实现

把上面的文字描述翻译成代码,核心逻辑只有几行:

// 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 层上限。

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] 插入一个新节点,要改哪些指针? 核心思路:找到每一层的"前驱节点",然后逐层缝入新节点。这就像拉链——先定位每一层的缺口,再把新节点的指针串进去。

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 计算细节,聚焦指针操作):

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,不影响其他层
  • 不需要旋转:对比红黑树插入后可能触发的多次旋转+重着色,跳表的插入操作"一气呵成"

删除过程

删除和插入是对称操作:同样先找前驱,然后反向拆链。

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 就要全量扫描。

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
// 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 个

关联笔记