Redis底层数据结构的实现原理

1、String——SDS

我们都知道redis是用C语言来写的,C语言中的字符串是用字符数组来实现的,但是redis为什么不用C语言中的String,而要自己定义一个动态字符串来存储呢?

C语言中存储String

char str[]=“redis”;  
C语言在读取时是以“\0”为结束符作为标志的,

但是redis在存储数据的时候万一存储的是“re\0dis”,在读取的时候就会导致数据的丢失,所以为了保证数据的安全性,我们需要在原来的基础上加一些参数,这就有了SDS。

SDS结构

struct sdshdr{
    int len;    //buf数组已使用的字节,即SDS保存的字符串长度:保证数据安全
    int free;   //未使用的字节长度:减少内存重新分配的次数
    char buf[]; //保存字符串的字节数组
}

SDS的扩容过程

如果要保存redis这几个字符,其SDS的结构就是:
len=5;
free=0;
char buf[]="redis";当想要在要redis的基础上再拼接一个“123”该怎么操作。
当SDS API需要对SDS进行修改时,API会先检查SDS的空间是否满足修改所需的要求,如果不满足的话,API会自动
将SDS的空间扩展至执行修改所需的大小,然后才执行实际的修改操作。
len=5;
addlen=3;
(len+addlen)*2=16  (当存储的数据达到1M的时候,扩容不再是2倍,而是每次多扩容1M)
创建一个长度为16的字符数组,把数据迁移过去,把123拼接到后面,此时:
len=8;
free=8;
char buf[]="redis123";
这样做的好处就是下次再次进行拼接的时候,可能就不需要再进行内存分配。

C字符串和SDS之间的区别

C字符串SDS
获取字符串长度的时间复杂度为O(n)获取字符串长度的时间复杂度为O(1)
API是不安全的,会造成缓冲区溢出API安全,不会造成缓冲区溢出
修改字符串长度N次必然需要执行N次内存分配修改字符串长度N次最多需要执行N次内存分配
只能保存文本数据可以保存文本或二进制数据

2、list

2.1 链表

当一个列表键包含了数量比较多的元素,又或者列表中包含的元素都是比较长的字符串时,Redis就会使用链表作为列表键的底层实现。

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

typedef struct listNode{
    //前置节点
    struct listNode *prev;
    //后置节点
    struct listNode *next;
    //节点的值
    void *value;
}listNode;	

虽然仅仅使用多个listNode结构就可以组成链表,但使用addlist.h/list来持有链表的话,操作起来会更方便:

typedef struct list{
    //表头节点
    listNode *head;
    //表尾节点
    listNode *tail;
    //链表所包含的节点数量
    unsigned long len;
    //节点值复制函数
    void *(*dup)(void *ptr);
    //节点值释放函数
    void (*free)(void *ptr);
    //节点值对比函数
    int (*match)(void *ptr,void *key);
}list;

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

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

2.2 压缩列表

当一个列表键只包含少量列表项,并且每个列表项要么就是小整数值,要么就是长度比较短的字符串,那么redis就会使用压缩列表来做列表键的底层实现。

redis>RPUSH lst 1 3 5 10086 "hello" "world"
redis>OBJECT ENCODING lst
“ziplist”

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

之所以说这种存储结构节省内存,是相较于数组的存储思路而言的,我们知道数组要求每个元素的大小相同,如果我们要存储不同长度的字符串,那我们就需要用最大长度的字符串大小作为元素的大小(假设是20个字节)。存储小于20个字节长度的字符串的时候,便会浪费部分存储空间。

数组的优势占用一片连续的空间可以很好的利用CPU缓存访问数据,如果我们想要保留这种优势,又想节省空间我们可以对数组进行压缩,但是在遍历的时候由于不知道每个元素的大小是多少,因此无法计算出下一个节点的具体位置,这个时候就需要给每个节点增加一个属性,表示当前结点元素的大小(也就是后面说的content),这就是简单的压缩列表。

