Redis的数据结构与对象

Redis数据结构与对象

一、简单动态字符串 SDS

Redis没有直接使用C语言传统的字符串,而是构建数据结构-----简单动态字符串(simple dynamic string,SDS)的抽象类型,作为Redis的默认字符串表示,当需要使用字符串的场景是可以修改时,采用SDS,否则也可使用C字符串

  • 在Redis里面,C字符串只会作为字符串字面量(string literal)用在一些无须对字符串值进行修改的地方,比如打印日志

  • 当Redis需要的不仅仅是一个字符串字面量,而是一个可以被修改的字符串值时,Redis就会使用SDS来表示字符串值,比如在Redis的数据库里面,包含字符串值的键值对在底层都是由SDS实现的

1、SDS定义

每个sds.h/sdshdr结构表示一个SDS值

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

SDS遵循C字符串以空字符结尾的惯例,保存空字符的1字节空间不计算在SDS的len属性里面,并且为空字符分配额外的1字节空间,以及添加空字符到字符串末尾等操作,都是由SDS函数自动完成的,所以这个空字符对于SDS的使用者来说是完全透明的。遵循空字符结尾这一惯例的好处是,SDS可以直接重用一部分C字符串函数库里面的函数

2、SDS与C字符串的区别

C字符串总是用长度为N+1的字符数组保存长度为N的字符串,并且字符数组中最后一个元素总是空字符“\0”

区别有:

2.1 常数时间复杂度获取字符长度

C字符串不保存字符数组长度,需要获取长度时,需要遍历数组,复杂度为O(N)

SDS字符串由于记录了已保存字符串的长度,在len属性中,获取长度为O(1)

好处是:

  1. 设置和更新SDS长度的工作是由SDS的API在执行时自动完成的,使用SDS无须进行任何手动修改长度的工作

  2. 通过使用SDS而不是C字符串,Redis将获取字符串长度所需的复杂度从O(N)降低到了O(1),这确保了获取字符串长度的工作不会成为Redis的性能瓶颈;因为字符串键在底层使用SDS来实现,所以即使我们对一个非常长的字符串键反复执行STRLEN命令,也不会对系统性能造成任何影响,因为STRLEN命令的复杂度仅为O(1)

2.2 杜绝缓冲区溢出

除了获取字符串长度的复杂度高之外,C字符串不记录自身长度带来的另一个问题是容易造成缓冲区溢出(buffer overflow);因为C字符串不记录自身的长度,所以strcat假定用户在执行这个函数时,已经为dest分配了足够多的内存,可以容纳src字符串中的所有内容,而一旦这个假定不成立时,就会产生缓冲区溢出

char *strcat(char *dest, const chat *src);

SDS的空间分配策略完全杜绝了发生缓冲区溢出的可能性:当SDS API需要对SDS进行修改时,API会先检查SDS的空间是否满足修改所需的要求,如果不满足的话,API会自动将SDS的空间扩展至执行修改所需的大小,然后才执行实际的修改操作,所以使用SDS既不需要手动修改SDS的空间大小,也不会出现前面所说的缓冲区溢出问题

SDS的API里面也有一个用于执行拼接操作的sdscat函数,它可以将一个C字符串拼接到给定SDS所保存的字符串的后面,但是在执行拼接操作之前,sdscat会先检查给定SDS的空间是否足够,如果不够的话,sdscat就会先扩展SDS的空间,然后才执行拼接操作

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

C字符串的底层实现总是一个N+1长度的字符数组,其字符串的长度与数组有着强关联性,因此每次增加或者删除一个字符,程序需要对这个数组进行一次内存重分配:

  1. 如果程序执行的是增长字符串的操作,比如拼接操作(append),那么在执行这个操作之前,程序需要先通过内存重分配来扩展底层数组的空间大小——如果忘了这一步就会产生缓冲区溢出

  2. 如果程序执行的是缩短字符串的操作,比如截断操作(trim),那么在执行这个操作之后,程序需要通过内存重分配来释放字符串不再使用的那部分空间——如果忘了这一步就会产生内存泄漏

内存重分配的影响:

涉及复杂的算法,可能需要进行系统调用,十分耗时,因此对于Redis数据库对于时间要求的严苛,数据被频繁修改的场合,会对性能造成影响

SDS字符串通过free属性以及未使用空间,解除字符串长度与底层字符数组之间的强关联性,数组中可以包含未使用的字符

通过未使用空间,采取了空间预分配和惰性空间释放的两种优化策略,来减少修改字符串带来的内存重分配次数

1、空间预分配

针对优化SDS的字符串增长操作:当SDS的API对一个SDS进行修改时,当需要对SDS进行空间扩展时,程序不仅会为SDS分配修改所必须需要的空间,还会为SDS分配额外的未使用空间,就是多分配一些空间

