redis-数据结构和对象

redis面试题:https://developer.aliyun.com/article/774125

一、数据结构和对象

2、简单动态字符串(SDS, Simple Dynamic String)

在Redis的数据库中,包含字符串的键值对在底层都是由SDS实现的。

  1. SDS的定义
    每个sds.h/sdshdr结构表示一个SDS值:
struct sdshdr {
    // 记录buf数组中已使用字节的数量,等于SDS所保存字符串的长度
    int len;
    // 记录buf数值中未使用的字节的数量
    int free;
    // 字节数组,用于保存字符串
    char buf[];
}

如下图所示:

  • 此时len的值为5,表示这个SDS保存了一个五字节长的字符串;
  • free的属性值为5,表示这个SDS没有分配、未使用的空间;
  • buf是一个char类型的数组,数组的前五个字节分别保存了'R'、'e'、'd'、'i'、's',紧接着的是一个空字符'\0',不计入len的长度,(与C)一致。
  1. SDS与C字符串的区别
    1. 常数复杂度获取字符串长度:SDS中len记录了SDS本身的长度,所以获取一个SDS长度的时间复杂度为O(1),以此确保获取字符串长度的工作不会变成redis的瓶颈;而C是使用长度为N+1的字符数组来表示长度为N的字符串的(字符数组的最后一个元素总是'\0'),获取长度时,需要进行遍历,所以时间复杂度为O(n)。

    2. 杜绝缓冲区溢出:当SDS API需要对SDS进行修改时,API会先检查SDS的空间是否满足修改所需的要求,如果不满足的话,API会自动将SDS的空间扩至修改所需的大小,然后再进行操作;而C需要手动对原来的字符数组进行操作,否则的话,就会出现溢出等操作。

    3. 减少修改字符串时带来的内存重分配次数:对于C来说,如果增长字符串,在执行之前程序需要通过内存重分配来扩展底层数组的空间大小,否则将产生缓冲溢出;如果缩短字符串,那么在执行过后,如果忘记通过内存重分配来释放不再使用的那部分空间,将 会产生内存泄露。这些短板对于需要频繁操作的redis数据库来说是非常致命的,所以SDS要避免产生这种情况。

      1. 空间预分配:当SDS的API对一个SDS进行修改、并且需要对SDS的空间进行扩展的时候(在扩展SDS的空间之前,SDS API会先检查未使用空间free是否足够),程序不仅会为SDS分配修改所必须的空间,还会为SDS分配额外的未使用的空间。公式:如果对SDS修改后,SDS的len小于1MB,那么程序分配和len属性同样大小的未使用空间,这时SDS的len属性将和free属性的值相同;如果SDS的len大于等于1MB,那么程序将会分配1MB的未使用空间(free)。
      2. 惰性空间释放:当SDS的API需要缩短SDS保存的字符串时,程序并不立即使用内存重新分配来回收缩短后多出来的字节,而是使用free属性将这些字节的数量记录起来,并等待使用。
    4. 二进制安全:C字符串中的字符必须符合某种编码(比如ASCII),并且除了字符串的末尾之外,字符串里面不能包含空字符,否则字符串中间的空字符会被误认为是字符串的结尾,这些限制使得C只能保存文本数据,而不能保存像图片、音频、视频、压缩文件等这样的二进制文件;而SDS没有这样的限制,所以称上述的buf为字节数组,可以存任意格式的二进制数据。

    5. SDS兼容部分C字符串的操作函数。

