Redis详解(四)redis底层数据结构详解

1、前言

上一篇咱们详细介绍了Redis的数据类型(Redis详解(三)redis的数据类型详解),本篇将带你们重点详细介绍Redis五种数据类型的底层数据结构。数据库键老是一个字符串对象,值则能够是字符串对象、列表对象、哈希对象、集合对象、有序集合对象这五种对象中的其中一种,Redis底层数据结构有如下数据类型:简单动态字符串、链表、字典、跳跃表、整数集合、压缩列表。redis

2、简单动态字符串

构建了一种名为简单动态字符串(SDS)的抽象类型,并将SDS用做Redis的默认字符串表示。在Redis里面,C字符串只会做为字符串字面量用在一些无须对字符串值进行修改的地方(好比打印日志)。当Redis须要一个能够被修改的字符串值时,Redis就会使用SDS来表示字符串。SDS还被用做缓冲区:AOF模块中的AOF缓冲区,以及客户端状态中的输入缓冲区,都是由SDS实现的。算法

1.定义

1.1 简单动态字符串结构

struct sdshdr {        
	// 记录buf数组中已使用字节的数量
    // 等于SDS所保存字符串的长度
    int len;
  
    // 记录buf数组中未使用字节的数量
    int free;
  
    // 字节数组,用于保存字符串
    char buf[];
}; 

复制代码

1.2 示例

用SDS保存字符串 “Redis”具体图示以下:数据库

2. SDS与C字符串的区别

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

因为 len 属性的存在,咱们获取 SDS 字符串的长度只须要读取 len 属性,时间复杂度为 O(1)。而对于 C 语言,获取字符串的长度一般是通过遍历计数来实现的,时间复杂度为 O(n)。经过 strlen key 命令能够获取 key 的字符串长度。数组

2. 杜绝缓冲区溢出

C字符串不记录自身长度带来的另外一个问题是容易形成缓冲区溢出。安全

假设程序中有两个在内存中紧邻着的字符串s1和s2,其中s1保存了字符串“redis”,而s2则保存了字符串“MongoDb”。服务器

此时将s1的内容修改成“Redis Cluster”,但忘记了为s1分配足够的空间,就会致使s1的数据将溢出到s2所在的空间中,致使s2保存的内容被意外地修改。微信

当SDS API须要对SDS进行修改时,API会先检查SDS的空间是否知足修改所需的要求,若是不知足的话,API会自动将SDS的空间扩展至修改所需的大小,而后才执行实际的修改操做,因此使用SDS既不须要手动修改SDS的空间大小,也不会出现前面所说的缓冲区溢出问题。markdown

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

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

  1. 若是程序执行的是增加字符串的操做,程序须要先经过内存重分配来扩展底层数组组的空间大小,若是忘了这一步就会产生缓冲区溢出函数

  2. 若是程序执行的是缩短字符串的操做,程序须要经过内存重分配来释放字符串再也不使用的那部分空间,若是忘了这一步就会产生内存泄露

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

  1. 空间预分配:空间预分配用于优化SDS的字符串增加操做。对字符串进行空间扩展操做时,扩展的内存比实际须要的多(程序不只会为SDS分配修改所必须的空间,还会为SDS分配额外的未使用空间),这样能够减小连续执行字符串增加操做所需的内存重分配次数。

  2. 惰性空间释放:惰性空间释放用于优化SDS的字符串缩短操做。对字符串进行缩短操做时,程序不当即使用内存从新分配来回收缩短后多余的字节,而是使用 free 属性将这些字节的数量记录下来,等待后续使用。(固然SDS也提供了相应的API,当咱们有须要时,也能够手动释放这些未使用的空间。)

4. 二进制安全

由于C字符串以空字符做为字符串结束的标识,而对于一些二进制文件(如图片等),内容可能包括空字符串,所以C字符串没法正确存取;而全部 SDS 的API 都是以处理二进制的方式来处理 buf 里面的元素,而且 SDS 不是以空字符串来判断是否结束,而是以 len 属性表示的长度来判断字符串是否结束。

5. 兼容部分 C 字符串函数

