Redis数据结构

1 简单动态字符串

Redis 是用 C 语言写的,但是对于Redis的字符串,却不是 C 语言中的字符串(即以空字符’\0’结尾的字符数组),它是自己构建了一种名为 简单动态字符串(simple dynamic string,SDS)的抽象类型,并将 SDS 作为 Redis的默认字符串表示。

SDS 定义:

struct sdshdr {
  int len;//字符串长度
  int free;//剩余使用空间,也就是buf数组中未使用的字节数
  char buf[];//字节数组,用于保存字符串
}

用SDS保存字符串 “Redis”具体图示如下:

image

SDS 数据类型的定义:

1、len 保存了SDS保存字符串的长度

2、buf[] 数组用来保存字符串的每个元素

3、free j记录了 buf 数组中未使用的字节数量

上面的定义相对于 C 语言对于字符串的定义,多出了 len 属性以及 free 属性。为什么不使用C语言字符串实现,而是使用 SDS呢?这样实现有什么好处?

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

由于 len 属性的存在,我们获取 SDS 字符串的长度只需要读取 len 属性,时间复杂度为 O(1)。而对于 C 语言,获取字符串的长度通常是经过遍历计数来实现的,时间复杂度为 O(n)。通过 strlen key 命令可以获取 key 的字符串长度。

2、杜绝缓冲区溢出

我们知道在 C 语言中使用 strcat 函数来进行两个字符串的拼接,一旦没有分配足够长度的内存空间,就会造成缓冲区溢出。而对于 SDS 数据类型,在进行字符修改的时候,会首先根据记录的 len 属性检查内存空间是否满足需求,如果不满足,会进行相应的空间扩展,然后在进行修改操作,所以不会出现缓冲区溢出。

3、减少修改字符串的内存重新分配次数

C语言由于不记录字符串的长度,所以如果要修改字符串,必须要重新分配内存(先释放再申请),因为如果没有重新分配,字符串长度增大时会造成内存缓冲区溢出,字符串长度减小时会造成内存泄露。

而对于SDS,由于len属性和free属性的存在,对于修改字符串SDS实现了空间预分配和惰性空间释放两种策略:

1、空间预分配:对字符串进行空间扩展的时候,扩展的内存比实际需要的多,这样可以减少连续执行字符串增长操作所需的内存重分配次数。

2、惰性空间释放:对字符串进行缩短操作时,程序不立即使用内存重新分配来回收缩短后多余的字节,而是使用 free 属性将这些字节的数量记录下来,等待后续使用。(当然SDS也提供了相应的API,当我们有需要时,也可以手动释放这些未使用的空间。)

4、二进制安全

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

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

虽然 SDS 是二进制安全的,但是一样遵从每个字符串都是以空字符串结尾的惯例,这样可以重用 C 语言库 中的一部分函数。

6、总结

一般来说,SDS 除了保存数据库中的字符串值以外,SDS 还可以作为缓冲区(buffer):包括 AOF 模块中的AOF缓冲区以及客户端状态中的输入缓冲区。

2 链表

链表是一种常用的数据结构,C 语言内部是没有内置这种数据结构的实现,所以Redis自己构建了链表的实现。

链表定义:

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

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

typedef struct listNode {
  struct listNode *prev;//指向前一个节点的指针
  struct listNode *next;//指向后一个节点的指针
  void *value;//节点保存的值
}listNode

Redis链表特性:

1、双端:链表具有前置节点和后置节点的引用,获取这两个节点时间复杂度都为O(1)。

2、无环:表头节点的 prev 指针和表尾节点的 next 指针都指向 NULL,对链表的访问都是以 NULL 结束。

3、带链表长度计数器:通过 len 属性获取链表长度的时间复杂度为 O(1)。

4、多态:链表节点使用 void* 指针来保存节点值,可以保存各种不同类型的值。

3 字典

字典又称为符号表或者关联数组、或映射(map),是一种用于保存键值对的抽象数据结构。字典中的每一个键 key 都是唯一的,通过 key 可以对值来进行查找或修改。C 语言中没有内置这种数据结构的实现,所以字典依然是 Redis自己构建的。

哈希表结构定义:

typedef struct dictht {
  //哈希表数组,每个元素存储的是哈希表节点链表
  dictEntry **table;
  //哈希表大小,也就是table数组的大小
  unsigned long size;
  //哈希表大小掩码,总是等于size-1,用于计算哈希表节点在table中存储的位置
  usigned long sizemask;
  //当前存储的节点总数
  unsigned long used;
} dictht;

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

typedef struct dictEntry {
  //键  
  void *key;
  //值,可以是一个指针,也可以是一个uint64_t整数,也可以是int64_t的整数
  union {
    void *val;
    uint64_tu64;
    int64_ts64;
  } v;
  //指向下一个节点的指针
  struct dictEntry *next;
} dictEntry;

key 用来保存键,val 属性用来保存值,值可以是一个指针,也可以是uint64_t整数,也可以是int64_t整数。

3.1 哈希算法

当要将一个新的键值对添加到字典里面时,Redis先根据键值对的键计算出哈希值,

hash = dict->type->hashFunction(key);

然后根据哈希值计算得到索引值

index = hash & dict->ht[x].sizemask;

再根据索引值,将包含新键值对的哈希表节点放到哈希表数组的指定索引上面。

注意:

Redis使用MurmurHash2算法计算哈希值,MurmurHash2算法的优点在于,即使输入的键

