Valkey有序集合:Zset数据结构与跳表算法
引言:为什么Zset是高性能存储的王牌?
你是否在寻找一种能同时支持快速插入、范围查询和排序的数据结构?在分布式缓存、排行榜、延迟队列等场景中,传统数组插入效率低,链表查询速度慢,平衡树实现复杂。Valkey(分布式内存数据库)的有序集合(Zset)通过跳表(Skip List) 与哈希表(Hash Table) 的组合,实现了O(logN)级别的插入、删除和查询操作,完美解决了这些痛点。本文将深入解析Zset的底层实现,带你掌握跳表算法的核心原理与实战应用。
一、Zset数据结构设计:双剑合璧的存储方案
Zset的高性能源于其独特的双结构设计:哈希表负责快速查找元素对应的分数(Score),跳表则维护元素的有序性。这种组合让Zset在各种操作中都能保持高效。
1.1 数据结构定义与内存布局
Valkey的Zset在src/t_zset.c中定义,核心结构体包含两个部分:
/* Zset同时使用哈希表和跳表存储数据 */
typedef struct zset {
dict *dict; /* 映射元素到分数 (O(1)查找) */
zskiplist *zsl; /* 按分数排序的跳表 (O(logN)操作) */
} zset;
- 哈希表(dict):键为元素值(SDS字符串),值为分数(double),支持O(1)时间复杂度的元素查找和分数更新。
- 跳表(zskiplist):按分数排序存储元素,支持O(logN)的范围查询、插入和删除。
内存优化:元素的SDS字符串在哈希表和跳表中共享,避免重复存储。释放时仅在跳表节点(zslFreeNode)中执行,确保内存安全。
1.2 两种编码格式:自适应存储优化
Zset根据元素数量和大小自动选择存储编码,平衡内存占用与性能:
| 编码格式 | 适用场景 | 底层结构 | 优势 |
|---|---|---|---|
| OBJ_ENCODING_ZIPLIST | 元素少(≤128个)且小(≤64字节) | 压缩列表(ziplist) | 内存紧凑,适合小数据集 |
| OBJ_ENCODING_SKIPLIST | 元素多或大 | 跳表+哈希表 | 支持高效插入/查询,适合大数据集 |
转换触发条件:当元素数量超过zset_max_ziplist_entries(默认128)或单个元素大小超过zset_max_ziplist_value(默认64字节)时,自动转换为跳表编码。
二、跳表算法:打破传统链表的性能瓶颈
跳表(Skip List)是Valkey Zset的核心,它通过多级索引实现了有序数据的高效操作。相比平衡树(如红黑树),跳表的实现更简单,并发性能更优。
2.1 跳表的核心原理
跳表在普通链表的基础上增加了多级索引,每级索引都是下一级的"快速通道"。以查询操作为例:
- 从最高级索引开始,快速跳过不符合范围的节点;
- 当遇到无法跳过的节点时,下降到下一级索引继续查找;
- 重复直至到达底层链表,找到目标节点。
示例:在包含6个节点的跳表中查找分数为70的节点:
Level 3: 10 ------------------------------> 70 -> NULL
Level 2: 10 ----------> 40 --------------> 70 -> NULL
Level 1: 10 -> 20 -> 40 -> 50 -> 60 -> 70 -> NULL
- 查询路径:Level 3(10→70)→ 找到目标节点,共2步(传统链表需6步)。
2.2 跳表节点与结构定义
Valkey的跳表实现位于src/t_zset.c,核心结构体如下:
/* 跳表节点 */
typedef struct zskiplistNode {
sds ele; /* 元素值(SDS字符串) */
double score; /* 分数(排序键) */
struct zskiplistNode *backward; /* 后向指针(仅Level 1有) */
struct zskiplistLevel {
struct zskiplistNode *forward; /* 前向指针 */
unsigned int span; /* 跨度(节点间距离) */
} level[]; /* 动态层级数组 */
} zskiplistNode;
/* 跳表结构 */
typedef struct zskiplist {
struct zskiplistNode *header, *tail; /* 表头、表尾节点 */
unsigned long length; /* 节点总数 */
int level; /* 当前最高层级 */
} zskiplist;
- 跨度(span):记录两个节点间的距离,用于快速计算节点的排名(Rank)。
- 后向指针:仅Level 1节点有,支持从尾到头遍历(如
ZREVRANGE命令)。
2.3 关键参数:为什么是32层和0.25概率?
Valkey跳表的核心参数在src/server.h中定义:
#define ZSKIPLIST_MAXLEVEL 32 /* 最大层级(支持2^64元素) */
#define ZSKIPLIST_P 0.25 /* 层级提升概率(1/4) */
- 最大层级(32):通过数学计算,32层足以支持2^64个节点(每层概率按0.25递减)。
- 层级提升概率(0.25):新节点创建时,每增加一层的概率为前一层的1/4,使层级分布呈指数衰减,平衡查询效率与内存占用。
三、核心操作实现:从插入到范围查询
3.1 节点插入:zslInsert的精妙逻辑
插入操作是跳表的核心,Valkey通过随机层级生成、前驱节点查找和指针调整实现高效插入:
zskiplistNode *zslInsert(zskiplist *zsl, double score, sds ele) {
zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x;
unsigned long rank[ZSKIPLIST_MAXLEVEL];
int i, level;
// 1. 查找各层前驱节点(update[i])及排名(rank[i])
x = zsl->header;
for (i = zsl->level - 1; i >= 0; i--) {
rank[i] = (i == zsl->level - 1) ? 0 : rank[i + 1];
while (x->level[i].forward &&
(x->level[i].forward->score < score ||
(x->level[i].forward->score == score &&
sdscmp(x->level[i].forward->ele, ele) < 0))) {
rank[i] += x->level[i].span;
x = x->level[i].forward;
}
update[i] = x;
}
// 2. 随机生成新节点层级
level = zslRandomLevel();
if (level > zsl->level) {
for (i = zsl->level; i < level; i++) {
rank[i] = 0;
update[i] = zsl->header;
update[i]->level[i].span = zsl->length;
}
zsl->level = level;
}
// 3. 创建节点并更新指针和跨度
x = zslCreateNode(level, score, ele);
for (i = 0; i < level; i++) {
x->level[i].forward = update[i]->level[i].forward;
update[i]->level[i].forward = x;
x->level[i].span = update[i]->level[i].span - (rank[0] - rank[i]);
update[i]->level[i].span = (rank[0] - rank[i]) + 1;
}
// 4. 更新后向指针和尾节点
x->backward = (update[0] == zsl->header) ? NULL : update[0];
if (x->level[0].forward)
x->level[0].forward->backward = x;
else
zsl->tail = x;
zsl->length++;
return x;
}
步骤解析:
- 查找前驱:从最高层开始,记录每一层的前驱节点和排名。
- 随机层级:通过
zslRandomLevel生成层级,控制跳表平衡性。 - 插入节点:调整前驱节点的前向指针和跨度,维护跳表结构。
3.2 范围查询:ZRANGE命令的底层实现
ZRANGE key start stop [WITHSCORES]命令通过跳表的按秩访问实现,核心函数为zslGetElementByRank:
/* 按排名查找节点(1-based) */
zskiplistNode *zslGetElementByRank(zskiplist *zsl, unsigned long rank) {
zskiplistNode *x = zsl->header;
unsigned long traversed = 0;
int i;
for (i = zsl->level - 1; i >= 0; i--) {
while (x->level[i].forward && (traversed + x->level[i].span) <= rank) {
traversed += x->level[i].span;
x = x->level[i].forward;
}
if (traversed == rank) return x;
}
return NULL;
}
性能对比:
- 传统数组:随机访问O(1),但插入O(N)。
- 跳表:访问、插入、删除均为O(logN),且实现更简单。
四、跳表vs其他排序结构:为什么Valkey选择跳表?
| 数据结构 | 插入复杂度 | 查询复杂度 | 范围查询 | 实现难度 | 并发性能 |
|---|---|---|---|---|---|
| 跳表 | O(logN) | O(logN) | O(logN + K) | 简单 | 高(局部修改) |
| 红黑树 | O(logN) | O(logN) | O(logN + K) | 复杂 | 低(全局旋转) |
| 平衡二叉树 | O(logN) | O(logN) | O(logN + K) | 复杂 | 低 |
| 有序数组 | O(N) | O(logN) | O(K) | 简单 | 低 |
Valkey选择跳表的原因:
- 实现简单:跳表的插入、删除逻辑比红黑树更直观,减少bug风险。
- 并发友好:跳表的修改集中在局部节点,无需全局调整(如红黑树的旋转)。
- 性能稳定:在极端情况下,跳表的查询效率仍接近O(logN),而平衡树可能因实现问题退化为O(N)。
五、实战应用:Zset的典型场景与命令优化
5.1 排行榜系统:高效维护Top N数据
场景:游戏积分排行榜、电商销量排名等。
核心命令:
ZADD key score member:添加/更新元素(O(logN))。ZRANGE key 0 9 WITHSCORES:获取Top 10元素(O(logN + 10))。ZREVRANK key member:查询元素排名(O(logN))。
示例:
# 添加玩家分数
ZADD game_rank 95 "player1" 88 "player2" 99 "player3"
# 获取前三名
ZRANGE game_rank 0 2 WITHSCORES
# 输出:1) "player2" 2) "88" 3) "player1" 4) "95" 5) "player3" 6) "99"
# 查询player1排名
ZREVRANK game_rank "player1" # 输出:1(0-based,第二名)
5.2 延迟队列:按时间顺序处理任务
场景:订单超时取消、定时任务调度。
实现思路:将任务ID作为member,超时时间戳作为score,通过ZRANGEBYSCORE轮询获取到期任务。
# 添加延迟任务(10秒后执行)
ZADD delay_queue $(date +%s +10) "task_1001"
# 轮询到期任务
while true; do
NOW=$(date +%s)
TASKS=$(ZRANGEBYSCORE delay_queue 0 $NOW LIMIT 0 10)
if [ -n "$TASKS" ]; then
# 处理任务...
ZREM delay_queue $TASKS
fi
sleep 1
done
5.3 优化建议:提升Zset性能的最佳实践
- 合理设置编码阈值:通过
zset-max-ziplist-entries和zset-max-ziplist-value调整压缩列表与跳表的转换阈值,小数据集优先使用压缩列表节省内存。 - 批量操作代替循环:使用
ZADD批量添加元素(一次调用O(M logN),M为元素数),避免多次单条调用。 - 避免大范围查询:
ZRANGE key 0 -1会扫描全表(O(N)),应限制查询范围(如ZRANGE key 0 99)。
六、底层源码深度解析:跳表的概率性平衡机制
6.1 层级生成算法:zslRandomLevel的数学原理
跳表通过几何分布生成节点层级,确保高层节点数量呈指数递减:
int zslRandomLevel(void) {
static const int threshold = ZSKIPLIST_P * RAND_MAX;
int level = 1;
while (random() < threshold) level++;
return (level < ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}
原理:每次以概率ZSKIPLIST_P(0.25)提升层级,节点层级的期望值为1/(1-P) = 1.33,最高层级被限制为32。
6.2 跨度(span)的作用:快速计算排名
跳表节点的span字段记录了该层前向指针跨越的节点数,使排名计算无需遍历底层链表:
/* 计算元素排名(1-based) */
unsigned long zslGetRank(zskiplist *zsl, double score, sds ele) {
zskiplistNode *x = zsl->header;
unsigned long rank = 0;
int i;
for (i = zsl->level - 1; i >= 0; i--) {
while (x->level[i].forward &&
(x->level[i].forward->score < score ||
(x->level[i].forward->score == score &&
sdscmp(x->level[i].forward->ele, ele) <= 0))) {
rank += x->level[i].span;
x = x->level[i].forward;
}
if (x->ele && x->score == score && sdscmp(x->ele, ele) == 0) return rank;
}
return 0; // 未找到
}
优势:排名计算时间复杂度从O(N)降至O(logN),支持ZRANK和ZREVRANK命令高效实现。
七、总结与展望:Zset的进化方向
Valkey的Zset通过跳表+哈希表的组合,在性能、内存和复杂度之间取得了完美平衡,成为分布式系统中的关键组件。未来,随着硬件和算法的发展,Zset可能会引入:
- 自适应层级调整:根据数据分布动态优化跳表层级,提升查询效率。
- 内存压缩技术:对分数和元素值采用更高效的编码方式(如整数压缩)。
- 分布式跳表:跨节点的有序集合,支持大规模数据存储与查询。
掌握Zset的底层原理,不仅能帮助你写出更高效的Valkey应用,更能在面对复杂数据结构问题时,选择最优解决方案。立即动手实践,体验跳表算法带来的性能飞跃吧!
扩展资源:
- Valkey官方文档:Zset命令参考
- 跳表原论文:William Pugh, "Skip Lists: A Probabilistic Alternative to Balanced Trees"
- 源码位置:Valkey仓库
src/t_zset.c(跳表实现)和src/dict.c(哈希表实现)
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考