虽然 SDS 是二进制安全的,可是同样听从每一个字符串都是以空字符串结尾的惯例,这样能够重用 C 语言库<string.h> 中的一部分函数。

6. 总结

3、链表

链表是一种经常使用的数据结构,C 语言内部是没有内置这种数据结构的实现,因此Redis本身构建了链表的实现。链表提供了高效的节点重排能力,以及顺序性的节点访问方式,而且能够经过增删节点来灵活地调整链表的长度。

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

此外,发布与订阅、慢查询、监视器等功能也用到了链表,Redis服务器自己还使用链表来保存多个客户端的状态信息,以及使用链表来构建客户端输出缓冲区

1.定义

1.1 链表节点结构

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

复制代码

1.2 示例

经过多个listNode能够经过prev和next指针组成链表,这是一个双向链表。

Redis还提供了操做链表的数据结构。

2.1 链表结构

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;
复制代码

2.2 示例

2.特性

Redis的链表实现的特性能够总结以下:

  1. 双端:链表节点带有prev和next指针,获取某个节点的前置节点和后置节点的复杂度都是O(1)。

  2. 无环:表头节点的prev指针和表尾节点的next指针都指向NULL,对链表的访问以NULL为终点。

  3. 带表头指针和表尾指针:经过list结构的head指针和tail指针,程序获取链表的表头节点和表尾节点的复杂度为O(1)。

  4. 带链表长度计数器:程序使用list结构的len属性来对list持有的链表节点进行计数,程序获取链表中节点数量的复杂度为O(1)。

  5. 多态:链表节点使用void*指针来保存节点值,而且能够经过list结构的dup、free、match三个属性为节点值设置类型特定函数,因此链表能够用于保存各类不一样类型的值。

4、字典

字典又称为符号表或者关联数组、或映射(map),是一种用于保存键值对的抽象数据结构。字典中的每个键 key 都是惟一的,经过 key 能够对值来进行查找或修改。

1.定义

1.1 哈希表结构

typedef struct dictht{
     //哈希表数组
     dictEntry **table;
     //哈希表大小
     unsigned long size;
     //哈希表大小掩码,用于计算索引值
     //老是等于 size-1
     unsigned long sizemask;
     //该哈希表已有节点的数量
     unsigned long used;
 
}dictht
复制代码
  • table属性是一个数组,数组中的每一个元素都是一个指向哈希表节点(dictEntry)的指针,每一个节点都保存着一个键值对;

  • size属性记录了哈希表的大小,也就是table数组的大小;

  • sizemask属性的值老是等于size-1,这个属性和哈希值一块儿决定一个键应该被放到table数组的那个索引上面;

  • used属性记录了哈希表目前已有节点的数量。

1.2 示例

一个大小为4的空哈希表结构图以下:

哈希表是由数组 table 组成,table 中每一个元素都是指向 dictEntry 结构。

2.1 哈希表节点结构

typedef struct dictEntry{
     //键
     void *key;
     //值
     union{
          void *val;
          uint64_tu64;
          int64_ts64;
     }v;
 
     //指向下一个哈希表节点,造成链表
     struct dictEntry *next;
}dictEntry
复制代码
  • key属性保存着键值对中的键;

  • v属性保存着键值对中的值,其中值用union定义,支持三种数据类型;能够是一个指针,还能够是uint64_t整数,也能够是int64_t整数。

  • next属性是指向另外一个哈希表节点的指针,这个指针能够将多个哈希值相同的键值对链接在一块儿,以此来解决键冲突的问题。经过next指针将两个索引值相同的键k1和k0链接在一块儿。

2.2 示例

3.1 字典结构

typedef struct dict {
    // 类型特定函数
    dictType *type;
    
    // 私有数据
    void *privedata;
    
    // 哈希表
    dictht  ht[2];
    
    // rehash 索引
    // 在rehash不在进行时,值为-1
    int trehashidx;
}dict;
复制代码
  • type属性是一个指向dictType结构的指针,每一个dictType结构保存了一组用于操做特定类型键值对的函数,Redis会为用途不一样的字典设置不一样的类型特定函数

  • privedata属性则保存了须要传给那些类型特定函数的可选参数

  • ht属性是一个包含两个项的数组,数组中的每一个项都是一个dictht哈希表,通常状况下,字典只使用ht[0]哈希表,ht[1]哈希表只会在对ht[0]哈希表进行rehash时使用

  • trehashidx属性记录了rehash目前的进度,若是目前没有在进行rehash,那么它的值为-1

