MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Redis 跳跃表 API 在数据检索中的高效运用

2022-04-084.1k 阅读

Redis 跳跃表基础概述

Redis 中的跳跃表(Skip List)是一种有序的数据结构,它通过在每个节点中维持多个指向其他节点的指针,来达到快速查找的目的。跳跃表的设计灵感来源于链表,但是相比普通链表,跳跃表在查找效率上有了质的飞跃。

在普通链表中,查找一个元素的时间复杂度为 O(n),因为需要依次遍历链表中的每个节点。而跳跃表通过构建多层索引结构,使得查找操作可以跳过部分节点,从而将时间复杂度降低到 O(log n)。

跳跃表由多层节点组成,最底层是一个普通的有序链表,每一层都是下面一层的“快速通道”。每个节点包含一个分值(score)和一个或多个指针,指针指向其他节点。分值用于决定节点在跳跃表中的顺序,相同分值的节点会按照插入顺序排列。

Redis 跳跃表的结构定义

在 Redis 源码中,跳跃表的结构定义如下:

// 跳跃表节点
typedef struct zskiplistNode {
    sds ele;        // 成员对象
    double score;   // 分值
    struct zskiplistNode *backward; // 后退指针
    struct zskiplistLevel {
        struct zskiplistNode *forward; // 前进指针
        unsigned long span;    // 跨度
    } level[];
} zskiplistNode;

// 跳跃表
typedef struct zskiplist {
    struct zskiplistNode *header, *tail; // 表头节点和表尾节点
    unsigned long length;                // 表中节点的数量
    int level;                           // 表中层数最大的节点的层数
} zskiplist;
  • zskiplistNode 是跳跃表节点的结构体。ele 是一个 SDS(简单动态字符串),用于存储节点的值;score 是分值,用于排序;backward 是后退指针,指向前一个节点;level 是一个柔性数组,包含多个 zskiplistLevel 结构体,每个结构体包含一个 forward 前进指针和 span 跨度。
  • zskiplist 是跳跃表的结构体。headertail 分别指向表头节点和表尾节点;length 记录表中节点的数量;level 表示表中层数最大的节点的层数。

Redis 跳跃表的创建与初始化

  1. 创建跳跃表节点 创建一个跳跃表节点的函数如下:
zskiplistNode *zslCreateNode(int level, double score, sds ele) {
    zskiplistNode *zn =
        zmalloc(sizeof(*zn)+level*sizeof(struct zskiplistLevel));
    zn->score = score;
    zn->ele = ele;
    return zn;
}

该函数使用 zmalloc 分配内存,sizeof(*zn)zskiplistNode 结构体本身的大小,level * sizeof(struct zskiplistLevel) 是柔性数组 level 的大小。然后设置节点的 scoreele

  1. 创建跳跃表 创建一个跳跃表的函数如下:
zskiplist *zslCreate(void) {
    int j;
    zskiplist *zsl;

    zsl = zmalloc(sizeof(*zsl));
    zsl->level = 1;
    zsl->length = 0;
    zsl->header = zslCreateNode(ZSKIPLIST_MAXLEVEL,0,NULL);
    for (j = 0; j < ZSKIPLIST_MAXLEVEL; j++) {
        zsl->header->level[j].forward = NULL;
        zsl->header->level[j].span = 0;
    }
    zsl->header->backward = NULL;
    zsl->tail = NULL;
    return zsl;
}

这里先分配 zskiplist 结构体的内存,初始化 level 为 1,length 为 0。然后创建表头节点,表头节点的分值为 0,eleNULL,并且将表头节点的所有层的 forward 指针初始化为 NULLspan 初始化为 0。backward 指针也初始化为 NULLtail 指针初始化为 NULL

Redis 跳跃表的插入操作

插入操作是跳跃表的核心操作之一。在 Redis 跳跃表中插入一个节点时,需要确定插入的位置,并更新相关节点的指针。

  1. 确定插入位置 在插入节点前,首先要确定该节点在跳跃表中的插入位置。这需要从表头节点的最高层开始查找,找到第一个 forward 指针指向的节点的 score 大于要插入节点的 score 的位置。
void zslInsert(zskiplist *zsl, double score, sds ele) {
    zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x;
    unsigned int rank[ZSKIPLIST_MAXLEVEL];
    int i, level;

    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;
    }
    // 省略后续插入节点的代码
}

