Redis Zset的底层秘密:跳表(Skip List)的精妙设计
在Redis众多数据结构中,有序集合(Zset)是一个非常强大的工具,它不仅支持集合的唯一性,还能根据分数进行自动排序。当我们使用zadd
、zrange
、zrank
等命令时,背后正是一个高效的数据结构在支撑——跳表(Skip List)。今天,我们就来深入探索Redis中Zset的底层实现,重点剖析跳表这一精妙的数据结构。
什么是跳表?
跳表是一种基于链表的数据结构,它通过在链表中添加多级索引,实现快速的查找、插入和删除操作。想象一下,普通的链表查找需要O(n)的时间复杂度,而跳表则通过在不同层级上"跳跃",将时间复杂度降低到O(log n)。
跳表的核心思想是:在链表的基础上,构建多级索引。每一级索引都包含当前层级的节点,通过这些索引,我们可以快速跳过大量节点,直接定位到目标区域。
跳表的结构
Redis中的跳表实现非常精巧,它由zskiplistNode
结构体组成:
typedef struct zskiplistNode {sds ele; // 成员字符串double score; // 分数值struct zskiplistLevel {struct zskiplistNode *forward; // 指向下一节点unsigned int span; // 跨越节点数} level[]; // 动态数组,表示不同层级的连接
} zskiplistNode;
每个节点包含:
ele
:成员字符串score
:分数值level[]
:动态数组,表示该节点在不同层级的连接
跳表还包含一个zskiplist
结构体,用于管理整个跳表:
typedef struct zskiplist {struct zskiplistNode *header, *tail;unsigned long length;int level;
} zskiplist;
跳表的工作原理
查找操作
跳表的查找过程类似于二分查找:
- 从最高层开始
- 比较当前节点的分数
- 如果当前节点的分数小于目标分数,则继续向后查找
- 如果当前节点的分数大于目标分数,则向下一层
- 重复直到找到目标节点或到达最低层
zskiplistNode *zslGetNode(zskiplist *zsl, double score, sds ele) {zskiplistNode *x = zsl->header;for (int i = zsl->level-1; i >= 0; i--) {while (x->level[i].forward && x->level[i].forward->score < score) {x = x->level[i].forward;}}x = x->level[0].forward;if (x && score == x->score && sdslen(ele) == sdslen(x->ele) && memcmp(ele, x->ele, sdslen(ele)) == 0) {return x;}return NULL;
}
插入操作
插入操作包含两个关键步骤:
- 查找插入位置
- 创建新节点并更新索引
Redis使用概率算法决定新节点的层数,确保较高的层数较少,平衡空间和效率:
int zslRandomLevel() {int level = 1;while ((random() & 0xFFFF) < ZSKIPLIST_P * 0xFFFF)level += 1;return (level < ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}
删除操作
删除操作与插入类似,先找到节点,然后从所有层级中移除该节点。
为什么Redis选择跳表而非红黑树或B+树?
Redis的作者Salvatore Sanfilippo曾明确表示:“跳跃表更易于实现、调试和扩展”。这背后有多个原因:
1. 实现简单
跳表的代码实现比红黑树简单得多。红黑树需要处理复杂的旋转、颜色标记和平衡操作,而跳表只需要简单的指针操作。
2. 内存效率
在Redis的内存环境中,跳表的平均每个元素占用64字节,而红黑树平均需要72字节(包含父指针和颜色标记)。对于内存敏感的Redis来说,这是重要的优势。
3. 缓存友好
跳表的连续内存访问模式比红黑树的指针跳转更缓存友好,现代CPU架构下性能更优。
4. 区间查询效率
跳表在区间查询时效率更高。它可以在O(log n)时间内定位到区间的起点,然后在原始链表中线性遍历,而红黑树需要遍历所有节点。
5. 并发友好
跳表在并发环境下可以通过改变索引构建策略,有效平衡执行效率和内存消耗。红黑树的平衡依赖于复杂的旋转操作。
跳表与哈希表的结合
Redis的Zset不仅使用跳表,还配合哈希表一起工作:
- 跳表:用于存储数据的排序和快速查找
- 哈希表:用于存储成员->分数的映射,提供快速查找
这种设计使得Zset既能高效进行范围查询(通过跳表),又能高效进行单点查询(通过哈希表)。
何时使用压缩列表?
Redis在Zset元素数量较少时,会使用压缩列表(ziplist)来节省内存:
- 元素个数 ≤ 128
- 每个元素的成员名和分数长度 ≤ 64字节
当不满足上述条件时,Redis会切换到跳表+哈希表的实现。
实际应用:Redis Zset的高效性能
在实际应用中,跳表的特性使Redis Zset在以下场景表现出色:
- 排行榜系统:快速获取前N名或特定分数范围内的成员
- 带优先级的任务队列:根据优先级排序任务
- 实时分析:按分数范围查询统计
例如,一个热门游戏的排行榜系统,使用Zset可以轻松实现:
# 添加玩家分数
ZADD leaderboard 1000 "player1"
ZADD leaderboard 1500 "player2"# 获取前3名
ZRANGE leaderboard 0 2 WITHSCORES
总结
跳表是Redis Zset底层实现的核心数据结构,它的设计体现了Redis作者对简单性、效率和实用性的追求。通过多级索引,跳表在O(log n)的时间复杂度下实现了快速的查找、插入和删除操作,同时保持了实现的简洁性。
Redis的Zset之所以能成为如此强大的数据结构,正是因为它巧妙地结合了跳表的有序性和哈希表的快速查找能力。这种设计不仅满足了Redis对高性能的要求,还保持了代码的简洁和可维护性。
在Redis的世界里,跳表就像一位默默工作的"幕后英雄",以优雅而高效的方式支撑着无数应用的有序集合需求。理解跳表的工作原理,不仅能帮助我们更好地使用Redis,也能让我们欣赏到数据结构设计的精妙之处。