Redis:底层数据结构

参考资料:

《Redis中的数据结构》

《Redis内部数据结构详解》

相关文章:

《Redis:数据对象与底层实现》

        写在开头:本文为个人学习笔记,内容比较随意,夹杂个人理解,如有错误,欢迎指正。

目录

一、整数集 - IntSet

        IntSet结构介绍

        相关特性

        整数集合的升级

二、简单动态字符串 - SDS

        SDS结构介绍

        为什么使用SDS替代默认字符串

        1、字符串长度

        2、避免缓冲区溢出

        3、预分配与惰性释放

        4、二进制安全

        空间预分配的理解

三、压缩列表 - ZipList

        ZipList结构介绍

        Entry

         1、普通结构

         2、特殊结构

        ZipList节省内存的根因

        ZipList的缺点

四、快表 - QuickList

        结构介绍

        quicklistNode

        quicklistLZF

        quicklist

        Quicklist特性介绍

        1、一个quicklist节点包含多长的ziplist合适

        2、如何节省Quicklist的空间

        插入逻辑

五、字典/哈希表 - Dict

六、跳表 - ZSkipList

        什么是跳跃表

        zskiplist的实现

        zskiplist与平衡树、哈希表的比较


        众所周知,Redis实现了字符串、列表、集合、哈希、有序集合等基础数据类型,本篇文章我们从底层出发,看看这些基础数据类型内部是如何实现的。

一、整数集 - IntSet

        IntSet结构介绍

        整数集合(intset)是集合类型的底层实现之一,当一个集合只包含整数值元素,并且这个集合的元素数量不多时,Redis 就会使用整数集合作为集合键的底层实现。

        源码结构如下:

typedef struct intset {
    //编码方式
    uint32_t encoding;
    //集合包含的元素数量
    uint32_t length;
    //保存元素的数组
    int8_t contents[];
} intset;

#define INTSET_ENC_INT16 (sizeof(int16_t))
#define INTSET_ENC_INT32 (sizeof(int32_t))
#define INTSET_ENC_INT64 (sizeof(int64_t))

         我们可以看到在结构体intset中,有三个属性值encoding、length、contents[],分别表示编码方式、整数集合的长度、以及元素内容,下面我们详细看下这三个属性。

  • encoding 表示编码方式,的取值有三个:INTSET_ENC_INT16, INTSET_ENC_INT32, INTSET_ENC_INT64。
  • length 代表其中存储的整数的个数。
  • contents 指向实际存储数值的连续内存区域, 就是一个数组;整数集合的每个元素都是 contents 数组的一个数组元素。

        相关特性

        需要注意的是,intset中保存的数组元素是由小到大排序的,且无重复元素。contents 数组虽然申明的类型为int8_t,但实际上保存的元素类型由encoding字段决定,所有元素类型保持一致。

        intset结构存储在内存中时布局如下:

        intset中的数值是以升序排列存储的, 所以插入与删除的复杂度均为O(n).。查找使用二分法, 复杂度为O(log(N))。
        intset不会预留空间, 即每一次插入操作都会调用zrealloc接口重新分配内存, 每一次删除也会调用zrealloc接口缩减占用的内存。这样的操作有利有弊,好处在于能够节省空间,但坏处就是时间消耗的增加。     
  

        在上面我们提到,所有元素类型保持一致,假如此时encoding的值为INTSET_ENC_INT16(即只能存放int16类型的整数),当我要存放一个int32类型的值时,原来的encoding无法存放,就需要升级,这就涉及到了整数集合的升级。

        整数集合的升级

        当在一个int16类型的整数集合中插入一个int32类型的值,整个集合的所有元素都会转换成32类型。 整个过程有三步:

(1)根据新元素的类型(比如int32),扩展整数集合底层数组的空间大小,并为新元素分配空间。

(2)将底层数组现有的所有元素都转换成与新元素相同的类型, 并将类型转换后的元素放置到正确的位上, 而且在放置元素的过程中, 需要继续维持底层数组的有序性质不变。

