第一章 数据结构

  1. 简单动态字符串

  下图是简单动态字符串(simple dynamic string, SDS)的结构表示redis

   

  • free属性的值为0,表示这个SDS没有分配任何未使用空间
  • len属性的值为5,表示这个SDS保存了一个5字节长的字符串
  • buf属性是一个char类型的数组,数组的前五个字节分别保存了R, e, d, i, s五个字符,最后一个字节则保存了空字符'\o'

  SDS遵循C语言字符串以空字符结尾的惯例,保存空字符的1字节空间不计算在SDS的len属性里面,这个空字符对SDS的使用者来讲是彻底透明的。遵循空字符结尾的好处是,能够重用一些C字符串函数库中的函数。例如,有一个指向图2-1所示SDS的指针s,那么能够直接使用<stdio.h>/printf函数,经过执行如下语句:printf("%s", s->buf);算法

  1.1 SDS与C字符串的区别数组

  1.1.1 常数复杂度得到字符串长度安全

  由于C字符串并不记录自身长度信息,因此为了获取一个C字符串长度,程序必须遍历整个字符串,对遇到的每一个字符进行计数,直到遇到表明字符串结尾的空字符为止,这个操做的复杂度为O(N),以下图所示。服务器

  

  经过访问SDS的len属性,Redis将获取字符串长度所需的复杂度从O(N)下降到了O(1)。 数据结构

  1.1.2 杜绝缓冲区溢出app

  <string.h>/strcat函数能够将src字符串中的内容拼接到dest字符串的末尾: char *strcat (char *dest, const char *src)函数

  strcat函数假定用户在执行这个函数时,已经为dest分配了足够多的内存,若是假设不成立,则会形成缓冲区溢出,致使s2保存的内容被意外修改了。性能

  

        

  当SDS API须要对SDS进行修改时,API先检查SDS的空间是否知足修改所需的要求,若是不知足,API会自动将SDS的空间扩展至执行修改所需的大小,而后再进行修改。用户不须要手动修改SDS空间的大小。 优化

           

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

  对于一个包含N个字符的C字符串,底层实现是一个N+1个字符长的数组。每次缩短或者增长C字符串,都须要对数组作一次内存重分配:

  • 若是程序执行的是增加字符串的操做,好比拼接(append),那么执行这个操做以前,程序须要先经过内存重分配来扩展底层数组的空间大小——否则会形成缓冲区溢出
  • 对于缩短字符串操做,好比截断(trim)操做,须要释放再也不须要的内存空间,否则会形成内存泄漏

  因为内存重分配涉及复杂的算法,比较耗时。SDS经过空间预分配和惰性空间释放两种优化策略减小内存重分配次数

  1. 空间预分配

  空间预分配用于优化SDS的字符串增加操做:当SDS的API对一个SDS进行修改,并须要对SDS进行空间扩展,程序不只会分配必须的空间,还会为SDS分配额外的未使用空间。若是SDS长度小于1MB,程序分配和len属性一样大小的未使用空间;若是长度大于1MB,程序分配1MB的未使用空间。

  

   

  2. 惰性空间释放

  当SDS的API须要缩短SDS保存的字符串时,程序并不当即使用内存重分配来回收缩短后多出来的字节,而是使用free属性将这些字节的数量记录起来,以备未来使用。

  1.1.4 二进制安全

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

  SDS API都会以处理二进制的方式来处理SDS存放在buf数组里的数据,程序不会对其中的数据作任何限制、过滤或者假设,数据写入是什么样子,读取时就是什么样子。

  1.1.5 兼容部分C字符串函数

  经过在buf数组分配空间时多分配一个字节来容纳空字符,使得SDS能够重用一部分<string.h>库定义的函数。表2-2列出了SDS主要操做API。

        

 

           

          

  2. 链表

  每一个链表节点使用一个adlist.h/listNode结构来表示:

   

   多个listNode能够经过prev和next指针组成双端链表:

  

  list结构表示:

   

  Redis链表特性:

  • 双端:链表节点带有prev和next指针,获取某个节点的前置节点和后置节点的复杂度都是O(1)
  • 无环:表头节点的prev指针和表尾节点的next指针都指向NULL,对链表的访问以NULL为终点
  • 带表头指针和表尾指针:经过list结构的head指针和tail指针,程序获取链表的表头节点和表尾节点的复杂度为O(1)
  • 带链表长度计数器:程序获取链表中节点数量的复杂度为O(1)
  • 多态:节点使用void*指针保存节点值,因此链表能够保存各类不一样类型的值

   

     

  3. 字典

  3.1 字典结构

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

  哈希表结构及哈希表节点定义: 

                                   

  数据存放以下所示:

   

  Redis中字典由dict.h/dict结构表示:

  

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

  • type属性是一个指向dictType结构的指针,每一个dictType结构保存了一簇用于操做特定类型键值对的函数,Redis会为用途不一样的字典设置不一样的类型特定函数
  • privdata属性则保存了须要传给那些类型特定函数的可选参数

  

  ht[1]哈希表会在对ht[0]哈希表进行rehash时使用。

   

  3.2 哈希算法

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

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

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

   index表示放在dictEntry中的哪一个槽内

  3.3 解决键冲突

  当两个或以上的键被分配到了哈希表数组的同一个索引上面时,称为发生了哈希碰撞。Redis的哈希表经过使用链地址法来解决键冲突,被分配到同一个索引上的多个节点能够用这个单向链表链接起来。

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

   

  3.4 rehash

  哈希表的扩展和收缩

  知足下面任一条件,哈希表会进行扩展操做:

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

  负载因子的计算:哈希表已保存节点数量/哈希表大小

  当负载因子小于0.1时,程序自动对哈希表进行收缩操做。

  Redis对字典的哈希表执行rehash的步骤以下:

  1) 为字典ht[1]哈希表分配空间:

  • 若是执行的是扩展操做,那么ht[1]的大小为第一个大于等于ht[0].used*2n
  • 若是执行的是收缩操做,那么ht[1]的大小也为第一个大于等于ht[0].used*2n

  2) 将保存在ht[0]中的全部键值对rehash到ht[1]上面

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

  示例步骤以下所示:

      

      

  3.5 渐进式rehash

  扩展或收缩哈希表须要将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操做结束。

               

                          

  字典主要API操做

       

  4. 跳跃表

   若是一个有序集合包含的元素数量比较多,或者有序集合中元素的成员是比较长的字符串时,Redis就会使用跳跃表来做为有序集合键的底层实现。例如,fruit-price是一个有序集合键,以水果名为成员,水果价钱为分值。fruit-price有序集合的全部数据都保存在一个跳跃表里面,其中每一个跳跃表节点会保存一款水果的信息,全部水果按价钱由低到高排序。

  4.1 跳跃表的实现

  Redis跳跃表由redis.h/zskiplistNode(用于表示跳跃表节点)和redis.h/zskiplist(保存跳跃表节点相关信息)两个结构定义,示例以下:

           

  4.2 跳跃表节点

  跳跃表节点的实现由redis.h/zskiplistNode结构定义:

           

 

 

   1. 层

  每次建立一个新的跳跃表节点时,程序都根据幂次定律(power low,越大的数出现的几率越小)随机生成一个介于1和32之间的值做为level数组的大小,即层的高度,如图5-2所示。

  跳跃表节点中每一个元素包含一个指向其余节点的指针,程序能够经过这些层加快访问其余节点的速度,通常来讲,层的数量越多,访问其余节点的速度就越快。

  2. 前进指针

  每一个层都有一个指向表尾方向的前进指针(level[i].forward属性),用于从表头向表尾方向访问节点。图5-3用虚线表示了程序从表头向表尾方向,遍历跳跃表中全部节点的路径。

  3. 跨度

  层的跨度(level[i].span)用于记录两个节点之间的距离:

  • 两个节点之间的跨度越大,相距的越远
  • 指向NULL的全部前进指针的跨度都为0

  遍历操做使用前进指针便可完成,跨度是用来计算排位的(节点在跳跃表中的位置)。

  4. 后退指针

  节点的后退指针(backward属性)用于从表尾向表头访问节点。

  5. 分值和成员

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

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

   4.3 跳跃表

  zskiplist结构的定义以下:

  

     

 

  header和tail指针分别指向跳跃表的表头和表尾节点,经过这两个指针,程序定位表头节点和表尾节点的复杂度为O(1).

  length属性记录节点的数量,程序能够在O(1)复杂度内返回跳跃表的长度。

  level属性用于得到跳跃表中层高最大的节点的层数量,表头节点的层高不计算在内。   

  4.4 跳跃表经常使用API

   

  5. 整数集合

  5.1 整数集合的实现

  其中encoding有三种取值:

  • INTSET_ENC_INT16   每一个项是int16_t类型的整数值(取值范围:-215~215-1)
  • INTSET_ENC_INT32   每一个项是int32_t类型的整数值(取值范围:-231~231-1) 
  • INTSET_ENC_INT64   每一个项是int64_t类型的整数值(取值范围:-263~263-1)

  

  5.2 升级

  若是新添加的元素类型比整数集合现有全部元素的类型都要长时,整数集合须要先进行升级(upgrade),而后再将新元素添加到整数集合里面。升级分三步进行:

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

  2) 将底层数组现有的全部元素都转换成与新元素相同的类型,并将类型转换后的元素放置到正确的位置上,并且在放置元素过程当中,须要维持底层数组的有序性质不变

  3) 将元素添加到底层数组里面

  contents数组中原先包含3个,每一个占用16位空间的元素。以后须要插入一个占用32位空间的元素65535,其过程以下所示:

             

     

   5.3 升级的好处

  5.3.1 提高灵活性

  整数集合能够经过自动升级底层数组来适应新元素,能够随意地将int16_t,int32_t或int64_t类型的整数添加到集合中,没必要担忧出现类型错误。

  5.3.2 节约内存

  尽可能先用int16_t类型来保存元素,在必要时进行升级,以节约内存。

  5.4 降级

  整数集合不支持降级。

  5.5 整数集合API

   

  6. 压缩列表

  6.1 压缩列表的构成

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

  下图为压缩列表各组成部分:

  

  

  图7-2展现了一个压缩列表实例:

  • zlbytes属性地值位0x50(十进制80),表示压缩列表总长80字节
  • zltail属性值为0x3c(十进制60),这表示若是咱们有一个指向压缩列表起始地址地指针p,那么p+60,就能够计算出表尾节点entry3的地址
  • zllen属性值为0x3,表示压缩列表包含三个节点

   

  6.2 压缩列表节点的构成

  下图为压缩列表各个组成部分:

  

  1. previous_entry_length

  节点的previous_entry_length属性以字节为单位,记录了压缩列表中前一个节点的长度:

  • 若是前一个节点长度小于254字节,previous_entry_length属性的长度为1字节
  • 若是前一个字节长度大于等于254字节,previous_entry_length的长度为5字节,第一个字节会被设置为0xFE(十进制254),以后的四个字节用于保存前一节点的长度 

   

  若是有一个指向当前节点起始地址的指针c,能够根据下图计算出前一个节点的指针p。压缩列表从表尾向表头遍历操做就是基于这一原理实现的。

   

  2. encoding

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

  值的最高位为00、0一、10是字节数组编码,数组的长度由编码除去最高两位以后的其余位记录

  

  值的最高位以11开头的是整数编码

   

  3. content  

    

  6.3 连锁更新

  下面讨论一种对列表插入或删除节点时,会发生的极端状况——连锁更新。

  前面小节提到过,若是前一个节点长度小于254字节,那么previous_entry_length属性须要用1字节来保存长度值;若是前一个节点长度大于254字节,那么previous_entry_length属性须要用5字节来保存长度值。

  如今,考虑这样一种状况,在一个压缩列表中,存在多个连续的、长度介于250字节到253字节之间的节点e1至eN,以下图所示:

  

  若是将一个长度大于等于254字节的新节点new设置为压缩列表的表头节点,以下图所示:

  

 

  此时e1节点的previous_entry_length属性从原来的1字节扩展为5字节,e1本来的长度介于250字节到253字节之间,因为previous_entry_length长度改变,e1的长度变为介于254字节到257字节,从而引起e2的更新,e2又会引起e3的扩展,直到eN为止,并将此过程称为连锁更新。下图是另外一种引发连锁更新的状况:

  

  由于连锁更新在最坏的状况下须要对压缩列表执行N次空间重分配操做,而每次空间重分配的最坏复杂度是O(N),因此连锁更新的最坏复杂度为O(N2) 。

  可是,尽管连锁更新的复杂度较高,可是真正形成性能问题的概率很低:

  • 压缩列表里面须要刚好有多个连续的、长度介于250字节到253字节之间的节点
  • 即便出现连锁更新,只要被更新的节点很少,就不会对性能形成太大影响

  压缩列表API  

  

相关文章
相关标签/搜索