具体分配策略是:

  1. 如果对SDS进行修改之后,SDS的长度(也即是len属性的值)将小于1MB,那么程序分配和len属性同样大小的未使用空间,这时SDS len属性的值将和free属性的值相同。

  2. 如果对SDS进行修改之后,SDS的长度将大于等于1MB,那么程序会分配1MB的未使用空间

2、惰性空间释放

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

SDS也提供了相应的API,让我们可以在有需要时,真正地释放SDS的未使用空间,所以不用担心惰性空间释放策略会造成内存浪费

2.4 二进制安全

C字符串必须符合某种编码,比如ASC码并且除了字符串的末尾之外,字符串里面不能包含空字符,否则会被误认为是字符串结尾,使得C字符串只能保存文本数据,而不能保存像图片、音频、视频、压缩文件等这样的二进制数据

SDS的API都是二进制安全的,所有的SDS API都会以处理二进制的方式处理SDS存放在buf数组里的数据,程序不会对其中的数据做任何限制、过滤或者假设,数据在写入时是什么样,读取就是原样,因此buf数组是字节数组,用于保存任意格式的二进制数据

SDS使用len属性判断字符串是否结束

2.5 兼容部分C字符串函数

通过遵循C字符串以空字符结尾的管理,SDS可以再有需要的时候重用<string.h>的函数库,从而避免了不必要的代码重复

二、链表

链表有着高效的节点重排能力,和顺序性的节点遍历方式,通过增删节点来灵活地调整链表的长度

redis中链表的使用有:链表键,发布与订阅,慢查询,监视器,服务器用链表存放多个客户端的状态信息,以及使用链表构建客户端输出缓冲区

链表和链表节点的实现

每个链表节点使用一个adlist.h/listNode结构来表示,可以看出是一个双端链表

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

虽然仅仅使用多个listNode结构就可以组成链表,但使用adlist.h/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);
}list;

主要特性:

  1. 双端链表,获取某个节点的前置节点和后置节点的复杂度都是O(1)

  2. 无环:头节点的prev和尾节点的next都是指向null空指针

  3. 带表头指针和表尾指针,且获取的复杂度都是O(1)

  4. 带链表长度计数器,获取长度的复杂度都是O(1)

  5. 多态:链表节点使用void*指针,即无类型指针保存节点值,并且可以通过list结构的dup、free、match三个属性为节点值设置类型特定函数,所以链表能存放不同类型的值

三、字典

字典,又称为符号表,映射,关联数组,是一种保存键值对的抽象数据结构,每个键独一无二,通过对键的操作来更新值和删除键值对等

Redis中字典的应用:Redis的数据库就是基于字典作为底层实现的,所以Redis被称为基于键值对的非关系型数据库,另外字典也是hash键的底层实现,hash键是redis常用的5种数据结构之一

1、字典的实现

Redis的字典是使用哈希表作为底层实现,哈希表中含有多个节点,一个节点包含一个键值对

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

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

哈希表节点使用dictEntry结构表示,每个dictEntry都保存一个键值对

typedef struct dictEntry {
   //键
   void *key;
   //值
   union{
       void * val;
       uint64_tu64;
       int64_ts64
  }v;
   //指向下个哈希表节点,形成链表,将多个哈希值相同的键值对连接在一起,解决键冲突问题
   struct dictEntry *next;
}dictEntry;

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

typedef struct dict {
   //是一个指向dictType结构的指针,每个dictType结构保存了一簇用于操作特定类型键值对的函数,Redis会为用途不同的字典设置不同的类型特定函数
   dictType *type;
   //保存了需要传给那些类型特定函数的可选参数,私有数据
   void *privdata;
   //哈希表,ht属性是一个包含两个项的数组,数组中的每个项都是一个dictht哈希表,一般情况下,字典只使用ht[0]哈希表,ht[1]哈希表只会在对ht[0]哈希表进行rehash时使用
   dictht ht[2];
   //索引,当rehash不在进行时,值为-1
   int trehashidx;
}dict;
typedef struct dictType {
   //计算哈希值函数
   unsigned int (*hashFunction)(const void *key);
   //复制键函数
   void *(*keyDup) (void *privdata, const void * key);
   //复制值函数
   void *(*valDup) (void *privdata, const void * obj);
   //对比键函数
   int (*keyCompare)(void *privdata,const void *key1,const void *key2);
   //销毁键函数
   void (*keyDestructor)(void *privdata, void * key);
   //销毁值函数
   void(*valDestructor) (void *privdata, void *obj);
}

