Redis底层数据结构

1. 简单动态字符串(SDS)

redis是用C编写的内存数据库,但是存储的字串符不是C传统的用字符数组表示的形式,而是自己定义了一套名为简单动态字符串的抽象类型。

set msg "hello world"

对于上面的存储是简单的String类型,他们底层的数据结构将会是:

​ 键(key)是一个字符串对象,对象底层保存着一个字符串"msg"的SDS;

​ 值也是一个字符串对象,对象底层页保存着一个“hello world”的sds。

对于SDS本质就是C语言中的结构体,

/*  
 * 保存字符串对象的结构  
 */  
struct sdshdr {  
      
    // buf 中已占用空间的长度  
    int len;  
  
    // buf 中剩余可用空间的长度  
    int free;  
  
    // 数据空间  
    char buf[];  
};

在这里插入图片描述

  1. 用len来记录存储的字符串的长度
  2. 用free变量记录buf中还空余的空间(初次分配空间,分配大小一般是字符串长度,free是0,当对字符串修改时,比如添加字符,会发生扩容,会有剩余空间的出现)。
  3. buf是存储字符串的数组,使用的依然是 \0作为结尾,但是字符串结束是根据len来计算的。

好处,相比较于C语言的字符串表示

  • 获取字符串的长度(O(1)和O(n)):

C字符串使用N+1的字符数组来表示长度为N的字符串,所以获取一个字符串的长度,必须遍历整个字符串数组,记录下\0前的长度。

SDS内部维护了一个len的变量,直接记录了字符串的长度,复杂度为O(1).
在这里插入图片描述

  • 杜绝缓存区溢出:

    C字符串不记录字符串的长度,除了获取长度复杂度高外,还发生缓存区溢出。

    具体原因通俗说是: C字符串是用字符数组存储的,如果程序中存在两个紧紧挨着的字符数组,

    “aaa\0”和“bbb\0”,如果a串减少字符,则不会发生问题,但是如果此时a串继续添加“AA”,但是又忘了重新为a串分配足够的空间由于C字符数组是不记录长度的,则会将修改发生到b串身上,则就发生了字符溢出。

假设程序中有两个在内存中紧邻着的 字符串 s1 和 s2,其中s1 保存了字符串“redis”,二s2 则保存了字符串“MongoDb”:
在这里插入图片描述
如果我们现在将s1 的内容修改为redis cluster,但是又忘了重新为s1 分配足够的空间,这时候就会出现以下问题:
在这里插入图片描述
我们可以看到,原本s2 中的内容已经被S1的内容给占领了,s2 现在为 cluster,而不是“Mongodb”。

对于redis解决这个问题的方式就是通过free变量的添加。

free+len是一个SDS所能容纳的字符个数,当对buf进行修改时,redis会在执行拼接之前,检查free是否足够容纳。如果不够,会先去扩展SDS的空间,然后在进行拼接操作。
在这里插入图片描述在这里插入图片描述
​ 对于扩展SDS空间,下面分析:

  • 减少修改字符串带来的内存分配次数

C语言字符串在进行字符串的扩充和收缩的时候,都会面临着内存空间的重新分配问题。对于C语言中字符串数组,如果发生拼接字符串,忘记申请分配空间,则会导致内存的溢出。在收缩的时候,由于没有记得字符串长度,当删减一部分字符后,那些删减的部分由于没有记录,就会发生内存泄漏。

对于redis采用预分配策略,刚开始初始化的时候分配的大小是字符串的大小,当发生修改时,会增加SDS的大小,通常大于所需要的空间大小,将多余的记录在free中,如果下次发生更改,则不需要再去申请内存空间了。

  • 二进制安全

C 字符串中的字符必须符合某种编码,并且除了字符串的末尾之外,字符串里面不能包含空字符,否则最先被程序读入的空字符将被误认为是字符串结尾,这些限制使得C字符串只能保存文本数据,而不能保存想图片,音频,视频,压缩文件这样的二进制数据。

但是在Redis中,不是靠空字符来判断字符串的结束的,而是通过len这个属性。那么,即便是中间出现了空字符对于SDS来说,读取该字符仍然是可以的。
在这里插入图片描述

  • 总结:在这里插入图片描述
    c语言存储字符串是通过定义char数组,用 \0表示字符串的结束,所以字符串中不能出现 \0,否则会认为字符串结束了,相比较于SDS,则可以实现 \0的存储。

对于SDS结构,通过预分配策略,可以有效的减少字符串修改带来的内存重新分配的次数,用free来记录剩余的大小。

2. 链表