3、链表

  1. 链表被广泛用于实现Redis的各种功能,比如列表键、发布与订阅、慢查询、监视器等;除此,Redis中列表键的实现之一就是链表。
    主要数据结构:
     // 链表节点的结构,adlist.h/listNode 
    typedef struct listNode {
        // 前置节点
        struct listNode *prev;
        // 后置节点
        struct listNode *next;
        // 节点的值
        void *value
    }listNode;
     // 链表的实现,adlist.h/list
    typedef struct list {
     //    表头节点
        listNode *head;
     //    表尾节点
        listNode *tail;
     //    链表所含的节点数量
        unsigned long len;
     //    dup函数用于复制链表节点所保存的值
        void *(*dup)(void *ptr);
     //    free用于释放链表节点所保存的值
        void (*free)(void *ptr);
     //    match用于对比链表节点所保存的值和另一个输入值是否相等
        int (*match)(void *ptr, void *key);
    }list;
    
  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、字典

字典中的每个键都是独一无二的,可以通过键对值进行查找、更新、删除等操作;Redis的数据库就是使用字典来作为底层实现的,对数据库的增、删、查、改操作也是构建在对字典的操作之上。除此,字典还是哈希键的底层实现之一,当一个哈希键包含的键值对比较多,又或者键值对中的元素都是比较长的字符串时,Redis就会使用字典作为哈希键的底层实现。

  1. 字典的实现:Redis的字典使用哈希表作为底层实现,一个哈希表里面可以有多个哈希表节点,每个哈希表节点就保存了字典中的一个键值对。

    1. 哈希表dictht:
    typedef struct dictht {
        // table是一个数组,数组中的每个元素都是一个指向dict.h/dictEntry结构的指针,
        // 每个dictEntry结构保存着一个键值对
        dictEntry **table;
        // size记录了哈希表的大小,也即是table数组的大小
        unsigned long size;
        // sizemask属性的值总是等于size-1,这个属性和哈希值一起决定一个键应该被放到table数组的哪个索引上面
        unsigned long sizemask;
        // used记录了哈希表目前已有节点(键值对)的数量
        unsigned long used;
    } dictht;
    

    1. 哈希表节点使用dictEntry结构表示,每个dictEntry结构都保存着一个键值对:
    typedef struct dictEntry {
        // 保存着键值对的键
        void *key;
        // 保存着键值对中的值,其中键值对的值可以是一个指针,可以是一个uint64_t整数
        // 又或者是一个int64_t整数。
        union {
            void *val;
            uint64_tu64;
            int64_ts64;
        } v;
        // next属性是指向另一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对连接
        // 在一起,依次来解决键冲突问题
        struct dictEntry *next;
    } dictEntry;
    


    3. 字典,由dict.h/dict结构表示:

    typedef struct dict {
        // type属性和privatedata属性是针对不同类型的键值对,为创建多态字典而设置的
        // type属性是一个指向dictType结构的指针,每个dictType结构保存了一簇用于操作特定类型键值对的函数,
        // Redis会为用途不同的字典设置不同的类型特定函数
        dictType *type;
        // privatedata属性是针对不同类型的键值对,为创建多态字典而设置的。
        void *privatedata;
        // ht是一个包含两个项的数组,数组中的每个项都是一个dictht哈希表,一般情况下,
        // 字典只使用ht[0]哈希表,ht[1]哈希表只对在对ht[0]进行rehash时使用
        dictht ht[2];
        // rehash的索引,当不在rehash时,为-1
        int rehashidx;
    } 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)
    } dictType;
    

  2. 哈希算法:当需要将一个新的键值对添加到字典中时,程序首先根据键值对中的键计算出哈希值和索引值,然后再根据索引值,将包含新键值对的哈希表节点放到哈希表数组的指定索引上。当字典被用作数据库的底层实现时,或者哈希键的底层实现时,Redis使用MurmurHash2算法来结算键的哈希,这种算法的优点在于即使输入的键是有规律的,算法仍能给出一个很好的随机性分布,并且算法的计算速度也非常快。

    // 计算哈希值和索引值的方法如下:
    
    # 使用字典设置的哈希函数,计算键key的哈希值
    hash = dict -> type -> hashFunction(key);
    
    # 使用哈希表的sizemask属性和哈希值计算出索引值,根据情况不同,ht[x]为ht[0]或ht[1]
    index = hash & dict -> ht[x].sizemask;
    

    如下图,假设对图4-3添加一个键值对k0、v0的节点,对k0哈希的结果为8, 8 & 3 = 0,所以将k0、v0添加到哈希表数组中索引为0的位置上。

  3. 解决哈希冲突:当有两个或以上数量的键被分配到了哈希表数组的同一个索引上面时,我们称这些键发生了哈希冲突,哈希表节点dictEntry中的next就是为了解决哈希冲突,将产生冲突的键值对用next指针连接起来。同时,为了速度考虑,程序总是将新节点添加到链表的表头位置(因为没有指向表尾的指针),时间复杂度为O(1),排在其他已有节点的前面。

  4. rehash:随着操作的不断进行,哈希表内保存的键值对会逐渐的增多或减少,为了让哈希表的负载因子(used / size)维持在一个合理的范围之内,需要对哈希表的大小进行相应的扩展或者收缩。这些工作是通过rehash(重新散列)操作完成的,步骤如下:

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

      1. 如果执行的是扩容操作,那么ht[1]的大小为第一个大于等于ht[0].used*2的2^n(2的n次方幂);比如当前used为4, 4 * 2 = 8,而8是2的3次方,恰好是第一个大于等于4 * 2的2^n,所以将ht[1]哈希表的大小设置成8,此时size=8,sizemask=7;
      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做准备。

    4. 扩容或收缩触发的条件:

      1. 服务器目前没有在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于1,进行扩容;
      2. 服务器目前正在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于5,进行扩容;
      3. 当哈希表的负载因子小于0.1时,程序自动开始对哈希表执行收缩操作。
  5. 渐进式rehash:在上述所说将ht[0]中的键值对rehash到ht[1]中时,并不是一次性把所有的键值对全部rehash到ht[1]中的,而是分多次、渐进式地将ht[0]里面的键值对慢慢地rehash到ht[1]中。具体步骤为:在rehash期间,程序除了执行指定的curd操作外,还会顺带将ht[0]哈希表在rehashidx索引上的所有键值对rehash到ht[1],完成之后rehashidx+=1,当ht[0]中全部键值对移到ht[1]的时候,rehashidx置为-1。注意,在rehash期间,新加的键值对是到ht[1]中的,其他操作是先到ht[0]、没有的话再到ht[1]中去操作

