Redis跳表(skiplist)底层原理浅析
引言
作为Redis有序集合(Sorted Set)的底层核心数据结构之一,跳表(Skip List)凭借其高效的增删查性能和相对简单的实现逻辑,成为Redis开发者必须掌握的知识点。今天我们就来扒一扒Redis跳表的“里里外外”,彻底搞懂它的设计思想和实现细节。
一、为什么是跳表?Redis的选择逻辑
在Redis的有序集合中,需要支持两种核心操作:
- 快速按分数排序(插入、删除、查找);
- 高效范围查询(如
ZRANGE key start stop
按排名取数据)。
常见的有序数据结构有平衡树(如红黑树)、哈希表、数组等,但都有明显短板:
- 哈希表:无法保证顺序,范围查询需遍历所有元素(O(n));
- 平衡树:虽然能保证O(logn)的增删查,但实现复杂(如红黑树需要处理旋转、颜色标记),且不支持高效的范围遍历(需中序遍历);
- 数组:插入/删除的时间复杂度高(O(n))。
而跳表通过多层索引+概率平衡的设计,完美解决了这些问题:
- 增删查时间复杂度均为O(logn);
- 天然支持范围查询(底层是有序链表,可直接顺序遍历);
- 实现逻辑简单(相比红黑树的复杂旋转操作,跳表仅需维护多层指针)。
这也是Redis选择跳表作为有序集合底层的重要原因。
二、跳表的核心结构:节点(zskiplistNode)
要理解跳表,首先得看它的“细胞”——节点结构。Redis的跳表节点定义在server.h
中,核心字段如下(简化版):
typedef struct zskiplistNode {// 成员对象(如"member1")robj *ele; // 分数值(排序依据,有序集合的核心)double score; // 后退指针:指向前一层的同位置节点(支持反向遍历)struct zskiplistNode *backward; // 层级数组:每个元素是一层的“索引信息”struct zskiplistLevel {// 前进指针:指向当前层的下一个节点struct zskiplistNode *forward; // 跨度:当前节点到下一个节点的“距离”(用于计算排名)unsigned long span; } level[]; // 动态数组,长度=节点当前层数
} zskiplistNode;
字段详解:
-
ele & score:
ele
是有序集合的成员(字符串类型,唯一),score
是分数(排序依据)。若两个成员分数相同,则按ele
的字典序排序。 -
level数组:
这是跳表的“灵魂”。每个元素zskiplistLevel
表示节点在某一层的索引信息:forward
:当前层的“前进指针”,指向下一个节点;span
:当前节点到forward
指向节点的“跨度”(中间跳过的节点数)。例如,若span=3
,说明当前节点与下一个节点之间有3个被跳过的节点。
节点的层数由
level
数组的长度决定。例如,若level
长度为3,则该节点占据第0、1、2层(底层是第0层)。 -
backward指针:
指向当前节点的下一层(更低层)的前驱节点。例如,若当前节点在第2层,backward
指向第1层的同位置节点。它的作用是支持反向遍历(如ZREVRANGE
命令)。
三、跳表的整体架构:从虚拟头节点到最大层数
跳表的整体结构可以类比“多层索引的链表”,主要由以下部分组成:
1. 虚拟头节点(header)
跳表的头节点是一个虚拟节点(不存储实际数据),作用是简化边界条件处理。它的level
数组长度等于跳表的最大层数(maxlevel
,Redis默认32)。初始时,所有forward
指针指向NULL
,相当于“空索引”。
2. 底层链表(第0层)
底层链表包含跳表的所有节点,按score
从小到大有序排列。这是跳表的“基础数据”,所有操作最终都需要回到这一层。
3. 高层索引(第1~maxlevel层)
每一层都是下一层的“稀疏索引”。例如,第1层的节点数约为第0层的1/2,第2层约为第1层的1/2,以此类推。这种“指数级稀疏”的结构,让跳表在查找时能快速跳过大量节点,将时间复杂度降到O(logn)。
4. 尾节点(tail)
尾节点是底层链表的最后一个节点(可能为空)。通过尾节点可以快速定位链表末尾,避免从头部遍历到末尾(时间复杂度O(n))。
四、层级随机生成:跳表的“概率魔法”
跳表的层数不是固定的,而是通过概率算法动态生成的。Redis的规则如下:
- 新节点初始层数
level=1
; - 循环抛“虚拟硬币”:以50%的概率决定是否增加层数(“正面”则
level++
,“反面”则停止); - 直到
level
达到maxlevel
(32)或抛硬币结果为“反面”时停止。
这种设计让节点的层数服从几何分布,平均层数约为log(n)
(例如,当n=100万时,平均层数约17)。举个例子:
- 50%的节点层数为1;
- 25%的节点层数为2;
- 12.5%的节点层数为3;
- 以此类推,直到最高层(32层)的概率仅为
1/(2^31)
(几乎可以忽略)。
为什么用概率生成?
如果手动指定层数,很难保证平衡(可能某些层节点过多,某些层过少)。而概率生成能自动保证各层节点数大致呈指数级递减,从而让跳表的查找效率稳定在O(logn)。
五、跳表的核心操作:查找、插入、删除
1. 查找(Search):从高层到低层的“跳跃”
查找目标分数target
的过程,就像“坐电梯下楼”:
- 从最高层出发:记录当前节点为
x
(初始为header
); - 逐层跳跃:在当前层,比较
x.forward
节点的score
与target
:- 若
x.forward.score < target
:沿当前层的forward
指针移动,并累加span
(记录跳过了多少节点); - 若
x.forward.score > target
:下降到下一层(x = x.level[i].backward
),继续查找; - 若
x.forward.score == target
:找到目标节点(需进一步比较ele
处理分数相同的情况)。
- 若
- 到底层仍未找到:返回
NULL
。
举个栗子:
假设要查找分数为85的节点,跳表结构如下(层数从0到2):
层2:A(70) → C(80) → E(90)
层1:A(70) → D(85) → E(90)
层0(底层):A(70) → B(75) → C(80) → D(85) → E(90)
查找过程:
- 层2:A(70)的下一节点是C(80)(<85),沿层2跳到C(80);
- 层2:C(80)的下一节点是E(90)(>85),下降到层1;
- 层1:C(80)的下一节点是D(85)(=85),找到目标!
2. 插入(Insert):先找位置,再“搭梯子”
插入节点的关键是找到每一层的插入位置,并更新索引:
- 记录“前驱节点”:类似查找过程,遍历各层,记录每一层中“最后一个小于目标分数的节点”(保存到
update
数组); - 生成随机层数:为新节点生成随机的
new_level
(不超过maxlevel
); - 调整层级指针:
- 如果
new_level
超过当前跳表的maxlevel
:更新跳表的maxlevel
,并将update
数组中超出原层数的部分指向header
; - 对于每一层
i ≤ new_level
:将新节点的level[i].forward
指向update[i].level[i].forward
,并将update[i].level[i].forward
指向新节点; - 设置新节点的
backward
指针(指向update[0]
的前驱节点,或NULL
)。
- 如果
3. 删除(Delete):找到节点,拆索引
删除节点需要先验证节点存在,再调整各层指针:
- 记录“前驱节点”:遍历各层,记录每一层中指向目标节点的前驱节点(保存到
update
数组); - 验证节点:检查
update[0].level[0].forward
是否为目标节点(避免误删); - 调整层级指针:
- 对于目标节点的每一层
i
:将update[i].level[i].forward
指向目标节点的level[i].forward
; - 如果某一层的头节点的
forward
变为NULL
(说明该层已空):更新跳表的maxlevel
(降低最大层数); - 释放目标节点内存。
- 对于目标节点的每一层
六、Redis跳表的优势:为什么比平衡树更香?
对比红黑树等平衡树,Redis跳表的优势非常明显:
- 实现简单:跳表仅需维护多层指针,无需处理复杂的旋转、颜色标记(红黑树的噩梦);
- 范围查询高效:底层是有序链表,配合
span
字段可直接计算排名(ZRANK
时间复杂度O(logn)),范围遍历(ZRANGE
)只需顺序访问底层链表的部分节点; - 内存占用友好:跳表的稀疏索引不会像平衡树那样占用过多内存(红黑树的每个节点需要额外存储父/子节点指针);
- 支持双向遍历:通过
backward
指针,跳表可以高效实现反向范围查询(如ZREVRANGE
)。
总结
Redis的跳表是一种“用空间换时间”的高效有序数据结构,通过多层索引和概率生成的层数,在保证O(logn)增删查性能的同时,天然支持范围查询。它的实现逻辑简单清晰,是Redis有序集合的“幕后功臣”。
下次使用ZADD
、ZRANGE
等命令时,不妨想想:这些操作的背后,跳表是如何通过多层索引快速定位数据的?理解跳表,能让你更深入掌握Redis的性能密码!