哈希算法:先计算哈希值,再同数组大小相与得到索引下标,当字典被用作数据库的底层实现,或者哈希键的底层实现时,Redis使用MurmurHash2算法来计算键的哈希值,这种算法的优点在于,即使输入的键是有规律的,算法仍能给出一个很好的随机分布性,并且算法的计算速度也非常快

2、解决键冲突和rehash

当两个键及以上键数量被分配到哈希表数组的同一个索引上时,即成为发生键冲突

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

为了使得哈希表的键值对维持在一定水平,即负载因子在合理的范围之内,对于哈希表的扩展和收缩操作需要进行先进行rehash操作,即重新计算哈希值和索引值,并转移至另一个数组ht[1]中,具体步骤如下:

  1. 为字典的ht[1]分配空间,空间大小取决于进行的操作和ht[0]中键值对的数量,也就是ht[0].used属性的值:

    • 扩展操作时,ht[1]的大小是大于等于ht[0].used*2的第一个2的n次幂的值,就是扩展两倍,并且是2的次幂

    • 收缩操作时,ht[1]的大小是大于等于ht[0].used的第一个2的n次幂的值,没有扩展两倍

  2. 进行rehash以及复制键值对到ht[1]中,rehash是重新计算键的哈希值和索引值

  3. 释放ht[0],将ht[1]设置为ht[0],并再重新创建一个空白哈希表,为下一次做准备

哈希表的扩展与收缩条件,即rehash条件:

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

扩展:

  1. 服务器没有进行BGSAVE或者BGREWRITEAOF命令,并且负载因子大于等于1

  2. 服务器正在进行BGSAVE或者BGREWRITEAOF命令,并且负载因子大于等于5

  3. 根据BGSAVE命令或BGREWRITEAOF命令是否正在执行,服务器执行扩展操作所需的负载因子并不相同,这是因为在执行BGSAVE命令或BGREWRITEAOF命令的过程中,Redis需要创建当前服务器进程的子进程,而大多数操作系统都采用写时复制(copy-on-write)技术来优化子进程的使用效率,所以在子进程存在期间,服务器会提高执行扩展操作所需的负载因子,从而尽可能地避免在子进程存在期间进行哈希表扩展操作,这可以避免不必要的内存写入操作,最大限度地节约内存

收缩:当哈希表的负载因子小于0.1时,自动进行收缩操作

3、渐进式rehash

rehash操作不是原子性的,而是分多次、渐进式进行,为了性能考虑:

如果ht[0]里只保存着四个键值对,那么服务器可以在瞬间就将这些键值对全部rehash到ht[1];但是,如果哈希表里保存的键值对数量不是四个,而是四百万、四千万甚至四亿个键值对,那么要一次性将这些键值对全部rehash到ht[1]的话,庞大的计算量可能会导致服务器在一段时间内停止服务

步骤:

  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而带来的庞大计算量

因为在进行渐进式rehash的过程中,字典会同时使用ht[0]和ht[1]两个哈希表,所以在渐进式rehash进行期间,字典的删除(delete)、查找(find)、更新(update)等操作会在两个哈希表上进行。例如,要在字典里面查找一个键的话,程序会先在ht[0]里面进行查找,如果没找到的话,就会继续到ht[1]里面进行查找,诸如此类

在渐进式rehash执行期间,新添加到字典的键值对一律会被保存到ht[1]里面,而ht[0]则不再进行任何添加操作,这一措施保证了ht[0]包含的键值对数量会只减不增,并随着rehash操作的执行而最终变成空表

四、跳跃表

1、跳跃表的介绍

跳跃表,skipList,是一种有序的数据结构,它通过在一个节点中维持指向多个节点的指针,达到快速访问的目的

查找节点的平均复杂度为O(logN) ,最坏复杂度是O(N),也可以通过顺序性操作批量处理节点

大部分情况,跳跃表的效率跟平衡树相差无几,且实现更为简单

在Redis中,跳跃表是作为有序集合zset的底层实现之一,当一个有序集合包含的元素数量比较多,或者有序集合中元素的成员是比较长的字符串时,Redis会使用跳跃表作为zset的底层实现

Redis内部,只有两个地方使用了跳跃表:

  1. 实现有序集合键;

  2. 在集群节点中用作内部数据结构

2、跳跃表的实现

Redis的跳跃表由redis.h/zskiplistNode和redis.h/zskiplist两个结构定义,其中zskiplistNode结构用于表示跳跃表节点,而zskiplist结构则用于保存跳跃表节点的相关信息,比如节点的数量,以及指向表头节点和表尾节点的指针等

zskiplist的结构属性:

typedef struct zskiplist{
   //1. header:指向跳跃表的表头节点
//2. tail:指向跳跃表的表尾节点
   struct skiplistNode *header, *tail;
   //记录跳跃表的长度,即跳跃表中节点数目总数,不包含头节点,O(1)时间返回
   unsigned long length;
   //记录目前跳跃表内,层数最大的那个节点的层数(表头节点的层数不计算在内)
   int level;
}zskiplist;