5、跳跃表

跳跃表(skiplist)是一种有序数据结构,它通过每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。支持平均O(logN)、最坏O(N)复杂度的节点查找,还可以通过顺序性操作来批量处理节点。在大部分情况下,跳跃表的效率可以和平衡树媲美,并且跳跃表的实现比平衡树更为简单。

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

  1. 跳跃表的实现:主要有redis.h/zskiplistNode和redis.h/zskiplist两个结构定义,其中zskiplistNode结构用于表示跳跃表节点,而zskiplist结构则用于保存跳跃表节点的相关信息。
    1. 上图中位于图片最左边的是zskiplist结构,主要包括以下属性:
      1. header:指向跳跃表的表头节点;
      2. tail:指向跳跃表的表尾节点;
      3. level:记录目前跳跃表内,层数最大的那个节点的层次(表头节点的层数不计算在内);
      4. length:记录跳跃表的长度,也即是跳跃表目前包含节点的数量(不包含表头节点)。
    2. 上图中右面是四个zskiplistNode结构,主要包括以下属性:
      1. 层(level):节点中的L1、L2、L3等字样标记节点的各个层,分别表示第一层、第二层等以此类推。每个层都有两个属性:前进指针和跨度。前进指针用于访问位于表尾方向的其他节点,而跨度则记录了前进指针所指向节点和当前节点的距离。上图中连线上带有数字的箭头就代表前进指针,数字就是跨度。跨度实际上是用来计算排位的,在查找某个节点的过程中,将沿途访问过的所有层的跨度累计起来,得到的结果就是目标节点在跳跃表中的排位。当程序从表头向表尾进行遍历时,访问会沿着层的前进指针进行。
      2. 后退指针(backward):节点中用BW字样标记节点的后退指针,它指向位于当前节点的前一个节点,后退指针在程序从表尾向表头遍历时使用。
      3. 分值(score):是一个double类型的浮点数,各个节点中的1.0、2.0、3.0是节点所保存的分值,在跳跃表中,节点按各自所保存的分值从小到大排列。
      4. 成员对象obj:各个节点中的o1、o2、o3是节点所保存的成员对象。它是一个指针,指向一个字符串对象,而字符串对象则保存着一个SDS值。

