redis系统性学习第五篇:Redis底层数据结构

目录

一、简单动态字符串

1、SDS与C字符串的区别

1.1、常数复杂度获取字符串长度:

1.2、杜绝缓冲区溢出:

1.3、减少修改字符串时带来的内存重分配次数:

1.4、二进制安全

二、链表

三、字典

1、哈希表

2、哈希表节点

3、字典实现

四、跳跃表

1、跳跃表节点

1.1、层

1.2、前进指针

1.3、跨度

1.4、后退指针

1.5、分值和成员

2、跳跃表

五、整数集合

1、升级

六、压缩列表

1、压缩列表节点的构成

1.1、previous_entry_length:

1.2、encoding:

1.3、content:

2、连锁更新


一、简单动态字符串

Redis没有直接使用C语言传统的字符串表示(以空字符结尾的字符数组,以下简称C字符串),而是自己构建了一种名为简单动态字符串( simple dynamic string,SDS)的抽象类型,并将SDS用作 Redis 的默认字符串表示。
sds的定义:

redis系统性学习第五篇:Redis底层数据结构_第1张图片

例如,"Redis" 这个字符串在 SDS 里面的数据可能是如下形式:

redis系统性学习第五篇:Redis底层数据结构_第2张图片

free属性的值为0,表示这个SDS没有分配任何未使用空间。

len属性的值为5,表示这个SDS保存了一个五字节长的字符串。

buf属性是一个char类型的数组,数组的前五个字节分别保存了'R'、'e '、'd'、'i'、's'五个字符,而最后一个字节则保存了空字符'\0 '。

SDS遵循C字符串以空字符结尾的惯例,保存空字符的1字节空间不计算在SDS的len属性里面,并且为空字符分配额外的1字节空间,以及添加空字符到字符串末尾等操作,都是由SDS函数自动完成的,所以这个空字符对于SDS 的使用者来说是完全透明的。遵循空字符结尾这一惯例的好处是,SDS可以直接重用一部分C字符串函数库里面的函数。

1、SDS与C字符串的区别

  1. 常数复杂度获取字符串长度;

  2. 杜绝缓冲区溢出;

  3. 减少修改字符串时带来的内存重分配次数;

  4. 二进制安全。

1.1、常数复杂度获取字符串长度:

因为C字符串并不记录自身的长度信息,所以为了获取一个C字符串的长度,程序必须遍历整个字符串,对遇到的每个字符进行计数,直到遇到代表字符串结尾的空字符为止,这个操作的复杂度为O(N)。而 SDS 使用 len 属性记录了字符串的长度,因此获取 SDS字符串长度的时间复杂度是 O(1)

通过使用SDS而不是C字符串,Redis将获取字符串长度所需的复杂度从O(N)降低到了O(1),这确保了获取字符串长度的工作不会成为Redis 的性能瓶颈。例如,因为字符串键在底层使用SDS来实现,所以即使我们对一个非常长的字符串键反复执行STRLEN命令,也不会对系统性能造成任何影响,因为STRLEN命令的复杂度仅为O(1)。

1.2、杜绝缓冲区溢出:

C字符串不记录自身长度带来的另一个问题是容易造成缓冲区溢出 ( buffer overflow)。举个例子,strcat函数可以将字符串拼接到另一个字符串的末尾。因为C字符串不记录自身的长度,所以strcat假定用户在执行这个函数时,已经为第一个字符串分配了足够多的内存,可以容纳另一个字符串中的所有内容,而一旦这个假定不成立时,就会产生缓冲区溢出。

假设程序里有两个在内存中紧邻着的C字符串s1和s2,其中 s1保存了字符串"Redis",而s2则保存了字符串"MongoDB",如图所示。

将s1的内容修改为"Redis cluster",但却忘了在执行strcat之前为s1分配足够的空间,那么在strcat函数执行之后,s1的数据将溢出到s2所在的空间中,导致s2保存的内容被意外地修改,如图所示。

    

