Redis 作为一款高性能的内存数据库,其背后隐藏着许多精巧的数据结构设计。今天我们就来聊聊 Redis 中有序集合(Sorted Set)的核心实现技术——跳表(Skip List)。如果你曾经好奇为什么 Redis 的有序集合能够同时兼顾高效的查询和修改操作,那么这篇文章正是为你准备的!

什么是跳表?

跳表是一种随机化的数据结构,可以被看作是对有序链表的优化升级版。它通过维护多层索引来加快查找速度,平均查找复杂度可以达到 O(log n),这与平衡树相当。

想象一下,如果我们有一个包含 1000 个节点的普通链表,要查找其中的某个值,我们可能需要遍历整个链表,最坏情况下要比较 1000 次。而使用跳表,我们可以显著减少比较次数,通常只需要约 10 次左右的比较!

graph LR
    A[普通链表: 1 -> 3 -> 7 -> 9 -> 12 -> 15 -> 19 -> 21 -> 25]
graph TD
    L3[Level 3] --- 1 --- 19 --- 25
    L2[Level 2] --- 1 --- 9 --- 19 --- 25
    L1[Level 1] --- 1 --- 7 --- 9 --- 19 --- 21 --- 25
    L0[Level 0] --- 1 --- 3 --- 7 --- 9 --- 12 --- 15 --- 19 --- 21 --- 25

为什么 Redis 选择跳表?

你可能会问,为什么 Redis 不使用平衡树(如红黑树)来实现有序集合呢?这个问题很有意思!

  1. 实现简单:跳表的代码实现比平衡树简单得多,维护起来也更容易
  2. 内存占用更灵活:跳表的内存布局比平衡树更加灵活
  3. 范围查询友好:跳表天然支持高效的范围查询,这正是有序集合的常见操作
  4. 插入、删除操作更简单:不需要复杂的树旋转操作来维持平衡

Redis 的作者 Antirez 曾说过:"跳表在实践中的效率几乎与平衡树一样好,但实现要简单太多了。"这就是技术选型的智慧!

Redis 跳表的实现细节

节点结构

Redis 中的跳表节点(zskiplistNode)包含以下关键信息:

typedef struct zskiplistNode {
    sds ele;                    // 节点存储的对象
    double score;               // 节点的分数
    struct zskiplistNode *backward;  // 后退指针
    struct zskiplistLevel {
        struct zskiplistNode *forward;  // 前进指针
        unsigned long span;             // 跨度
    } level[];                          // 层
} zskiplistNode;

想象成现实生活中的例子:每个节点就像一栋多层的大楼,不同的是,每栋大楼可能有不同的层数。每层都有一条"天桥"(forward 指针)连向前方的某栋大楼。

backward 指针是指向前一个节点的指针,主要用于逆序遍历跳表。当执行类似ZREVRANGE这样的命令时,Redis 需要从高分到低分访问元素,这时就可以直接通过 backward 指针向前移动,而不需要从头重新查找,大大提高了逆向遍历的效率。

同样,ZREVRANK命令(获取元素的逆序排名)也利用了 backward 指针。与ZRANK使用正向遍历和累加 span 计算排名不同,ZREVRANK可以从尾节点开始,通过 backward 指针逆向遍历,同时累加相应层次的 span 值,从而高效地计算出元素的逆序排名。

跨度(span)是一个很关键但容易被忽视的字段,它记录了当前层指针跳过了多少个节点。这个设计非常巧妙,主要有两个作用:

  1. 快速计算排名:在执行ZRANK命令时,可以通过累加查找路径上的 span 值直接得到目标元素的排名,无需再次遍历底层链表
  2. 高效区间操作:在范围查询时,利用 span 可以直接知道两个节点之间有多少元素,简化了代码实现