链表是list的数据类型底层数据结构,是双向链表,key中存储了链表的头尾指针,可以获取到头尾节点。

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

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

多个链表节点组成的双端链表:

我们可以通过直接操作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);
}

结构图如下:
在这里插入图片描述
从图中可以看到:

  • 双端: 链表节点带有prev 和next 指针,获取某个节点的前置节点和后置节点的时间复杂度都是O(N)
  • 无环: 头结点的prev和尾节点的next都是执行null,所以访问时指向null即可停止查找。
  • 表头和表尾:因为链表带有head指针和tail 指针,程序获取链表头结点和尾节点的时间复杂度为O(1)
  • 长度计数器:链表中存有记录链表长度的属性 len

3. 字典

dictht 是一个散列表结构,使用拉链法解决哈希冲突。字典中存储的是两个hash表还有trehashidx,rehash的索引,作用是在rehash的时候可以渐进式的进行操作,避免数据量过大时,导致计算量大。hash表是真正的数据结构,它采用拉链法解决hash冲突,hash表中有位桶数组,数组中存储的元素类型是dictEntry ,采用头插法解决hash冲突。具体的数据结构:

3.1 哈希表(dictht)

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

typedef struct dictht {
   //哈希表数组
   dictEntry **table; 
   //哈希表大小
   unsigned long size;

   //哈希表大小掩码,用于计算索引值,应该是size-1的大小。
   unsigned long sizemask; 
   //该哈希表已有节点的数量
   unsigned long used;
}

一个空的字典的结构图如下:
在这里插入图片描述

我们可以看到,在结构中存有指向dictEntry 数组的指针,而我们用来存储数据的空间既是dictEntry

3.2 哈希表节点(dictEntry)
typeof struct dictEntry{
   //键
   void *key;
   //值
   union{
      void *val;
      uint64_tu64;
      int64_ts64;
   }
    //冲突的下一个节点
   struct dictEntry *next;
}

在数据结构中,我们清楚key 是唯一的,但是我们存入里面的key 并不是直接的字符串,而是一个hash 值,通过hash 算法,将字符串转换成对应的hash 值,然后在dictEntry 中找到对应的位置。

这时候我们会发现一个问题,如果出现hash 值相同的情况怎么办?Redis 采用了链地址法:
在这里插入图片描述
 当k1 和k0 的hash 值相同时,将k1中的next 指向k0 想成一个链表。

3.3 字典

个人理解字典的出现其实即使对dictht进行了一个简单封装,封装了两个哈希表,目的是为了进行rehash的时候方便,操作的本质还是对哈希表。

typedef struct dict {
    // 类型特定函数
    dictType *type;
    // 私有数据
    void *privedata;
    // 哈希表
    dictht  ht[2];
    // rehash 索引
    in trehashidx;

}

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

ht属性是一个dictht类型的数组,大小是2,所以存有两个哈希表。

普通状态下的字典:

使用的只有哈希表(dictht)只有有个,ht[0],ht[1]设置为空的哈希表,没有分配位桶数组,只有当需要扩容时,才会新创建一个dictht,存放在ht[1]中,并且位桶数组的大小是ht[1]的2倍。rehash下面分析。
在这里插入图片描述

3.4 解决hash冲突

​ jdk1.7的方式一样,忽略。

3.5 Rehash

随着对哈希表的不断操作,哈希表保存的键值对会逐渐的发生改变,为了让哈希表的负载因子维持在一个合理的范围之内,我们需要对哈希表的大小进行相应的扩展或者压缩,这时候,我们可以通过 rehash(重新散列)操作来完成。

当ht[0]对应的hash表使用达到扩容的阈值,则需要扩展,当前哈希表的状态:

为hash表分配空间:

哈希表空间分配的规则:

​ 如果是扩展操作,那么ht[1]的位桶数组大小为第一个大小等于ht[0]的2的n次幂。

因此这里分配的大小是8
在这里插入图片描述
数据迁移:
在将ht[0]中的数据迁移到ht[1]中的时候,需要对节点进行重新结算,插入到ht[1]合适的位置。
在这里插入图片描述
当数据迁移完毕后,会将ht[0]释放,然后将ht[1]设置成ht[0],最后再将ht[1]分配一个空的哈希表。
在这里插入图片描述

3.6 渐进式 Rehash

​ 在进行拓展或者压缩的时候,可以直接将所有的键值对rehash 到ht[1]中,这是因为数据量比较小。在实际开发过程中,这个rehash 操作并不是一次性、集中式完成的,而是分多次、渐进式地完成的。