(3)最后改变encoding的值,length+1。

        那么如果我们删除掉刚加入的int32类型时,会不会做一个降级操作呢?

        答案是不会,这主要还是为了减少资源开销的选择。

二、简单动态字符串 - SDS

        SDS结构介绍

        Redis使用简单动态字符串作为其内部字符串的默认实现(而不是C语言默认的字符串实现),用于存储字符串及二进制数据的一种结构, 具有动态扩容的特点. 其实现位于src/sds.h与src/sds.c中。

        SDS的结构如下图,我们可以看到其以\0作为结尾, 这里需要和C语言中中默认的字符串实现区别开,虽然这两者都是以\0作为结尾的。

         我们可以看到SDS被分为了三个部分,头部、数据及末尾\0。

        SDS源码如下:

typedef char *sds;

/* Note: sdshdr5 is never used, we just access the flags byte directly.
 * However is here to document the layout of type 5 SDS strings. */
struct __attribute__ ((__packed__)) sdshdr5 {
    unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len; /* used */
    uint8_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr16 {
    uint16_t len; /* used */
    uint16_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
    uint32_t len; /* used */
    uint32_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
    uint64_t len; /* used */
    uint64_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};

         我们可以看到,sdshdr有五种不同的实现. 其中sdshdr5实际并未使用到. 所以实际上头部有四种不同的实现, 分别如下:

  •  len在不同的实现中分别以uint8, uint16, uint32, uint64表示用户数据的长度(不包括末尾的\0)。
  • alloc对应len以uint8, uint16, uint32, uint64表示整个SDS, 除过头部与末尾的\0, 剩余的字节数。(因为sds存在预留空间,即数据+\0+预留空间,这里alloc就是获取数据+预留空间的大小,预留空间会在下面讲到)
  • flag始终为一字节, 以低三位标示着头部的类型, 高5位未使用。
  • buf 存储数据+\0。

        当在程序中持有一个SDS实例时, 直接持有的是数据区的头指针, 这样做的用意是: 通过这个指针, 向前偏一个字节, 就能取到flag(低三位表示存储类型), 通过判断flag低三位的值, 能迅速判断: 头部的类型, 已用字节数, 总字节数, 剩余字节数. 这也是为什么sds类型即是char *指针类型别名的原因。

        为什么使用SDS替代默认字符串

        1、字符串长度

               在C语言默认的字符串实现中,并没有记录字符串长度的字段,没想想要获取其长度都需要进行一次遍历,这就需要O(n)的时间。 而SDS 字符串因为在头部添加了len属性,因此每次只需要读取 len 属性就能获取字符串的长度,时间复杂度为 O(1)。


        2、避免缓冲区溢出

        C 语言两个字符串的拼接时,一旦没有分配足够长度的内存空间,就会造成缓冲区溢出。而对于 SDS 数据类型,在进行字符修改的时候,会首先根据记录的 len 属性检查内存空间是否满足需求,如果不满足,会进行相应的空间扩展,然后在进行修改操作,所以不会出现缓冲区溢出。


        3、预分配与惰性释放

        C语言由于不记录字符串的长度,所以如果要修改字符串,必须要重新分配内存(先释放再申请),因为如果没有重新分配,字符串长度增大时会造成内存缓冲区溢出,字符串长度减小时会造成内存泄露。

        而SDS由于len与alloc属性的存在,对于修改字符串SDS实现了空间预分配和惰性空间释放两种策略:

        1、空间预分配:每次进行空间扩展的时候,扩展的内存比实际需要的多,这样可以减少连续执行字符串增长操作所需的内存重分配次数。

        2、惰性空间释放:每次缩短操作时,程序不立即使用内存重新分配来回收缩短后多余的字节,而是使用 alloc 属性将这些字节的数量记录下来,等待后续使用。(当然SDS也提供了相应的API,当我们有需要时,也可以手动释放这些未使用的空间。)


        4、二进制安全

        SDS存储的内容包括字符串和二进制数据(比如图片),由于C语言的默认字符以\0作为结尾标志,如果用来存储二进制数据,其中出现了\0,就会被当作是结束标志,这就导致了二进制数据的存取异常。