与C字符串不同,SDS 的空间分配策略完全杜绝了发生缓冲区溢出的可能性。当SDS API需要对SDS进行修改时,API会先检查SDS的空间是否满足修改所需的要求,如果不满足的话,API会自动将SDS的空间扩展至执行修改所需的大小,然后才执行实际的修改操作,所以使用SDS既不需要手动修改SDS的空间大小,也不会出现前面所说的缓冲区溢出问题。举个例子,SDS 的API里面也有一个用于执行拼接操作的sdscat函数,它可以将一个C字符串拼接到给定SDS所保存的字符串的后面,但是在执行拼接操作之前,sdscat会先检查给定SDS的空间是否足够,如果不够的话,sdscat就会先扩展SDS的空间,然后才执行拼接操作。

1.3、减少修改字符串时带来的内存重分配次数:

C字符串并不记录自身的长度,所以对于一个包含了N个字符的C字符串来说,这个C字符串的底层实现总是一个N+1个字符长的数组(额外的一个字符空间用于保存空字符)。因为C字符串的长度和底层数组的长度之间存在着这种关联性,所以每次增长或者缩短一个C字符串,程序都总要对保存这个C字符串的数组进行一次内存重分配操作:

  • 如果程序执行的是增长字符串的操作,比如拼接操作( append ),那么在执行这个操作之前,程序需要先通过内存重分配来扩展底层数组的空间大小——如果忘了这一步就会产生缓冲区溢出。

  • 如果程序执行的是缩短字符串的操作,比如截断操作(( trim ),那么在执行这个操作之后,程序需要通过内存重分配来释放字符串不再使用的那部分空间——如果忘了这一步就会产生内存泄漏。

因为内存重分配涉及复杂的算法,并且可能需要执行系统调用,所以它通常是一个比较耗时的操作,对于 Redis 来说,字符串修改是一个十分频繁的操作。如果每次修改字符串长度都像 C 字符串那样进行内存重分配,那么光是执行内存重分配的时间就会占去修改字符串所用时间的一大部分,如果这种修改频繁地发生的话,可能还会对性能造成影响。

为了避免C字符串的这种缺陷,SDS通过未使用空间解除了字符串长度和底层数组长度之间的关联:在SDS 中,buf数组的长度不一定就是字符数量加一,数组里面可以包含未使用的字节,而这些字节的数量就由SDS的free属性记录。
通过未使用空间,SDS实现了 空间预分配和惰性空间释放 两种优化策略。

1.3.1、空间预分配

空间预分配用于优化SDS的字符串增长操作:当SDS的API对一个SDS进行修改,并且需要对SDS进行空间扩展的时候,程序不仅会为SDS分配修改所必须要的空间,还会为SDS分配额外的未使用空间。简单来说就是当字节数组空间不足触发重分配的时候,总是会预留一部分空闲空间。这样的话,就能减少连续执行字符串增长操作时的内存重分配次数。

其中,额外分配的未使用空间数量由以下公式决定:

  • 如果对SDS进行修改之后,SDS的长度(也即是len属性的值)将小于1MB,那么程序分配和len属性同样大小的未使用空间,这时SDS len属性的值将和free属性的值相同。举个例子,如果进行修改之后,SDS的len将变成13字节,那么程序也会分配13字节的未使用空间,SDS 的buf数组的实际长度将变成13+13+1=27字节(额外的一字节用于保存空字符)。
  • 如果对SDS进行修改之后,SDS的长度将大于等于1MB,那么程序会分配1MB的未使用空间。举个例子,如果进行修改之后,SDS的len将变成30MB,那么程序会分配1MB的未使用空间,SDS的 buf数组的实
    际长度将为30MB + 1MR +1 byte。

通过这种预分配策略,SDS将连续增长N次字符串所需的内存重分配次数从必定N次降低为最多N次。

1.3.2、惰性空间释放

惰性空间释放用于优化SDS的字符串缩短操作:当SDS的API需要缩短SDS保存的字符串时,程序并不立即使用内存重分配来回收缩短后多出来的字节,而是使用free属性将这些字节的数量记录起来,作为未使用空间保存在sds里面,并等待将来使用。与此同时,SDS也提供了相应的API,让我们可以在有需要时,真正地释放SDS的未使用空间,所以不用担心惰性空间释放策略会造成内存浪费。

1.4、二进制安全

C字符串中的字符必须符合某种编码(比如ASCII),并且除了字符串的末尾之外,字符串里面不能包含空字符,否则最先被程序读入的空字符将被误认为是字符串结尾,这些限制使得C字符串只能保存文本数据,而不能保存像图片、音频、视频、压缩文件这样的二进制数据。因此,为了确保Redis可以适用于各种不同的使用场景,SDS的API都是二进制安全的( binary-safe ),所有SDS API都会以处理二进制的方式来处理SDS存放在buf数组里的数据,程序不会对其中的数据做任何限制、过滤、或者假设,数据在写入时是什么样的,它被读取时就是什么样。因为SDS使用len属性的值而不是空字符来判断字符串是否结束。

这也是我们将SDS的 buf属性称为字节数组的原因——Redis不是用这个数组来保存字符,而是用它来保存一系列二进制数据。

redis系统性学习第五篇:Redis底层数据结构_第3张图片

二、链表

作为一种常用数据结构,链表内置在很多高级的编程语言里面,因为Redis使用的C语言并没有内置这种数据结构,所以 Redis构建了自己的链表实现。

每个链表节点使用 listNode 结构表示:

redis系统性学习第五篇:Redis底层数据结构_第4张图片

多个listNode可以通过prev和next 指针组成双端链表

虽然仅仅使用多个listNode结构就可以组成链表,但Redis 使用了list结构持有链表。
redis系统性学习第五篇:Redis底层数据结构_第5张图片

list 结构为链表提供了表头指针 head、表尾指针 tail,以及链表长度计数器 len,而 dup、free 和 match 成员则是实现多态链表所需类型的特定函数。

dup函数用于复制 链表节点所保存的值;

free函数用于释放 链表节点所保存的值;

match函数则用于对比链表节点所保存的值和另一个输入值是否相等;

由一个list结构和三个listNode结构组成的链表。

redis系统性学习第五篇:Redis底层数据结构_第6张图片

Redis 的链表实现的特性可以总结如下:

  • 双端:链表节点带有prev和 next指针,获取某个节点的前置节点和后置节点的复杂度都是O(1)。
  • 无环:表头节点的prev指针和表尾节点的next 指针都指向NULL,对链表的访问以NULL为终点。
  • 带表头指针和表尾指针:通过list结构的head指针和tail指针,程序获取链表的表头节点和表尾节点的复杂度为O(1)。
  • 带链表长度计数器:程序使用list结构的1en属性来对list持有的链表节点进行计数,程序获取链表中节点数量的复杂度为O(1)。
  • 多态:链表节点使用void*指针来保存节点值,并且可以通过list结构的dup,free、match三个属性为节点值设置类型特定函数,所以链表可以用于保存各种不同类型的值。

三、字典

Redis 的字典使用哈希表作为底层实现,一个哈希表里面可以有多个哈希表节点,而每个哈希表节点就保存了字典中的一个键值对。

1、哈希表

Redis字典所使用的哈希表由dict.h/ dictht结构定义:

redis系统性学习第五篇:Redis底层数据结构_第7张图片

  • table属性是一个数组,数组中的每个元素都是一个指向dict.h/dictEntry结构的指针,每个dictEntry结构保存着一个键值对。
  • size属性记录了哈希表的大小,也即是table数组的大小,
  • used属性则记录了哈希表目前已有节点(键值对)的数量。
  • sizemask属性的值总是等于size-1,这个属性和哈希值一起决定一个键应该被放到table数组的哪个索引上面。

2、哈希表节点

哈希表节点使用dictEntry结构表示,每个dictEntry结构都保存着一个键值对:

redis系统性学习第五篇:Redis底层数据结构_第8张图片next属性是指向另一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对连接在一次,以此来解决键冲突(collision)的问题。

redis系统性学习第五篇:Redis底层数据结构_第9张图片

3、字典实现

Redis 中的字典由dict.h/ dict 结构表示:

redis系统性学习第五篇:Redis底层数据结构_第10张图片

ht属性是一个包含两个项的数组,数组中的每个项都是一个dictht哈希表,一般情况下,字典只使用ht [0]哈希表,ht [1]哈希表只会在对ht[0]哈希表进行rehash时使用。除了ht[1]之外,另一个和rehash有关的属性就是rehashidx,它记录了rehash目前的进度,如果目前没有在进行rehash,那么它的值为-1。

一个普通状态下(没有进行rehash)的字典:

redis系统性学习第五篇:Redis底层数据结构_第11张图片

关于hash算法、rehash、解决键冲突本文不做介绍,之后单独写一篇文章总结。

四、跳跃表

跳跃表( skiplist)是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。Redis的跳跃表由redis.h/zskiplistNode和redis.h/zskiplist两个结构定义,其中zskiplistNode结构用于表示跳跃表节点,而zskiplist结构则用于保存跳跃表节点的相关信息,比如节点的数量,以及指向表头节点和表尾节点的指针等等。

redis系统性学习第五篇:Redis底层数据结构_第12张图片

1、跳跃表节点

redis系统性学习第五篇:Redis底层数据结构_第13张图片

1.1、层

跳跃表节点的level数组可以包含多个元素,每个元素都包含一个指向其他节点的指针,程序可以通过这些层来加快访问其他节点的速度,一般米玩,层的效重趣多,明问共他节点的速度就越快。每次创建一个新跳跃表节点的时候,程序都根据幂次定律( power law,越大的数出现的概率越小)随机生成一个介于1和32之间的值作为level数组的大小,这个大小就是层的“高度”。

1.2、前进指针

每个层都有一个指向表尾方向的前进指针( level[i].forward属性),用于从表头向表尾方向访问节点。

1.3、跨度

层的跨度( level[i].span属性)用于记录两个节点之间的距离。

1.4、后退指针

节点的后退指针( backward属性)用于从表尾向表头方向访问节点:跟可以一次跳过多个节点的前进指针不同,因为每个节点只有一个后退指针,所以每次只能后退至前一个节点。

1.5、分值和成员

节点的分值(score属性)是一个double类型的浮点数,跳跃表中的所有节点都按分值从小到大来排序。节点的成员对象( obj属性)是一个指针,它指向一个字符串对象,而字符串对象则保存着一个SDS值。在同一个跳跃表中,各个节点保存的成员对象必须是唯一的,但是多个节点保存的分值却可以是相同的:分值相同的节点将按照成员对象在字典序中的大小来进行排序,成员对象较小的节点会排在前面(靠近表头的方向),而成员对象较大的节点则会排在后面(靠近表尾的方向)。

redis系统性学习第五篇:Redis底层数据结构_第14张图片

2、跳跃表

仅靠多个跳跃表节点就可以组成一个跳跃表。但通过使用一个zskiplist结构来持有这些节点,程序可以更方便地对整个跳跃表进行处理,比如快速访问跳跃表的表头节点和表尾节点,或者快速地获取跳跃表节点的数量(也即是跳跃表的长度)等信息。

typedef struct zskiplist {

//表头节点和表尾节点

structz skiplistNode *header, *tail;

//表中节点的数量

unsigned long length;

//表中层数最大的节点的层数

int level;
)zskiplist;

redis系统性学习第五篇:Redis底层数据结构_第15张图片

  • header和tail指针分别指向跳跃表的表头和表尾节点,通过这两个指针,程序定位表头节点和表尾节点的复杂度为O(1)。
  • 通过使用length属性来记录节点的数量,程序可以在O(1)复杂度内返回跳跃表的长度。
  • level属性则用于在O(1)复杂度内获取跳跃表中层高最大的那个节点的层数量,注意表头节点的层高并不计算在内。
     

五、整数集合

整数集合(intset)是 Redis 用于保存整数值的集合抽象数据结构,它可以保存类型为 int16_t、int32_t 或者 int64_t 的整数值,并且保证集合中的数据不会重复。Redis 使用 intset 结构表示一个整数集合。

redis系统性学习第五篇:Redis底层数据结构_第16张图片

contens数组是整数集合的底层实现:整数集合的每个元素都是 contents 数组的一个数组项,各个项在数组中按值大小从小到大有序排列,并且数组中不包含重复项。

虽然 contents 属性声明为 int8_t 类型的数组,但实际上,contents 数组不保存任何 int8_t 类型的值,数组中真正保存的值类型取决于 encoding。如果 encoding 属性值为 INTSET_ENC_INT16,那么 contents 数组就是 int16_t 类型的数组,以此类推。

length属性就是记录了contens数组的长度;

redis系统性学习第五篇:Redis底层数据结构_第17张图片

因为每个集合元素都是int16类型的整数值,所以contents数组的大小等于sizeof (int16) *5=16*5=80位;

1、升级

当新插入元素的类型比整数集合现有类型元素的类型都要长时,整数集合必须先升级,然后才能将新元素添加进来。这个过程分以下三步进行:

  • 根据新元素类型,扩展整数集合底层数组空间大小;

  • 将底层数组现有所有元素都转换为与新元素相同的类型,并且维持底层数组的有序性;

  • 将新元素添加到底层数组里面。

还有一点需要注意的是,整数集合不支持降级。一旦对数组进行了升级,编码就会一直保持升级后的状态。

六、压缩列表

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

redis系统性学习第五篇:Redis底层数据结构_第18张图片

列表zlbytes属性的值为0x50(十进制80 ),表示压缩列表的总长为80字节。

列表zltail属性的值为0x3c(十进制60 ),这表示如果我们有一个指向压缩列表起始地址的指针p,那么只要用指针p加上偏移量60,就可以计算出表尾节点entry3的地址。

列表zllen属性的值为0x3(十进制3),表示压缩列表包含三个节点。

1、压缩列表节点的构成

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

1.1、previous_entry_length:

节点的previous_entry_length属性以字节为单位,记录了压缩列表中前一个节点的长度。previous_entry_length属性的长度可以是1字节或者5字节:

  • 如果前一节点的长度小于254字节,那么previous_entry_length属性的长度为1字节:前一节点的长度就保存在这一个字节里面。
  • 如果前一节点的长度大于等于254字节,那么previous_entry_length属性的长度为5字节:其中属性的第一字节会被设置为0xFE(十进制值254 ),而之后的四个字节则用于保存前一节点的长度。

因为节点的previous_entry_length属性记录了前一个节点的长度,所以程序可以通过指针运算,根据当前节点的起始地址来计算出前一个节点的起始地址。举个例子,如果我们有一个指向当前节点起始地址的指针c,那么我们只要用指针c减去当前节点previous_entry_length属性的值,就可以得出一个指向前一个节点起始地址的指针p,

redis系统性学习第五篇:Redis底层数据结构_第19张图片

压缩列表的从表尾向表头遍历操作就是使用这一原理实现的,只要我们拥有了一个指向某个节点起始地址的指针,那么通过这个指针以及这个节点的previous_entry_length属性,程序就可以一直向前一个节点回溯,最终到达压缩列表的表头节点。

1.2、encoding:

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

redis系统性学习第五篇:Redis底层数据结构_第20张图片redis系统性学习第五篇:Redis底层数据结构_第21张图片

1.3、content:

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

编码的最高两位00表示节点保存的是一个字节数组;

编码的后六位001011记录了字节数组的长度11;

content属性保存着节点的值"hello world"。

编码11000000表示节点保存的是一个int16_t类型的整数值;

content属性保存着节点的值10086。

2、连锁更新

就是在一个压缩列表中,有多个连续的长度介于250字节到253字节之间的节点e1-eN,因为e1-eN的所有节点的长度都小于254字节,所以记录这些节点的长度只需要1字节长的previous_entry_length属性。

这时我们将一个长度大于等于254字节的新节点new设置为压缩列表的表头节点,那么new将成为e1的前置节点,又因为e1的previous_entry_length属性只有1个字节,没法保存new的长度,所以程序要对压缩列表执行空间重分配,并将e1节点的previous_entry_length属性扩展为5个字节长。

而现在,e1本来是250--253字节之间,扩展后就变成了254-257字节之间,这样一来e2的previous_entry_length又不能保存了,所以又要执行空间重分配,以此类推直到eN为止,这一过程称为 “连锁更新”。

因为连锁更新在最坏情况下需要对压缩列表执行N次空间重分配操作,而每次空间重分配的最坏复杂度为O(N),所以连锁更新的最坏复杂度为O(N )。要注意的是,尽管连锁更新的复杂度较高,但它真正造成性能问题的几率是很低的;

redis系列的文章均作为个人学习《redis设计与实现》书籍的笔记,内容图片来自《redis设计与实现》。

你可能感兴趣的:(深入理解Redis,java,redis)