Skip to content

Redis 跳跃表(ZSet 底层实现)

你可能见过这张图:

                    ┌───┐
                    │ 1 │──────────────┐
              ─────►└───┘              │
          ┌───┐  ┌───┐  ┌───┐      ┌───┐
    ─────►│ 3 │──│ 5 │──│ 7 │──────│ 9 │───► NULL
          └───┘  └───┘  └───┘      └───┘
          ┌───┐  ┌───┐  ┌───┐      ┌───┐
    ─────►│ 2 │──│ 4 │──│ 6 │──────│ 8 │───► NULL
          └───┘  └───┘  └───┘      └───┘
          ┌───┐  ┌───┐  ┌───┐      ┌───┐
    ─────►│ 1 │──│ 2 │──│ 3 │──────│ 4 │───► NULL
          └───┘  └───┘  └───┘      └───┘

这就是跳跃表(Skip List),ZSet 的底层实现之一。

为什么 Redis 选择了跳表而不是红黑树?跳表是怎么工作的?

为什么要跳跃表?

先从一个问题开始:如何在有序数组中查找一个元素?

  • 顺序遍历:O(n)
  • 二分查找:O(log n)

但有序数组的插入/删除是 O(n),因为需要移动元素。

链表的插入/删除是 O(1),但查找是 O(n)。

跳跃表就是来解决这个矛盾的:让链表也能二分查找。

跳跃表的核心思想

第一次跳跃:建立索引

普通链表:

1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → 10 → NULL

建立一层索引(每两个节点抽一个):

1 ────────────────────→ 5 ────────────────────→ 9 → NULL
1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → 10 → NULL

查找 7:

  1. 在索引层从 1 跳到 5,发现 5 < 7,继续
  2. 跳到 9,发现 9 > 7,从 5 降到原层
  3. 从 5 遍历到 7,找到

原来需要遍历 7 次,现在只需要 1 + 3 = 4 次

多次跳跃:更稀疏的索引

                              ┌───────────────────┐
                              │ 1 ────────────────→ 9 → NULL

1 ────────────────→ 3 ────────────────→ 5 ────────────────→ 7 ────────────────→ 9 → NULL
▼               ▼               ▼               ▼               ▼
1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → 10 → NULL

查找 8:

  1. 第 2 层:1 → 9(9 > 8,降层)
  2. 第 1 层:5 → 9(9 > 8,降层)
  3. 第 0 层:遍历 7 → 8,找到

层数越多,查找越快。理论上,n 个节点的跳表高度约为 log₂n,每层最多遍历 1 个节点。

跳表的数据结构

Redis 中的实现

c
// 跳表节点
typedef struct zskiplistNode {
    robj *obj;                    // 成员对象(SDS)
    double score;                 // 分值(排序依据)
    struct zskiplistLevel {
        struct zskiplistNode *forward;  // 前进指针
        unsigned int span;             // 跨度(用于计算 rank)
    } level[];
} zskiplistNode;

// 跳表
typedef struct zskiplist {
    struct zskiplistNode *header, *tail;  // 头尾节点
    unsigned long length;                   // 节点数量
    int level;                             // 最大层数
} zskiplist;

关键设计:随机层数

新节点插入时,层数是随机决定的:

java
/**
 * Redis 使用这个算法决定层数:
 * 
 * 每次有 50% 概率升一层
 * 
 * 层数分布:
 * 1 层:50%
 * 2 层:25%
 * 3 层:12.5%
 * 4 层:6.25%
 * ...
 * 
 * 平均每个节点的层数约为 1/(1-p) = 2
 * 当 p = 0.5 时
 * 
 * 这样设计:
 * - 保证上层节点是下层的子集(索引稀疏均匀)
 * - 空间开销可控(层数期望为常数)
 * - 实现简单,不需要平衡操作
 */

Redis 的具体实现:

c
// 随机层数生成(Redis 源码)
int zslRandomLevel(void) {
    int level = 1;
    // ZSKIPLIST_P = 0.25,每层有 25% 概率升一层
    while ((random() & 0xFFFF) &lt; (ZSKIPLIST_P * 0xFFFF))
        level += 1;
    // 最大层数 32
    return (level &lt; ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}

注意:Redis 使用 p=0.25(每层 25% 概率升一层),而不是 50%。

跳表的操作

插入操作

java
/**
 * 跳表插入步骤:
 * 
 * 1. 找到每一层的插入位置(记录前驱节点)
 * 2. 生成随机层数
 * 3. 创建新节点,逐层插入
 * 4. 更新每一层的指针
 * 
 * 时间复杂度:O(log n)
 * - 查找位置:O(log n)(多层索引加速)
 * - 插入节点:O(1)(只需要修改指针)
 */

伪代码:

java
public void insert(zskiplist zsl, double score, robj obj) {
    // 1. 找到每一层的插入位置
    zskiplistNode[] update = new zskiplistNode[zsl.level];
    int[] rank = new int[zsl.level];
    
    zskiplistNode x = zsl.header;
    for (int i = zsl.level - 1; i >= 0; i--) {
        rank[i] = (i == zsl.level - 1) ? 0 : rank[i + 1];
        while (x.level[i].forward != null && 
               (x.level[i].forward.score &lt; score || 
                (x.level[i].forward.score == score && 
                 compare(x.level[i].forward.obj, obj) &lt; 0))) {
            rank[i] += x.level[i].span;
            x = x.level[i].forward;
        }
        update[i] = x;
    }
    
    // 2. 生成随机层数
    int level = randomLevel();
    
    // 3. 创建新节点并插入
    for (int i = 0; i &lt; level; i++) {
        x.level[i].forward = update[i].level[i].forward;
        update[i].level[i].forward = x;
        // 更新 span
    }
}

范围查询

java
/**
 * ZRANGEBYSCORE 如何利用跳表?
 * 
 * 1. 从最高层开始,找到第一个 >= min 的节点
 * 2. 逐层下降,定位到具体位置
 * 3. 顺序遍历收集结果
 * 
 * 定位:O(log n)
 * 遍历:O(m),m 为返回元素数量
 * 
 * 总复杂度:O(log n + m)
 */

删除操作

java
/**
 * 删除类似插入:
 * 1. 找到每一层的节点位置
 * 2. 更新前后指针
 * 3. 释放内存
 * 
 * 时间复杂度:O(log n)
 */

为什么 Redis 选择跳表而不是红黑树?

这是面试经典问题,来看对比:

特性跳表红黑树
实现复杂度简单(几百行)复杂(平衡旋转)
范围查询天然支持(顺序遍历)需要中序遍历
插入/删除只改指针,均摊 O(log n)需要旋转,可能 O(log n)
查找O(log n)O(log n)
空间复杂度O(n log n)(索引层)O(n)
线程安全单线程无需加锁多线程需要锁

Redis 选择跳表的原因

java
/**
 * 1. 实现简单:
 *    - Redis 代码量有限,跳表更容易维护
 *    - 红黑树的旋转、染色等操作容易出错
 * 
 * 2. 范围查询优势:
 *    - ZSet 需要频繁做 ZRANGE、ZREVRANGE
 *    - 跳表顺序遍历更自然
 *    - 红黑树需要中序遍历,代码复杂
 * 
 * 3. 更容易实现:
 *    - Redis 作者 antirez 亲口说过:实现简单是重要原因
 * 
 * 4. 内存开销可接受:
 *    - 跳表索引层约占用 2n 个节点
 *    - 对于现代内存不值一提
 * 
 * 5. 单线程无需考虑并发:
 *    - 不需要像红黑树那样加锁
 */

跳表 vs 红黑树 vs 平衡树

java
public class SkipListVsTree {
    
    /**
     * 跳表:
     * - LevelDB、RocksDB 的 MemTable
     * - Redis ZSet
     * - 优势:实现简单,范围查询友好
     */
    
    /**
     * 红黑树:
     * - JDK TreeMap
     * - Linux 内核调度器
     * - 优势:内存占用稳定,查找稳定 O(log n)
     */
    
    /**
     * B+ 树:
     * - MySQL InnoDB 索引
     * - 优势:磁盘友好,减少磁盘 IO
     */
    
    /**
     * 选择依据:
     * - 数据在内存中,选跳表或红黑树
     * - 数据在磁盘上,选 B+ 树
     * - 需要简单实现,选跳表
     */
}

ZSet 的双重索引

前面讲过,ZSet 同时使用跳表和哈希表

java
/**
 * ZSet 结构:
 * 
 * typedef struct zset {
 *     dict *dict;      // member → score,O(1) 查找分数
 *     zskiplist *zsl;  // 按 score 排序,支持范围查询
 * } zset;
 * 
 * 为什么需要两个?
 * 
 * 1. 跳表操作:
 *    - ZRANGE:按排名范围查(跳表天然有序)
 *    - ZRANK:获取排名(跳表记录了 span)
 *    - ZRANGEBYSCORE:按分数范围查
 * 
 * 2. 哈希表操作:
 *    - ZSCORE:获取某成员的分数(O(1))
 *    - ZREM:删除成员
 * 
 * 3. 如果只用跳表:
 *    - ZSCORE 需要遍历,O(n)
 *    - 无法满足性能要求
 * 
 * 4. 如果只用哈希表:
 *    - 范围查询需要遍历所有元素,O(n)
 * 
 * 两者结合,各司其职
 */

面试场景模拟

面试官:Redis ZSet 用什么实现的?

候选人:跳表和哈希表。

面试官:为什么用跳表而不用红黑树?

候选人(思考后):主要有三个原因...

(好的候选人应该能展开讲)

  1. 实现简单:Redis 代码量有限,跳表的插入删除只需要改指针,不需要旋转平衡。
  2. 范围查询友好:ZSet 需要频繁做 ZRANGE 范围查询,跳表顺序遍历更自然,红黑树需要中序遍历。
  3. 单线程无需并发控制:跳表的指针修改在单线程环境下天然安全,红黑树的多线程操作需要加锁。

面试官:跳表是怎么保证上层索引是下层子集的?

候选人:通过随机层数。Redis 用 25% 的概率决定每层是否升级,所以上层节点数量大约是下层的 1/4。这样在查找时,上层找不到就降层继续找,最终一定能定位到目标。

性能分析

跳表的性能取决于层数和节点分布

java
/**
 * 跳表查找性能分析:
 * 
 * 假设 n 个节点,p = 0.25(每层 25% 概率升层)
 * 
 * 1. 层数期望:
 *    L(n) ≈ log_{1/p}(n) = log_4(n) ≈ log₂(n) / 2
 *    例如 n = 1000000,L ≈ 10
 * 
 * 2. 每层遍历期望:
 *    每层最多遍历常数个节点(因为上层稀疏)
 * 
 * 3. 总体时间复杂度:
 *    O(log n)
 * 
 * 4. 与红黑树对比:
 *    - 期望复杂度相同
 *    - 红黑树更稳定(最坏情况也是 O(log n))
 *    - 跳表有随机性,但概率极低
 */

总结

跳跃表是 Redis ZSet 的核心数据结构,理解它需要理解:

概念说明
多层索引稀疏索引加速查找
随机层数均匀分布的保证
指针操作插入删除只需要改指针
与 dict 配合满足不同操作的需求

留给你的问题

Redis 的跳表最大层数是 32,可以存储 2^32 个元素。

如果 Redis 存储了 2^32 个元素,跳表的实际层数会达到 32 吗?为什么?

提示:考虑随机层数的概率分布。

基于 VitePress 构建