        SDS虽然也以\0作为结尾,但头部使用len标记数组的长度,因此不用担心内容中出现\0导致存储的二进制数据不安全。

        空间预分配的理解

        SDS的预分配遵循一个规则,即当新字符串的长度小于1M时,redis会分配他们所需大小一倍的空间,当大于1M的时候,就为他们额外多分配1M的空间。

        我们以“Hello World”为例(初始时长度11,且此时无预留空间),当我们要追加字符“ again!”(长度7)时,新字符串长度18,由于未超过1M,将为扩大一倍空间,即18+18+1,这里的1是因为计算了\0,合并后的字符串在内存中的样子服下"Hello World again!\0...................."(.表示预留空间)。当下次还有append追加的时候,如果预分配空间足够,就无须在进行空间分配了。

        需要注意的是,追加字符串时获得的预分配空间不会主动释放,除非该字符串所对应的键被删除。不过执行追加命令的字符串键数量通常并不多,占用内存的体积通常也不大,所以这一般并不算什么问题。

三、压缩列表 - ZipList

        ZipList结构介绍

        压缩列表是redis中节省内存的典范,在实现了类似双链表的结构的同时做到了极致的压缩,可以字符串或者整数。

        ziplist在内存中的分布如下图:

        

         entry节点用来存储数据,其余都是辅助节点,我们进行下简单的介绍:

  • zlbytes:uint32_t类型, 这个字段中存储的是整个ziplist所占用的内存的字节数
  • zltail:字段的类型是uint32_t, 它指的是ziplist中最后一个entry的偏移量. 用于快速定位最后一个entry, 以快速完成pop等操作(可以当作尾指针)。
  • zllen:字段的类型是uint16_t, 它指的是整个ziplit中entry的数量. 这个值只占2bytes(16位): 如果ziplist中entry的数目小于65535(2的16次方), 那么该字段中存储的就是实际entry的值. 若等于或超过65535, 那么该字段的值固定为65535, 但实际数量需要一个个entry的去遍历所有entry才能得到。
  • zlend:终止字节, 0xff(即255),ziplist保证任何情况下, 一个entry的首字节都不会是255(否则会被当做ziplist末尾)

        Entry

        entry作为数据节点,从源码中可以看到其拥有两种模式。

         1、普通结构

        普通结构<prevlen> <encoding> <entry-data>

        prevlen表示前一个entry的大小,通过这个字段,我们可以快速找到上一个节点的起始位置,起到了前指针的作用。

        encoding用于表示当前entry的类型和长度,不同的情况下值不同,下文会进行讲解。

        entry-data用于存放数据。

         2、特殊结构

        特殊结构<prevlen> <encoding>

        当entry中存储的是int类型时,encoding和entry-data会合并在encoding中表示,此时没有entry-data字段(当值为String时,redis会先尝试将string转换成int存储,节省空间)

        prevlen细节:当entry节点前一个元素长度小于254(255用于zlend)的时候,prevlen长度为1个字节,值即为前一个entry的长度,如果长度大于等于254的时候,prevlen用5个字节表示,第一字节设置为254,后面4个字节存储一个小端的无符号整型,表示前一个entry的长度;

        encoding细节:的长度和值根据保存的是int还是string,还有数据的长度而定;前两位用来表示类型,当为“11”时,表示entry存储的是int类型,其它表示存储的是string;

        样例:

        存储int时:|11000000|, encoding为3个字节,后2个字节表示一个int16;

        存储String时:|00pppppp| ,此时encoding长度为1个字节,该字节的后六位表示entry中存储的string长度,因为是6位,所以entry中存储的string长度不能超过63;     

        ZipList节省内存的根因

        我们以数组为例,假设有一个int数组,可以存储1到65535之间的值,没个元素占用的内存大小都是一样的,但实际上存储1所用的空间必然是小于65535的,这实际上就造成了内存空间的浪费(不过也有好处,那就是数组元素的快速获取)。