graph LR
    A["节点结构
    - 数据
    - 分数
    - 后退指针(用于逆序遍历)
    - 多个层级(每层有前进指针和计算排名的跨度)"]

跳表结构

整个跳表(zskiplist)的结构如下:

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

就像是一个特殊的小区,记录着最高的那栋楼有多少层(level),小区里有多少栋楼(length),以及小区的入口(header)和尽头(tail)。

值得注意的是,tail指针方便了从尾部访问元素,这对于获取分数最高的元素非常有用,例如ZREVRANGE命令就可以直接从 tail 开始,通过 backward 指针向前遍历。

层数随机生成算法

每个新节点的层数是如何决定的?Redis 使用一个随机算法:

int zslRandomLevel(void) {
    int level = 1;
    while ((random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF))
        level += 1;
    return (level<ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}

这个算法遵循几何分布模型,具体实现细节如下:

  1. random()&0xFFFF通过位运算(&)截取随机数的低 16 位,生成 0-65535 之间的均匀分布随机数
  2. ZSKIPLIST_P * 0xFFFF将概率 P(0.25)转换为对应的阈值,即 0.25 * 65535 ≈ 16384
  3. 随机数小于 16384 的概率约为 25%,这样就实现了每一层以 25%概率继续增长

实际上,每个节点至少有 1 层,当随机数小于阈值时,层数增加 1,直到随机数大于阈值或达到最大层数(32)为止。

从数学角度看,这实现了一个修改版的几何分布。令 X 表示额外增加的层数(即总层数减 1),则 X 服从几何分布,其概率质量函数为:
P(X = k) = (1-p)p^k,其中 p=0.25

节点的总层数为 1+X,其期望值为:
E[level] = 1 + E[X] = 1 + p/(1-p) = 1 + 0.25/0.75 = 1 + 1/3 = 4/3 ≈ 1.33

这就意味着平均每个节点有约 1.33 层,而不是前文错误提到的 4 层。这种设计确保了跳表的期望高度为 O(log n),从而维持查找复杂度的平衡性。

flowchart TD
    A[开始] --> B[level = 1]
    B --> C{"随机数(0-65535) < 16384?"}
    C -->|是 25%概率| D[level += 1]
    D --> C
    C -->|否 75%概率| E[返回level]
    E --> F[结束]

内存优化:柔性数组的应用

注意节点结构中的level[]是 C99 标准引入的柔性数组(Flexible Array Member)。这种设计允许每个节点根据实际需要的层数动态分配内存,而不是为所有节点都分配固定大小的内存(例如最大 32 层)。

当创建一个新节点时,Redis 会根据随机生成的层数 n 来分配内存:

zskiplistNode *zslCreateNode(int level, double score, sds ele) {
    zskiplistNode *zn = zmalloc(sizeof(*zn)+level*sizeof(struct zskiplistLevel));
    // ...初始化节点...
    return zn;
}

这种设计显著节省了内存空间,尤其是在大量节点的情况下,因为大多数节点的层数都比较少(接近平均值 1.33)。

跳表的核心操作

查找操作

查找操作是跳表的精髓所在。想象你在寻找分数为 20 的节点:

  1. 从最高层开始查找
  2. 在每一层,向前查找直到遇到大于等于目标值的节点
  3. 如果当前层没找到,就下降一层继续查找
  4. 重复以上步骤直到最底层

这就像是在高楼大厦间穿梭:先从高空瞭望,快速跳过大片区域,然后逐渐降低高度,最终精确定位到目标。

在查找过程中,如果需要知道元素排名(如 ZRANK 命令),可以累加路径上经过的所有跨度(span)值,得到的总和就是目标元素的排名。这比在找到元素后再从头遍历计算排名要高效得多。

graph TB
    Start[开始从最高层查找] --> A{当前节点的下一个值 <= 目标值?}
    A -->|是| B[向前移动到下一个节点累加span值计算排名]
    B --> A
    A -->|否| C{是最底层?}
    C -->|否| D[降到下一层]
    D --> A
    C -->|是| E[找到目标或确定不存在]

举个生活例子:假设你在一个按房价排序的小区里找一套价格为 800 万的房子。你可能先站在高处,看到前方有 500 万区域和 1000 万区域,那么就知道应该去 500 万和 1000 万之间找。然后降低高度,进一步缩小范围,最终找到目标位置。同时,你还能记录跳过了多少房子,从而知道这套房子在整个小区的排名。

插入操作

插入新节点需要以下步骤:

  1. 找到合适的插入位置(同时记录每一层的前驱节点)
  2. 随机生成新节点的层数
  3. 更新各层的指针,将新节点链接到跳表中
  4. 更新跨度(span)值,确保排名计算的正确性
  5. 设置后退指针(backward),支持逆序遍历
  6. 更新跳表的相关信息(如最大层数、节点计数)

在实现中,需要特别注意 span 值的更新:对于新节点层数 n 以内的层,需要调整前驱节点的 span 值(减去新节点的 span);对于高于 n 的层,需要将前驱节点的 span 值加 1(因为底层增加了一个节点)。

graph TB
    A[查找插入位置并记录每层的前驱节点] --> B[随机生成新节点的层数]
    B --> C[创建新节点根据层数动态分配内存]
    C --> D[更新前驱节点和新节点之间的指针]
    D --> E[更新各层的span值确保排名计算正确]
    E --> F[设置后退指针支持逆序遍历]
    F --> G[更新跳表的最大层数和节点总数]

删除操作

删除操作与插入类似,但方向相反:

  1. 找到需要删除的节点,同时记录每层的前驱节点
  2. 更新所有层的指针,绕过要删除的节点
  3. 调整 span 值,维持排名计算的准确性
  4. 更新相邻节点的 backward 指针
  5. 释放节点内存

在删除操作中,span 值的更新非常关键。以一个具体例子说明:

假设有三个节点 A → X → B,其中 X 是要被删除的节点。在删除前,A 的某一层有一个指向 B 的 forward 指针,span 值为 5(表示从 A 到 B 跨越了 5 个底层节点,包括 X 本身)。当删除 X 后,A 仍然指向 B,但中间少了一个节点,因此 span 值应更新为 4(原 span 值减 1)。

对于更高层的前驱节点,如果它们原本就跨过了 X 节点(即 forward 指针不指向 X),则 span 值也需要减 1,因为底层总节点数减少了 1 个。

这种细致的 span 值调整确保了删除操作后排名计算仍然准确。

案例:使用跳表解决排行榜问题

假设我们需要为一个在线游戏设计一个实时更新的玩家积分排行榜。要求能够:

  • 快速查询某玩家的排名
  • 获取前 N 名玩家
  • 实时更新玩家分数

这正是 Redis Sorted Set 的看家本领!

// 添加或更新玩家分数
ZADD leaderboard 1500 "player1"
ZADD leaderboard 2200 "player2"
ZADD leaderboard 1800 "player3"

// 获取玩家排名(从高到低)
ZREVRANK leaderboard "player2"  // 返回0(第一名)

// 获取前3名玩家
ZREVRANGE leaderboard 0 2 WITHSCORES

在这个案例中,Redis 内部使用跳表维护所有玩家的分数。排名查询通过累加查找路径上的 span 值实现 O(log n)的复杂度;范围查询先用 O(log n)时间定位起始位置,然后通过 forward 指针或 backward 指针线性遍历所需的 k 个元素,总复杂度为 O(log n + k)。

这里的 O(log n + k)数学表达式可以理解为:找到范围起点需要 O(log n)的时间(类似二分查找),然后遍历 k 个结果元素需要 O(k)的时间,总复杂度为两者之和。这明显优于在无序结构中先 O(n)的全表遍历再 O(n log n)的排序操作。

性能分析与对比

让我们对比一下几种常见数据结构在有序集合操作上的性能:

操作平衡树(如红黑树)跳表哈希表+链表
查找O(log n)O(log n)O(1)+O(n)*
插入O(log n)O(log n)O(1)+O(n)*
删除O(log n)O(log n)O(1)+O(n)*
范围查询O(log n + k)O(log n + k)O(n)*
内存占用中等中高较低
实现复杂度

* 注:哈希表+链表的复杂度取决于链表是否有序。若链表无序,范围查询需要 O(n)时间排序;若有序,则定位起点后遍历 k 个元素的复杂度为 O(1+k)。

关于内存占用的具体比较:

  • 跳表:平均每个节点有 1.33 个 forward 指针(由随机层数决定)+ 1 个 backward 指针 + 每层 1 个 span 字段,再加上数据和分数字段
  • 红黑树:每个节点有 2 个子指针(左右子节点)+ 1 个父指针 + 1 个颜色标记(通常 1 位)+ 数据字段

跳表的内存占用略高,但其柔性数组设计允许更灵活的内存分配,避免了为所有节点分配最大高度的空间浪费。而且跳表的指针更新操作更简单,不需要复杂的树平衡旋转操作。

跳表的优化问题

在使用 Redis 的过程中,我们可能会遇到一些与跳表相关的性能问题:

问题:有序集合数据量过大导致内存占用高

分析:跳表节点除了存储数据外,还有指针和层级信息,内存开销不小。

解决方案

  1. 考虑使用 Redis 的过期策略,定期清理不需要的数据
  2. 对数据进行分片,分散到多个有序集合中
  3. 在应用层做数据筛选,只将必要的数据放入 Redis
  4. 使用 Redis 的内存优化配置,如启用压缩列表编码(小型有序集合时自动使用)

问题:批量添加大量数据时性能下降

分析:每次 ZADD 操作都需要维护跳表结构,大量操作会导致性能下降。

解决方案

  1. 使用 ZADD 的批量添加模式:ZADD key score1 member1 score2 member2 ...
  2. 使用 Pipeline 机制批量发送命令
  3. 考虑使用 Redis 的事务机制减少网络往返
  4. 在 Redis 6.0+版本中,可以考虑使用 MULTI/EXEC 包裹批量操作,利用预分配内存减少内存碎片

问题:大数据量下的范围查询延迟

分析:当执行大范围的 ZRANGE 操作时,即使跳表查找很快,但遍历大量连续元素仍会占用显著时间。

解决方案

  1. 限制范围查询的元素数量,使用分页方式获取
  2. 利用 LIMIT 参数减少返回结果集大小
  3. 在应用层做缓存,避免频繁请求相同范围的数据

总结

graph LR
    A[跳表] --- B[定义: 随机化的多层链表结构]
    A --- C["特点: 平均O(log n)复杂度"]
    A --- D[应用: Redis有序集合]
    A --- E[优势: 实现简单且效率高]

下面用表格总结一下 Redis 跳表的关键知识点:

知识点说明
数据结构跳表是一种随机化的多层链表结构,每个节点维护多个指向不同距离节点的指针
时间复杂度查找、插入、删除操作平均时间复杂度为 O(log n)
空间复杂度O(n),但每个节点平均需要 1.33 个前向指针,加上其他字段
随机层数通过几何分布模型决定,每层增长概率为 0.25,节点平均层数为 1.33
跨度(span)记录层间节点距离,用于快速计算排名,是 ZRANK 命令的关键
后退指针支持逆序遍历,使 ZREVRANGE/ZREVRANK 等命令高效执行
内存优化使用柔性数组根据节点层数动态分配内存,减少内存浪费
Redis 应用主要用于实现有序集合(Sorted Set),支持按分数或字典序排序
典型应用场景排行榜、优先级队列、范围查询等

感谢您耐心阅读到这里!如果觉得本文对您有帮助,欢迎点赞 👍、收藏 ⭐、分享给需要的朋友,您的支持是我持续输出技术干货的最大动力!

如果想获取更多技术深度解析,欢迎点击头像关注我,后续会每日更新高质量技术文章,陪您一起进阶成长~


异常君
1 声望1 粉丝

在 Java 的世界里,永远有下一座技术高峰等着你。我愿做你登山路上的同频伙伴,陪你从看懂代码到写出让自己骄傲的代码。咱们,代码里见!