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

Redis 跳跃表 API 在实时数据处理中的应用

2021-04-158.0k 阅读

Redis 跳跃表简介

Redis 是一个开源的内存数据结构存储系统,可作为数据库、缓存和消息代理使用。在 Redis 众多的数据结构中,跳跃表(Skip List)是一种特殊的数据结构,它以一种巧妙的方式提供了类似平衡树的查找效率,同时在插入和删除操作上具有相对简单的实现。

跳跃表是一种有序的数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而实现快速查找。跳跃表的每一层都是一个有序的链表,最底层(第 0 层)包含了所有的元素,而高层的链表则是底层链表的子集,高层链表中的节点通过“跳跃”操作可以快速跳过一些中间节点,从而加速查找过程。

跳跃表的节点结构通常包含以下几个部分:

  • 数据域:存储节点所代表的实际数据。
  • 指针数组:每个节点包含一个指针数组,数组中的每个指针指向同一层的下一个节点。指针数组的大小会随着节点在跳跃表中的层数而增加。
  • 层数:记录该节点所在的跳跃表层数。

例如,一个简单的跳跃表节点结构在 C 语言中的定义可能如下:

typedef struct skiplistNode {
    sds ele;         // 存储的数据,sds 是 Redis 自定义的字符串结构
    double score;    // 节点的分值,用于排序
    struct skiplistNode *backward;  // 指向前一个节点的指针
    struct skiplistLevel {
        struct skiplistNode *forward;  // 指向同一层的下一个节点
        unsigned long span;  // 记录当前节点到下一个节点之间跨越的节点数
    } level[];  // 柔性数组,实际大小根据节点的层数动态分配
} skiplistNode;

Redis 跳跃表 API 概述

Redis 为跳跃表提供了一系列的 API 来进行操作,主要包括以下几类:

  1. 创建与销毁:用于初始化一个新的跳跃表和释放跳跃表占用的内存。
  2. 插入操作:将一个新的节点插入到跳跃表中,保持跳跃表的有序性。
  3. 删除操作:从跳跃表中移除指定的节点。
  4. 查找操作:在跳跃表中查找特定分值或数据的节点。
  5. 范围查询:获取跳跃表中指定分值范围内的节点。

下面我们详细介绍这些 API 的具体功能和使用方法。

创建与销毁跳跃表

在 Redis 中,通过 zslCreate 函数来创建一个新的跳跃表。该函数的定义如下:

zskiplist *zslCreate(void) {
    int j;
    zskiplist *zsl;

    zsl = zmalloc(sizeof(*zsl));
    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;
    zsl->length = 0;
    zsl->level = 1;
    return zsl;
}

zslCreate 函数首先为跳跃表分配内存空间,然后创建一个头节点。头节点的层数初始化为 ZSKIPLIST_MAXLEVEL(Redis 中定义的最大层数,通常为 32),但实际上只有最底层的指针会被使用,其他高层指针初始化为 NULL。头节点的分值为 0,不存储实际数据。

跳跃表的销毁通过 zslFree 函数实现,该函数会递归地释放跳跃表中所有节点的内存:

void zslFree(zskiplist *zsl) {
    zskiplistNode *node = zsl->header->level[0].forward, *next;

    zfree(zsl->header);
    while(node) {
        next = node->level[0].forward;
        zfree(node);
        node = next;
    }
    zfree(zsl);
}

插入操作

插入操作是将一个新的节点插入到跳跃表中,同时保持跳跃表的有序性。Redis 中通过 zslInsert 函数实现插入操作,其核心步骤如下:

  1. 查找插入位置:从跳跃表的顶层开始,通过比较分值找到新节点应该插入的位置。
  2. 确定新节点的层数:随机生成一个层数,用于决定新节点在跳跃表中的高度。
  3. 插入新节点:在找到的插入位置处插入新节点,并调整相关节点的指针。

下面是简化后的 zslInsert 函数代码:

zskiplistNode *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;
    }
    x = x->level[0].forward;
    if (x && x->score == score && sdscmp(x->ele,ele) == 0) {
        // 节点已存在,更新数据
        sdsfree(x->ele);
        x->ele = sdsdup(ele);
        return x;
    } else {
        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;
        }
        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++;
        }
        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;
    }
}

删除操作

删除操作是从跳跃表中移除指定的节点。Redis 中通过 zslDelete 函数实现删除操作,其主要步骤如下:

  1. 查找要删除的节点:从跳跃表的顶层开始,通过比较分值找到要删除的节点。
  2. 调整指针:将指向该节点的指针重新指向其下一个节点,从而将该节点从跳跃表中移除。
  3. 释放内存:释放被删除节点占用的内存。

下面是简化后的 zslDelete 函数代码:

int 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;
    }
    x = x->level[0].forward;
    if (x && score == x->score && sdscmp(x->ele,ele) == 0) {
        zslDeleteNode(zsl, x, update);
        if (!zsl->header->level[0].forward)
            zsl->tail = zsl->header;
        else
            zsl->tail = update[0]->level[0].forward;
        while(zsl->level > 1 && zsl->header->level[zsl->level-1].forward == NULL)
            zsl->level--;
        zsl->length--;
        if (node)
            *node = x;
        zfree(x->ele);
        zfree(x);
        return 1;
    } else {
        if (node)
            *node = NULL;
        return 0;
    }
}