具体步骤:

  1. 为ht[1]分配空间,让字典同时又ht[0]和ht[1]两个哈希表。
  2. 在字典中维护一个索引计数器 rehashidx,并将它设置为0,表示rehash的开始。
  3. 在rehash运行期间,每次对字典的执行操作时,程序除了执行指定的操作外,还会将ht[0]中的数据rehash到ht[1]中,并将rehashidx加1.
  4. 当ht[0]中的数据都迁移到 了ht[1]中,将rehashindex设置为-1,表示结束。

渐进式 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 带来的庞大计算量。

4. 跳表

跳跃表(skiplist)是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。跳跃表是一种随机化的数据,跳跃表以有序的方式在层次化的链表中保存元素,效率和平衡树媲美 ——查找、删除、添加等操作都可以在对数期望时间下完成,并且比起平衡树来说,跳跃表的实现要简单直观得多。

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

跳表在redis中的数据结构定义的很复杂,根据以前的跳表结构学习,知道每个节点都有四个指针指向上下左右,跳表中元素是有序的,是基于链表实现了。在redis中定义了跳表的最高高度为32层。

完整的结构:在这里插入图片描述
主要组成是有两部分,zskiplist和zskiplistNode组成。

zskiplist 数据结构:

typedef struct zskiplist {
     //表头节点和表尾节点
     structz skiplistNode *header,*tail;
     //表中节点数量
     unsigned long length;
     //表中层数最大的节点的层数
     int level;

}zskiplist;

在这里插入图片描述
从结构图中我们可以清晰的看到,header,tail分别指向跳跃表的头结点和尾节点。level 用于记录最大的层数,length 用于记录我们的节点数量。

zskiplistNode(节点) 数据结构:

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

1、层:level 数组可以包含多个元素,每个元素都包含一个指向其他节点的指针。

2、前进指针:用于指向表尾方向的前进指针

3、跨度:用于记录两个节点之间的距离

4、后退指针:用于从表尾向表头方向访问节点

5、分值和成员:跳跃表中的所有节点都按分值从小到大排序。成员对象指向一个字符串,这个字符串对象保存着一个SDS值

总结:

  • 跳表是zset数据类型的底层实现。
  • 每个跳表的节点的高度在于1~32之间的随机数。
  • 在同一个跳表中,多个节点,可以包含相同的分值,但每个节点的对象必须是唯一的。
  • 节点按照分值的大小从大到小排序,如果分值相同,则按成员对象大小排序。

5. 整数集合(intSet)

只能使用元素数量不多时,redis就会使用整数集合intset作为集合的底层实现。

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

}

我们观察一下一个完成的整数集合结构图:
在这里插入图片描述

1、encoding:用于定义整数集合的编码方式

2、length:用于记录整数集合中变量的数量

​ 3、contents:用于保存元素的数组,虽然我们在数据结构图中看到,intset将数组定义为int8_t,但实际上数组保存的元素类型取决于encoding

整数集合的升级

在上述数据结构图中我们可以看到,intset 在默认情况下会帮我们设定整数集合中的编码方式,但是当我们存入的整数不符合整数集合中的编码格式时,就需要使用到Redis 中的升级策略来解决

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

1、根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素分配空间

2、将底层数组现有的所有元素都转换成新的编码格式,重新分配空间

3、将新元素加入到底层数组中

比如,我们现在有如下的整数集合:
在这里插入图片描述
我们现在需要插入一个32位的整数,这显然与整数集合不符合,我们将进行编码格式的转换,并为新元素分配空间:
在这里插入图片描述
第二步,将原有数据他们的数据类型转换为与新数据相同的类型:(重新分配空间后的数据)
在这里插入图片描述
第三部,将新数据添加到数组中:
在这里插入图片描述
整数集合升级的好处

1、提升灵活性

2、节约内存

6. 压缩列表

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

一个压缩列表的组成如下:
在这里插入图片描述

1、zlbytes:用于记录整个压缩列表占用的内存字节数

2、zltail:记录要列表尾节点距离压缩列表的起始地址有多少字节

3、zllen:记录了压缩列表包含的节点数量。

4、entryX:要说列表包含的各个节点

5、zlend:用于标记压缩列表的末端

​ 压缩列表是一种为了节约内存而开发的顺序型数据结构

压缩列表被用作列表键和哈希键的底层实现之一

压缩列表可以包含多个节点,每个节点可以保存一个字节数组或者整数值

添加新节点到压缩列表,可能会引发连锁更新操作。

下一篇是解析如何将底层数据结构和redis数据类型结合使用的。
数据类型与数据结构

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值