zskiplistNode的结构:

typedef struct zskiplistNode{
    //跳跃表节点的level数组可以包含多个元素,每个元素都包含一个指向其他节点的指针,程序可以通过这些层来加快访问其他节点的速度,一般来说,层的数量越多,访问其他节点的速度就越快。
    //每次创建一个新跳跃表节点的时候,程序都根据幂次定律(power law,越大的数出现的概率越小)随机生成一个介于1和32之间的值作为level数组的大小,这个大小就是层的“高度”
    //层相当于当前节点访问其他节点的地图
    struct zskiplistLevel{
        //每个层都有一个指向表尾方向的前进指针(level[i].forward属性),用于从表头向表尾方向访问节点
        //指示当前节点可以连接的节点
        struct zskiplistNode * forward;
        //层的跨度(level[i].span属性)用于记录两个节点之间的距离;两个节点之间的跨度越大,它们相距得就越远;指向NULL的所有前进指针的跨度都为0,因为它们没有连向任何节点
        //当前节点与目标节点的距离,可用于计算不同节点之间的距离,即使没有在同一层中可以查阅到
        unsigned int span;
    }level[];
    //节点的后退指针(backward属性)用于从表尾向表头方向访问节点:跟可以一次跳过多个节点的前进指针不同,因为每个节点只有一个后退指针,所以每次只能后退至前一个节点,一次只能退一步
    struct zskiplistNode * backward;
    //节点的分值(score属性)是一个double类型的浮点数,跳跃表中的所有节点都按分值从小到大来排序。
    double score;
    //节点的成员对象(obj属性)是一个指针,它指向一个字符串对象,而字符串对象则保存着一个SDS值
    //在同一个跳跃表中,各个节点保存的成员对象必须是唯一的,但是多个节点保存的分值却可以是相同的:分值相同的节点将按照成员对象在字典序中的大小来进行排序,成员对象较小的节点会排在前面(靠近表头的方向),而成员对象较大的节点则会排在后面(靠近表尾的方向)
    robj *obj;
}zskiplistNode;

五、整数集合

intset是集合键的底层实现之一,当集合中只包含整数元素,且集合元素不多时,Redis采用整数集合作为集合的底层实现

1、整数集合的实现

整数集合intset是Redis用于保存整数值的集合抽象数据结构,可以保存int_16t、int_32t、int_64t的整数值,另外跟集合一样,不会有重复元素

每个intset.h/intset结构表示一个整数集合:

typedef struct intset{
    //编码方式,就是整数具体类型
    //如果encoding属性的值为INTSET_ENC_INT16,那么contents就是一个int16_t类型的数组,数组里的每个项都是一个int16_t类型的整数值(最小值为-32768,最大值为32767)
    //如果encoding属性的值为INTSET_ENC_INT32,那么contents就是一个int32_t类型的数组,数组里的每个项都是一个int32_t类型的整数值(最小值为-2147483648,最大值为2147483647)
    //如果encoding属性的值为INTSET_ENC_INT64,那么contents就是一个int64_t类型的数组,数组里的每个项都是一个int64_t类型的整数值(最小值为-9223372036854775808,最大值为9223372036854775807)
    uint32_t encoding;
    //集合中元素数量
    uint32_t length;
    //底层实现数组,初始类型为int8_t,但实际类型由编码类型决定
    //各个项在数组中按值的大小从小到大进行排列,且没有重复元素
    int8_t contents[];
}intset;

2、升级与降级

升级:当新添加的元素到整数集合中时,如果其大小超过整数集合的范围,即比原集合中所有元素都要长时,整数集合需要进行升级,然后再将新元素添加至集合中

每次向整数集合添加新元素都可能会引起升级,而每次升级都需要对底层数组中已有的所有元素进行类型转换,所以向整数集合添加新元素的时间复杂度为O(N)

具体步骤有:

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

  2. 复制原数组元素。将底层数组现有的所有元素都转换成与新元素相同的类型,并将类型转换后的元素放置到正确的位上,而且在放置元素的过程中,需要继续维持底层数组的有序性质不变

  3. 新元素添加。将新元素添加到底层数组里面;因为引发升级的新元素的长度总是比整数集合现有所有元素的长度都大,所以这个新元素的值要么就大于所有现有元素,放在新数组最后,要么就小于所有现有元素,放在新数组最前面

升级的好处:保存数据的灵活性和尽可能节约内存

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

六、压缩列表

压缩列表,ziplist,列表键和哈希键的底层实现之一,即针对项是小整数值,或者长度较短的字符串时

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

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