在上述代码中,update 数组用于记录每一层需要更新指针的节点,rank 数组用于记录从表头到当前节点的跨度。从最高层开始遍历,通过比较 scoreele 来确定插入位置。

  1. 生成随机层数 跳跃表节点的层数是随机生成的,这样可以保证跳跃表的结构相对平衡。
int zslRandomLevel(void) {
    int level = 1;
    while ((random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF))
        level += 1;
    return (level<ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}

这里使用 random() 函数生成一个随机数,通过与 ZSKIPLIST_P * 0xFFFF 比较来决定是否增加层数,ZSKIPLIST_P 是一个概率值,通常为 0.25。

  1. 插入节点 确定插入位置和生成随机层数后,就可以插入节点了。
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;
}

for (i = level; i < zsl->level; i++) {
    update[i]->level[i].span++;
}

if (update[0] == zsl->header)
    x->backward = NULL;
else
    x->backward = update[0];
if (x->level[0].forward)
    x->level[0].forward->backward = x;
else
    zsl->tail = x;
zsl->length++;
if (level > zsl->level)
    zsl->level = level;

上述代码将新节点插入到跳跃表中,并更新相关节点的指针和跨度。同时更新跳跃表的长度和最大层数。

Redis 跳跃表的删除操作

删除操作与插入操作类似,也需要先确定要删除节点的位置,然后更新相关节点的指针。

  1. 确定删除节点位置
void zslDelete(zskiplist *zsl, double score, sds ele, zskiplistNode **node) {
    zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x;
    int i;

    x = zsl->header;
    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)))
        {
            x = x->level[i].forward;
        }
        update[i] = x;
    }
    // 省略后续删除节点的代码
}

这里与插入操作类似,通过从最高层开始遍历找到要删除节点的位置,并记录每一层需要更新指针的节点。

  1. 删除节点
x = update[0]->level[0].forward;
if (x && score == x->score && sdscmp(x->ele,ele) == 0) {
    zslDeleteNode(zsl, x, update);
    if (!zsl->length) {
        zsl->tail = zsl->header;
    } else if (zsl->tail == x) {
        zsl->tail = update[0];
    }
    if (node)
        *node = x;
    zslFreeNode(x);
    zsl->length--;
    while(zsl->level > 1 && zsl->header->level[zsl->level-1].forward == NULL)
        zsl->level--;
} else {
    if (node)
        *node = NULL;
}

首先检查找到的节点是否是要删除的节点,如果是,则调用 zslDeleteNode 函数删除节点,并更新跳跃表的相关信息,如 tail 指针、长度和最大层数。如果不是,则直接返回。

Redis 跳跃表在数据检索中的应用

  1. 范围查询 Redis 跳跃表非常适合进行范围查询。例如,要查询分值在某个范围内的所有节点,可以从表头节点开始,通过比较分值找到范围的起始节点,然后顺着链表遍历,直到找到范围的结束节点。
zskiplistNode *zslFirstInRange(zskiplist *zsl, zrangespec *range) {
    zskiplistNode *x;
    int i;

    x = zsl->header;
    for (i = zsl->level-1; i >= 0; i--) {
        while (x->level[i].forward &&
               !zslValueGteMin(x->level[i].forward->score,x->level[i].forward->ele,range))
        {
            x = x->level[i].forward;
        }
    }
    x = x->level[0].forward;
    if (x &&!zslValueLteMax(x->score,x->ele,range))
        return NULL;
    return x;
}

上述代码通过从最高层开始查找,找到第一个分值大于等于范围最小值的节点,然后检查该节点是否在范围内,如果在则返回该节点,否则返回 NULL

  1. 快速定位 由于跳跃表的多层索引结构,在查找特定分值的节点时,可以快速定位。例如,要查找分值为 score 的节点:
zskiplistNode *zslFind(zskiplist *zsl, double score, sds ele) {
    zskiplistNode *x = zsl->header;
    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)))
        {
            x = x->level[i].forward;
        }
    }
    x = x->level[0].forward;
    if (x && x->score == score && sdscmp(x->ele,ele) == 0)
        return x;
    else
        return NULL;
}

从最高层开始遍历,通过比较分值和 ele 找到目标节点,如果找到则返回,否则返回 NULL

基于 Redis 跳跃表 API 的应用示例(以 C 语言为例)