是有规律的,算法仍能给出一个很好的随机分布性,并且算法的计算速度也非常快。

3.2 解决键冲突

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

3.3 rehash(扩容和收缩)

为了让哈希表的负载因子(load factor)维持在一个合理的范围之内,当哈希表保存的键值对数量太多或者太少时,程序需要对哈希表的大小进行相应的扩展或者收缩。
扩展和收缩哈希表的工作可以通过执行rehash(重新散列)操作来完成,Redis对字典的哈希表执行rehash的步骤如下:
1.为字典的ht[1]哈希表分配空间,这个哈希表的空间大小取决于要执行的操作,以及ht[0]当前包含的键值对数量(也即是ht[0].used属性的值):
(1) 如果执行的是扩展操作,那么ht[1]的大小为第一个大于等于ht[0].used*2的2的n次方幂;
(2) 如果执行的是收缩操作,那么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做准备。

3.4 哈希表扩展和收缩的触发条件

扩展操作:
1.服务器目前没有在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于1。
2.服务器目前正在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于5。
Redis需要创建当前服务器进程的子进程,而大多数操作系统都采用写时复制(copy-on-write)技术来优化子进程的使用效率,所以在子进程存在期间,服务器会提高执行扩展操作所需的负载因子,从而尽可能地避免在子进程存在期间进行哈希表扩展操作,这可以避免不必要的内存写入操作,最大限度地节约内存。
收缩操作:
当哈希表的负载因子小于0.1时,程序自动开始对哈希表执行收缩操作。

3.5 渐进式rehash

当键值对比较多时,如果要一次性完成rehash那么会对性能产生影响,所以可以分多次,渐进式地完成rehash操作。
哈希表渐进式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而带来的庞大计算量。

4 跳跃表

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

1、由很多层结构组成;

2、每一层都是一个有序的链表,排列顺序为由高层到底层,都至少包含两个链表节点,分别是前面的head节点和后面的nil节点;

3、最底层的链表包含了所有的元素;

4、如果一个元素出现在某一层的链表中,那么在该层之下的链表也全都会出现(上一层的元素是当前层的元素的子集);

5、链表中的每个节点都包含两个指针,一个指向同一层的下一个链表节点,另一个指向下一层的同一个链表节点;

Redis中跳跃表节点定义如下:

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;

1、搜索:从最高层的链表节点开始,如果比当前节点要大并且比当前层的下一个节点要小,那么则往下找,也就是和当前层的下一层的节点的下一个节点进行比较,以此类推,一直找到最底层的最后一个节点,如果找到则返回,反之则返回空。

2、插入:首先确定插入的层数,有一种方法是假设抛一枚硬币,如果是正面就累加,直到遇见反面为止,最后记录正面的次数作为插入的层数。当确定插入的层数k后,则需要将新元素插入到从底层到k层。

3、删除:在各个层中找到包含指定值的节点,然后将节点从链表中删除即可,如果删除以后只剩下头尾两个节点,则删除这一层。

5 整数集合

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

定义如下:

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

}intset;

整数集合的每个元素都是 contents 数组的一个数据项,它们按照从小到大的顺序排列,并且不包含任何重复项。

length 属性记录了 contents 数组的大小。

需要注意的是虽然 contents 数组声明为 int8_t 类型,但是实际上contents 数组并不保存任何 int8_t 类型的值,其真正类型有 encoding 来决定。

5.1 升级

当我们新增的元素类型比原集合元素类型的长度要大时,需要对整数集合进行升级,才能将新元素放入整数集合中。具体步骤:

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

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

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

5.2 升级的好处

1.提升灵活性。
因为C语言是静态类型语言,为了避免类型错误,我们通常不会将两种不同类型的值放在同一个数据结构里面。整数集合可以通过自动升级底层数组来适应新元素,所以我们可以随意地将int16_t、int32_t或者int64_t类型的整数添加到集合中,而不必担心出现类型错误,这种做法非常灵活。
2.节约内存
要让一个数组可以同时保存int16_t、int32_t、int64_t三种类型的值,最简单的做法就是直接使用int64_t类型的数组作为整数集合的底层实现。不过这样一来,即使添加到整数集合里面的都是int16_t类型或者int32_t类型的值,数组都需要使用int64_t类型的空间去保存它们,从而出现浪费内存的情况。而整数集合现在的做法既可以让集合能同时保存三种不同类型的值,又可以确保升级操作只会在有需要的时候进行,这可以尽量节省内存。

5.3 降级

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

6 压缩列表

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

压缩列表的原理:压缩列表并不是对数据利用某种算法进行压缩,而是将数据按照一定规则编码在一块连续的内存区域,目的是节省内存。

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

image

1、previous_entry_length:记录压缩列表前一个字节的长度。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_length属性都是用1字节来保存,那么节点的长度只要小于等于253字节previous_entry_length都可以记录,但是,如果添加一个长度大于253字节的节点,那么下一个节点的previous_entry_length就无法保存该长度的值,同样的,下下个节点也无法保存上个节点的长度,由此将导致连续多次空间扩展操作。

添加节点和删除节点都可能导致连锁更新,但是这种操作出现的几率很低。

Redis详解(四)------ redis的底层数据结构 - YSOcean - 博客园

客官,这是一份精心编写的《Redis设计与实现》读书心得(上篇)_大厂面试 - SegmentFault 思否

你可能感兴趣的:(Redis数据结构)