1、压缩列表的构成

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

  • zlbytes,4字节,整个压缩列表所占内存字节数

  • zltail,4字节,记录压缩列表尾节点距离压缩列表的起始地址有多少个字节,无需遍历即可知道尾节点

  • zllen,2字节,记录节点数量,当值小于uint16_max即65535时,属性值就是节点数量,大于时,真实数量余姚重新遍历整个列表得出

  • entryx,列表节点,大小不定,由保存内容决定

  • zlend,1字节,特殊值0xFF十进制255,用于标记压缩列表的末端

2、压缩列表节点的构成

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

  1. 字节数组包括:

    • 长度小于等于63(2 的6次幂–1)字节的字节数组

    • 长度小于等于16383(2 的14次幂–1)字节的字节数组

    • 长度小于等于4294967295(2 的32次幂–1)字节的字节数组;

  2. 整数值包括:

    • 4位长,介于0至12之间的无符号整数

    • 1字节长的有符号整数

    • 3字节长的有符号整数

    • int16_t类型整数

    • int32_t类型整数

    • int64_t类型整数

每个压缩列表节点都由previous_entry_length、encoding、content三个部分组成:

  1. previous_entry_length:节点的previous_entry_length属性以字节为单位,记录了压缩列表中前一个节点的长度。previous_entry_length属性的长度可以是1字节或者5字节

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

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

    • 压缩列表的从表尾向表头遍历操作就是使用这一原理实现的,只要我们拥有了一个指向某个节点起始地址的指针,那么通过这个指针以及这个节点的previous_entry_length属性,程序就可以一直向前一个节点回溯,最终到达压缩列表的表头节点

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

    • 一字节、两字节或者五字节长,值的最高位为00、01或者10的是字节数组编码:这种编码表示节点的content属性保存着字节数组,数组的长度由编码除去最高两位之后的其他位记录

    • 一字节长,值的最高位以11开头的是整数编码:这种编码表示节点的content属性保存着整数值,整数值的类型和长度由编码除去最高两位之后的其他位记录

  3. content:节点的content属性负责保存节点的值,节点值可以是一个字节数组或者整数,值的类型和长度由节点的encoding属性决定

3、连锁更新

描述:扩展e1引发了对e2的扩展一样,扩展e2也会引发对e3的扩展,而扩展e3又会引发对e4的扩展......为了让每个节点的previous_entry_length属性都符合压缩列表对节点的要求,程序需要不断地对压缩列表执行空间重分配操作,直到eN为止,Redis将这种在特殊情况下产生的连续多次空间扩展操作称之为“连锁更新。

连锁更新在最坏情况下需要对压缩列表执行N次空间重分配操作,而每次空间重分配的最坏复杂度为O(N),所以连锁更新的最坏复杂度为O(N^2)

尽管连锁更新的复杂度较高,但它真正造成性能问题的几率是很低的:

  • 首先,压缩列表里要恰好有多个连续的、长度介于250字节至253字节之间的节点,连锁更新才有可能被引发,在实际中,这种情况并不多见

  • 其次,即使出现连锁更新,但只要被更新的节点数量不多,就不会对性能造成任何影响:比如说,对三五个节点进行连锁更新是绝对不会影响性能的

七、对象系统,五种常用的对象

1、对象类型

对象特性:

  1. 基于主要数据结构实现,有5种常用的类型对象:字符串对象、列表对象、哈希对象、集合对象、有序集合对象

  2. 每种对象至少用到了一种主要数据结构:‘简单动态字符串、双端链表、字典、压缩列表、整数集合

  3. Redis可以针对不同应用场景,为每种对象选择具体的数据结构实现,优化效率

  4. 对象系统的基于引用计数的内存回收机制,当程序不再使用某个对象的时候,这个对象所占用的内存就会被自动释放

  5. 对象共享机制,可以在适当的条件下,通过让多个数据库键共享同一个对象来节约内存

  6. Redis的对象带有访问时间记录信息,该信息可以用于计算数据库键的空转时长,在服务器启用了maxmemory功能的情况下,空转时长较大的那些键可能会优先被服务器删除

Redis使用对象表示数据库的键和值,每次当我们在Redis的数据库中新创建一个键值对时,我们至少会创建两个对象,一个对象用作键值对的键(键对象),另一个对象用作键值对的值(值对象)。

Redis中的每个对象都由一个redisObject结构表示,对象还有其他属性,不只下面三个,该结构中和保存数据有关的三个属性分别是type属性、encoding属性和ptr属性:

typedef struct redisObject{
    //类型,对于Redis数据库保存的键值对来说,键总是一个字符串对象,而值则可以是字符串对象、列表对象、哈希对象、集合对象或者有序集合对象的其中一种
    //当我们称呼一个数据库键为“字符串键”时,我们指的是“这个数据库键所对应的值为字符串对象
    //TYPE命令的实现方式也与此类似,当我们对一个数据库键执行TYPE命令时,命令返回的结果为数据库键对应的值对象的类型,而不是键对象的类型
    unsigned type:4;
    //编码,每种类型的对象都至少使用了两种不同的编码
    //使用OBJECT ENCODING命令可以查看一个数据库键的值对象的编码
    unsigned encoding:4;
    //指向底层实现数据结构的指针
    void *ptr;
}robj;

2、字符串对象

字符串对象的编码可以是int、raw(简单动态字符串)、embstr(embstr编码的简单动态字符串)。

字符串对象是Redis五种类型的对象中唯一一种会被其他四种类型对象嵌套的对象

  1. 如果一个字符串对象保存的是整数值,并且这个整数值可以用long类型来表示,那么字符串对象会将整数值保存在字符串对象结构的ptr属性里面(将void*转换成long),并将字符串对象的编码设置为int。

  2. 如果字符串对象保存的是一个字符串值,并且这个字符串值的长度大于32字节,那么字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串值,并将对象的编码设置为raw。raw编码会调用两次内存分配函数来分别创建redisObject结构和sdshdr结构。

  3. 如果字符串对象保存的是一个字符串值,并且这个字符串值的长度小于等于32字节,那么字符串对象将使用embstr编码的方式来保存这个字符串值。embstr编码则通过调用一次内存分配函数来分配一块连续的空间,空间中依次包含redisObject和sdshdr两个结构

使用embstr编码的字符串对象来保存短字符串值有以下好处:

  • embstr编码将创建字符串对象所需的内存分配次数从raw编码的两次降低为一次。

  • 释放embstr编码的字符串对象只需要调用一次内存释放函数,而释放raw编码的字符串对象需要调用两次内存释放函数

  • 因为embstr编码的字符串对象的所有数据都保存在一块连续的内存里面,所以这种编码的字符串对象比起raw编码的字符串对象能够更好地利用缓存带来的优势,意思是raw对象指向的底层结构并不是和raw对象本身同处同一块连续空间,而embstr是对象与所使用的底层结构是连续在一起的

可以用long double类型表示的浮点数在Redis中也是作为字符串值来保存的。如果我们要保存一个浮点数到字符串对象里面,那么程序会先将这个浮点数转换成字符串值,然后再保存转换所得的字符串值;在有需要的时候,程序会将保存在字符串对象里面的字符串值转换回浮点数值,执行某些操作,然后再将执行操作所得的浮点数值转换回字符串值,并继续保存在字符串对象里面。

int编码的字符串对象和embstr编码的字符串对象在条件满足的情况下,会被转换为raw编码的字符串对象

  • 对于int编码的字符串对象来说,如果我们向对象执行了一些命令,使得这个对象保存的不再是整数值,而是一个字符串值,那么字符串对象的编码将从int变为raw

  • embstr编码的字符串对象在执行修改命令之后,总会变成一个raw编码的字符串对象

3、列表对象

列表对象的编码可以是ziplist或者linkedlist

当列表对象可以同时满足以下两个条件时,列表对象使用ziplist编码:

  • 列表对象保存的所有字符串元素的长度都小于64字节

  • 列表对象保存的元素数量小于512个

不能满足这两个条件的列表对象需要使用linkedlist编码;以上两个条件的上限值是可以修改的,具体请看配置文件中关于list-max-ziplist-value选项和list-max-ziplist-entries选项的说明

对于使用ziplist编码的列表对象来说,当使用ziplist编码所需的两个条件的任意一个不能被满足时,对象的编码转换操作就会被执行,原本保存在压缩列表里的所有列表元素都会被转移并保存到双端链表里面,对象的编码也会从ziplist变为linkedlist

4、哈希对象

哈希对象的编码可以是ziplist或者hashtable

ziplist编码的哈希对象使用压缩列表作为底层实现,每当有新的键值对要加入到哈希对象时,程序会先将保存了键的压缩列表节点推入到压缩列表表尾,然后再将保存了值的压缩列表节点推入到压缩列表表尾;保存了同一键值对的两个节点总是紧挨在一起,保存键的节点在前,保存值的节点在后

hashtable编码的哈希对象使用字典作为底层实现,哈希对象中的每个键值对都使用一个字典键值对来保存:

  • 字典的每个键都是一个字符串对象,对象中保存了键值对的键

  • 字典的每个值都是一个字符串对象,对象中保存了键值对的值

当哈希对象可以同时满足以下两个条件时,哈希对象使用ziplist编码:

  • 哈希对象保存的所有键值对的键和值的字符串长度都小于64字节

  • 哈希对象保存的键值对数量小于512个