3.2 示例

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

2.哈希算法

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

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

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

3.解决键冲突

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

Redis的哈希表使用链地址法来解决键冲突,每一个哈希表节点都有一个next指针,多个哈希表节点能够用next指针构成一个单向链表,被分配到同一个索引上的多个节点能够用这个单向链表链接起来,这就解决了键冲突的问题。

由于dictEntry节点组成的链表没有指向链表表尾的指针,因此为了速度考虑,程序老是将新节点添加到链表的表头位置(复杂度为O(1)),排在其余已有节点的前面。

使用链表解决k2和k1的冲突:

4.扩容和收缩

为了让哈希表的负载因子维持在一个合理的范围以内,当哈希表保存的键值对数量太多或者太少时,程序须要对哈希表的大小进行相应的扩展或者收缩。

1.扩容和收缩步骤

扩展和收缩哈希表的工做能够经过执行rehash(从新散列)操做来完成,Redis对字典的哈希表执行rehash的步骤以下:

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

    • 若是执行的是扩展操做,那么ht[1]的大小为第一个大于等于ht[0].used*2n(也就是每次扩展都是根据原哈希表已使用的空间扩大一倍建立另外一个哈希表)

    • 若是执行的是收缩操做,每次收缩是根据已使用空间缩小一倍建立一个新的哈希表。

  2. 从新利用上面的哈希算法,计算索引值,而后将键值对放到新的哈希表位置上。

  3. 当ht[0]包含的全部键值对都迁移到了ht[1]以后(ht[0]变为空表),释放ht[0],将ht[1]设置为ht[0],并在ht[1]新建立一个空白哈希表,为下一次rehash作准备)

2.扩容和收缩触发条件

哈希表的负载因子计算公式:load_factor = ht[0].used / ht[0].size(负载因子=哈希表已保存节点数量/哈希表大小)

  1. 当如下条件中的任意一个被知足时,程序会自动开始对哈希表执行扩展操做:

    • 服务器目前没有在执行BGSAVE命令或者BGREWRITEAOF命令,而且哈希表的负载因子大于等于1。

    • 服务器目前正在执行BGSAVE命令或者BGREWRITEAOF命令,而且哈希表的负载因子大于等于5。

  2. 当哈希表的负载因子小于0.1时,程序自动开始对哈希表执行收缩操做。

5.渐进式rehash

为了不rehash对服务器性能形成影响,服务器不是一次性将ht[0]里面的全部键值对所有rehash到ht[1],而是分屡次、渐进式地将ht[0]里面的键值对慢慢地rehash到ht[1]。由于在进行渐进式rehash的过程当中,字典会同时使用ht[0]和ht[1]两个哈希表,因此在渐进式rehash进行期间,字典的删除、查找、更新等操做会在两个哈希表上进行。而新添加到字典的键值对一概会被保存到ht[1]里面,而ht[0]则再也不进行任何添加操做。

5、跳跃表

跳跃表(skiplist)是一种有序数据结构,它经过在每一个节点中维持多个指向其它节点的指针,从而达到快速访问节点的目的。Redis使用跳跃表做为有序集合键的底层实现之一,若是一个有序集合包含的元素数量比较多,又或者有序集合中元素的成员是比较长的字符串时,Redis就会使用跳跃表来做为有序集合键的底层实现。Redis只在两个地方用到了跳跃表,一个是实现有序集合键,另外一个是在集群节点中用做内部数据结构

1.定义

1.1 跳跃表节点结构

