Redis 作为一款高性能的内存数据库,其背后隐藏着许多精巧的数据结构设计。今天我们就来聊聊 Redis 中有序集合(Sorted Set)的核心实现技术——跳表(Skip List)。如果你曾经好奇为什么 Redis 的有序集合能够同时兼顾高效的查询和修改操作,那么这篇文章正是为你准备的!
什么是跳表?
跳表是一种随机化的数据结构,可以被看作是对有序链表的优化升级版。它通过维护多层索引来加快查找速度,平均查找复杂度可以达到 O(log n),这与平衡树相当。
想象一下,如果我们有一个包含 1000 个节点的普通链表,要查找其中的某个值,我们可能需要遍历整个链表,最坏情况下要比较 1000 次。而使用跳表,我们可以显著减少比较次数,通常只需要约 10 次左右的比较!
为什么 Redis 选择跳表?
你可能会问,为什么 Redis 不使用平衡树(如红黑树)来实现有序集合呢?这个问题很有意思!
- 实现简单:跳表的代码实现比平衡树简单得多,维护起来也更容易
- 内存占用更灵活:跳表的内存布局比平衡树更加灵活
- 范围查询友好:跳表天然支持高效的范围查询,这正是有序集合的常见操作
- 插入、删除操作更简单:不需要复杂的树旋转操作来维持平衡
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)是一个很关键但容易被忽视的字段,它记录了当前层指针跳过了多少个节点。这个设计非常巧妙,主要有两个作用:
- 快速计算排名:在执行
ZRANK
命令时,可以通过累加查找路径上的 span 值直接得到目标元素的排名,无需再次遍历底层链表 - 高效区间操作:在范围查询时,利用 span 可以直接知道两个节点之间有多少元素,简化了代码实现
跳表结构
整个跳表(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;
}
这个算法遵循几何分布模型,具体实现细节如下:
random()&0xFFFF
通过位运算(&)截取随机数的低 16 位,生成 0-65535 之间的均匀分布随机数ZSKIPLIST_P * 0xFFFF
将概率 P(0.25)转换为对应的阈值,即 0.25 * 65535 ≈ 16384- 随机数小于 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),从而维持查找复杂度的平衡性。
内存优化:柔性数组的应用
注意节点结构中的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 的节点:
- 从最高层开始查找
- 在每一层,向前查找直到遇到大于等于目标值的节点
- 如果当前层没找到,就下降一层继续查找
- 重复以上步骤直到最底层
这就像是在高楼大厦间穿梭:先从高空瞭望,快速跳过大片区域,然后逐渐降低高度,最终精确定位到目标。
在查找过程中,如果需要知道元素排名(如 ZRANK 命令),可以累加路径上经过的所有跨度(span)值,得到的总和就是目标元素的排名。这比在找到元素后再从头遍历计算排名要高效得多。
举个生活例子:假设你在一个按房价排序的小区里找一套价格为 800 万的房子。你可能先站在高处,看到前方有 500 万区域和 1000 万区域,那么就知道应该去 500 万和 1000 万之间找。然后降低高度,进一步缩小范围,最终找到目标位置。同时,你还能记录跳过了多少房子,从而知道这套房子在整个小区的排名。
插入操作
插入新节点需要以下步骤:
- 找到合适的插入位置(同时记录每一层的前驱节点)
- 随机生成新节点的层数
- 更新各层的指针,将新节点链接到跳表中
- 更新跨度(span)值,确保排名计算的正确性
- 设置后退指针(backward),支持逆序遍历
- 更新跳表的相关信息(如最大层数、节点计数)
在实现中,需要特别注意 span 值的更新:对于新节点层数 n 以内的层,需要调整前驱节点的 span 值(减去新节点的 span);对于高于 n 的层,需要将前驱节点的 span 值加 1(因为底层增加了一个节点)。
删除操作
删除操作与插入类似,但方向相反:
- 找到需要删除的节点,同时记录每层的前驱节点
- 更新所有层的指针,绕过要删除的节点
- 调整 span 值,维持排名计算的准确性
- 更新相邻节点的 backward 指针
- 释放节点内存
在删除操作中,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 的过程中,我们可能会遇到一些与跳表相关的性能问题:
问题:有序集合数据量过大导致内存占用高
分析:跳表节点除了存储数据外,还有指针和层级信息,内存开销不小。
解决方案:
- 考虑使用 Redis 的过期策略,定期清理不需要的数据
- 对数据进行分片,分散到多个有序集合中
- 在应用层做数据筛选,只将必要的数据放入 Redis
- 使用 Redis 的内存优化配置,如启用压缩列表编码(小型有序集合时自动使用)
问题:批量添加大量数据时性能下降
分析:每次 ZADD 操作都需要维护跳表结构,大量操作会导致性能下降。
解决方案:
- 使用 ZADD 的批量添加模式:
ZADD key score1 member1 score2 member2 ...
- 使用 Pipeline 机制批量发送命令
- 考虑使用 Redis 的事务机制减少网络往返
- 在 Redis 6.0+版本中,可以考虑使用 MULTI/EXEC 包裹批量操作,利用预分配内存减少内存碎片
问题:大数据量下的范围查询延迟
分析:当执行大范围的 ZRANGE 操作时,即使跳表查找很快,但遍历大量连续元素仍会占用显著时间。
解决方案:
- 限制范围查询的元素数量,使用分页方式获取
- 利用 LIMIT 参数减少返回结果集大小
- 在应用层做缓存,避免频繁请求相同范围的数据
总结
下面用表格总结一下 Redis 跳表的关键知识点:
知识点 | 说明 |
---|---|
数据结构 | 跳表是一种随机化的多层链表结构,每个节点维护多个指向不同距离节点的指针 |
时间复杂度 | 查找、插入、删除操作平均时间复杂度为 O(log n) |
空间复杂度 | O(n),但每个节点平均需要 1.33 个前向指针,加上其他字段 |
随机层数 | 通过几何分布模型决定,每层增长概率为 0.25,节点平均层数为 1.33 |
跨度(span) | 记录层间节点距离,用于快速计算排名,是 ZRANK 命令的关键 |
后退指针 | 支持逆序遍历,使 ZREVRANGE/ZREVRANK 等命令高效执行 |
内存优化 | 使用柔性数组根据节点层数动态分配内存,减少内存浪费 |
Redis 应用 | 主要用于实现有序集合(Sorted Set),支持按分数或字典序排序 |
典型应用场景 | 排行榜、优先级队列、范围查询等 |
感谢您耐心阅读到这里!如果觉得本文对您有帮助,欢迎点赞 👍、收藏 ⭐、分享给需要的朋友,您的支持是我持续输出技术干货的最大动力!
如果想获取更多技术深度解析,欢迎点击头像关注我,后续会每日更新高质量技术文章,陪您一起进阶成长~
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。