不能满足这两个条件的哈希对象需要使用hashtable编码;具体请看配置文件中关于hash-max-ziplist-value选项和hash-max-ziplist-entries选项的说明

对于使用ziplist编码的列表对象来说,当使用ziplist编码所需的两个条件的任意一个不能被满足时,对象的编码转换操作就会被执行,原本保存在压缩列表里的所有键值对都会被转移并保存到字典里面,对象的编码也会从ziplist变为hashtable

5、集合对象

集合对象的编码可以是intset或者hashtable

ntset编码的集合对象使用整数集合作为底层实现,集合对象包含的所有元素都被保存在整数集合里面

hashtable编码的集合对象使用字典作为底层实现,字典的每个键都是一个字符串对象,每个字符串对象包含了一个集合元素,而字典的值则全部被设置为NULL,跟java中的hashset一致

当集合对象可以同时满足以下两个条件时,对象使用intset编码:

  • 集合对象保存的所有元素都是整数值

  • 集合对象保存的元素数量不超过512个

不能满足这两个条件的集合对象需要使用hashtable编码。第二个条件的上限值是可以修改的,具体请看配置文件中关于set-max-intset-entries选项的说明

对于使用intset编码的集合对象来说,当使用intset编码所需的两个条件的任意一个不能被满足时,就会执行对象的编码转换操作,原本保存在整数集合中的所有元素都会被转移并保存到字典里面,并且对象的编码也会从intset变为hashtable

6、有序集合对象

有序集合对象的编码可以是ziplist或者skiplist

有序集合每个元素的成员都是一个字符串对象,而每个元素的分值都是一个double类型的浮点数

ziplist编码的压缩列表对象使用压缩列表作为底层实现,每个集合元素使用两个紧挨在一起的压缩列表节点来保存,第一个节点保存元素的成员(member),而第二个元素则保存元素的分值(score);压缩列表内的集合元素按分值从小到大进行排序,分值较小的元素被放置在靠近表头的方向,而分值较大的元素则被放置在靠近表尾的方向。

skiplist编码,说是跳跃表编码,其实结构里面还多了一个字典,的有序集合对象使用zset结构作为底层实现,一个zset结构同时包含一个字典和一个跳跃表:

typedef struct zset{
  //zset结构中的zsl跳跃表按分值从小到大保存了所有集合元素,每个跳跃表节点都保存了一个集合元素:跳跃表节点的object属性保存了元素的成员,而跳跃表节点的score属性则保存了元素的分值。通过这个跳跃表,程序可以对有序集合进行范围型操作,比如ZRANK、ZRANGE等命令就是基于跳跃表API来实现的
   zskiplist *zsl;
   //zset结构中的dict字典为有序集合创建了一个从成员到分值的映射,字典中的每个键值对都保存了一个集合元素:字典的键保存了元素的成员,而字典的值则保存了元素的分值。通过这个字典,程序可以用O(1)复杂度查找给定成员的分值,ZSCORE命令就是根据这一特性实现的,而很多其他有序集合命令都在实现的内部用到了这一特性
   dict *dict;
}zset;

zset结构同时使用跳跃表和字典来保存有序集合元素,但这两种数据结构都会通过指针来共享相同元素的成员和分值,所以同时使用跳跃表和字典来保存集合元素不会产生任何重复成员或者分值,也不会因此而浪费额外的内存

为什么zset需要同时使用跳跃表和字典实现?
  • 在理论上,有序集合可以单独使用字典或者跳跃表的其中一种数据结构来实现,但无论单独使用字典还是跳跃表,在性能上对比起同时使用字典和跳跃表都会有所降低。举个例子,如果我们只使用字典来实现有序集合,那么虽然以O(1)复杂度查找成员的分值这一特性会被保留,但是,因为字典以无序的方式来保存集合元素,所以每次在执行范围型操作——比如ZRANK、ZRANGE等命令时,程序都需要对字典保存的所有元素进行排序,完成这种排序需要至少O(NlogN)时间复杂度(快排),以及额外的O(N)内存空间(因为要创建一个数组来保存排序后的元素)

  • 另一方面,如果我们只使用跳跃表来实现有序集合,那么跳跃表执行范围型操作的所有优点都会被保留,但因为没有了字典,所以根据成员查找分值这一操作的复杂度将从O(1)上升为O(logN)

因此,为了让有序集合的查找和范围型操作都尽可能快地执行,Redis选择了同时使用字典和跳跃表两种数据结构来实现有序集合

当有序集合对象可以同时满足以下两个条件时,对象使用ziplist编码:

  • 有序集合保存的元素数量小于128个

  • 有序集合保存的所有元素成员的长度都小于64字节