        ziplist通过entry实现了内存的压缩,每个节点都只存储当前元素所需的空间大小,不额外分配空间(对比下SDS中的空间预分配)。

        但由于每个元素分配的内存空间大小不一,我们无法进行随机读取,为了解决这一问题,entry提供了prevlen来快速定位前一个元素,并使用encoding快速定位下一个元素的位置。这就实现了双链表的空能。同时,ziplist还提供了zltail来实现了尾指针的效果,用以快速定位最后一个元素的位置。

        ZipList的缺点

        由于ziplist不预留内存空间, 并且在移除结点后, 立即缩容, 这代表每次写操作都会进行内存分配操作。

        由于整个ziplist都是一个连续的内存块,当其中一个节点扩容时,如果超过了254字节,其后一个结点的entry.prevlen需要从一字节扩容至五字节。最坏情况下, 第一个结点的扩容, 会导致整个ziplist表中的后续所有结点的entry.prevlen字段扩容,这就造成了O(n)级别的时间耗费,幸运的是,出现这种情况的概率极低。

四、快表 - QuickList

        结构介绍

        quicklist这个结构是Redis在3.2版本后新加的, 之前的版本是list(即linkedlist),它是一种以ziplist为结点的双端链表结构. 宏观上, quicklist是一个链表, 微观上, 链表中的每个结点都是一个ziplist。

        quicklist在内存中的分布如下图:

         我们从上图可以看出quicklist所展现的就是一个双向链表的结构,每个节点又单独指向一个ziplist,之所以设计成这样,是基于以下2点考虑:

        (1)普通的双线链表便于在表的两端进行push和pop操作,但它在每个节点上除了要保存数据之外,还要额外保存两个指针,造成内存开销比较大;其次,双向链表的各个节点是单独的内存块,地址不连续,节点多了容易产生内存碎片。
        (2)ziplist由于是一整块连续内存,所以存储效率很高。但是,它不利于修改操作,每次数据变动都会引发一次内存的realloc。特别是当ziplist长度很长的时候,一次realloc可能会导致大批量的数据拷贝,进一步降低性能。

        综合时间与空间的考虑,我们选择结合双向链表和ziplist的优点,quicklist就应运而生了。

        下面来看其在源码中的实现:

/* quicklist基本节点,描述了一个ziplist实例 */
typedef struct quicklistNode {
    struct quicklistNode *prev;
    struct quicklistNode *next;
    unsigned char *zl;
    unsigned int sz;             /* ziplist size in bytes */
    unsigned int count : 16;     /* count of items in ziplist */
    unsigned int encoding : 2;   /* RAW==1 or LZF==2 */
    unsigned int container : 2;  /* NONE==1 or ZIPLIST==2 */
    unsigned int recompress : 1; /* was this node previous compressed? */
    unsigned int attempted_compress : 1; /* node can't compress; too small */
    unsigned int extra : 10; /* more bits to steal for future usage */
} quicklistNode;

/* 压缩后的quicklistNode */
typedef struct quicklistLZF {
    unsigned int sz; /* LZF size in bytes*/
    char compressed[];
} quicklistLZF;


/* 双链表根节点 */
typedef struct quicklist {
    quicklistNode *head;
    quicklistNode *tail;
    unsigned long count;        /* total count of all entries in all ziplists */
    unsigned long len;          /* number of quicklistNodes */
    int fill : 16;              /* fill factor for individual nodes */
    unsigned int compress : 16; /* depth of end nodes not to compress;0=off */
} quicklist;


        这里展示了三个比较重要的结构:

        quicklistNode

        描述了一个双链表的节点

        (1)prev、next 分别指向双链表的前后一个节点

        (2)zl、sz、count 分别表示指向ziplist的指针、zl所指向的ziplist的大小(即使该ziplist被压缩该值也不变)、该ziplist包含的数据项个数。

        (3)encoding 表示ziplist是否压缩了,目前只有1和2两种值,分别表示未压缩和使用LZF算法压缩。

        (4)container 表示quicklistNode使用哪种数据结构存储数据。目前只有2一种值,表示使用ziplist作为数据容器。