用于实现有序集合,另一个作用是在集群节点中用作内部数据结构。

6、整数集合

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

  1. 整数集合的实现:整数集合是Redis用于保存整数值的集合抽象数据结构,它可以保存类型为int16_t、int32_t、int64_t的整数值,并且保证集合中不会出现重复元素。

    typedef struct intset {
        // 决定contents数组中数据的类型,注意contents数组中的数据类型并不是int8_t。
        uint32_t encoding;
        // 记录contents数组的长度
        uint32_t length;
        // contenst数组是整数集合的底层实现,整数集合的每个元素都是都是contents数组的一个数据项,
        // 各个项在数组中按值的大小从小到大有序地排列,并且数组中不包含任何重复项。
        int8_t contents[];
    }
    

    一个整数集合示例如下图所示:

    其中,encoding值为INTSET_ENC_INT64,说明contens数组中的数据类型为int64_t,由于数组保存了4个元素,所以contents数组的大小为64 * 4 = 256位。

  2. 升级:每当我们要将一个新元素添加到整数集合里面,并且新元素的类型比整数集合现有所有元素的类型都要长时,整数集合需要先进行升级,然后才能将新元素添加到整数集合里面。步骤如下:

    1. 根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素分配空间;
    2. 将底层数组现有的所有元素都转换成与新元素相同的类型,并将类型转换后的元素放置到正确的位置上,而且在放置元素的过程中,需要继续维持底层数组的有序性质不变。
    3. 将新元素添加到底层数组中。
    4. 因为引发升级的新元素的长度总是比整数集合现有所有元素的长度都大,所以这个新元素的值要么大于所有现有元素,要么小于所有现有元素。
    5. 由于每次添加元素都可能会引起升级,所以向整数集合中添加新元素的时间复杂度为O(N)
  3. 升级的好处:

    1. 提升灵活性:可以随意的添加不同类型的整数到整数集合中;
    2. 节约内存:可以让数据按需获得空间,尽量节省内存。
  4. 降级:不支持!!!!!!

7、压缩列表

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

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

  2. 压缩列表节点的构成:每个压缩列表节点都可以保存一个字节数组或者一个整数值,都由previous_entry_length、encoding、content三个部分组成。

    1. previous_entry_length属性以字节为单位,记录了压缩列表中前一个节点的长度;如果前一节点的长度小于254字节,那么该属性的长度为1字节,保存的便是前一个节点的长度;如果前一节点的长度大于等于254字节,则该属性的第一个字节为0xFE,后四字节保存前一节点的长度。

    2. encoding属性记录了节点的content属性所保存数据的类型以及长度。值的最高位为00、01、10的是字节数组编码,表示content属性保存着字节数组,后面的数表示content保存内容的长度;值的最高位是11开头的是整数编码,表示content属性保存着整数值。

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

  3. 连锁更新:由于每个压缩列表节点的previous_entry_length属性都记录了前一个节点的长度,而且该属性的长度由前面节点的长度决定,所以假设在一个有多个连续的、长度介于250字节到253字节的节点e1-eN,如果在e1之前添加一个长度大于等于254字节的新节点,将会使得e1-eN中的previous_entry_length长度扩展,这种现象为连锁更新。