下面通过一个简单的示例展示如何使用 Redis 跳跃表 API 进行数据检索。假设我们要实现一个简单的排行榜功能,使用跳跃表来存储玩家的得分。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "redis.h"

int main() {
    zskiplist *zsl = zslCreate();

    // 插入玩家得分
    zslInsert(zsl, 85.0, sdsnew("Player1"));
    zslInsert(zsl, 92.0, sdsnew("Player2"));
    zslInsert(zsl, 78.0, sdsnew("Player3"));
    zslInsert(zsl, 95.0, sdsnew("Player4"));

    // 查找特定玩家
    zskiplistNode *node = zslFind(zsl, 92.0, sdsnew("Player2"));
    if (node) {
        printf("找到玩家: %s, 得分: %f\n", node->ele, node->score);
    } else {
        printf("未找到玩家\n");
    }

    // 范围查询
    zrangespec range = {.min = 80.0, .max = 90.0, .minex = 1, .maxex = 1 };
    zskiplistNode *rangeNode = zslFirstInRange(zsl, &range);
    while (rangeNode) {
        printf("范围内玩家: %s, 得分: %f\n", rangeNode->ele, rangeNode->score);
        rangeNode = zslNextInRange(zsl, rangeNode, &range);
    }

    // 释放跳跃表
    zslFree(zsl);
    return 0;
}

在上述示例中,首先创建一个跳跃表,然后插入一些玩家的得分信息。接着使用 zslFind 函数查找特定玩家,使用 zslFirstInRangezslNextInRange 函数进行范围查询。最后释放跳跃表的内存。

Redis 跳跃表与其他数据结构在数据检索方面的比较

  1. 与普通链表比较 普通链表在数据检索方面的时间复杂度为 O(n),因为需要依次遍历链表中的每个节点。而 Redis 跳跃表通过多层索引结构,将时间复杂度降低到 O(log n)。例如,在一个包含 10000 个节点的链表中查找一个节点,平均需要遍历 5000 次;而在同样规模的跳跃表中,平均只需要遍历约 14 次(以 2 为底 10000 的对数约为 13.29)。
  2. 与平衡二叉树比较 平衡二叉树(如 AVL 树、红黑树)在数据检索方面的时间复杂度也为 O(log n)。但是,平衡二叉树的插入和删除操作相对复杂,需要进行旋转操作来维持树的平衡。而 Redis 跳跃表的插入和删除操作相对简单,不需要进行复杂的旋转操作,并且在实现上更加简洁。
  3. 与哈希表比较 哈希表在查找单个元素时的时间复杂度为 O(1),但是哈希表不支持范围查询。而 Redis 跳跃表不仅可以快速查找单个元素,还可以高效地进行范围查询。例如,在一个存储用户信息的系统中,如果需要根据用户的年龄范围查询用户,哈希表就无法直接实现,而跳跃表可以很方便地做到。

Redis 跳跃表在实际项目中的优化与注意事项

  1. 内存优化 跳跃表节点的层数是随机生成的,为了避免层数过高导致内存浪费,可以适当调整随机层数生成的概率。例如,将 ZSKIPLIST_P 的值从 0.25 适当调小,可以减少高层节点的出现概率,从而降低内存消耗。
  2. 并发访问 在多线程环境下使用 Redis 跳跃表时,需要注意并发访问的问题。可以使用互斥锁等机制来保证跳跃表操作的原子性,避免数据竞争。例如,在插入和删除节点时,先获取锁,操作完成后再释放锁。
  3. 数据规模与性能 随着数据规模的增大,跳跃表的性能可能会受到一定影响。虽然其时间复杂度为 O(log n),但在非常大规模的数据量下,多层索引结构可能会占用大量内存,导致缓存命中率下降。此时可以考虑对跳跃表进行分段管理,或者结合其他数据结构来优化性能。

通过深入理解 Redis 跳跃表的 API 以及其在数据检索中的应用,可以在实际项目中更高效地使用跳跃表来管理和检索数据,提高系统的性能和效率。无论是在排行榜系统、搜索引擎的倒排索引,还是其他需要有序数据管理和范围查询的场景中,Redis 跳跃表都能发挥重要作用。同时,合理地对跳跃表进行优化和注意并发访问等问题,可以进一步提升其在实际应用中的表现。