Valkey有序集合:Zset数据结构与跳表算法

Valkey有序集合:Zset数据结构与跳表算法

【免费下载链接】valkey A new project to resume development on the formerly open-source Redis project. We're calling it Valkey, like a Valkyrie. 【免费下载链接】valkey 项目地址: https://gitcode.com/GitHub_Trending/va/valkey

引言:为什么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 跳表的核心原理

跳表在普通链表的基础上增加了多级索引,每级索引都是下一级的"快速通道"。以查询操作为例:

  1. 从最高级索引开始,快速跳过不符合范围的节点;
  2. 当遇到无法跳过的节点时,下降到下一级索引继续查找;
  3. 重复直至到达底层链表,找到目标节点。

示例:在包含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;
}

步骤解析

  1. 查找前驱:从最高层开始,记录每一层的前驱节点和排名。
  2. 随机层级:通过zslRandomLevel生成层级,控制跳表平衡性。
  3. 插入节点:调整前驱节点的前向指针和跨度,维护跳表结构。

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选择跳表的原因

  1. 实现简单:跳表的插入、删除逻辑比红黑树更直观,减少bug风险。
  2. 并发友好:跳表的修改集中在局部节点,无需全局调整(如红黑树的旋转)。
  3. 性能稳定:在极端情况下,跳表的查询效率仍接近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性能的最佳实践

  1. 合理设置编码阈值:通过zset-max-ziplist-entrieszset-max-ziplist-value调整压缩列表与跳表的转换阈值,小数据集优先使用压缩列表节省内存。
  2. 批量操作代替循环:使用ZADD批量添加元素(一次调用O(M logN),M为元素数),避免多次单条调用。
  3. 避免大范围查询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),支持ZRANKZREVRANK命令高效实现。

七、总结与展望:Zset的进化方向

Valkey的Zset通过跳表+哈希表的组合,在性能、内存和复杂度之间取得了完美平衡,成为分布式系统中的关键组件。未来,随着硬件和算法的发展,Zset可能会引入:

  1. 自适应层级调整:根据数据分布动态优化跳表层级,提升查询效率。
  2. 内存压缩技术:对分数和元素值采用更高效的编码方式(如整数压缩)。
  3. 分布式跳表:跨节点的有序集合,支持大规模数据存储与查询。

掌握Zset的底层原理,不仅能帮助你写出更高效的Valkey应用,更能在面对复杂数据结构问题时,选择最优解决方案。立即动手实践,体验跳表算法带来的性能飞跃吧!


扩展资源

  • Valkey官方文档:Zset命令参考
  • 跳表原论文:William Pugh, "Skip Lists: A Probabilistic Alternative to Balanced Trees"
  • 源码位置:Valkey仓库src/t_zset.c(跳表实现)和src/dict.c(哈希表实现)

【免费下载链接】valkey A new project to resume development on the formerly open-source Redis project. We're calling it Valkey, like a Valkyrie. 【免费下载链接】valkey 项目地址: https://gitcode.com/GitHub_Trending/va/valkey

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值