typedef struct zskiplistNode {
     //层
     struct zskiplistLevel{
           //前进指针
           struct zskiplistNode *forward;
           //跨度
           unsigned int span;
     }level[];
 
     //后退指针
     struct zskiplistNode *backward;
     //分值
     double score;
     //成员对象
     robj *obj;
 
} zskiplistNode
复制代码
  • level属性能够包含多个元素,每一个元素都包含一个指向其余节点的指针,程序能够经过这些层来加快访问其余节点的速度,通常来讲,层的数量越多,访问其余节点的速度就越快。

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

    • span属性(跨度)用于记录两个节点之间的距离。

      • 两个节点之间的跨度越大,它们相距得就越远。
      • 指向NULL的全部前进指针的跨度都为0,由于它们没有连向任何节点。
  • backward属性(后退指针)用于从表尾向表头方向访问节点:跟能够一次跳过多个节点的前进指针不一样,由于每一个节点只有一个后退指针,因此每次只能后退至前一个节点。

  • score属性(分值)是一个double类型的浮点数,跳跃表中的全部节点都按分值从小到大来排序。

  • obj属性(成员对象)是一个指针,它指向一个字符串对象,而字符串对象则保存着一个SDS值。

在同一个跳跃表中,各个节点保存的成员对象必须是惟一的,可是多个节点保存的分值却能够是相同的:分值相同的节点将按照成员对象在字典序中的大小来进行排序,成员对象较小的节点会排在前面(靠近表头的方向),而成员对象较大的节点则会排在后面(靠近表尾的方向)。

1.2 示例

下图中分别展现了三个高度为1层、3层和5层的节点

2.1 跳跃表结构

typedef struct zskiplist {
	//表头节点和表尾节点
	structz skiplistNode *header,*tail;
    
	//表中节点数量
	unsigned long length;
    
	//表中层数最大的节点的层数
	int level;
}zskiplist;
复制代码
  • header 属性指向跳跃表的表头节点。

  • tail 属性指向跳跃表的表尾节点。

  • length 属性记录跳跃表的长度,即跳跃表目前包含节点的数量。

  • level 属性记录目前跳跃表内,层数最大的那个节点的层数。

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

2.2 示例

6、整数集合

整数集合是集合键的底层实现之一,当一个集合只包含整数值元素,而且这个集合的元素数量很少时,Redis就会使用整数集合做为集合键的底层实现。整数集合(intset)是Redis用于保存整数值的集合抽象数据类型,它能够保存类型为int16_t、int32_t 或者int64_t 的整数值,而且保证集合中不会出现重复元素。

1.定义

1.1 整数结构

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

复制代码
  • contents contents数组是整数集合的底层实现:整数集合的每一个元素都是contents数组的一个数组项,各个项在数组中按值的大小从小到大有序地排列,而且数组中不包含任何重复项。contents数组的真正类型取决于enconding属性的值。

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

1.2 示例

2. 升级

当咱们新增的元素类型比原集合元素类型的长度要大时,须要对整数集合进行升级,才能将新元素放入整数集合中。整数集合的升级策略有两个好处,一个是提高整数集合的灵活性,另外一个是尽量地节约内存。

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

  2. 将底层数组现有的全部元素都转成与新元素相同类型的元素,并将转换后的元素放到正确的位置,放置过程当中,维持整个元素顺序都是有序的。

  3. 将新元素添加到整数集合中(保证有序)。

3. 降级

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

7、压缩列表

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

1.定义

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

压缩列表各个组成部分的详细说明:

压缩列表节点的各个组成部分:

  • previous_entry_ength:节点的previous_entry_length属性以字节为单位,记录了压缩列表中前一个节点的长度。previous_entry_length属性的长度能够是1字节或者5字节。由于节点的previous_entry_length属性记录了前一个节点的长度,因此程序能够经过指针运算,根据当前节点的起始位置来计算出前一个节点的起始位置。

    • 若是前一节点的长度小于254字节,那么previous_entry_length属性的长度为1字节:前一节点的长度就保存在这一个字节里面

    • 若是前一个节点的长度大于254字节,那么previous_entry_length属性的长度为5字节:其中属性的第一字节会被设置为0xFE(十进制254),而以后的四个字节则用于保存前一节点的长度

  • encoding:节点的encoding保存的是节点的content的内容类型以及长度,encoding类型一共有两种,一种字节数组一种是整数,encoding区域长度为1字节、2字节或者5字节长。

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


有一种境界叫自黑:聪明绝顶的我再没用过梳子。

关注微信公众号‘秃顶记’,聪明绝顶走到黑。

相关文章
相关标签/搜索