8、对象

以上主要是Redis用到的主要数据结构,包括简单动态字符串(SDS)、双端链表、字典、压缩列表、整数集合等等。Redis并没有直接使用这些数据结构来实现键值对数据库,而是基于这些数据结构创建了一个对象系统,包括字符串对象、列表对象、哈希对象、集合对象和有序集合对象这五种类型的对象,每种对象都用到了至少一种前面的数据结构。

除此,Redis的对象系统还实现了基于引用计数的内存回收机制,当程序不再使用某个对象时,这个对象所占用的内存就会自动释放掉;另外,Redis还通过引用计数实现了对象共享机制,可以在适当的条件下,通过让多个数据库键共享同一个对象来节约内存。

  1. 对象的类型和编码:Redis中的每个对象都由一个redisObject结构表示,该结构中和保存数据有关的三个属性分别是type、encoding、和ptr属性。

    1. 类型type:对象的type属性记录了对象的类型,这个属性的值可以为下图中的其中一个。对于Redis数据库保存的键值对来说,键总是一个字符串对象,而值则可以是字符串对象、列表对象、哈希对象、集合对象、有序集合对象的一种。

      当对一个数据库键执行TYPE命令时,命令返回的结果为数据库键对应的值对象的类型,而不是键对象的类型,不同类型值对象的TYPE命令输出如下:

    2. 编码和底层实现:对象的ptr指针指向对象的底层实现数据结构,而这些数据结构由对象的encoding属性决定。encoding记录了对象所使用的编码,也即是说这个对象使用了什么数据结构作为对象的底层实现,对应关系如下图:

      每种类型的对象都至少使用了两种不同的编码,如下图:

      通过encoding属性来设定对象所使用的编码,而不是为特定类型的对象关联一种固定的编码,极大地提升Redis的灵活性和效率,因为Redis可以根据不同的使用场景来为一个对象设置不同的编码,从而优化对象在某一场景下的效率。

  2. 字符串对象:字符串对象的编码可以是int、raw或者embstr。

    1. 三种编码方式的使用场景:
      1. 如果一个字符串对象保存的是整数值,并且这个整数值可以用long类型来表示,那么字符串对象会将整数值保存在字符串对象结构的ptr属性里面,并将字符串对象的编码设置为int;
      2. 如果字符串对象保存的是一个字符串值,并且这个字符串值的长度大于32字节,那么字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串值,并将其编码设置为raw;
      3. 如果字符串对象保存的是一个字符串值,并且这个字符串值的长度小于等于32字节,那么字符串对象将使用embstr编码的方式来保存这个字符串值。
    2. 编码的转换:int和embstr编码的字符串对象在条件满足的情况下,会被转换为raw编码的字符串对象。
      1. 对于int编码的字符串来说,如果我们向对象执行了一些命令,使得这个对象保存的不再是整数值,而是一个字符串值,那么字符串对象的编码将从int变为raw;
      2. 当对于embstr编码的字符串对象来执行任何修改命令时,程序会先将对象的编码从embstr转换成raw,然后再执行修改命令,因为在redis中,embstr编码的字符串对象是只读的。
  3. 列表对象:列表对象的编码可以是ziplist或者linkedlist。

    1. 编码介绍:
      1. ziplist编码的列表对象使用压缩列表作为底层实现,每个压缩列表节点(entry)保存了一个列表元素。假设执行RPUSH number 1 "there" 5,如果number使用的是ziplist编码,这个值对象将会如下图所示:
      2. linkedlist编码的列表对象使用双端链表作为底层实现,每个双端链表节点(node)都保存了一个字符串对象,而每个字符串对象都保存了一个列表元素,保存方式如下所示:
      3. 注意:linkedlist编码的列表对象在底层的双端链表结构中包含了多个字符串对象,这种现象在哈希对象、集合对象和有序集合对象中都会出现,也就是说,字符串对象是五种对象中唯一会被其他四种类型对象嵌套的对象。
    2. 编码转换:
      1. 当列表对象可以同时满足以下两个条件时,列表对象使用ziplist编码:列表对象保存的所有字符串元素的长度都小于64字节;列表对象保存的元素数量小于512个;不能同时满足这两个条件的列表对象需要使用linkedlist编码。
      2. 对于使用ziplist编码的列表对象来说,当两个条件都不能满足时,对象的编码转换操作就会执行,原本保存在压缩列表里的所有列表元素都会被转移并保存到双端链表里面,对象的编码也会从ziplist变为linkedlist。
  4. 哈希对象:哈希对象的编码可以是ziplist或者hashtable。

    1. 编码介绍:
      1. ziplist编码的哈希对象使用压缩列表作为底层实现,每当有新的键值对要加入到哈希对象时,程序会先将保存了键的压缩列表节点推入到压缩列表表尾,然后再将保存了值的压缩列表节点推入到压缩列表表尾,因此:保存了同一键值对的两个节点总是紧挨在一起,保存键的节点在前,保存值的节点在后;先添加到哈希对象中的键值对会被放在压缩列表的表头方向,而后来添加到哈希对象中的键值对会被放在压缩列表的表尾方向。
      2. hashtable编码的哈希对象使用字典作为底层实现,哈希对象中的每个键值对都使用一个字典键值对来保存:字典的每个键都是一个字符串对象,对象中保存了键值对的键;字典的每个值都是一个字符串对象,对象中保存了键值对的值。
    2. 编码转换:
      1. 当哈希对象可以同时满足以下两个条件时,哈希对象使用ziplist编码:哈希对象保存的所有键值对的键和值的字符串长度都小于64字节;哈希对象保存的键值对数量小于512个。不能同时满足这两个条件的哈希对象需要使用hashtable编码。
      2. 对于使用ziplist编码的哈希对象来说,当上述两个条件中任意一个不能满足时,对象的编码转换操作就会执行,原本保存在压缩列表里的所有键值对都会被转移并保存到字典里,对象的编码也会从ziplist变为hashtable。
  5. 集合对象:集合对象的编码可以是intset或者hashtable。

    1. 编码介绍:

      1. intset编码的集合对象使用整数集合作为底层实现,集合对象包含的所有元素都被保存在整数集合里面;
      2. hashtable编码的集合使用字典作为底层实现,字典的每个键都是一个字符串对象,每个字符串对象包含了一个集合元素,而字典的值全部被设置为NULL。
    2. 编码转换:

      1. 当集合对象可以同时满足以下两个条件时,对象使用intset编码:集合对象保存的所有元素都是整数值;集合对象保存的元素数量不超过512个。不能同时满足这两个条件的集合对象需要使用hashtable编码。
      2. 对于使用intset编码的集合对象来说,当上面两个条件之一不满足时,就会执行对象的编码转换操作,原本保存在整数集合中的所有元素都会被转移并保存到字典里面,对象的编码也会从intset变为hashtable。
  6. 有序集合对象:有序集合独享的编码可以是ziplist或者skiplist。

    1. 编码介绍:

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

      2. skiplist编码的有序集合对象使用zset结构作为底层实现,一个zset结构同时包含一个字典和一个跳跃表:

      typedef struct zset {
         zskiplist *zsl;
         dict *dict
      } zset;
      

      zset结构中的zsl跳跃表按分值从小到大保存了所有集合元素,每个跳跃表节点都保存了一个集合元素:跳跃表节点的object属性保存了元素的成员,而跳跃表节点的score则保存了元素的分值,通过跳跃表,程序可以对有序集合进行范围型操作;除此,zset结构中的dict字典为有序集合创建了一个从成员到分值的映射,字典中的每个键值对都保存了一个集合对象:字典的键保存了元素的成员,而字典的值则保存了元素的分支。通过字典,可以用O(1)复杂度查找给定成员的分值。

      1. 有序集合每个元素的成员都是一个字符串对象,而每个元素的分值都是一个double类型的浮点数。虽然zset结构同时使用跳跃表和字典来保存有序集合元素,但是这两种数据结构都会通过指针来共享相同的成员和分值,所以同时使用跳跃表和字典来保存集合元素不会产生任何重复成员或者分值,也不会因此浪费额外的内存。

    2. 编码转换:

      1. 当有序集合对象可以同时满足以下两个条件时,使用ziplist编码:有序集合保存的元素数量小于128个;有序集合保存的所有元素成员的长度都小于64字节。不能同时满足上述条件时,将使用skiplist编码。
      2. 对于使用ziplist编码的有序集合对象,当上述两个条件任一不满足时,就会执行对象的编码转换操作。
  7. 类型检查与命令多态

    1. 一种命令只能对特定类型的键执行:
      1. SET、GET、APPEND、STRLEN等命令只能对字符串键执行;
      2. HSET、HGET、HDEL、HLEN等命令只能对哈希键执行;
      3. RPUSH、LPOP、LINSERT、LLEN等命令只能对列表键执行;
      4. SADD、SPOP、SINTER、SCARD等命令只能对集合键执行;
      5. ZADD、ZSCARD、ZRANK、ZSCORE等命令只能对有序集合键执行。
    2. 类型检查:在执行一个特定类型的命令之前,Redis会先检查输入键的类型与数据库已存在键的类型是否匹配(通过数据库存储对象的type属性),然后再决定是否执行给定的命令。
    3. 多态命令的实现:如果对一个键执行LLEN命令,那么服务器除了要确保执行命令的是列表键外,还需要根据键的值对象所使用的编码来选择正确的LLEN命令实现:
      1. 如果列表对象的编码为ziplist,那么说明列表的实现为压缩列表,程序将使用ziplistLen函数来返回列表的长度;
      2. 如果列表对象的编码为linkedList,那么说明列表对象的实现为双端链表,程序将使用ListLength函数来返回双端链表的长度。
  8. 内存回收