        (5)recompress 表示当我们使用类似lindex这样的命令查看了某一项本来压缩的数据时,需要把数据暂时解压,这时就设置recompress=1做一个标记,等有机会再把数据重新压缩。

        quicklistLZF

        表示一个被压缩过的ziplist

        (1)sz 表示压缩后的ziplist大小。

        (2)compressed 存放压缩后的ziplist字节数组。

        quicklist

        表示整个双链表

        (1)head、tail 分别表示当前双链表结构的第一个和最后一个节点。

        (2)count、len 分别表示所有ziplist数据项的个数总和、quicklist节点的个数。

        (3)fill 16bit,ziplist大小设置,存放list-max-ziplist-size参数的值。

        (4)compress 16bit,节点压缩深度设置,存放list-compress-depth参数的值。
  

        在quicklist中有2个属性fill 和compress,都和Quicklist的特性有关,下面我们进行详细介绍。

        Quicklist特性介绍

        1、一个quicklist节点包含多长的ziplist合适

        想要存储12个数据项,既可以是一个quicklist包含3个节点,而每个节点的ziplist又包含4个数据项,也可以是一个quicklist包含6个节点,而每个节点的ziplist又包含2个数据项。

        这是一个需要找平衡点的难题。我们只从存储效率上分析一下:

  •  每个quicklist节点上的ziplist越短,则内存碎片越多。内存碎片多了,有可能在内存中产生很多无法被利用的小碎片,从而降低存储效率。这种情况的极端是每个quicklist节点上的ziplist只包含一个数据项,这就蜕化成一个普通的双向链表了。
  • 每个quicklist节点上的ziplist越长,则为ziplist分配大块连续内存空间的难度就越大。有可能出现内存里有很多小块的空闲空间(它们加起来很多),但却找不到一块足够大的空闲空间分配给ziplist的情况。这同样会降低存储效率。这种情况的极端是整个quicklist只有一个节点,所有的数据项都分配在这仅有的一个节点的ziplist里面。这其实蜕化成一个ziplist了。

        可见,一个quicklist节点上的ziplist要保持一个合理的长度。那到底多长合理呢?这可能取决于具体应用场景。实际上,Redis提供了一个配置参数list-max-ziplist-size(即上文中的fill所控制的属性),就是为了让使用者可以来根据自己的情况进行调整。

        当取正值的时候,表示按照数据项个数来限定每个quicklist节点上的ziplist长度。比如,当这个参数配置成5的时候,表示每个quicklist节点的ziplist最多包含5个数据项。

        当取负值的时候,表示按照占用字节数来限定每个quicklist节点上的ziplist长度。例如该值为-5时每个quicklist节点上的ziplist大小不能超过64 Kb。

        2、如何节省Quicklist的空间

        根据局部性原理,最容易被访问的很可能是两端的数据,中间的数据被访问的频率比较低(访问起来性能也很低)。如果应用场景符合这个特点,那么list还提供了一个选项,能够把中间的数据节点进行压缩,从而进一步节省内存空间。Redis的配置参数list-compress-depth(即quicklist结构中的compress 属性)就是用来完成这个设置的。

        该参数表示一个quicklist两端不被压缩的节点个数。注:这里的节点个数是指quicklist双向链表的节点个数,而不是指ziplist里面的数据项个数,例如list-compress-depth为1时,表示quicklist两端各有1个节点不压缩,中间的节点压缩。

        插入逻辑

        当要在双链表的头部或是尾部插入数据,都包含两种情况:

        如果头节点(或尾节点)上ziplist大小没有超过限制,那么新数据被直接插入到ziplist中(调用ziplistPush)。
        如果头节点(或尾节点)上ziplist太大了,那么新创建一个quicklistNode节点(对应地也会新创建一个ziplist),然后把这个新创建的节点插入到quicklist双向链表中。

五、字典/哈希表 - Dict

