Redis中的数据结构(二):跳跃表、整数集合、压缩列表和快表

一、跳跃表

跳跃表(skiplist)是一种有序的数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问的目的,在大部分情况下,跳跃表的效率可以和平衡树媲美,而且跳跃表的实现比平衡树更简单

跳跃表的大致实现如图:
在这里插入图片描述

1. Redis中的跳跃表

Redis只在两个地方用到跳跃表,一个是实现有序集合的键,一个是集群节点中用作内部数据结构

Redis中的跳跃表主要由节点zskiplistNode和跳表zskiplist来实现

1.1 zskiplistNode 跳跃表节点

跳跃表节点的实现由 redis.h / zskiplistNode 结构定义:

typedef struct zskiplistNode {
	// 后退指针
	struct zskiplistNode *backward;
	// 分值
	double score;

	// 成员对象
	robj *obj;
	
	// 层
	struct zskiplistLevel {
		// 前进指针
		struct zskiplistNode *forward;
		
		// 跨度
		unsigned int span;
	} level[];

} zskiplistNode;

1.1.1 层

  • 跳跃表的结点的level 数组可以包含多个元素,每个元素包含一个指向其他节点的指针,程序可以通过这些层来加快访问其他节点的速度

  • 每次创建一个新跳跃表节点的时候,程序都根据幂次定律(越大的数出现的概率越低)随机生成一个1 ~ 32 的值作为level数组的大小,这个值就是层的高度

  • 因为层的字段只有forward 前进指针,所以是一个单链表,而所有zskiplistNode结构中有backward字段,所以是一个双链表,就相当于,跳跃表结构最下面一层是双链表,上面的层数则为双链表,如图
    在这里插入图片描述

  • 例如查找45号元素,则先从最上层的头结点开始查找,如果下一个结点是比自己大或者是NULL之后则向下一层查找

  • 再下一层也如同上一步查找,直到找到最下一层,再通过范围内遍历节点找到45

  • 层的跨度(level[i].span 属性) 用于记录两个节点之间的距离。主要用于计算rank,
    rank是排位,头节点开始到目标节点的跨度,由沿途的span相加获得

1.1.2 分值和成员

  • 节点的分值(score属性): 是一个double类型的浮点数,跳跃表中所有节点都按分值从小到大排序
  • 节点的成员对象(obj属性): 是一个指针,指向一个字符串SDS对象
  • score和obj共同来决定一个元素在跳表中的顺序。score不同则根据score进行排序,score相同则根据obj来进行排序
    跳表中score是可以相同的,而obj是肯定不同的

1.2 zskiplist 跳跃表

仅靠多个跳跃表节点就可以组成一个跳跃表

但是通过一个zskiplist 结构可以对整个表更方便的做一些处理

zskiplist 结构如下

typedef struct zskiplist {
    // 头尾指针,用于保存头结点和尾节点
    struct zskiplistNode *header, *tail;
    
    // 跳跃表的长度,即除头结点以外的节点数
    unsigned long length;
    
    // 最大层数,保存了节点中拥有的最大层数(不包括头结点)
    int level;
} zskiplist;

二、整数集合

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

1. 整数集合的实现

整数集合(intset)是Redis用于保存整数值的集合抽象数据结构,它可以保存类型为int16_t、int32_t或者int64_t的整数值,并且保证集合中不会出现重复元素且按照从小到大的顺序排列

typedef struct intset {
    // 编码方式
    uint32_t encoding;
    
    // 集合包含的元素数量
    uint32_t length;
    
    // 保存元素的数组
    int8_t contents[];
} intset;
  • contents数组是整数集合的底层实现,每个项在数组中按值的大小从小到大有序排列,并且不包含重复项。另外,虽然intset结构将contents属性声明为int8_t,但实际上数组类型由encoding决定
    • 如果encoding属性的值为INTSET_ENC_INT16,那么contents就是一个int16_t类型的数组,数组里的每个项都是一个int16_t类型的整数值
    • 如果encoding属性的值为INTSET_ENC_INT32,那么contents就是一个int32_t类型的数组,数组里的每个项都是一个int32_t类型的整数值
    • 以此类推
  • length 属性记录了整数集合包含的元素数量,也就是数组长度

2. 升级

每当我们要将一个新元素添加到整数集合里面,并且新元素的类型比整数集合现有所有元素的类型都要长时,整数集合需要先进行升级(upgrade),然后才能将新元素添加到整数集合里面

