Redis数据结构底层设计

简单动态字符串

Redis没有直接使用C语言传统的字符串表示,而是自己构建了一种名为简单的动态字符串(simple dynamic string,SDS)的抽象类型。

SDS定义(sds.h/sdshd):

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

如图展示了SDS存储”Redis“字符串示例:

Redis数据结构底层设计_第1张图片

  • free属性值为0,表示这个SDS没有分配任何未使用空间。
  • len属性值为5,表示SDS保存五个字节的字符串长度。
  • buf属性是一个char类型的数组,最后一个字符串保存空字符串‘\0’。

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

杜绝缓冲区溢出

C字符串是不记录自身的长度,所以strcat假定用户在执行这个函数时,已经为dest分配了足够多的内存,可以容纳下src字符串,如果假定不成立就会产生缓冲区溢出。与C不同的是,SDS空间分配策略杜绝了发生缓冲区溢出的可能性:当SDS API需要对SDS进行修改时,会先检查SDS空间是否满足,如果不满足会自动扩容。

减少修改字符串带来的内存重新分配

因为C字符串不记录自身的长度,一个包含N个字符的字符串,底层实现总是一个N+1的字符长的数组,C字符串长度和底层数组之间的长度存在这个这种关联性,所以每次增长或者缩短C字符串,程序都要对数组进行一次内存重新分配操作。为了避免C字符串的这种缺陷,SDS通过未使用空间空间解除了字符串长度和底层数组之间的长度关联关系,buf数组的长度根据字符串的长度动态调节,通过未使用空间,SDS实现了空间预分配和惰性空间释放两种优化策略:

  • 空间预分配

    空间预分配用于优化SDS增长操作:当SDS的API对于一个SDS进行修改操作,并且需要对SDS进行空间扩展的时候,程序不仅会为SDS分配修改所需的空间,还会分配额外的未使用空间。分配公式如下:如果修改SDS之后长度(len)小于1MB,那么未分配空间(free)等于len的值,如程序修改之后len为13字节,那么buf数组的实际长度为13+13+1=27字节;如果修改之后长度大于等于1MB,那么将会分配1MB的空间。

  • 惰性空间释放

​ 惰性空间释放用于优化SDS缩短操作:当SDS需要缩短字符串时,程序并不立即使用内存重新分配来回收缩短后多出来的字节,而是用free属性记录这些字符串的数量记录起来,并等待将来使用。

二进制安全

兼容部分C字符串函数

链表

链表提供了高效的节点重排能力,以及顺序性的节点访问方式,并且可以通过增删节点来灵活调整链表的长度。链表广泛应用于Redis各种功能:列表、发布与订阅、慢查询、监视器等。

每个链表的节点使用adlist/listNode表示

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

通过多个 listNode 结构就可以组成链表,这是一个双向链表,Redis还提供了操作链表的数据结构

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

Redis的链表实现的特征如下:

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

字典(map)

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

哈希表结构定义:

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

哈希表是由数组 table 组成,table 中每个元素都是指向 dict.h/dictEntry 结构,dictEntry 结构定义如下:

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

键值对中的值可以是一个指针,也可以是uint64_t整数,也可以是int64_t整数,next属性是指向另一个哈希节点的指针,这个指针可以将多个哈希值相同的键值对连接在一起,以此来解决建冲突。

Redis数据结构底层设计_第2张图片

rehash过程

扩展和收缩哈希表的工作可以通过rehash(重新散列)完成:

1)为字典的ht[1]哈希表分配空间,这个哈希表的空间大小取决于要执行的操作,以及ht[0]当前包含键值数量(ht[0].used)。如果执行扩展操作,会基于原哈希表创建一个大小等于 ht[0].used*2n 的哈希表(也就是每次扩展都是根据原哈希表已使用的空间扩大一倍创建另一个哈希表)。相反如果执行的是收缩操作,每次收缩是根据已使用空间缩小一倍创建一个新的哈希表。

  1. 将ht[0]中的所有键值rehash到ht[1]上。

3)数据迁移完成之后是否ht[0],将ht[1]设置为ht[0],并在ht[1]新创建一个空白哈希表,为下一次rehash做准备。