压缩列表的构成

在这里插入图片描述

属性类型长度用途
zlbytesuint32_t4字节记录整个压缩列表占用的内存字节数,在对压缩列表进行内存重分配或者计算zlend的位置时使用
zltailuint32_t4字节记录压缩列表尾节点距离起始地址有多少字节,通过这个偏移量,程序无须遍历整个压缩列表就可以确定表尾节点的地址
zllenuint16_t2字节记录了压缩列表包含的节点数量,当这个属性的值小于UINT16_MAX(65535)时,这个属性的值就是压缩列表包含节点的数量;当这个值等于UINT16_MAX时,节点的真实数量需要遍历整个压缩列表才能计算得出
entryX列表节点不定压缩列表包含的各个节点,节点的长度由节点保存的内容决定
zlenduint8_t1字节特殊值0xFF(十进制255),用于标记压缩列表的末端

压缩列表节点的构成

每个压缩列表节点可以保存一个字节数组或者一个整数值,其中,字节数组可以是:

  • 长度小于等于63(2^6-1)字节的字节数组;
  • 长度小于等于16383(2^14-1)字节的字节数组;
  • 长度小于等于4294967295(2^32-1)字节的字节数组;

而整数值则可以是以下六种长度的其中一种:

  • 4位长,介于0至12之间的无符号整数;
  • 1字节长的有符号整数;
  • 3字节长的有符号整数;
  • int16_t 类型整数;
  • int32_t 类型整数;
  • int64_t 类型整数;

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

1、pervious_entry_length

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

  • 如果前一节点的长度小于254字节,那么pervious_entry_length属性的长度为1字节。
  • 如果前一节点的长度大于等于254字节,那么pervious_entry_length属性的长度为5字节。

因为节点的pervious_entry_length属性记录了前一个节点的长度,并且是顺序存储,所以程序可以通过指针运算,根据当前节点的起始地址计算出前一个节点的起始地址

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

2、encoding

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

  • 一字节、两字节、五字节长,值的最高位为00、01、10的是字节数组编码,这种编码表示节点的content属性保存着字节数组,数组的长度由编码除去最高两位之后的其他位记录。
编码编码长度content属性保存的值
00bbbbbb1字节长度小于等于63字节的字节数组
01bbbbbb bbbbbbbb2字节长度小于等于16383字节的字节数组
10bbbbbb bbbbbbbb bbbbbbbb bbbbbbbb bbbbbbbb5字节长度小于等于4294967295字节的字节数组
  • 一字节长,值的最高位以11开头的是整数编码,这种编码表示节点的content属性保存着整数值,整数值的类型和长度由编码除去最高两位之后的其他位记录。

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

previous_entry_lengthencodingcontent
00001011“hello world”
  • 编码的最高两位00表示节点保存的是一个字节数组;
  • 编码的后六位001011记录字节数组的长度11;
  • content属性保存着节点的值“hello world”;

3、hash

3.1 字典

字典:又称为符号表。关联数组或映射,是一种用于保存键值对的抽象数据结构

当一个哈希键包含的键值对比较多,又或者键值对中的元素都是比较长的字符串时,Redis就会使用字典作为哈希键的底层实现。

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

哈希表结构

typedef struct dictht{
     //哈希表数组
     dichEntry **table;
     //哈希表大小
     unsigned long size;
     //哈希表大小掩码,用于计算索引值
     unsigned long sizemask;
     //该哈希表已有节点的数量
     unsigned long used;
}dictht;

哈希表节点,每个dictEntry结构都保存着一个键值对。

typedef struct dictEntry{
      //键
      void *key;
      //值
      union{
           void *val;
           unit64_tu64;
           int64_ts64;
      }v;
      //指向下个哈希表节点,形成链表
      struct dictEntry *next;
}dictEntry;