升级并且添加新元素步骤

  1. 根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素分配空间
  2. 将底层数组(contents[])现有的所有元素都转换成与新元素相同的类型,并将类型转换后的元素放置到正确的位上,而且在放置元素的过程中,需要继续维持底层数组的有序性质不变
  3. 将新元素添加到底层数组里面,因为该数的类型长度是最长的所以要么放在第一个,要么放在最后一个

升级的好处

  • 提升灵活性:C语言是静态类型语言,为了避免类型错误,一般不会将两种不同的类型的值放在一个数据结构中,通过升级可以自动适应新元素,就可以随意的将各种类型整数加入其中
  • 节约内存:会用尽量小的空间保存数据,节约内存,例如如果所有类型都是int16_t ,那就不会采用更大的类型

整数集合不支持降级的操作,一旦对数组进行了升级,编码就会一直保持升级后的状态

三、压缩列表

压缩列表(ziplist)是列表键(list)和哈希键(hash)的底层实现之一

  • 当一个列表键(list)只包含少量列表项,并且每个列表项要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来做列表键的底层实现(根据书本上的例子使用的好像是快表)
    在这里插入图片描述
  • 当一个哈希键只包含少量键值对,比且每个键值对的键和值要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来做哈希键的底层实现
    在这里插入图片描述

1. 压缩列表的构成

压缩列表是Redis为了节约内存而开发的,是由一系列特殊编码的连续内存块组成的顺序型(sequential)数据结构。一个压缩列表可以包含任意多个节点(entry),每个节点可以保存一个字节数组或者一个整数值。具体组成如下

在这里插入图片描述

属性类型长度用途
zlbytesuint32_t4字节记录整个压缩列表占用的内存字节数,在对压缩列表进行内存重分配或者计算zlend的位置时使用
zltailuint32_t4字节记录压缩列表表尾节点举例压缩列表的起始地址有多少字节,如果我们有一个指向压缩列表起始地址的指针p,通过p+zltail就能直接访问压缩列表的最后一个节点
zllenuint16_t2字节压缩列表中的节点数,当这个值大于UINT16_MAX 之后,就得遍历压缩列表才能得到节点数
entryX列表节点不定压缩列表包含的各个节点
zlenduint8_t1字节标记压缩列表的末端(我认为用于指针碰撞分配内存)

2. 压缩列表节点的构成

每个压缩列表节点都由previous_entry_length、encoding、content三个部分组成

在这里插入图片描述
previous_entry_length

  • 节点的previous_entry_length属性以字节为单位,记录了压缩列表中前一个节点的长度,其值长度为1个字节或者5个字节
    • 如果前一节点的长度小于254字节,那么previous_entry_length属性的长度为1字节
    • 如果前一节点的长度大于等于254字节,那么previous_entry_length属性的长度为5字节
  • 因为节点的previous_entry_length属性记录了前一个节点的长度,所以程序可以通过指针运算,计算出前一个结点的起始地址。
    • 因为内存是连续的所以用过这种方法减小前驱指针的内存

encoding

  • 节点的encoding属性记录了节点的content属性所保存数据的类型以及长度

content

  • 节点的content属性负责保存节点的值,节点值可以是一个字节数组或者整数,值的类型和长度由节点的encoding属性决定

3. 连锁更新

每个节点的previous_entry_length属性都记录了前一个节点的长度,如果,有多个连续的长度介于250~253字节之间的节点,那么在第一个节点之前插入了一条大于254字节的节点,这时候下一个结点previous_entry_length的长度就得变为五个字节,则他的总长度就变为了255,则下一个节点也需要更新,导致一连串连锁更新

删除节点同理,也可能造成这种反应

因为连锁更新在最坏情况下需要对压缩列表执行N次空间重分配操作,而每次分配的最坏复杂度为O(N),所以连锁更新最坏复杂度为O(N²)

但是这种情况还是小概率事件,依然可以放心使用压缩列表

四、快表

1. 快表的结构

quicklist是Redis 3.2中新引入的数据结构,能够在时间效率和空间效率间实现较好的折中。Redis中对quciklist的注释为A doubly linked list of ziplists。顾名思义,quicklist是一个双向链表,链表中的每个节点是一个ziplist结构。quicklist可以看成是用双向链表将若干小型的ziplist连接到一起组成的一种数据结构。当ziplist节点个数过多,quicklist退化为双向链表,一个极端的情况就是每个ziplist节点只包含一个entry,即只有一个元素。当ziplist元素个数过少时,quicklist可退化为ziplist,一种极端的情况就是quicklist中只有一个ziplist节点