        本质上就是哈希表, 这个不难理解,就不多做介绍了,仅介绍下Dict和常规HashMap的不同之处。

/* dict的顶层实现 */
typedef struct dict {
    dictType *type;
    void *privdata;
    dictht ht[2];
    long rehashidx; /* rehashing not in progress if rehashidx == -1 */
    unsigned long iterators; /* number of iterators currently running */
} dict;


/* 哈希表的实现 */
typedef struct dictht {
    dictEntry **table;
    unsigned long size;
    unsigned long sizemask;
    unsigned long used;
} dictht;


/* 为了解决哈希冲突而采用的链表 */
typedef struct dictEntry {
    void *key;
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    struct dictEntry *next;
} dictEntry;

         我们可以看到,在dict中有2个哈希表的结构,这是为了应对扩容和收缩(通过rerehash重新散列)所作的优化手段。

        dict使用渐进式哈希,如果执行扩展操作,会基于原哈希表创建一个大小等于 ht[0].used*2n 的哈希表(也就是每次扩展都是根据原哈希表已使用的空间扩大一倍创建另一个哈希表)。相反如果执行的是收缩操作,每次收缩是根据已使用空间缩小一倍创建一个新的哈希表。

        渐进式 rehash 通过记录 dict 的 rehashidx 完成,它从 0 开始,然后每执行一次 rehash 都会递增。例如在一次 rehash 中,要把 dict[0] rehash 到 dict[1],这一次会把 dict[0] 上 table[rehashidx] 的键值对 rehash 到 dict[1] 上,dict[0] 的 table[rehashidx] 指向 null,并令 rehashidx++。

        在 rehash 期间,每次对字典执行添加、删除、查找或者更新操作时,都会执行一次渐进式 rehash。采用渐进式 rehash 会导致字典中的数据分散在两个 dictht 上,因此对字典的查找操作也需要到对应的 dictht 去执行。

六、跳表 - ZSkipList

        Redis使用zskiplist实现了sorted set。

        什么是跳跃表

        跳跃表是在有序链表的基础上发展起来的。

        我们先来看一个有序链表,如下图(最左侧的灰色节点表示一个空的头结点):

        在这样一个链表中,如果我们要查找某个数据,那么需要从头开始逐个进行比较,直到找到包含数据的那个节点,或者找到第一个比给定数据大的节点为止(没找到)。也就是说,时间复杂度为O(n)。同样,当我们要插入新数据的时候,也要经历同样的查找过程,从而确定插入位置。

        假如我们每相邻两个节点增加一个指针,让指针指向下下个节点,如下图:

       

         这样所有新增加的指针连成了一个新的链表,但它包含的节点个数只有原来的一半(上图中是7, 19, 26)。现在当我们想查找数据的时候,可以先沿着这个新链表进行查找。当碰到比待查数据大的节点时,再回到原来的链表中进行查找。比如,我们想查找23,查找的路径是沿着下图中标红的指针所指向的方向进行的:

  • 23首先和7比较,再和19比较,比它们都大,继续向后比较。
  • 但23和26比较的时候,比26要小,因此回到下面的链表(原链表),与22比较。
  • 23比22要大,沿下面的指针继续向后和26比较。23比26小,说明待查数据23在原链表中不存在,而且它的插入位置应该在22和26之间

        在这个查找过程中,由于新增加的指针,我们不再需要与链表中每个节点逐个进行比较了。需要比较的节点数大概只有原来的一半。

利用同样的方式,我们可以在上层新产生的链表上,继续为每相邻的两个节点增加一个指针,从而产生第三层链表。如下图

         可以想象,当链表足够长的时候,这种多层链表的查找方式能让我们跳过很多下层节点,大大加快查找的速度。

        zskiplist正是受这种多层链表的想法的启发而设计出来的。实际上,按照上面生成链表的方式,上面每一层链表的节点个数,是下面一层的节点个数的一半,这样查找过程就非常类似于一个二分查找,使得查找的时间复杂度可以降低到O(log n)。但是,这种方法在插入数据的时候有很大的问题。新插入一个节点之后,就会打乱上下相邻两层链表上节点个数严格的2:1的对应关系。如果要维持这种对应关系,就必须把新插入的节点后面的所有节点(也包括新插入的节点)重新进行调整,这会让时间复杂度重新蜕化成O(n)。删除数据也有同样的问题。

