Redis 跳表用了"随机"的魔法,实现了比平衡树更快的查找!

【技术干货】Redis 跳表实现原理深度剖析 | 为什么 Redis 选择了跳表?

在 Redis 的有序集合(zset)背后,藏着一个精妙的数据结构------跳表。它用"随机"的智慧,实现了 O(log n) 的高效查找。今天,让我们深入理解跳表的实现原理,以及 Redis 为什么选择它而不是红黑树。


📑 目录

  • 一、从链表到跳表的演进
  • 二、跳表的核心原理
  • [三、Redis 中跳表的实现](#三、Redis 中跳表的实现 "#%E4%B8%89redis-%E4%B8%AD%E8%B7%B3%E8%A1%A8%E7%9A%84%E5%AE%9E%E7%8E%B0")
  • [四、跳表 vs 平衡树](#四、跳表 vs 平衡树 "#%E5%9B%9B%E8%B7%B3%E8%A1%A8-vs-%E5%B9%B3%E8%A1%A1%E6%A0%91")
  • [五、Python 实现跳表](#五、Python 实现跳表 "#%E4%BA%94python-%E5%AE%9E%E7%8E%B0%E8%B7%B3%E8%A1%A8")
  • [六、Redis 跳表命令实战](#六、Redis 跳表命令实战 "#%E5%85%ADredis-%E8%B7%B3%E8%A1%A8%E5%91%BD%E4%BB%A4%E5%AE%9E%E6%88%98")
  • 七、常见误区与避坑指南
  • 八、总结

一、从链表到跳表的演进

1.1 普通链表的问题

想象一下,你有一个有序链表:

复制代码
1 → 3 → 7 → 11 → 15 → 23 → 35 → 40 → 50

如果要查找 23,你需要从头开始遍历:

  • 1 → 3 → 7 → 11 → 15 → 23
  • 遍历了 6 次!

这是因为链表不支持随机访问,只能顺序遍历。

1.2 跳表的灵感:索引

让我们给链表加一层"索引":

makefile 复制代码
一级索引:  1 → 7 → 15 → 35 → 50
底层链表:  1 → 3 → 7 → 11 → 15 → 23 → 35 → 40 → 50

现在找 23:

  1. 从一级索引的 1 开始
  2. 7 < 23,继续往右 → 15
  3. 15 < 23,继续往右 → 35
  4. 35 > 23,下沉到一层
  5. 从 15 开始往下找 → 23

只需要 4 次遍历!

1.3 更多层的索引

再加入二级索引:

graph TB subgraph L2[&#34;二级索引&#34;] L2_1[&#34;15&#34;] L2_2[&#34;50&#34;] end subgraph L1[&#34;一级索引&#34;] L1_1[&#34;1&#34;] L1_2[&#34;7&#34;] L1_3[&#34;15&#34;] L1_4[&#34;35&#34;] L1_5[&#34;50&#34;] end subgraph L0[&#34;底层链表&#34;] N1[&#34;1&#34;] N2[&#34;3&#34;] N3[&#34;7&#34;] N4[&#34;11&#34;] N5[&#34;15&#34;] N6[&#34;23&#34;] N7[&#34;35&#34;] N8[&#34;40&#34;] N9[&#34;50&#34;] end L2_1 --> L1_3 L2_2 --> L1_5 L1_1 --> N1 L1_2 --> N3 L1_3 --> N5 L1_4 --> N7 L1_5 --> N9 N1 --> N2 N2 --> N3 N3 --> N4 N4 --> N5 N5 --> N6 N6 --> N7 N7 --> N8 N8 --> N9

现在找 23:

  1. 从二级索引的 15 开始
  2. 15 < 23,继续往右 → 50
  3. 50 > 23,下沉到一级索引的 35
  4. 35 > 23,下沉到链表的 15
  5. 从 15 开始往右找 → 23

只需要 3 次遍历!

这就是跳表的核心思想:用空间换时间,通过多层索引实现快速查找。


二、跳表的核心原理

2.1 随机层数:跳表的灵魂

跳表之所以高效,关键在于随机层数算法。

为什么需要随机?

如果每两个元素建一个索引:

makefile 复制代码
索引: 1 → 7 → 15 → 35 → 50
底层: 1 → 3 → 7 → 11 → 15 → 23 → 35 → 40 → 50

这样确实能加快查找,但会导致:

  1. 索引层数固定,插入/删除时要重建大量索引
  2. 最坏情况还是 O(n)

Redis 的随机层数算法:

c 复制代码
// Redis 源码 t_zset.c
#define ZSKIPLIST_MAXLEVEL 32    // 最大层数
#define ZSKIPLIST_P 0.25         // 每层被选中的概率

int zslRandomLevel(void) {
    int level = 1;
    // 每次有 25% 的概率升一层
    while ((random() & 0xFFFF) < (ZSKIPLIST_P * 0xFFFF))
        level += 1;
    return (level < ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}

概率分析:

  • 1 层:100%
  • 2 层:25%
  • 3 层:6.25%
  • 4 层:1.56%

期望层数 = 1 / (1 - P) = 1 / 0.75 ≈ 1.33 层

这意味着平均每个节点只有 1.33 个前向指针,内存开销可控!

2.2 时间复杂度分析

操作 时间复杂度 说明
查找 O(log n) 从顶层开始,逐层下沉
插入 O(log n) 查找位置 + 插入节点
删除 O(log n) 查找节点 + 删除链接
范围查询 O(log n + m) m 为返回元素个数

为什么是 O(log n)?

假设有 n 个元素,平均层数为 L = 1.33。

  • 每层最多遍历约 n/2 个元素
  • 需要遍历 L 层
  • 总复杂度 ≈ (n/2) * (1 + 1/4 + 1/16 + ...) ≈ n/2 * 4/3 = O(log n)

三、Redis 中跳表的实现

3.1 节点结构

c 复制代码
// Redis 源码 server.h
typedef struct zskiplistNode {
    // member:成员,SDS 字符串对象
    robj *obj;
    // score:分值,用于排序
    double score;
    // 后退指针,用于逆序遍历
    struct zskiplistNode *backward;

    // 每层的结构
    struct zskiplistLevel {
        // 前进指针
        struct zskiplistNode *forward;
        // 跨度:当前节点到下一个节点跨越的节点数
        unsigned int span;
    } level[];
} zskiplistNode;

3.2 跳表结构

c 复制代码
typedef struct zskiplist {
    // 头节点和尾节点
    struct zskiplistNode *header, *tail;
    // 节点数量
    unsigned long length;
    // 当前最大层数
    int level;
} zskiplist;

3.3 结构图示

ini 复制代码
跳表结构:
┌─────────────────────────────────────────────────────────┐
│  zskiplist                                              │
│  ┌──────────────────────────────────────────────────┐   │
│  │ header ──────────►  NULL                        │   │
│  │ tail   ◄────────────────────────────────────────│   │
│  │ length = 5                                      │   │
│  │ level  = 3                                      │   │
│  └──────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────┘

节点示例:
┌────────────┬────────────┬────────────┬────────────┐
│   Level    │  Forward   │    Span    │            │
├────────────┼────────────┼────────────┼────────────┤
│     3      │   NULL     │     0      │            │
├────────────┼────────────┼────────────┼────────────┤
│     2      │     ●──────┼───────────►│  span=2    │
├────────────┼────────────┼────────────┼────────────┤
│     1      │     ●──────┼───────────►│  span=1    │
├────────────┼────────────┼────────────┼────────────┤
│     0      │     ●──────┼──────►────►│  span=1    │
└────────────┴────────────┴────────────┴────────────┘
     backward ◄────────────────────────────────────

3.4 zset 的双重结构

重要 :Redis 的 zset 实际上使用哈希表 + 跳表的组合:

c 复制代码
typedef struct zset {
    dict *dict;           // 哈希表:O(1) 查找 member
    zskiplist *zsl;       // 跳表:O(log n) 按 score 排序
} zset;

这样设计的好处:

  • ZADD/ZREM:同时更新哈希表和跳表
  • ZSCORE:从哈希表 O(1) 获取分数
  • ZRANGE:从跳表 O(log n) 获取有序结果

四、跳表 vs 平衡树

4.1 核心对比

特性 跳表(Skip List) 平衡树(AVL/红黑树)
查找复杂度 O(log n) O(log n)
插入/删除 O(log n),局部调整 O(log n),可能需要旋转
范围查询 ✅ 简单,链表特性 ❌ 需要中序遍历
实现复杂度 ✅ 简单,约 200 行 ❌ 复杂,平衡条件繁琐
内存占用 略高(多层指针) 较低(固定节点结构)
有序性 按 score 排序 按 key 排序
遍历方向 ✅ 双向 单向
并发友好 ✅ 锁粒度可更细 较差

4.2 Redis 为什么选择跳表?

1. 实现简单

红黑树需要维护平衡条件,代码量约 500+ 行;跳表只需随机层数,约 200 行。

2. 范围查询友好

复制代码
ZRANGE leaderboard 90 100

跳表:找到 90 的位置,往右遍历即可 红黑树:需要中序遍历,效率较低

3. 有序性保证

  • 跳表:按 score 排序,score 可以重复
  • 红黑树:按 key 排序,key 必须唯一

zset 的 member 可以重复,score 可以重复,跳表更合适。

4. 并发友好

插入跳表:只需要锁定局部节点 插入红黑树:可能需要锁定从根到叶子节点的路径


五、Python 实现跳表

5.1 完整实现

python 复制代码
import random
from typing import Optional, List

class SkipListNode:
    """跳表节点"""
    def __init__(self, val: int, level: int = 1):
        self.val = val
        # 每层的下一个节点
        self.forward: List[Optional[SkipListNode]] = [None] * level
        # 每层的跨度
        self.span: List[int] = [0] * level


class SkipList:
    """跳表实现"""
    MAX_LEVEL = 16  # 最大层数
    P = 0.25        # 每层被选中的概率

    def __init__(self):
        # 头节点,值为负无穷
        self.header = SkipListNode(-float('inf'), self.MAX_LEVEL)
        self.level = 1  # 当前最大层数
        self.length = 0  # 节点数量

    def random_level(self) -> int:
        """生成随机层数"""
        level = 1
        while random.random() < self.P and level < self.MAX_LEVEL:
            level += 1
        return level

    def search(self, target: int) -> bool:
        """查找目标值"""
        node = self.header
        # 从最高层开始,逐层下沉
        for i in range(self.level - 1, -1, -1):
            while node.forward[i] and node.forward[i].val < target:
                node = node.forward[i]
        node = node.forward[0]
        return node is not None and node.val == target

    def insert(self, val: int) -> None:
        """插入值"""
        update = [None] * self.MAX_LEVEL
        rank = [0] * self.MAX_LEVEL

        # 记录每层要更新的节点和累计跨度
        node = self.header
        for i in range(self.level - 1, -1, -1):
            if i == self.level - 1:
                rank[i] = 0
            else:
                rank[i] = rank[i + 1]
            while node.forward[i] and node.forward[i].val < val:
                rank[i] += node.span[i]
                node = node.forward[i]
            update[i] = node

        # 生成新节点的层数
        new_level = self.random_level()
        if new_level > self.level:
            for i in range(self.level, new_level):
                rank[i] = 0
                update[i] = self.header
            self.level = new_level

        # 创建新节点
        new_node = SkipListNode(val, new_level)
        for i in range(new_level):
            new_node.forward[i] = update[i].forward[i]
            update[i].forward[i] = new_node
            new_node.span[i] = rank[0] - rank[i] + 1

        self.length += 1

    def delete(self, val: int) -> bool:
        """删除值"""
        update = [None] * self.MAX_LEVEL
        node = self.header

        # 找到每层的前驱节点
        for i in range(self.level - 1, -1, -1):
            while node.forward[i] and node.forward[i].val < val:
                node = node.forward[i]
            update[i] = node

        node = node.forward[0]
        if node is None or node.val != val:
            return False

        # 删除节点
        for i in range(self.level):
            if update[i].forward[i] == node:
                update[i].forward[i] = node.forward[i]
                update[i].span[i] += node.span[i] - 1
            else:
                update[i].span[i] -= 1

        self.length -= 1
        return True

    def range(self, start: int, end: int) -> List[int]:
        """范围查询"""
        result = []
        node = self.header

        # 找到起始位置
        for i in range(self.level - 1, -1, -1):
            while node.forward[i] and node.forward[i].val < start:
                node = node.forward[i]

        node = node.forward[0]
        while node and node.val <= end:
            result.append(node.val)
            node = node.forward[0]

        return result


# 测试
if __name__ == "__main__":
    sl = SkipList()

    # 插入测试
    for v in [3, 6, 7, 9, 12, 15, 18, 20]:
        sl.insert(v)

    print(f"节点数量: {sl.length}")
    print(f"当前层数: {sl.level}")

    # 查找测试
    print(f"查找 9: {sl.search(9)}")    # True
    print(f"查找 10: {sl.search(10)}")  # False

    # 范围查询
    print(f"范围查询 [7, 15]: {sl.range(7, 15)}")  # [7, 9, 12, 15]

    # 删除测试
    sl.delete(9)
    print(f"删除后查找 9: {sl.search(9)}")  # False

5.2 运行结果

yaml 复制代码
节点数量: 8
当前层数: 4
查找 9: True
查找 10: False
范围查询 [7, 15]: [7, 9, 12, 15]
删除后查找 9: False

六、Redis 跳表命令实战

6.1 基本操作

bash 复制代码
# 添加元素(分数在前,成员在后)
ZADD leaderboard 100 "Alice"
ZADD leaderboard 95 "Bob"
ZADD leaderboard 98 "Charlie"
ZADD leaderboard 92 "David"

# 获取排名(从小到大)
ZRANK leaderboard "Alice"    # 3(Alice 排第 4 名)

# 获取排名(从大到小)
ZREVRANK leaderboard "Alice" # 0(Alice 排第 1 名)

# 获取分数
ZSCORE leaderboard "Alice"   # "100"

# 获取指定范围的元素(从小到大)
ZRANGE leaderboard 0 2 WITHSCORES
# 输出:
# 1) "David"
# 2) "92"
# 3) "Bob"
# 4) "95"
# 5) "Charlie"
# 6) "98"

# 获取指定范围的元素(从大到小)
ZREVRANGE leaderboard 0 2 WITHSCORES
# 输出:
# 1) "Alice"
# 2) "100"
# ...

6.2 场景应用

bash 复制代码
# 排行榜:获取前 10 名
ZREVRANGE leaderboard 0 9 WITHSCORES

# 获取指定分数段的玩家
ZRANGEBYSCORE leaderboard 90 100

# 获取分数在 90-100 之间的玩家数量
ZCOUNT leaderboard 90 100

# 更新玩家分数
ZINCRBY leaderboard 5 "Alice"  # Alice 的分数 +5

# 删除玩家
ZREM leaderboard "Alice"

七、常见误区与避坑指南

7.1 误区一:跳表层数越高越好

错误理解:既然层数越高查找越快,那就让所有节点都有 32 层。

真相:

  • Redis 限制最大层数为 32
  • 平均层数约 1.33(由 P=0.25 决定)
  • 层数太高会增加内存开销和维护成本

7.2 误区二:Redis 总是用跳表实现 zset

错误理解:zset 底层就是跳表。

真相:

  • 当元素数量 ≤ zset-max-ziplist-entries(默认 128)时,使用压缩列表(ziplist)
  • 元素超过阈值才切换到跳表
  • 这是 Redis 的内存优化策略

7.3 误区三:跳表一定比红黑树好

错误理解:Redis 用了跳表,说明跳表全面优于红黑树。

真相:

  • 纯查找性能:两者相当
  • 内存占用:红黑树更低
  • 实现复杂度:跳表更简单
  • 范围查询:跳表更友好

7.4 误区四:忽略 zset 的双重结构

错误理解:zset 就是跳表。

真相:

  • zset = 哈希表 + 跳表
  • 哈希表:O(1) 查找 member
  • 跳表:O(log n) 按 score 排序

7.5 误区五:并发场景下随意使用跳表

错误理解:跳表并发友好,所以在多线程环境下直接用。

真相:

  • Redis 单线程不存在并发问题
  • 其他语言/系统使用跳表时,需要自行处理并发控制
  • 跳表的局部加锁确实比红黑树容易

八、总结

8.1 核心要点回顾

要点 说明
数据结构 多层索引的链表,每层是有序链表的"快速通道"
核心原理 随机层数 + 逐层下沉查找
时间复杂度 O(log n),与平衡树相当
空间复杂度 O(n),平均每节点 1.33 个前向指针
Redis 应用 zset 的底层实现之一(元素多时使用)
为什么选跳表 实现简单、范围查询友好、并发友好

8.2 一句话总结

跳表通过"随机层数"构建多层索引,实现 O(log n) 的高效查找;Redis 选择它是因为:实现比红黑树简单、范围查询比红黑树友好、并发放肆比红黑树容易!


如果这篇文章对你有帮助,欢迎点赞、评论、转发!

相关推荐
lv__pf1 小时前
redis【TL redis(6)】
数据库·redis·缓存
不会就选b1 小时前
算法日常・每日刷题--<动态规划>29
数据结构·算法·leetcode
垆边人似月.1 小时前
最少的元素个数使和为 target
数据结构·c++·算法
hweiyu002 小时前
Redis命令:RPOPLPUSH
redis·缓存
Tsuki_tl2 小时前
Redis 单线程 + 多编码,到底怎么配合的?
数据库·redis·哈希表·redis数据类型·编码方式·单线程模型·redis底层
彧azz2 小时前
B-树学习总结:从结构特性到核心操作的系统复习
数据结构·笔记·b树·学习
March.s3 小时前
Redis 核心知识与高可用架构实践
数据库·redis·架构
All for pursuit.13 小时前
【动态规划-21】312.戳气球
数据结构·c++·算法·leetcode·动态规划
Knight_AL13 小时前
本地服务连接 SaaS 测试环境实践:Docker 部署 Redis 与 RocketMQ 完成接口联调
redis·docker·rocketmq