Redis 跳跃表(Skiplist)实现详解(基于 6.0.10 版本)
跳跃表(Skiplist)是一种高效的有序数据结构,通过在节点中维护多层指针,实现快速查找、插入和删除操作(平均时间复杂度 O (logN))。Redis 中,跳跃表是有序集合(ZSet)的底层实现之一(当 ZSet 元素数量多或元素较大时使用,替代 ziplist)。本文结合 Redis 源码中的结构体,详解跳跃表的设计与工作原理。
跳跃表的核心作用
ZSet 需要支持按分值(score)排序和快速范围查询(如 ZRANGE、ZREVRANGE),跳跃表相比其他结构(如平衡树)的优势:
- 实现简单:无需像红黑树那样维护复杂的旋转平衡逻辑。
- 性能均衡:查找、插入、删除的平均时间复杂度均为 O (logN),且常数因子小。
- 适合范围查询:通过层级指针可直接定位到范围起点,高效遍历区间元素。
跳跃表的结构体设计
Redis 中跳跃表由两个核心结构体组成:zskiplistNode(节点)和 zskiplist(表本身),定义如下(简化自源码):
跳跃表节点(zskiplistNode)
1 | typedef struct zskiplistNode { |
关键字段解析:
ele:ZSet 中的成员(member),是一个字符串(sds 类型),具有唯一性(ZSet 不允许重复 member)。score:成员对应的分值,ZSet 按 score 升序排序(相同 score 的节点按ele字典序排序)。backward:后退指针,仅用于反向遍历(如ZREVRANGE),指向当前节点的前一个节点(类似双向链表的 prev 指针),但仅在最底层有效。level[]:层级数组,是跳跃表 “跳跃” 特性的核心:- 每个节点的层级数量是随机生成的(通常 1-64 层,Redis 中最大层级为 64)。
- 层级越高,前进指针跨越的节点越多,查询时可快速 “跳过” 无关节点。
跳跃表本身(zskiplist)
1 | typedef struct zskiplist { |
关键字段解析:
header:表头节点(哨兵节点),不存储实际数据(ele为 NULL,score为 0),仅用于简化查询逻辑。tail:表尾节点,快速定位最后一个节点(如ZREVRANGE从尾部开始遍历)。length:记录节点总数,可直接返回 ZSet 的元素个数(ZCARD命令直接使用此值)。level:当前跳跃表中所有节点的最大层级(不包含表头),用于优化查询时的起始层级。
跳跃表的整体结构示意图
一个包含 3 个节点的跳跃表示例(层级简化):
1 | level 3: header -> [node1] -----------------------> [node3] (tail) |
- 表头(header):每层都有前进指针,作为查询的起点。
- 层级:节点的层级随机生成(如 node1 有 3 层,node2 有 2 层,node3 有 3 层)。
- 前进指针:同一层级的指针串联起节点,高层级指针跨越更多节点(如 level 3 中,node1 直接指向 node3)。
- 跨度(span):level 3 中 node1 到 node3 的 span 为 2(中间跳过 node2),用于计算节点排名(
ZRANK命令通过累加 span 实现)。
核心特性与操作原理
层级的随机生成(关键设计)
跳跃表的高效性依赖于节点层级的随机分布:层级越高的节点越稀疏,确保查询时能快速跳过大部分节点。
Redis 中节点层级的生成规则:
- 初始层级为 1。
- 以 50% 的概率(
p=0.5)提升层级,直到达到最大层级(64)或概率试验失败。 - 示例:约 50% 的节点有 1 层,25% 有 2 层,12.5% 有 3 层,以此类推(符合几何分布)。
这种分布使得跳跃表的平均层级为 2,最大层级为 O (logN),保证查询效率。
查找操作
查找指定 score 和 ele 的节点步骤:
- 从表头的最高层级(
zskiplist.level)开始遍历。 - 若当前层级的前进指针指向的节点score 小于目标值,则移动到该节点(利用高层级指针快速跳跃)。
- 若前进指针指向的节点score 大于目标值或score 相等但 ele 字典序更大,则降低一层继续查找。
- 重复步骤 2-3,直到降至第 1 层,找到目标节点或确认不存在。
示例:查找 score=2 的节点
- 从 header 的 level 3 开始,发现前进指针指向
score=3的 node3(大于 2),降至 level 2。 - level 2 中,header 的前进指针指向
score=1的 node1(小于 2),移动到 node1。 - node1 的 level 2 前进指针指向
score=2的 node2(匹配),查找结束。
插入操作
插入新节点的核心是确定新节点的层级和更新相关节点的指针与跨度:
- 随机生成新节点的层级(按上述 50% 概率规则)。
- 从表头最高层级开始,查找新节点的插入位置,同时记录各层级中需要更新的前驱节点(及对应的跨度)。
- 创建新节点,设置
score、ele和层级数组。 - 调整前驱节点的前进指针(指向新节点),并更新新节点和后续节点的跨度值。
- 更新跳跃表的
level(若新节点层级大于当前最大层级)和length(+1)。
删除操作
删除节点需反向调整相关指针和跨度:
- 查找待删除节点的所有前驱节点(各层级中指向该节点的节点)。
- 调整前驱节点的前进指针(跳过待删除节点,指向其下一个节点),并更新跨度。
- 释放待删除节点的内存。
- 更新跳跃表的
length(-1),若删除的是最高层级节点,需更新level。
范围查询(ZRANGE 核心实现)
跳跃表的层级结构天然支持高效范围查询:
- 先定位到范围的起始节点(如
ZRANGE min max中的 min 节点)。 - 从起始节点的第 1 层开始,通过
forward指针依次遍历后续节点,直到超出范围(score > max)。 - 遍历过程中收集节点的
ele和score,形成结果集。
跳跃表与 ZSet 的关系
ZSet 在 Redis 中采用混合存储策略:
- 当元素数量少(≤128 个)且元素小(每个 member 长度 ≤64 字节)时,使用 ziplist 存储(节省内存)。
- 当元素数量多或元素大时,自动转为跳跃表 + 哈希表的组合:
- 跳跃表:按
score排序,支持范围查询和排序操作。 - 哈希表(dict):映射
member到score,支持 O (1) 时间复杂度的ZSCORE等命令。
- 跳跃表:按
跳跃表的优势与局限性
优势
- 查询高效:平均 O (logN) 时间复杂度,适合 ZSet 的高频查询场景。
- 范围操作友好:无需像数组那样从头遍历,可直接定位范围起点。
- 实现简单:相比红黑树,无需维护平衡,代码可读性高,调试难度低。
局限性
- 内存开销:每个节点需存储多层指针和跨度,内存占用比数组或链表高(但远低于平衡树的指针开销)。
- 随机层级的不确定性:最坏情况下(节点层级全为 1),性能退化为 O (N),但概率极低(Redis 通过限制最大层级为 64 规避)