哈希表的扩展与收缩条件

  • 服务器目前没有执行 BGSAVE 命令或者 BGREWRITEAOF 命令,并且负载因子大于等于1。

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

渐进式rehash

如果ht[0]中的键值对很少,那么在瞬间就可以完成rehash;但是如果哈希表中存在百万、千万的键值对,那么要一次性将这些键值对全部rehash到ht[1]的话需要大量的时间。为了避免对服务器性能造成影响,Redis采用分多次、渐进式地将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过程中,字典会同时持有ht[0]和ht[1],字典的删除、查询、更新等操作会在两个哈希表上进行。如字典在查询的时候先去ht[0]上查询找不到的话再去ht[1]上查询。另外渐进式rehash添加操作一律在ht[1]上进行。

跳跃表

跳跃表是一种有序的数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问的目的。Redis在两个地方用到跳跃表,一个是实现有序集合键,另一个是在集群节点中用作内部数据结构。

Redis数据结构底层设计_第3张图片

跳跃表节点定义如下:

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

多个跳跃节点构成一个跳跃表:

typedef struct zskiplist{
     //表头节点和表尾节点
     structz skiplistNode *header, *tail;
     //表中节点的数量
     unsigned long length;
     //表中层数最大的节点的层数
     int level;
 
}zskiplist;
  • 层(level):层是介于1和32之间的随机数,程序可以通过这些层来加快访问其他节点的速度。
  • 前进指针:每个层都有一个指向表尾的前进指针(level[i].forward),用于从表头向表尾访问节点。
  • 跨度:层的跨度(level[i].span)用于记录两个节点之间的距离。
  • 后退指针:用于从表尾向表头方向访问节点,跟可以一次跳跃多个节点的前进指针不太,因为每个节点只有一个后退指针,所以每次只能后退至前一个节点。
  • 分值和成员:节点的分支(score)是一个double类型浮点数,跳跃表中的所有节点都按分值从小到大排序。节点的成员对象(obj)是一个指针,指向一个字符串对象,而字符串对象则保存一个SDS值。

整数集合

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

Inset.h/intset定义:

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

升级

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

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

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

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

升级的好处

1)提升灵活性:C语言是静态类型语言,为了避免类型错误,我们通常不会将两种不同类型的值放在一个数据结构里面。

2)节省空间:通过必要的时候对数据进行升级,能降低空间的使用。

降级

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

压缩列表

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

压缩列表组成:
Redis数据结构底层设计_第4张图片

压缩列表的每个节点构成如下:

在这里插入图片描述

1)previous_entry_ength:记录压缩列表前一个字节的长度。previous_entry_ength的长度可能是1个字节或者是5个字节,如果上一个节点的长度小于254,则该节点只需要一个字节就可以表示前一个节点的长度了,如果前一个节点的长度大于等于254,则previous length的第一个字节为254,后面用四个字节表示当前节点前一个节点的长度。利用此原理即当前节点位置减去上一个节点的长度即得到上一个节点的起始位置,压缩列表可以从尾部向头部遍历。这么做很有效地减少了内存的浪费。

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

3)content:content区域用于保存节点的内容,节点内容类型和长度由encoding决定。

连锁更新

每个节点previous_entry_ength属性都记录前一个节点的长度:如果前一节点的长度小于254字节,那么previous_entry_ength需要1字节的空间来保存这个长度的值;如果前一节点的长度大于254字节,那么previous_entry_ength需要5字节。现在考虑一个压缩列表有多个连续、长度介于250-253字节之间的节点e1-eN,这些节点的previous_entry_ength都需要1字节,这时将一个长度大于等于254字节的新节点new设置为压缩列表的表头,那么new将成为e1的前置节点,此时e1节点的previous_entry_ength将变为5;现在e1原本长度介于250-253字节,就变成介于254-257字节,这中长度1字节的previous_entry_ength无法存储,为了让e2的previous_entry_ength可以记录e1长度,e2的previous_entry_ength也需要变为5字节。。。直到eN节点。

你可能感兴趣的:(Redis)