key属性保存着键值对中的键,而v属性则保存着键值对中的值,其中键值对的值可以是一个指针,或者是一个unit64_t整数,又或者是一个int64_t整数。

next属性是指向另一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对连接在一起,以此来解决键冲突的问题。

字典结构

typedef struct dict{
    //类型特定函数
    dictType *type;
    //私有数据
    void *privdata;
    //哈希表
    dictht ht[2];
    //rehash索引 当rehash不在进行时,值为1
    int trehashidx;
}dict;

type属性和privdata属性是针对不同类型的键值对,为创建多态字典而设置的:

  • type属性是一个指向dictType结构的指针,每个dictType结构保存了一簇用于操作特定类型键值对的函数,Redis会为用途不同的字典上设置不同的类型特定函数。
  • 而privdata属性则保存了需要传给哪些类型特定函数的可选参数。
typedef struct dictType{
    //计算哈希值的函数
    unsigned int (*hashFunction)(const void *key)
    //复制键的函数
    void *(*keyDup)(void *privdata,const void *obj);
    //复制值的函数
    void *(*valDup)(void *privdata,const void *obj);
    //对比键的函数
    int (*keyCompare)(void *privadta,const void *key1,const void (key2);
    //销毁键的函数
    void (*keyDestructor)(void *privdata,void *key);
    //销毁值的函数
    void (*valDestruvtor)(void *privdata,void *obj);
}dictType;
  • ht属性是一个包含两个项的数组,数组中的每个项都是一个dictht哈希表,一般情况下,字典只使用ht[0]哈希表,h[1]哈希表只会在对h[0]哈希表进行rehash时使用。
  • 除了ht[1]之外,另一个和rehash有关的属性就是rehashidx,它记录了rehash目前的进度,如果目前没有在进行rehash,那么它的值为-1;

普通状态下(没有进行rehash)的字典。
在这里插入图片描述
哈希算法

当要将一个新的键值对添加到字典里面时,程序需要先根据键值对的键计算出哈希值和索引值,然后再根据索引值,将包含新键值对的哈希表节点放到哈希表数组的指定索引上面。

Redis计算哈希值和索引值的方法:

# 使用字典设置的哈希函数,计算键key的哈希值
hash=dict->type->hashFunction(key);
# 使用哈希表的sizemask属性和哈希值,计算出索引值
# 根据情况不同,ht[x]可以是ht[0]或者ht[1]
index=hash&dict->ht[x].sizemask;

当字典被用作数据库的底层实现,或者哈希键的底层实现时,Redis使用MurmurHash2算法来计算键的哈希值。

解决键冲突

当有两个或者以上数量的键被分配到了哈希表数组的同一个索引上面时,我们称这些键发生了冲突。

Redis的哈希表使用链地址方来解决键冲突,每个哈希表节点都有一个next指针,多个哈希表节点可以用next指针构成一个单向链表,因为dictEntry节点组成的链表没有指向链表表尾的指针,所以为了速度考虑,程序总是将新节点添加到链表的表头位置(复杂度为O(1)),排在其他已有节点的前面。

rehash

随着操作的不断进行,哈希表保存的键值对会逐渐的增多或者减少,为了让哈希表的负载因子维持在一个合理的范围之内,当哈希表保存的键值对数量太多或者太少时,程序需要对哈希表的大小进行相应的扩展或者收缩

扩展和收缩哈希表的工作可以通过执行rehash(重新散列)操作来完成,Redis对字典的哈希表执行rehash的步骤如下:

1、为字典的ht[1]哈希表分配空间,这个哈希表的空间大小取决于要执行的操作,以及ht[0]当前包含的键值对数量(也就是ht[0].uesd属性的值)。

  • 扩展操作: ht[1]的大小为第一个大于等于ht[0].used*2的2^n(2的n次方幂)。
  • 收缩操作:ht[1]的大小为第一个大于等于ht[0].used的2^n。

2、将保存在ht[0]中的所有键值对rehash到ht[1]上面:rehash指的是重新计算键的哈希值和索引值,然后将键值对放置到ht[1]哈希表的指定位置上。

3、当ht[0]包含的所有键值对都迁移到了ht[1]之后(ht[0]变为空表),释放ht[0],将ht[1]设置为ht[0],并在ht[1]新创建一个空白哈希表,为下一次rehash做准备。

渐进式rehash

扩展或收缩哈希表都需要将ht[0]里面的所有键值对rehash到ht[1]里面1,但是,这个rehash动作并不是一次性的、集中式的完成的,而是分多次、渐进式地完成的。

这样做的原因在于,如果ht[0]里只保存着四个键值对,那么服务器就可以在瞬间就将这些键值对全部rehash到ht[1],但是,如果哈希表里保存的键值对数量不是四个,而是四百万甚至更多,那么要一次性将这些键值对全部rehash到ht[1]的话,庞大的计算量可能会导致服务器在一段时间内停止服务。

因此,为了避免rehash对服务器性能造成影响,服务器不是一次性将ht[0]里面的所有键值对全部rehash到ht[1],而是分多次、渐进式地将ht[0]里面的键值对慢慢地rehash到ht[1]。

以下式哈希表渐进式rehash的步骤:

1、为ht[1]分配空间,让字典同时持有ht[0]和ht[1]两个哈希表。

2、在字典中维持一个索引计数器变量rehashidx,并将它的值设置为0,表示rehash工作正式开始。

3、在rehash进行期间,==每次对字典执行添加、删除、查找或者更新操作时,程序出了指定的操作以外,还会顺带将ht[0]哈希表在rehashidx索引还是那个的所有键值对rehash至ht[1],==当rehash工作完成之后,程序将rehashidx属性的值增一。

4、随着字典操作的不断执行,最终在某个时间点上,ht[0]的所有键值对都会被rehash至ht[1],这时程序将rehashidx的属性的值设为-1,表示rehash操作已完成。

渐进式rehash的好处在于它采取分而治之的方式,将rehash键值对所需的计算工作均摊到对字典的每个添加、删除、查找和更新操作还是那个,从而避免了集中式rehash而带来的庞大计算量。

3.2 压缩列表

当一个哈希键只包含少量键值对,并且每个键值对的键和值要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来做哈希键的底层实现。

redis>HMSET profile "name" "Jack" "age" 28 "job" "Programmer"
O**加粗样式**K
redis>OBJECT ENCODING profile
"ziplist"

上面list中有关于压缩列表的详细解释。

4、set

4.1 整数集合

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

整数集合的实现

整数集合是Redis用于保存整数值的集合抽象数据结构,它可以保存类型为int16_t、int32_t、int64_t的整数值,并且保证集合中不会出现重复元素

每个intset.h/inset结构表示一个整数集合:

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

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

length属性记录了整数集合包含的元素数量,也即是contents数组的长度。

虽然intset结构将contents属性声明为int8_t类型的数组,但实际上contents数组并不保存任何int8_t类型的值,contents数组的真正类型取决于encoding属性的值:

  • encoding属性的值为INTSET_ENC_INT16,那么contents就是一个int16_t类型的数组(最小值为-32768,最大值为32767)。
  • ncoding属性的值为INTSET_ENC_INT32,那么contents就是一个int32_t类型的数组(最小值为-2^31,最大值为 2^31-1)。
  • ncoding属性的值为INTSET_ENC_INT64,那么contents就是一个int64_t类型的数组(最小值为-2^63,最大值为 2^63-1)。

升级

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

升级整数集合并添加新元素共分为三步进行:

  • 1、根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素分配空间。
  • 2、将底层数组现有的所有元素都转换成与新数组相同的类型,并将类型转换后的元素放置到正确的位上,而且在放置元素的过程,需要继续维持底层数组的有序性质不变。
  • 3、将新元素添加到底层数组里面。

因为每次向整数集合添加新元素都可能会引起升级,而每次升级都需要对底层数组中已有的所有元素进行类型转换,所以向整数集合添加新元素的时间复杂度都是O(N)

因为引发升级的新元素的长度总是比整数集合现有的元素长度都大,所以这个新元素的值要么就大于所有现有元素要么小于所有现有元素。

  • 新元素小于所有现有元素:新元素会被放置在底层数组的最开头(索引为0)。
  • 新元素大于所有现有元素:新元素会被放置在底层数组的最末尾(索引length-1)。

升级的好处

1、提升灵活性

整数集合可以通过自动升级底层数组来适应新元素,所以我们可以随意地将int16_t、int32_t或者int64_t类型的整数添加到集合中,而不必担心出现类型错误,这种做法非常灵活。

2、节约内存

如果一个数组要同时保存int16_t、int32_t、int64_t三种类型的值,最简单的做法就是直接使用int64_t类型的数组作为整数集合的底层实现,不过这样一来,就是添加到集合中的元素都是int16_t类型,数组都需要使用int64_t类型的空间去保存它们,从而出现浪费内存的情况。

而整数集合现在的做法既可以让集合能同时保存三种不同类型的值,又可以确保升级操作只会在有需要的时候进行,这可以尽量节省内存。

整数集合不支持降级操作。

5、Zset

5.1 跳跃表

Redi使用跳跃表作为有序集合键的底层实现之一,如果一个有序集合包含的元素数量的比较多,又或者有序集合中元素的成员是比较长的字符串时Redis就会使用跳跃表来作为有序集合键的底层实现。

跳跃表(skiplist)是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。

Redis中的跳跃表由zskiplistNode和zskiplist两个结构定义,其中zskiplistNode结构用于标识跳跃表节点,而zskiplist结构用于保存跳跃表节点的相关信息。

zskiplist

header:指向跳跃表的表头节点。
tail:指向跳跃表的表尾节点。
level:记录目前跳跃表内,层数最大的那个节点的层数(表头节点的层数不计算在内)。
length:记录跳跃表的长度,也即是,跳跃表目前包含节点的数量。

zskiplistNode

typedef struct zskiplistNode{
       //层
       struct zskiplistLevel{
            //前进指针
            struct zskiplistNode *forward;
            //跨度
            unsigned int span;
       }level[];
      //后退指针
      struct zskilistNode *backward;
      //分值
      double score;
      //成员对象
      robj *obj;
}zskiplistNode;

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

redis中随机层数的算法:

#define ZSKIPLIST_MAXLEVEL 32;
#define ZSKIPLIST_P 0.25
int zslRandomLevel(void) {
    int level = 1;
    //random() 返回一个[0...1)的随机数
    while ((random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF))
        level += 1;
    return (level<ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
} 

根据上面计算层数的算法,可以看出,产生越高的节点数,概率越低:

  • 节点层数至少为1,而大于1的节点层数,满足一个概率分布。p=0.25
  • 节点层数恰好等于1的概率为1-p;
  • 节点层数大于等于2的概率为p,而节点层数恰好等于2的概率为p(1-p);
  • 节点层数大于等于3的概率为p^2,而节点层数恰好等于2的概率为p*p(1-p);

当skiplist中有n个节点的时候,

  • 第一层链表固有n个节点;
  • 第二层链表平均有n*p个节点;
  • 第三层链表平均有n*p^2个节点;
  • 第i层链表平均有n*p^(i-1)个节点;

本来想算一个时间复杂度的,但是算到这不会算了,以后算出来了再写吧

插入元素过程
在这里插入图片描述查找元素过程
在这里插入图片描述

查找元素46:
1、在L4层与55对比,发现小了,退回到第三层
2、在L3层与21比对,发信大了,继续与55比,发现小了,退回到第二层
3、在L2层与37比较,发现小了,与55对比,发现小了,退回到第一层。
4、在L1层与46对比,查找成功。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值