开发易忽视的问题:zset 排序实现原理

291 阅读4分钟

在 Redis 中,有序集合(Sorted Set)中的分数是用来对元素进行排序的关键,而其底层实现主要依赖于跳表(Skip List)和哈希表的结合。下面是关于其底层存储与实现的详细解释:

底层数据结构

  1. 跳表(Skip List)

    • 跳表是一种概率性数据结构,支持快速的插入、删除、查找操作,平均时间复杂度为 O(log N)。
    • 在有序集合中,跳表用于维护所有元素的排序信息。每个节点包含一个成员和它对应的分数。
  2. 哈希表(Hash Table)

    • 另一个数据结构是哈希表,用于从成员快速获取其对应的分数。
    • 这样可以实现快速的分数查找和更新。

分数的存储

  • 双重索引:每个有序集合中的元素在这两个数据结构中都有各自的表示:

    • 在跳表中,根据分数进行排序,以支持范围查询等操作。
    • 在哈希表中,通过成员名作为键,分数作为值,支持快速的分数读取和更新。

举例说明

假设我们有一个空的有序集合,我们将依次插入一些元素,并观察底层数据结构的变化。

初始状态

开始时,有序集合是空的,没有任何元素。

插入第一个元素

ZADD leaderboard 100 "player1"
  • 跳表

    • 创建一个新的节点 player1,其分数为 100。
    • 由于这是第一个元素,所以直接插入到跳表中。
  • 哈希表

    • 在哈希表中添加键值对 "player1" -> 100

插入第二个元素

ZADD leaderboard 150 "player2"
  • 跳表

    • 创建一个新节点 player2,其分数为 150。
    • 将 player2 插入到跳表中,在 player1 之后,因为 150 > 100。
  • 哈希表

    • 添加键值对 "player2" -> 150

插入第三个元素

ZADD leaderboard 120 "player3"
  • 跳表

    • 创建一个新节点 player3,其分数为 120。
    • 将 player3 插入到跳表中,在 player1 和 player2 之间,即 player1 < player3 < player2
  • 哈希表

    • 添加键值对 "player3" -> 120

ZADD 操作源码分析

  1. 关键流程

    • 查找或创建节点

      • 使用哈希表判断成员是否已经存在。
      • 如果存在,则根据 xx/nx 参数决定是更新分数还是直接返回。
      • 如果不存在,则创建一个新的跳表节点和哈希表条目。
    • 更新跳表

      • 新元素或分数变化导致位置变化时,需要在跳表中找到正确的插入位置。
      • 跳表通过多级索引快速定位插入点,随后插入新节点。
    • 更新哈希表

      • 同步更新哈希表中的成员分数映射。
  2. 源码片段探讨

    在 Redis 的源代码文件 t_zset.c 中,实现了有序集合的各种操作。zaddGenericCommand 函数负责处理 ZADD 命令:

    void zaddGenericCommand(client *c, int flags) {
        ... // 解析参数,初始化变量
    
        zobj = lookupKeyWrite(c->db,c->argv[1]);
        if (zobj == NULL) {
            zobj = createZsetObject();
            dbAdd(c->db,c->argv[1],zobj);
        } else {
            if (checkType(c,zobj,OBJ_ZSET)) return;
        }
    
        zsetConvert(zobj,ZSKIPLIST);
    
        for (j = 0; j < elements; j++) {
            ele = c->argv[scoreidx+1+j*2];
            score = scores[j];
    
            /* 更新或插入跳表 */
            if (zzlInsert(zobj,ele,score,&incr,flags,&newscore))
                updated++;
         }
         
         if (updated) server.dirty++;
    
         addReplyLongLong(c,updated);
     }
    

    在这段代码中:

    • lookupKeyWrite:查找集合对象,如果不存在则创建一个新的 ZSET 对象。
    • zsetConvert:确保当前对象为跳表实现。
    • zzlInsert:核心插入逻辑,在跳表中插入或更新节点,并根据需要调整位置。

补充:xx/nx 参数

在 Redis 的 ZADD 命令中,如果不指定 NXXX 选项,默认的行为是既插入新成员又更新已有成员。也就是说,默认情况下:

  • 如果成员已经存在,则更新其分数
  • 如果成员不存在,则添加新的成员及其分数。

在 Redis 的 ZADD 命令中,XXNX 是用来控制元素插入和更新行为的选项:

  1. NX(Not eXists)

    • 仅在指定的成员不存在时才添加。
    • 如果成员已经存在,则不执行任何操作,也不更新分数
    • 常用于确保不会意外覆盖已有的成员及其分数。
  2. XX(eXists)

    • 仅在指定的成员已经存在时才更新分数。
    • 如果成员不存在,则不执行任何操作。
    • 常用于需要更新特定已有成员的分数,而不希望插入新成员的场景。

使用示例

假设我们有一个有序集合 leaderboard

  • 使用 NX:仅在成员不存在时才添加

    ZADD leaderboard NX 100 "player1"
    

    如果 "player1" 已经存在,则这条命令不会改变有序集合。

  • 使用 XX:仅在成员存在时才更新分数

    ZADD leaderboard XX 200 "player1"
    

    如果 "player1" 不存在,则这条命令不会对有序集合产生影响。