查找操作

查找操作是在跳跃表中查找特定分值或数据的节点。Redis 中通过 zslGetElementByRank 函数来根据排名查找节点,通过 zslFindNodeByScore 函数来根据分值查找节点。

zslGetElementByRank 函数从跳跃表的顶层开始,沿着指针逐步向下移动,直到找到指定排名的节点:

zskiplistNode *zslGetElementByRank(zskiplist *zsl, unsigned long rank) {
    zskiplistNode *x;
    unsigned long traversed = 0;
    int i;

    x = zsl->header;
    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;
}

zslFindNodeByScore 函数则从跳跃表的顶层开始,通过比较分值找到指定分值的节点:

zskiplistNode *zslFindNodeByScore(zskiplist *zsl, double score) {
    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,"") <= 0)))
        {
            x = x->level[i].forward;
        }
        if (x->level[i].forward && x->level[i].forward->score == score) {
            return x->level[i].forward;
        }
    }
    return NULL;
}

范围查询

范围查询是获取跳跃表中指定分值范围内的节点。Redis 中通过 zslFirstInRangezslLastInRange 函数来确定范围的起始和结束节点,然后通过遍历获取范围内的所有节点。

zslFirstInRange 函数找到满足范围条件的第一个节点:

zskiplistNode *zslFirstInRange(zskiplist *zsl, zrangespec *range) {
    zskiplistNode *x;
    int i;

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

zslLastInRange 函数找到满足范围条件的最后一个节点:

zskiplistNode *zslLastInRange(zskiplist *zsl, zrangespec *range) {
    zskiplistNode *x;
    int i;

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

Redis 跳跃表 API 在实时数据处理中的应用场景

  1. 实时排行榜:在游戏、直播等应用中,经常需要实时更新用户的排名信息。跳跃表的有序性和快速插入、删除、查找操作使得它非常适合用于实现实时排行榜。例如,在一个在线游戏中,玩家的得分会实时更新,通过 Redis 跳跃表可以快速插入新的得分记录,并保持排行榜的有序性,同时能够高效地查询某个玩家的排名。
  2. 时间序列数据处理:实时收集的时间序列数据,如传感器数据、网站访问日志等,需要按时间顺序存储和查询。跳跃表可以按时间戳对数据进行排序,通过范围查询可以快速获取指定时间范围内的数据,满足实时数据分析和监控的需求。
  3. 实时推荐系统:在推荐系统中,需要根据用户的实时行为(如点击、购买等)动态调整推荐列表。跳跃表可以根据用户行为的权重(如点击次数、购买金额等)对推荐项目进行排序,通过插入和删除操作实时更新推荐列表。

代码示例:使用 Redis 跳跃表实现实时排行榜

下面我们通过一个简单的 C 语言示例,展示如何使用 Redis 跳跃表 API 实现一个实时排行榜。

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

// 假设这是 Redis 相关的头文件和函数定义

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

    // 插入玩家得分
    zslInsert(rankList, 100.0, sdsnew("Player1"));
    zslInsert(rankList, 120.0, sdsnew("Player2"));
    zslInsert(rankList, 90.0, sdsnew("Player3"));

    // 查找玩家排名
    zskiplistNode *node = zslFindNodeByScore(rankList, 120.0);
    if (node) {
        unsigned long rank = zslGetRank(rankList, node);
        printf("Player2's rank is %lu\n", rank);
    }

    // 更新玩家得分
    zslDelete(rankList, 120.0, sdsnew("Player2"), NULL);
    zslInsert(rankList, 130.0, sdsnew("Player2"));

    // 重新查找玩家排名
    node = zslFindNodeByScore(rankList, 130.0);
    if (node) {
        unsigned long rank = zslGetRank(rankList, node);
        printf("Player2's new rank is %lu\n", rank);
    }

    // 范围查询:获取前两名玩家
    zrangespec range = {0, 2, 0, 0};
    zskiplistNode *first = zslFirstInRange(rankList, &range);
    if (first) {
        printf("Top 2 players:\n");
        zskiplistNode *current = first;
        for (int i = 0; i < 2 && current; i++) {
            printf("Player: %s, Score: %f\n", current->ele, current->score);
            current = current->level[0].forward;
        }
    }

    zslFree(rankList);
    return 0;
}

在这个示例中,我们首先创建了一个跳跃表 rankList,然后插入了几个玩家的得分。通过 zslFindNodeByScorezslGetRank 函数查找玩家的排名,通过 zslDeletezslInsert 函数更新玩家的得分。最后,通过范围查询获取前两名玩家的信息。

性能分析

  1. 时间复杂度
    • 查找操作:平均情况下,跳跃表的查找时间复杂度为 $O(\log n)$,其中 $n$ 是跳跃表中的节点数。这是因为在每一层链表中,平均每次比较可以跳过大约一半的节点。最坏情况下,查找时间复杂度为 $O(n)$,当跳跃表的层数退化为 1 层时,查找就退化为普通链表的查找。
    • 插入和删除操作:平均情况下,插入和删除操作的时间复杂度也为 $O(\log n)$。插入操作需要先查找插入位置,然后调整指针;删除操作需要先查找要删除的节点,然后调整指针。这两个操作都与查找操作密切相关,所以时间复杂度相似。最坏情况下,插入和删除操作的时间复杂度为 $O(n)$。
  2. 空间复杂度:跳跃表的空间复杂度为 $O(n)$,因为每个节点都需要额外的指针空间。平均情况下,每个节点的指针数为 $O(\log n)$,所以总的空间复杂度为 $O(n \log n)$。然而,在实际应用中,由于 Redis 采用了一些优化策略,如柔性数组来动态分配节点的指针空间,实际的空间占用可能会更接近线性空间复杂度。

与其他数据结构的比较

  1. 与平衡树的比较
    • 查找性能:跳跃表和平衡树在平均情况下的查找时间复杂度都是 $O(\log n)$。然而,跳跃表的实现相对简单,不需要像平衡树那样在插入和删除操作后进行复杂的旋转操作来维持平衡。
    • 插入和删除性能:跳跃表的插入和删除操作在平均情况下与平衡树相当,但在最坏情况下,平衡树的性能更稳定,仍然保持 $O(\log n)$ 的时间复杂度,而跳跃表可能会退化为 $O(n)$。
    • 空间复杂度:平衡树的空间复杂度为 $O(n)$,而跳跃表平均情况下为 $O(n \log n)$。虽然跳跃表的空间占用相对较高,但由于其实现简单,在一些对空间要求不是特别苛刻的场景中仍然具有优势。
  2. 与普通链表的比较
    • 查找性能:普通链表的查找时间复杂度为 $O(n)$,而跳跃表的平均查找时间复杂度为 $O(\log n)$,跳跃表在查找效率上具有明显优势。
    • 插入和删除性能:普通链表在插入和删除操作上,在已知插入或删除位置的情况下,时间复杂度为 $O(1)$。但在实际应用中,往往需要先查找位置,此时普通链表的查找开销较大。而跳跃表虽然插入和删除操作的时间复杂度在平均情况下为 $O(\log n)$,但结合其查找优势,整体性能在很多场景下更优。
    • 空间复杂度:普通链表的空间复杂度为 $O(n)$,而跳跃表平均情况下为 $O(n \log n)$,普通链表在空间占用上更有优势。

优化与扩展

  1. 减少空间占用:虽然跳跃表的空间复杂度相对较高,但可以通过一些方法来优化。例如,可以采用分层跳跃表的结构,减少高层链表中节点的数量,从而降低空间占用。同时,在实际应用中,可以根据数据的特点和访问模式,动态调整跳跃表的层数,避免过多的冗余指针。
  2. 提高并发性能:在多线程环境下,Redis 跳跃表的操作需要考虑并发控制。可以采用读写锁或者无锁数据结构来提高并发性能。例如,使用无锁跳跃表的实现,可以避免锁竞争,提高多线程环境下的操作效率。
  3. 数据持久化:Redis 本身支持数据持久化,对于跳跃表数据,可以通过 RDB(Redis Database)或 AOF(Append - Only File)方式进行持久化。RDB 方式通过定期快照将内存数据保存到磁盘,AOF 方式则通过记录每次写操作来保证数据的一致性。在实时数据处理中,需要根据应用的需求选择合适的持久化方式,以确保数据的可靠性。

应用案例分析

  1. 某直播平台的礼物排行榜:该直播平台使用 Redis 跳跃表来实时更新主播收到礼物的排行榜。每当有用户给主播送礼物时,通过跳跃表的插入操作更新主播的礼物价值得分,并重新排序排行榜。观众可以随时查看实时的礼物排行榜,了解主播的受欢迎程度。由于跳跃表的高效插入和查询性能,即使在高并发的礼物赠送场景下,排行榜也能实时、准确地展示。
  2. 工业物联网中的传感器数据监控:在工业物联网环境中,大量传感器实时采集数据,如温度、压力等。这些数据按时间戳通过 Redis 跳跃表存储。监控系统可以通过跳跃表的范围查询功能,快速获取过去一段时间内的传感器数据,用于实时监测设备状态、发现异常情况等。跳跃表的有序性和高效查询性能使得数据处理和分析更加便捷。

总结

Redis 跳跃表 API 在实时数据处理中具有重要的应用价值。其高效的插入、删除、查找和范围查询操作,使得它非常适合用于实现实时排行榜、时间序列数据处理、实时推荐系统等应用场景。通过深入理解跳跃表的原理和 API 用法,并结合实际应用需求进行优化和扩展,可以充分发挥 Redis 跳跃表在实时数据处理中的优势,为各种应用提供高性能的数据管理和分析能力。同时,与其他数据结构的比较分析,也有助于我们在不同场景下选择最合适的数据结构来满足系统的性能和功能要求。在实际应用中,还需要考虑数据持久化、并发控制等问题,以确保系统的可靠性和稳定性。