        zskiplist为了避免这一问题,它不要求上下相邻两层链表之间的节点个数有严格的对应关系,而是为每个节点随机出一个层数(level)。比如,一个节点随机出的层数是3,那么就把它链入到第1层到第3层这三层链表中(该点会在下文内存分布中展示)。

        zskiplist的实现

/* ZSETs use a specialized version of Skiplists */
typedef struct zskiplistNode {
    sds ele;
    double score;
    struct zskiplistNode *backward;
    struct zskiplistLevel {
        struct zskiplistNode *forward;
        unsigned int span;
    } level[];
} zskiplistNode;

typedef struct zskiplist {
    struct zskiplistNode *header, *tail;
    unsigned long length;
    int level;
} zskiplist;

         其在内存中的布局如下:

         zskiplist 作为跳跃表的主体结构,其内部属性如下

  • 头指针header和尾指针tail(注意头指针指向的node不包含数据,但尾指针指向的node有数据)。
  • 链表长度length,即链表包含的节点总数。注意,新创建的skiplist包含一个空的头指针,这个头指针不包含在length计数中。
  • level表示skiplist的总层数,即所有节点层数的最大值。

        zskiplistNode定义了skiplist的节点结构

  • ele字段,持有数据,是sds类型
  • score字段, 其标示着结点的得分, 结点之间凭借得分来判断先后顺序, 跳跃表中的结点按结点的得分升序排列.
  • backward指针, 这是原版跳跃表中所没有的. 该指针指向结点的前一个紧邻结点.
  • level字段, 用以记录所有结点(除过头节点外);每个结点中最多持有32个zskiplistLevel结构. 实际数量在结点创建时, 按幂次定律随机生成(不超过32). 每个zskiplistLevel中有两个字段
  • forward字段指向比自己得分高的某个结点(不一定是紧邻的), 并且, 若当前zskiplistLevel实例在level[]中的索引为X, 则其forward字段指向的结点, 其level[]字段的容量至少是X+1。
  • span字段代表forward字段指向的结点, 距离当前结点的距离. 紧邻的两个结点之间的距离定义为

        zskiplist与平衡树、哈希表的比较

  • skiplist和各种平衡树(如AVL、红黑树等)的元素是有序排列的,而哈希表不是有序的。因此,在哈希表上只能做单个key的查找,不适宜做范围查找。所谓范围查找,指的是查找那些大小在指定的两个值之间的所有节点。
  • 在做范围查找的时候,平衡树比skiplist操作要复杂。在平衡树上,我们找到指定范围的小值之后,还需要以中序遍历的顺序继续寻找其它不超过大值的节点。如果不对平衡树进行一定的改造,这里的中序遍历并不容易实现。而在skiplist上进行范围查找就非常简单,只需要在找到小值之后,对第1层链表进行若干步的遍历就可以实现。
  • 平衡树的插入和删除操作可能引发子树的调整,逻辑复杂,而skiplist的插入和删除只需要修改相邻节点的指针,操作简单又快速。
  • 从内存占用上来说,skiplist比平衡树更灵活一些。一般来说,平衡树每个节点包含2个指针(分别指向左右子树),而skiplist每个节点包含的指针数目平均为1/(1-p),具体取决于参数p的大小。如果像Redis里的实现一样,取p=1/4,那么平均每个节点包含1.33个指针,比平衡树更有优势。
  • 查找单个key,skiplist和平衡树的时间复杂度都为O(log n),大体相当;而哈希表在保持较低的哈希值冲突概率的前提下,查找时间复杂度接近O(1),性能更高一些。所以我们平常使用的各种Map或dictionary结构,大都是基于哈希表实现的。
  • 从算法实现难度上来比较,skiplist比平衡树要简单得多。

        结合以上几点,Redis的作者结合内存占用、对范围查找的支持和实现难易程度这三方面选择了zskiplist作为有序集合的底层实现()。

        

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值