不能满足以上两个条件的有序集合对象将使用skiplist编码。以上两个条件的上限值是可以修改的,具体请看配置文件中关于zset-max-ziplist-entries选项和zset-max-ziplist-value选项的说明

对于使用ziplist编码的有序集合对象来说,当使用ziplist编码所需的两个条件中的任意一个不能被满足时,就会执行对象的编码转换操作,原本保存在压缩列表里的所有集合元素都会被转移并保存到zset结构里面,对象的编码也会从ziplist变为skiplist

7、类型检查与命令多态

Redis用于操作键的命令分为:

  • 可以对任何类型的键执行,比如说DEL命令、EXPIRE命令、RENAME命令、TYPE命令、OBJECT命令等

  • 另一种命令只能对特定类型的键执行

为了确保只有指定类型的键可以执行某些特定的命令,在执行一个类型特定的命令之前,Redis会先检查输入键的类型是否正确,然后再决定是否执行给定的命令。类型特定命令所进行的类型检查是通过redisObject结构的type属性来实现的

多态(类似于java中的编译型多态和运行时多态):

Redis除了会根据值对象的类型来判断键是否能够执行指定命令之外,还会根据值对象的编码方式,选择正确的命令实现代码来执行命令

DEL、EXPIRE等命令和LLEN等命令的区别在于,前者是基于类型的多态——一个命令可以同时用于处理多种不同类型的键,而后者是基于编码的多态——一个命令可以同时用于处理多种不同编码

8、内存回收

每个对象的引用计数信息由redisObject结构的refcount属性记录,对象的引用计数信息的使用状态变化规则:

  1. 在创建一个新对象时,引用计数的值会被初始化为1

  2. 当对象被一个新程序使用时,它的引用计数值会被增一

  3. 当对象不再被一个程序使用时,它的引用计数值会被减一

  4. 当对象的引用计数值变为0时,对象所占用的内存会被释放

对象的整个生命周期可以划分为创建对象、操作对象、释放对象三个阶段,Redis中有修改对象引用计数的API,这些API分别用于增加incrRefCount、减少decrRefCount、重置对象resetRefCount的引用计数

9、对象共享

在Redis中,让多个键共享同一个值对象需要执行以下两个步骤:

  1. 将数据库键的值指针指向一个现有的值对象

  2. 将被共享的值对象的引用计数增一

目前来说,Redis会在初始化服务器时,创建一万个字符串对象,这些对象包含了从0到9999的所有整数值,当服务器需要用到值为0到9999的字符串对象时,服务器就会使用这些共享对象,而不是新创建对象。创建共享字符串对象的数量可以通过修改redis.h/REDIS_SHARED_INTEGERS常量来修改

这些共享对象不单单只有字符串键可以使用,那些在数据结构中嵌套了字符串对象的对象(linkedlist编码的列表对象、hashtable编码的哈希对象、hashtable编码的集合对象,以及zset编码的有序集合对象)都可以使用这些共享对象

为什么Redis不共享包含字符串的对象?(注意:不是指字符串对象而是指底层结构是字符串的对象)

当服务器考虑将一个共享对象设置为键的值对象时,程序需要先检查给定的共享对象和键想创建的目标对象是否完全相同,只有在共享对象和目标对象完全相同的情况下,程序才会将共享对象用作键的值对象,而一个共享对象保存的值越复杂,验证共享对象和目标对象是否相同所需的复杂度就会越高,消耗的CPU时间也会越多:

  • 如果共享对象是保存整数值的字符串对象,那么验证操作的复杂度为O(1)

  • 如果共享对象是保存字符串值的字符串对象,那么验证操作的复杂度为O(N)

  • 如果共享对象是包含了多个值(或者对象的)对象,比如列表对象或者哈希对象,那么验证操作的复杂度将会是O(N 2)

尽管共享更复杂的对象可以节约更多的内存,但受到CPU时间的限制,Redis只对包含整数值的字符串对象进行共享

10、对象的空转时间

除了前面介绍过的type、encoding、ptr和refcount四个属性之外,redisObject结构包含的最后一个属性为lru属性,该属性记录了对象最后一次被命令程序访问的时间

OBJECT IDLETIME命令可以打印出给定键的空转时长,这一空转时长就是通过将当前时间减去键的值对象的lru时间计算得出的

键的空转时长还有另外一项作用:如果服务器打开了maxmemory选项,并且服务器用于回收内存的算法为volatile-lru或者allkeys-lru,那么当服务器占用的内存数超过了maxmemory选项所设置的上限值时,空转时长较高的那部分键会优先被服务器释放,从而回收内存。配置文件的maxmemory选项和maxmemory-policy选项的说明介绍了关于这方面的更多信息。

posted @ 2020-08-01 21:53  小玉揍扁了阿福  阅读(163)  评论(0)    收藏  举报