由于C语言并不具备自动内存回收功能,所以Redis在自己的对象系统中构建了一个引用计数实现的内存回收机制,通过这个机制,程序可以通过跟踪对象的引用计数信息,在适当的时候自动释放对象并进行内存回收

typedef struct redisObject {
   // ...

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

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

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

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

  5. 对象共享:除了用于实现引用计数内存回收机制之外,对象的引用计数属性还带有对象共享的作用。

    1. 在Redis中,让多个键共享同一个值对象需要的步骤:将数据库键的值指针指向一个现有的值对象;将共享的值对象的引用计数加一;
    2. 共享对象机制对于节约内存非常有帮助,数据库中保存的相同值对象越多,对象共享机制就能节约越多的内存;
    3. 需要注意的是,Redis只对包含整数值的字符串对象进行共享,因为当服务器考虑将一个共享对象设置为键的值对象时,程序会先检查给定的共享对象和键想创建的目标对象是否完全相同,只有在完全相同的情况下,才能共享对象。如果共享对象是保存的整数值的话,验证的时间复杂度为O(1);如果是字符串值的话,时间复杂度为O(N);如果为多个值的话,时间复杂度为O(N^2);所以出于CPU性能的考虑,只保存整数值。
  6. 对象的空转时长:redisObject结构体中还包含一个lru属性,该属性记录了对象最后一次被命令程序访问的时间,如果服务器被打开了maxmemory选项,并且服务器用于回收内存的算法为volatile-lru或者allkeys-lru,那么当服务器占用的内存数超过了maxmemory选项所设置的上限值时,空转时长较高的那部分键会优先被服务器释放,从而回收内存。

posted @ 2021-06-21 17:35  xiaofeidu  阅读(96)  评论(0编辑  收藏  举报