quicklist结构

typedef struct quicklist {
    //指向头部(最左边)quicklist节点的指针
    quicklistNode *head;

    //指向尾部(最右边)quicklist节点的指针
    quicklistNode *tail;

    //ziplist中的entry节点计数器
    unsigned long count;        /* total count of all entries in all ziplists */

    //quicklist的quicklistNode节点计数器
    unsigned int len;           /* number of quicklistNodes */

    //保存ziplist的大小,配置文件设定,占16bits
    int fill : 16;              /* fill factor for individual nodes */

    //保存压缩程度值,配置文件设定,占16bits,0表示不压缩
    unsigned int compress : 16; /* depth of end nodes not to compress;0=off */
} quicklist;

quicklist节点结构

typedef struct quicklistNode {
    struct quicklistNode *prev;     //前驱节点指针
    struct quicklistNode *next;     //后继节点指针

    //不设置压缩数据参数recompress时指向一个ziplist结构
    //设置压缩数据参数recompress指向quicklistLZF结构
    unsigned char *zl;

    //压缩列表ziplist的总长度
    unsigned int sz;                  /* ziplist size in bytes */

    //ziplist中包的节点数,占16 bits长度
    unsigned int count : 16;          /* count of items in ziplist */

    //表示是否采用了LZF压缩算法压缩quicklist节点,1表示压缩过,2表示没压缩,占2 bits长度
    unsigned int encoding : 2;        /* RAW==1 or LZF==2 */

    //表示一个quicklistNode节点是否采用ziplist结构保存数据,2表示压缩了,1表示没压缩,默认是2,占2bits长度
    unsigned int container : 2;       /* NONE==1 or ZIPLIST==2 */

    //标记quicklist节点的ziplist之前是否被解压缩过,占1bit长度
    //如果recompress为1,则等待被再次压缩
    unsigned int recompress : 1; /* was this node previous compressed? */

    //测试时使用
    unsigned int attempted_compress : 1; /* node can't compress; too small */

    //额外扩展位,占10bits长度
    unsigned int extra : 10; /* more bits to steal for future usage */
} quicklistNode;

在这里插入图片描述

2. 快表的常用操作

① 插入

quicklist可以选择在头部或者尾部进行插入(quicklistPushHead和quicklistPushTail),而不管是在头部还是尾部插入数据,都包含两种情况:

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

也可以从任意指定的位置插入。quicklistInsertAfter和quicklistInsertBefore就是分别在指定位置后面和前面插入数据项。这种在任意指定位置插入数据的操作,要比在头部和尾部的进行插入要复杂一些。

  • 当插入位置所在的ziplist大小没有超过限制时,直接插入到ziplist中就好了;
  • 当插入位置所在的ziplist大小超过了限制,但插入的位置位于ziplist两端,并且相邻的quicklist链表节点的ziplist大小没有超过限制,那么就转而插入到相邻的那个quicklist链表节点的ziplist中;
  • 当插入位置所在的ziplist大小超过了限制,但插入的位置位于ziplist两端,并且相邻的quicklist链表节点的ziplist大小也超过限制,这时需要新创建一个quicklist链表节点插入。
  • 对于插入位置所在的ziplist大小超过了限制的其它情况(主要对应于在ziplist中间插入数据的情况),则需要把当前ziplist分裂为两个节点,然后再其中一个节点上插入数据

② 查找

quicklist查找元素主要是针对index,即通过元素在链表中的下标查找对应元素。基本思路是,首先找到index对应的数据所在的quicklistNode节点,之后调用ziplist的接口函数ziplistGet得到index对应的数据。简而言之就是:定位quicklistNode,再在quicklistNode中的ziplist中寻找目标节点

③ 删除

区间元素删除的函数是 quicklistDelRange

quicklist 在区间删除时,会先找到 start 所在的 quicklistNode,计算删除的元素是否小于要删除的 count,如果不满足删除的个数,则会移动至下一个 quicklistNode 继续删除,依次循环直到删除完成为止。

quicklistDelRange 函数的返回值为 int 类型,当返回 1 时表示成功的删除了指定区间的元素,返回 0 时表示没有删除任何元素。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值