【读书笔记】-《Redis设计与实现》数据结构与对象

【读书笔记】-《Redis设计与实现》数据结构与对象

1.简单动态字符串

Redis没有直接使用C语言传统的字符串表示(以空字符结尾的字符数组,以下简称C字符串),而是自己构建了一种名为简单动态字符串(simple dynamic string,SDS)的抽象类型,并将SDS用作Redis的默认字符串表示。

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

redisLog(REDIS_WARNING,"Redis is now ready to exit, bye bye...

当Redis需要的不仅仅是一个字符串字面量,而是一个可以被修改的字符串值时,Redis就会使用SDS来表示字符串值

应用场景:

  • 比如在Redis的数据库里面,包含字符串值的键值对在底层都是由SDS实现的。
  • 除了用来保存数据库中的字符串值之外,SDS还被用作缓冲区(buffer):AOF模块中的 AOF缓冲区,以及客户端状态中的输入缓冲区,都是由SDS实现的。

1.1 SDS的定义

SDS又称为简单动态字符串(Simple Dynamic String),SDS的定义如下图所示:

img

如果将一个值为“Redis”的字符串放入SDS中,它的状态如下图所示:

img

  • free属性的值为0,表示这个SDS没有分配任何未使用空间。
  • len属性的值为5,表示这个SDS保存了一个五字节长的字符串。
  • buf属性是一个char类型的数组,数组的前五个字节分别保存了’R’、‘e’、‘d’、‘i’、‘s’五个字符,而最后一个字节则保存了空字符’\0’。
  • SDS遵循C字符串以空字符结尾的惯例,保存空字符的1字节空间不计算在SDS的len属性里面,并且为空字符分配额外的1字节空间,以及添加空字符到字符串末尾等操作,都是由 SDS函数自动完成的,所以这个空字符对于SDS的使用者来说是完全透明的。遵循空字符结尾这一惯例的好处是,SDS可以直接重用一部分C字符串函数库里面的函数。

1.2 SDS与C字符串的区别

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

  • 因为C字符串并不记录自身的长度信息,所以为了获取一个C字符串的长度,程序必须遍历整个字符串,对遇到的每个字符进行计数,直到遇到代表字符串结尾的空字符为止,这个操作的复杂度为O(N)
  • 和C字符串不同,因为SDS在len属性中记录了SDS本身的长度,所以获取一个SDS长度的复杂度仅为O(1)

通过使用SDS而不是C字符串,Redis将获取字符串长度所需的复杂度从O(N)降低到了 O(1),这确保了获取字符串长度的工作不会成为Redis的性能瓶颈

1.2.2 杜绝缓冲区溢出

  • 除了获取字符串长度的复杂度高之外,C字符串不记录自身长度带来的另一个问题是容易造成缓冲区溢出(buffer overflow)。
  • 与C字符串不同,SDS的空间分配策略完全杜绝了发生缓冲区溢出的可能性:当SDS API 需要对SDS进行修改时,API会先检查SDS的空间是否满足修改所需的要求,如果不满足的话,API会自动将SDS的空间扩展至执行修改所需的大小,然后才执行实际的修改操作,所以使用SDS既不需要手动修改SDS的空间大小,也不会出现前面所说的缓冲区溢出问题。

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

因为C字符串并不记录自身的长度,所以对于一个包含了N个字符的C字符串来说,这个C字符串的底层实现总是一个N+1个字符长的数组(额外的一个字符空间用于保存空字符)。因为C字符串的长度和底层数组的长度之间存在着这种关联性,所以每次增长或者缩短一个C字符串,程序都总要对保存这个C字符串的数组进行一次内存重分配操作:

  • 如果程序执行的是增长字符串的操作,比如拼接操作(append),那么在执行这个操作之前,程序需要先通过内存重分配来扩展底层数组的空间大小—如果忘了这一步就会产生缓冲区溢出
  • 如果程序执行的是缩短字符串的操作,比如截断操作(trim),那么在执行这个操作之后,程序需要通过内存重分配来释放字符串不再使用的那部分空间—如果忘了这一步就会产生内存泄漏

为了避免C字符串的这种缺陷,SDS通过未使用空间解除了字符串长度和底层数组长度之间的关联:

  • 在SDS中,buf数组的长度不一定就是字符数量加一,数组里面可以包含未使用的字节,而这些字节的数量就由SDS的free属性记录。
  • 通过未使用空间,SDS实现了空间预分配惰性空间释放两种优化策略。

(1)空间预分配

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

具体分配策略:

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

好处:通过空间预分配策略,Redis可以减少连续执行字符串增长操作所需的内存重分配次数

(2)惰性空间释放

惰性空间释放用于优化SDS的字符串缩短操作:当SDS的API需要缩短SDS保存的字符串时,程序并不立即使用内存重分配来回收缩短后多出来的字节,而是使用free属性将这些字节的数量记录起来,并等待将来使用。与此同时,SDS也提供了相应的API,让我们可以在有需要时,真正地释放SDS的未使用空间,所以不用担心惰性空间释放策略会造成内存浪费

好处:通过惰性空间释放策略,SDS避免了缩短字符串时所需的内存重分配操作,并为将来可能有的增长操作提供了优

1.2.4 二进制安全

  • C字符串中的字符必须符合某种编码(比如ASCII),并且除了字符串的末尾之外,字符串里面不能包含空字符,否则最先被程序读入的空字符将被误认为是字符串结尾,这些限制使得C字符串只能保存文本数据,而不能保存像图片、音频、视频、压缩文件这样的二进制数据
  • SDS API都会以处理二进制的方式来处理SDS存放在buf数组里的数据。Redis不是用这个数组来保存字符,而是用它来保存一系列二进制数据。

通过使用二进制安全的SDS,而不是C字符串,使得Redis不仅可以保存文本数据,还可以保存任意格式的二进制数据。

1.2.5 兼容部分C字符串函数

虽然SDS的API都是二进制安全的,但它们一样遵循C字符串以空字符结尾的惯例:这些 API总会将SDS保存的数据的末尾设置为空字符,并且总会在为buf数组分配空间时多分配一个字节来容纳这个空字符,这是为了让那些保存文本数据的SDS可以重用一部分库定义的函数。

image-20220228104512738

通过遵循C字符串以空字符结尾的惯例,SDS可以在有需要时重用函数库,从而避免了不必要的代码重复

1.2.6 总结

C字符串 SDS
获取长度的时间复杂度为O(n) 获取长度的时间复杂度为O(1)
API是不安全的,可能会造成缓冲区溢出 API是安全的,不会造成缓冲区溢出
修改字符串n次必定执行n次内存分配 修改字符串n次最多执行n次内存分配
只能保存文本数据 可以保存文本数据或者二进制数据
可以使用所有<string.h>库中的函数 可以使用一部分<string.h>库中的函数

1.3 重点回顾

Redis只会使用C字符串作为字面量,在大多数情况下,Redis使用SDS(Simple Dynamic String,简单动态字符串)作为字符串表示。

比起C字符串,SDS具有以下优点:

  1. 常数复杂度获取字符串长度。
  2. 杜绝缓冲区溢出。
  3. 减少修改字符串长度时所需的内存重分配次数。
  4. 二进制安全。
  5. 兼容部分C字符串函数。

2.链表

链表提供了高效的节点重排能力,以及顺序性的节点访问方式,并且可以通过增删节点来灵活地调整链表的长度。

应用场景:

  • 链表在Redis中的应用非常广泛,比如列表键的底层实现之一就是链表。当一个列表键包含了数量比较多的元素,又或者列表中包含的元素都是比较长的字符串时,Redis就会使用链表作为列表键的底层实现。
  • 除了链表键之外,发布与订阅、慢查询、监视器等功能也用到了链表,Redis服务器本身还使用链表来保存多个客户端的状态信息,以及使用链表来构建客户端输出缓冲区。

2.1 链表和链表节点的实现

每个链表节点使用一个listNode结构来表示:

typedef struct listNode {
    // 保存前驱节点
    struct listNode *prev;
    // 保存后继节点
    struct listNode *next;
    // 保存值
    void *value;
} listNode;

多个listNode可以通过prev和next指针组成双端链表:

img

虽然仅仅使用多个listNode结构就可以组成链表,但使用list来持有链表的话,操作起来会更方便:

typedef struct list {
    // 头结点
    listNode *head;
    // 尾节点
    listNode *tail;
    // 复制函数
    void *(*dup)(void *ptr);
    // 释放函数
    void (*free)(void *ptr);
    // 匹配函数
    int (*match)(void *ptr, void *key);
    // 链表长度
    unsigned long len;
} list;

list结构为链表提供了表头指针head、表尾指针tail,以及链表长度计数器len,而dupfreematch成员则是用于实现多态链表所需的类型特定函数:

  • dup函数用于复制链表节点所保存的值
  • free函数用于释放链表节点所保存的值
  • match函数则用于对比链表节点所保存的值和另一个输入值是否相等

img

Redis的链表实现的特性可以总结如下:

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

2.2 重点回顾

  • 链表被广泛用于实现Redis的各种功能,比如列表键、发布与订阅、慢查询、监视器等。
  • 每个链表节点由一个listNode结构来表示,每个节点都有一个指向前置节点和后置节点的指针,所以Redis的链表实现是双端链表
  • 每个链表使用一个list结构来表示,这个结构带有表头节点指针、表尾节点指针,以及链表长度等信息。
  • 因为链表表头节点的前置节点和表尾节点的后置节点都指向NULL,所以Redis的链表实现是无环链表
  • 通过为链表设置不同的类型特定函数,Redis的链表可以用于保存各种不同类型的值。

3.字典

字典,又称为符号表、关联数组或映射, 是一种用于保存键值对(key-value pair)的抽象数据结构

  • 在字典中,一个键(key)可以和一个值(value)进行关联(或者说将键映射为值), 这些关联的键和值就称为键值对
  • 字典中的每个键都是独一无二的,程序可以在字典中根据键查找与之关联的值,或者通过键来更新值,又或者根据键来删除整个键值对,等等。

应用场景:

  1. 字典在Redis中的应用相当广泛,比如Redis的数据库就是使用字典来作为底层实现的,对数据库的增、删、查、改操作也是构建在对字典的操作之上的。
  2. 除了用来表示数据库之外,字典还是哈希键的底层实现之一,当一个哈希键包含的键值对比较多,又或者键值对中的元素都是比较长的字符串时,Redis就会使用字典作为哈希键的底层实现。

3.1 字典的实现

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

3.1.1 哈希表

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

typedef struct dictht {
    // 哈希表数组
    // 类似于Java中HashMap的
    //transient Node<K,V>[] table;
    dictEntry **table;
    
    // 哈希表大小
    unsigned long size;
    
    // 哈希表掩码,大小为size-1
    unsigned long sizemask;
    
    // 哈希表中已有的节点数
    unsigned long used;
} dictht;
  • table属性是一个数组,数组中的每个元素都是一个指向dictEntry结构的指针,每个dictEntry结构保存着一个键值对。
  • size属性记录了哈希表的大小,也即是table数组的大小
  • used属性则记录了哈希表目前已有节点(键值对)的数量。
  • sizemask属性的值总是等于size-1,这个属性和哈希值一起决定一个键应该被放到table数组的哪个索引上面。

img

3.1.2 哈希表节点

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

typedef struct dictEntry {
    // 键
    void *key;
    // 值
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    // 指向下一个哈希节点,形成链表
    struct dictEntry *next;
} dictEntry;
  • key属性保存着键值对中的键,而v属性则保存着键值对中的值,其中键值对的值可以是一个指针,或者是一个uint64_t整数,又或者是一个int64_t整数。
  • next属性是指向另一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对连接在一次,以此来解决键冲突(collision)的问题。
image-20220228120743776

3.1.3 字典

Redis中的字典由dict结构表示:

typedef struct dict {
    //类型特定函数
    dictType *type;
    //私有数据
    void *privdata;
    //哈希表
    dictht ht[2];
    //索引,当rehash不在进行时,值为-1
    long rehashidx; /* rehashing not in progress if rehashidx == -1 */
    unsigned long iterators; /* number of iterators currently running */
} dict;

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

  • type属性是一个指向dictType结构的指针,每个dictType结构保存了一簇用于操作特定类型键值对的函数,Redis会为用途不同的字典设置不同的类型特定函数。
  • privdata属性则保存了需要传给那些类型特定函数的可选参数。
typedef struct dictType {
    // 计算哈希值的函数
    uint64_t (*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;
  • ht属性是一个包含两个项的数组,数组中的每个项都是一个dictht哈希表,一般情况下, 字典只使用ht[0]哈希表,ht[1]哈希表只会在对ht[0]哈希表进行rehash时使用。
  • 除了ht[1]之外,另一个和rehash有关的属性就是rehashidx,它记录了rehash目前的进度,如果目前没有在进行rehash,那么它的值为-1

一个普通状态下(没有进行rehash)的字典:

img

3.2 哈希算法

当要将一个新的键值对添加到字典里面时,程序需要先根据键值对的键计算出哈希值和索引值,然后再根据索引值,将包含新键值对的哈希表节点放到哈希表数组的指定索引上面。

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

img

例子

如果我们要将一个键值对k0和v0添加到容量为4字典里面,那么程序会先使用语句:

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

计算出对应的hash值,假设计算的hash值为8,则再通过sizemask(值为3)来计算出索引:

index = hash & dict->ht[x].sizemask; // 8 & 3 = 0

计算出key0的索引值为0,放入对应的位置上:

img

当字典被用作数据库的底层实现,或者哈希键的底层实现时,Redis使用MurmurHash2算法来计算键的哈希值。

3.3 解决键冲突

当有两个或以上数量的键被分配到了哈希表数组的同一个索引上面时,我们称这些键发生了冲突(collision)。

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

因为dictEntry节点组成的链表没有指向链表表尾的指针,所以为了速度考虑,程序总是将新节点添加到链表的表头位置(复杂度为O(1)),排在其他已有节点的前面,头插法

冲突前

img

冲突后

img

3.4 rehash

随着操作的不断执行,哈希表保存的键值对会逐渐地增多或者减少,为了让哈希表的负载因子(load_factor)维持在一个合理的范围之内(可以减少出现哈希冲突的几率),当哈希表保存的键值对数量太多或者太少时,程序需要对哈希表的大小进行相应的扩展或者收缩

扩展和收缩哈希表的工作可以通过执行**rehash(重新散列)**操作来完成,Redis对字典的哈希表执行rehash的步骤如下:

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

    • 如果执行的是扩展操作,那么ht[1]的大小为第一个大于ht[0].used*2 的 2n (和Java 中的 HashMap一样,这样可以保证sizemask的值必定为11…11)

    • 如果执行的是收缩操作,那么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做准备(类似于垃圾回收算法中的标记-复制算法 FROM-TO,然后交换FROM 和 TO)

例子:

假设程序要对下图所示字典的ht[0]进行扩展操作,那么程序将执行以下步骤:

img

  1. ht[0].used当前的值为4,4*2=8,所以程序会将ht[1]哈希表的大小设置为8。下图展示了ht[1]在分配空间之后,字典的样子

img

  1. 将ht[0]包含的四个键值对都rehash到ht[1]

img

  1. 释放ht[0],并将ht[1]设置为ht[0],然后为ht[1]分配一个空白哈希表,如下图所示。至此,对哈希表的扩展操作执行完毕,程序成功将哈希表的大小从原来的4改为了现在的8

img

哈希表的扩展与收缩

当以下条件中的任意一个被满足时,程序会自动开始对哈希表执行扩展操作:

  1. 服务器目前没有在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于1。
  2. 服务器目前正在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于5。

负载因子的计算方法如下:

// 负载因子=哈希表保存节点数量/哈希表大小
load_factory = ht[0].used/ht[0].size

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

另一方面,当哈希表的负载因子小于0.1时,程序自动开始对哈希表执行收缩操作。

3.5 渐进式rehash

扩展或收缩哈希表需要将ht[0]里面的所有键值对rehash到ht[1]里面,但是,这个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操作已完成

例子:

  1. 准备开始rehash

    img

  2. 开始rehash,rehash索引为0的键值对

    img

  3. rehash索引为1的键值对

    img

  4. … 依次rehash

  5. rehash完成,rehashidx再次变为-1

    img

渐进式rehash执行期间的哈希表操作

  • 因为在进行渐进式rehash的过程中,字典会同时使用ht[0]和ht[1]两个哈希表,所以在渐进式rehash进行期间,字典的删除(delete)、查找(find)、更新(update)等操作会在两个哈希表上进行。例如,要在字典里面查找一个键的话,程序会先在ht[0]里面进行查找,如果没找到的话,就会继续到ht[1]里面进行查找,诸如此类
  • 另外,在渐进式rehash执行期间,新添加到字典的键值对一律会被保存到ht[1]里面,而ht[0]则不再进行任何添加操作,这一措施保证了ht[0]包含的键值对数量会只减不增,并随着rehash操作的执行而最终变成空表

3.6 重点回顾

  • 字典被广泛用于实现Redis的各种功能,其中包括数据库和哈希键
  • Redis中的字典使用哈希表作为底层实现,每个字典带有两个哈希表,一个平时使用, 另一个仅在进行rehash时使用
  • 当字典被用作数据库的底层实现,或者哈希键的底层实现时,Redis使用MurmurHash2 算法来计算键的哈希值。
  • 哈希表使用链地址法来解决键冲突,被分配到同一个索引上的多个键值对会连接成一个单向链表。
  • 在对哈希表进行扩展或者收缩操作时,程序需要将现有哈希表包含的所有键值对rehash到新哈希表里面,并且这个rehash过程并不是一次性地完成的,而是渐进式地完成的

4.跳跃表

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

  • 跳跃表支持平均O(logN)、最坏O(N)复杂度的节点查找,还可以通过顺序性操作来批量处理节点。 在大部分情况下,跳跃表的效率可以和平衡树相媲美,并且因为跳跃表的实现比平衡树要来得更为简单,所以有不少程序都使用跳跃表来代替平衡树。

应用场景:

  • Redis使用跳跃表作为有序集合键的底层实现之一,如果一个有序集合包含的元素数量比较多,又或者有序集合中元素的成员(member)是比较长的字符串时,Redis就会使用跳跃表来作为有序集合键的底层实现。
  • Redis只在两个地方用到了跳跃表,一个是实现有序集合键,另一个是在集群节点中用作内部数据结构

4.1 跳跃表的实现

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

跳跃表结构如下:

img

上图展示了一个跳跃表示例,位于图片最左边的是zskiplist结构,该结构包含以下属性:

  • header:指向跳跃表的表头节点。
  • tail:指向跳跃表的表尾节点。
  • level:记录目前跳跃表内,层数最大的那个节点的层数(表头节点的层数不计算在内)。
  • length:记录跳跃表的长度,也即是,跳跃表目前包含节点的数量(表头节点不计算在内)。

位于zskiplist结构右方的是四个zskiplistNode结构,该结构包含以下属性:

  • 层(level):节点中用L1、L2、L3等字样标记节点的各个层,L1代表第一层,L2代表第二层,以此类推。

    每个层都带有两个属性:前进指针和跨度。

    • 前进指针用于访问位于表尾方向的其他节点
    • 跨度则记录了前进指针所指向节点和当前节点的距离。

    在上面的图片 中,连线上带有数字的箭头就代表前进指针,而那个数字就是跨度。当程序从表头向表尾进行遍历时,访问会沿着层的前进指针进行。

  • 后退(backward)指针:节点中用BW字样标记节点的后退指针,它指向位于当前节点的前一个节点。后退指针在程序从表尾向表头遍历时使用。

  • 分值(score):各个节点中的1.0、2.0和3.0是节点所保存的分值。在跳跃表中,节点按各自所保存的分值从小到大排列

  • 成员对象(obj):各个节点中的o1、o2和o3是节点所保存的成员对象。

4.2 跳跃表节点

跳跃表节点的实现由zskiplistNode结构定义:

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

img

  1. :跳跃表节点的level数组可以包含多个元素,每个元素都包含一个指向其他节点的指针,程序可以通过这些层来加快访问其他节点的速度,一般来说,层的数量越多,访问其他节点的速度就越快。

    每次创建一个新跳跃表节点的时候,程序都根据幂次定律(power law,越大的数出现的概率越小)随机生成一个介于1和32之间的值作为level数组的大小,这个大小就是层的“高度”

  2. 前进指针:每个层都有一个指向表尾方向的前进指针(level[i].forward属性),用于从表头向表尾方向访问节点。

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

    • 两个节点之间的跨度越大,它们相距得就越远。
    • 指向NULL的所有前进指针的跨度都为0,因为它们没有连向任何节点。

    跨度实际上是用来计算排位rank)的:在查找某个节点的过程中,将沿途访问过的所有层的跨度累计起来,得到的结果就是目标节点在跳跃表中的排位。

  4. 后退指针:节点的后退指针(backward属性)用于从表尾向表头方向访问节点:跟可以一次跳过多个节点的前进指针不同,因为每个节点只有一个后退指针,所以每次只能后退至前一个节 点。

  5. 分值和成员

    • 节点的分值(score属性)是一个double类型的浮点数,跳跃表中的所有节点都按分值从小到大来排序。
    • 节点的成员对象(obj属性)是一个指针,它指向一个字符串对象,而字符串对象则保存着一个SDS值。

    注意:

    同一个跳跃表中,各个节点保存的成员对象必须是唯一的,但是多个节点保存的分值却可以是相同的

    分值相同的节点将按照成员对象在字典序中的大小来进行排序,成员对象较小的节点会排在前面(靠近表头的方向),而成员对象较大的节点则会排在后面(靠近表尾的方向)。

4.3 跳跃表

仅靠多个跳跃表节点就可以组成一个跳跃表

但通过使用一个zskiplist结构来持有这些节点,程序可以更方便地对整个跳跃表进行处理,比如快速访问跳跃表的表头节点和表尾节点,或者快速地获取跳跃表节点的数量等信息。

如图:

img

  • headertail指针分别指向跳跃表的表头和表尾节点,通过这两个指针,程序定位表头节点和表尾节点的复杂度为O(1)。
  • 通过使用length属性来记录节点的数量,程序可以在O(1)复杂度内返回跳跃表的度。
  • level属性则用于在O(1)复杂度内获取跳跃表中层高最大的那个节点的层数量,注意表头节点的层高并不计算在内。

4.4 重点回顾

  • 跳跃表是有序集合的底层实现之一。
  • Redis的跳跃表实现由zskiplistzskiplistNode两个结构组成,其中zskiplist用于保存跳跃表信息(比如表头节点、表尾节点、长度),而zskiplistNode则用于表示跳跃表节点
  • 每个跳跃表节点的层高都是1至32之间的随机数。
  • 在同一个跳跃表中,多个节点可以包含相同的分值,但每个节点的成员对象必须是唯一的
  • 跳跃表中的节点按照分值大小进行排序,当分值相同时,节点按照成员对象的大小进行排序。

5.整数集合

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

例子:

image-20220302085354263

5.1 整数集合的实现

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

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

typedef struct intset {
    // 编码方式
    uint32_t encoding;
    // contents数组的长度
    uint32_t length;
    // 保存元素的数组,也就是set集合
    int8_t contents[];
} intset;
  • contents:是整数集合的底层实现,整数集合的每个元素都是contents数组的一个数组项(item),各个项在数组中按值的大小从小到大有序地排列,并且数组中不包含任何重复项。
  • length:记录了整数集合包含的元素数量,也即是contents数组的长度。
  • encoding:虽然intset结构将contents属性声明为int8_t类型的数组,但实际上contents数组并不保存任何int8_t类型的值,contents数组的真正类型取决于encoding属性的值。
    • 如果encoding属性的值为INTSET_ENC_INT16,那么contents就是一个int16_t类型的数组,数组里的每个项都是一个int16_t类型的整数值
    • 如果encoding属性的值为INTSET_ENC_INT32,那么contents就是一个int32_t类型的数组,数组里的每个项都是一个int32_t类型的整数值
    • 如果encoding属性的值为INTSET_ENC_INT64,那么contents就是一个int64_t类型的数组,数组里的每个项都是一个int64_t类型的整数值

如图:

img

该整数集合中有5个元素,contents的类型为int16_t

5.2 升级

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

升级整数集合并添加新元素共分为三步进行:

  1. 根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素分配空间。
  2. 将底层数组现有的所有元素都转换成与新元素相同的类型,并将类型转换后的元素放置到正确的位上,而且在放置元素的过程中,需要继续维持底层数组的有序性质不变。
  3. 将新元素添加到底层数组里面。

例子:

假设现在有一个INTSET_ENC_INT16编码的整数集合,集合中包含三个 int16_t类型的元素,如图:

img

因为每个元素都占用16位空间,所以整数集合底层数组的大小为3*16=48位。如下:

img

这时,需要将65535添加到整数集合里面,因为int16_t能够表示的范围为(-32768~32767),无法容纳该数字,所以需要升级。升级过程如下:

  1. 扩展content的分配的内存空间,由3x16 扩展为 3x32

    img

  2. 将数组中的元素类型改为int32_t,并放入扩展后的contents中。最后添加新插入的元素

    img

    img

    img

    img

  3. 最后,程序将整数集合encoding属性的值从INTSET_ENC_INT16改为 INTSET_ENC_INT32,并将length属性的值从3改为4,设置完成之后整数集合如图:

    img

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

升级之后新元素的摆放位置

因为引发升级的新元素的长度总是比整数集合现有所有元素的长度都大,所以这个 新元素的值要么就大于所有现有元素,要么就小于所有现有元素:

  1. 在新元素小于所有现有元素的情况下,新元素会被放置在底层数组的最开头(索引 0)
  2. 在新元素大于所有现有元素的情况下,新元素会被放置在底层数组的最末尾(索引 length-1)

5.3 升级的好处

5.3.1 提升灵活性

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

但是,因为整数集合可以通过自动升级底层数组来适应新元素,所以我们可以随意地将 int16_t、int32_t或者int64_t类型的整数添加到集合中,而不必担心出现类型错误,这种做法非常灵活。

5.3.2 节约内存

当然,要让一个数组可以同时保存int16_t、int32_t、int64_t三种类型的值,最简单的做法就是直接使用int64_t类型的数组作为整数集合的底层实现。不过这样一来,即使添加到整数集合里面的都是int16_t类型或者int32_t类型的值,数组都需要使用int64_t类型的空间去保存它们,从而出现浪费内存的情况。

而整数集合现在的做法既可以让集合能同时保存三种不同类型的值,又可以确保升级操作只会在有需要的时候进行,这可以尽量节省内存

5.4 不支持降级

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

5.5 重点回顾

  • 整数集合是集合键的底层实现之一。
  • 整数集合的底层实现为数组,这个数组以有序、无重复的方式保存集合元素,在有需要时,程序会根据新添加元素的类型,改变这个数组的类型。
  • 升级操作为整数集合带来了操作上的灵活性,并且尽可能地节约了内存。
  • 整数集合只支持升级操作,不支持降级操作

6.压缩列表

压缩列表(ziplist)是列表键哈希键的底层实现之一。

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

    image-20220302092440556

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

    image-20220302092419618

6.1 压缩列表的构成

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

组成如图:

image-20220302092619806

  • zlbytes:记录整个压缩列表占用的内存字节数,在对压缩列表进行内存重分配或者计算zlend位置时使用
  • zltail:记录压缩列表表尾节点距离压缩列表的起始地址有多少字节,通过这个偏移量,程序无须遍历整个压缩列表就可以确定表尾节点的地址
  • zllen:记录了压缩列表包含的节点数量。
    • 当这个属性的值小于UINT16_MAX(65535)时,这个属性的值就是压缩列表包含节点的数量
    • 当这个值等于UINT16_MAX(65535)时,节点的真实数量需要遍历整个压缩列表才能计算得出
  • entryX:压缩列表包含的各个节点,节点的长度由节点保存的内容决定
  • zlend:特殊值0xFF(十进制255),用于标记压缩列表的末端

6.2 压缩列表节点的构成

每个压缩列表节点都由previous_entry_lengthencodingcontent三个部分组成。如图:

image-20220302093907401

6.2.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属性,程序就可以一直向前一个节点回溯,最终到达压缩列表的表头节点。

6.2.2 encoding

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

  1. 一字节、两字节或者五字节长,值的最高位为00、01或者10的是字节数组编码:这种编码表示节点的content属性保存着字节数组,数组的长度由编码除去最高两位之后的其他位记 录
  2. 一字节长,值的最高位以11开头的是整数编码:这种编码表示节点的content属性保存着整数值,整数值的类型和长度由编码除去最高两位之后的其他位记录

6.2.3 content

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

例子:

  • 编码的最高两位00表示节点保存的是一个字节数组,编码的后六位001011记录了字节数组的长度11:

    image-20220302095042747

  • 编码11000000表示节点保存的是一个int16_t类型的整数值,content属性保存着节点的值10086:

    image-20220302095319075

6.3 连锁更新

Redis将这种在特殊情况下产生的连续多次空间扩展操作称之为连锁更新。

除了添加新节点可能会引发连锁更新之外,删除节点也可能会引发连锁更新。

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

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

  • 首先,压缩列表里要恰好有多个连续的、长度介于250字节至253字节之间的节点,连锁更新才有可能被引发,在实际中,这种情况并不多见
  • 其次,即使出现连锁更新,但只要被更新的节点数量不多,就不会对性能造成任何影响:比如说,对三五个节点进行连锁更新是绝对不会影响性能的

因为以上原因,ziplistPush等命令的平均复杂度仅为O(N),在实际中,我们可以放心地使用这些函数,而不必担心连锁更新会影响压缩列表的性能。

6.4 重点回顾

  • 压缩列表是一种为节约内存而开发的顺序型数据结构。
  • 压缩列表被用作列表键哈希键的底层实现之一。
  • 压缩列表可以包含多个节点,每个节点可以保存一个字节数组或者整数值
  • 添加新节点到压缩列表,或者从压缩列表中删除节点,可能会引发连锁更新操作,但这种操作出现的几率并不高

7.对象

基本数据结构与对象的关系:

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

使用对象的好处:

  • 通过这五种不同类型的对象,Redis可以在执行命令之前,根据对象的类型来判断一个对象是否可以执行给定的命令。
  • 使用对象的另一个好处是,我们可以针对不同的使用场景,为对象设置多种不同的数据结构实现,从而优化对象在不同场景下的使用效率。

对象的回收:

Redis的对象系统还实现了基于引用计数技术的内存回收机制,当程序不再使用某个对象的时候,这个对象所占用的内存就会被自动释放。

另外,Redis还通过引用计数技术实现了对象共享机制,这一机制可以在适当的条件下,通过让多个数据库键共享同一个对象来节约内存。

7.1 对象的类型与编码

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

例子:

其中键值对的键是一个包含了字符串值"msg"的对象,而键值对的值则是一个包含了字符串值"hello world"的对象

set hello "hello world"

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

typedef struct redisObject {
    // 类型(对象类型)
    unsigned type:4;
    // 编码(对象底层使用的数据结构)
    unsigned encoding:4;
    // 指向底层实现数据结构的指针
    void *ptr;
} robj;

类型

对象的type属性记录了对象的类型。这个属性的值可以是下标所示的值:

类型常量 对象名称
REDIS_STRING 字符串对象
REDIS_LIST 列表对象
REDIS_HASH 哈希对象
REDIS_SET 集合对象
REDIS_ZSET 有序集合对象

对于Redis数据库保存的键值对来说,键总是一个字符串对象,而值则可以是字符串对象、列表对象、哈希对象、集合对象或者有序集合对象的其中一种

  • 当我们称呼一个数据库键为“字符串键”时,我们指的是“这个数据库键所对应的值为字符串对象”
  • 当我们称呼一个键为“列表键”时,我们指的是“这个数据库键所对应的值为列表对象”。

TYPE命令的实现方式也与此类似,当我们对一个数据库键执行TYPE命令时,命令返回的结果为数据库键对应的值对象的类型,而不是键对象的类型,例如:

下图列出了TYPE命令在面对不同类型的值对象时所产生的输出:

编码和底层实现

对象的ptr指针指向对象的底层实现数据结构,而这些数据结构由对象的encoding属性决定。

encoding属性记录了对象所使用的编码,也即是说这个对象使用了什么数据结构作为对象的底层实现。

编码常量 编码所对应的底层数据结构
REDIS_ENCODING_INT long类型的整数
REDIS_ENCODING_EMBSTR embstr编码的简单动态字符串
REDIS_ENCODING_RAW 简单动态字符串
REDIS_ENCODING_HT 字典
REDIS_ENCODING_LINKEDLIST 双向链表
REDIS_ENCODING_ZIPLIST 压缩列表
REDIS_ENCODING_INTSET 整数集合
REDIS_ENCODING_SKIPLIST 跳跃表和字典

每种类型的对象都至少使用了两种不同的编码

使用OBJECT ENCODING命令可以查看一个数据库键的值对象的编码:

OBJECT ENCODING key

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

7.2 字符串对象

字符串对象的编码可以是intraw或者embstr

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ICnwIcm4-1648856499679)(C:\Users\30287\Desktop\笔记\面试问题合集\图片\blog-image-master\img/20220313141000.png)]

int

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-k96vjiUo-1648856499681)(C:\Users\30287\Desktop\笔记\面试问题合集\图片\blog-image-master\img/20220302123858.png)]

row

如果字符串对象保存的是一个字符串值,并且这个字符串值的长度大于32字节,那么字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串值,并将对象的编码设置为 raw

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-5mLelHvY-1648856499683)(C:\Users\30287\Desktop\笔记\面试问题合集\图片\blog-image-master\img/20220302123915.png)]

embstr

如果字符串对象保存的是一个字符串值,并且这个字符串值的长度小于等于32字节,那么字符串对象将使用embstr编码的方式来保存这个字符串值。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mOFVRzQM-1648856499684)(C:\Users\30287\Desktop\笔记\面试问题合集\图片\blog-image-master\img/20220302123925.png)]

embstr编码是专门用于保存短字符串的一种优化编码方式,这种编码和raw编码一样, 都使用redisObject结构和sdshdr结构来表示字符串对象,但raw编码会调用两次内存分配函数来分别创建redisObject结构和sdshdr结构,而embstr编码则通过调用一次内存分配函数来分配一块连续的空间,空间中依次包含redisObject和sdshdr两个结构

简单来说,raw和embstr都是用来保存字符串的。字符串长度较短时使用embstr,较长时使用raw

编码的转换

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

  1. 对于int编码的字符串对象来说,如果我们向对象执行了一些命令,使得这个对象保存的不再是整数值,而是一个字符串值,那么字符串对象的编码将从int变为raw。
  2. 当我们对embstr编码的字符串对象执行任何修改命令时,程序会先将对象的编码从embstr转换成raw,然后再执行修改命令

7.3 列表对象

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

ziplist

ziplist编码的列表对象使用压缩列表作为底层实现,每个压缩列表节点(entry)保存了一个列表元素。

例子:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QoOVLxfX-1648856499685)(C:\Users\30287\Desktop\笔记\面试问题合集\图片\blog-image-master\img/20220313134753.png)]

其结构如下图所示:

linkedlist

linkedlist编码的列表对象使用双端链表作为底层实现,每个双端链表节点(node)都保存了一个字符串对象,而每个字符串对象都保存了一个列表元素。

如果前面所说的numbers键创建的列表对象使用的不是ziplist编码,而是 linkedlist编码,结构则如下图所示:

编码转换

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

  1. 列表对象保存的所有字符串元素的长度都小于64字节
  2. 列表对象保存的元素数量小于512

不能满足这两个条件的列表对象需要使用linkedlist编码

7.4 哈希对象

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

ziplist

ziplist编码的哈希对象使用压缩列表作为底层实现,每当有新的键值对要加入到哈希对象时,程序会先将保存了的压缩列表节点推入到压缩列表表尾,然后再将保存了的压缩列表节点推入到压缩列表表尾,因此:

  1. 保存了同一键值对的两个节点总是紧挨在一起,保存键的节点在前,保存值的节点在后
  2. 先添加到哈希对象中的键值对会被放在压缩列表的表头方向,而后来添加到哈希对象中的键值对会被放在压缩列表的表尾方向

例子:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-OkOO0Wix-1648856499687)(C:\Users\30287\Desktop\笔记\面试问题合集\图片\blog-image-master\img/20220313134215.png)]

其结构如下图所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-02IlUUEL-1648856499688)(C:\Users\30287\Desktop\笔记\面试问题合集\图片\blog-image-master\img/20220313134054.png)]

hashtable

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

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

如果前面profile键创建的不是ziplist编码的哈希对象,而是hashtable编码的哈希对象,结构则如下图所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-aQUgaF0f-1648856499689)(C:\Users\30287\Desktop\笔记\面试问题合集\图片\blog-image-master\img/20220313134209.png)]

编码转换

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

  1. 哈希对象保存的所有键值对的键和值的字符串长度都小于64字节
  2. 哈希对象保存的键值对数量小于512

不能满足这两个条件的哈希对象需要使用 hashtable编码

7.5 集合对象

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

intset

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

例子:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-bil60iAK-1648856499690)(C:\Users\30287\Desktop\笔记\面试问题合集\图片\blog-image-master\img/20220313131413.png)]

其结构如下图所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-kiG7znWV-1648856499692)(C:\Users\30287\Desktop\笔记\面试问题合集\图片\blog-image-master\img/20220313131434.png)]

hashtable

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

例子:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-dEliMLld-1648856499693)(C:\Users\30287\Desktop\笔记\面试问题合集\图片\blog-image-master\img/20220313131709.png)]

其结构如下图所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gs8w8V7T-1648856499694)(C:\Users\30287\Desktop\笔记\面试问题合集\图片\blog-image-master\img/20220313131729.png)]

编码的转换

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

  1. 集合对象保存的所有元素都是整数值
  2. 集合对象保存的元素数量不超过512

能满足这两个条件的集合对象需要使用hashtable编码

7.6 有序集合对象

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

ziplist

ziplist编码的压缩列表对象使用压缩列表作为底层实现

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

例子:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-q2Y5F7UA-1648856499698)(C:\Users\30287\Desktop\笔记\面试问题合集\图片\blog-image-master\img/20220313131422.png)]

其结构如下图所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-dv6BXmQF-1648856499699)(C:\Users\30287\Desktop\笔记\面试问题合集\图片\blog-image-master\img/20220313125045.png)]

skiplist

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

typedef struct zset {
    // 跳跃表
    zskiplist *zsl;
    // 字典
    dict *dict
} zset;

其结构如下图所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-yJrAw2cl-1648856499702)(C:\Users\30287\Desktop\笔记\面试问题合集\图片\blog-image-master\img/20220313125308.png)]

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

有序集合元素同时被保存在字典和跳跃表中:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-eWjyGHi7-1648856499705)(C:\Users\30287\Desktop\笔记\面试问题合集\图片\blog-image-master\img/20220313125338.png)]

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

为什么有序集合需要同时使用跳跃表和字典来实现?

  • 字典可以保证根据成员查找分值查询效率为O(1),但是因为字典以无序的方式来保存集合元素,所以对于范围查询就无能为力了
  • 跳跃表可以保证数据是有序的,范围查询效率较高,但是根据成员查找分值这样操作效率就会低

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

编码的转换

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

  1. 有序集合保存的元素数量小于128
  2. 有序集合保存的所有元素成员的长度都小于64字节

不能满足以上两个条件的有序集合对象将使用skiplist编码。

8.快表

8.1 简介

Redis中的列表list,在版本3.2之前,列表底层的编码是ziplistlinkedlist实现的,但是在版本3.2之后,重新引入 quicklist,列表的底层都由quicklist实现。

在版本3.2之前,当列表对象中元素的长度比较小或者数量比较少的时候,采用压缩列表ziplist来存储,当列表对象中元素的长度比较大或者数量比较多的时候,则会转而使用双向列表linkedlist来存储。

这两种存储方式的优缺点:

  • 双向链表linkedlist便于在表的两端进行push和pop操作,在插入节点上复杂度很低,但是它的内存开销比较大。首先,它在每个节点上除了要保存数据之外,还要额外保存两个指针;其次,双向链表的各个节点是单独的内存块,地址不连续,节点多了容易产生内存碎片。

    因此时间效率ok,但是空间效率太低。

  • 压缩列表ziplist存储在一段连续的内存上,所以存储效率很高。但是,它不利于修改操作,插入和删除操作需要频繁的申请和释放内存。特别是当ziplist长度很长的时候,一次realloc可能会导致大批量的数据拷贝。

    因此空间效率ok,但是时间效率太低。

由于以上两点,在redis 3.2中引入了快表quicklist,能够在时间效率和空间效率间实现较好的折中

8.2 结构

quicklist是一个双向链表,链表中的每个节点是一个ziplist结构。quicklist可以看成是用双向链表将若干小型的ziplist连接到一起组成的一种数据结构。

quicklist是由quicklist node组成的双向链表,quicklist node中又由ziplist充当节点。

quicklist的存储结构如图:

quicklist

typedef struct quicklist {
    quicklistNode *head;
    quicklistNode *tail;
    unsigned long count;        /* total count of all entries in all ziplists */
    unsigned long len;          /* number of quicklistNodes */
    int fill : QL_FILL_BITS;              /* fill factor for individual nodes */
    unsigned int compress : QL_COMP_BITS; /* depth of end nodes not to compress;0=off */
    unsigned int bookmark_count: QL_BM_BITS;
    quicklistBookmark bookmarks[];
} quicklist;
  • head、tail:head和tail分别指向快表的首位节点
  • count:count为quicklist中元素总数
  • len:len为quicklist Node(节点)个数
  • fill:fill用来指明每个quicklistNode中ziplist长度

quicklistNode

typedef struct quicklistNode {
    struct quicklistNode *prev;
    struct quicklistNode *next;
    unsigned char *zl;  		 /* 指向压缩列表的指针 */
    unsigned int sz;             /* ziplist size in bytes */
    unsigned int count : 16;     /* count of items in ziplist */
    unsigned int encoding : 2;   /* RAW==1 or LZF==2 */
    unsigned int container : 2;  /* NONE==1 or ZIPLIST==2 */
    unsigned int recompress : 1; /* was this node previous compressed? */
    unsigned int attempted_compress : 1; /* node can't compress; too small */
    unsigned int extra : 10; /* more bits to steal for future usage */
} quicklistNode;
  • prev、next:因为quicklist为双向链表,所以有prev和next指针,分别指向前驱节点和后继节点
  • zl:zl指向该节点对应的ziplist结构
  • encoding:encoding代表采用的编码方式
    • 1代表是原生的ziplist(未进行再次压缩)
    • 2代表使用LZF进行压缩
  • container:container为quicklistNode节点zl指向的容器类型
    • 1代表none
    • 2代表使用ziplist存储数据
  • recompress:recompress代表这个节点之前是否是压缩节点,若是,则在使用压缩节点前先进行解压缩,使用后需要重新压缩,此外为1,代表是压缩节点
  • attempted_compress:attempted_compress测试时使用
  • extra:extra为预留

quickLZF

quicklist允许ziplist进行再次压缩。当我们对ziplist利用LZF算法进行压缩时,quicklistNode节点指向的结构为quicklistLZF。其中sz表示compressed所占字节大小,quicklistLZF结构如下所示:

typedef struct quicklistLZF {
    
    
    unsigned int sz; /* LZF size in bytes*/
    char compressed[];
} quicklistLZF;

quicklistEntry

当我们使用quicklistNode中ziplist中的一个节点时,Redis提供了quicklistEntry结构以便于使用,该结构如下

可以理解为其为ziplist中的一个节点,只不过记录了更详细的信息

typedef struct quicklistEntry {
    // 指向当前元素所在的quicklist
    const quicklist *quicklist;
    
    // 指向当前元素所在的quicklistNode结构
    quicklistNode *node;
    
    // 指向当前元素所在的ziplist
    unsigned char *zi;
    
    // 指向该节点的字符串内容
    unsigned char *value;
    
    // 该节点的整型值
    long long longval;
    
    // 该节点的大小
    unsigned int sz;
    
    // 该节点相对于整个ziplist的偏移量,即该节点是ziplist第多少个entry
    int offset;
} quicklistEntry;

8.3 基本操作

初始化

初始化是构建quicklist结构的第一步,由quicklistCreate函数完成,该函数的主要功能就是初始化quicklist结构。初始化后的quicklist如下图所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ZhPD100C-1648856499709)(C:\Users\30287\Desktop\笔记\面试问题合集\图片\blog-image-master\img/20220321093639.png)]

插入操作

插入操作分为:

  1. 插入quicklist node
  2. 插入ziplist中的节点

插入时可以选择头插尾插,对应list的lpush和rpush,底层调用的是quicklistPushHead与quicklistPushTail方法

  • quicklistPushHead的基本思路是:查看quicklist原有的head节点是否可以插入,如果可以就直接利用ziplist的接口进行插入,否则新建quicklistNode节点进行插入。函数的入参为待插入的quicklist,需要插入的数据value及其大小sz;函数返回值代表是否新建了head节点,0代表没有新建,1代表新建了head

当quicklist中只有一个节点时,其结构如下图所示:

具体的插入(zlentry)情况如下

  • 当前插入位置所在的quicklistNode仍然可以继续插入,此时可以直接插入
  • 当前插入位置所在的quicklistNode不能继续插入,此时可以分为如下几种情况:
    1. 需要向当前quicklistNode第一个元素(entry1)前面插入元素,当前ziplist所在的quicklistNode的前一个quicklistNode可以插入,则将数据插入到前一个quicklistNode。如果前一个quicklistNode不能插入(不包含前一个节点为空的情况),则新建一个quicklistNode插入到当前quicklistNode前面
    2. 需要向当前quicklistNode的最后一个元素(entryN)后面插入元素,当前ziplist所在的quicklistNode的后一个quicklistNode可以插入,则直接将数据插入到后一个quicklistNode。如果后一个quicklistNode不能插入(不包含为后一个节点为空的情况),则新建一个quicklistNode插入到当前quicklistNode的后面
    3. 不满足前面2个条件的所有其他种情况,将当前所在的quicklistNode以当前待插入位置为基准,拆分成左右两个quicklistNode,之后将需要插入的数据插入到其中一个拆分出来的quicklistNode中

查找操作

quicklist查找元素主要是针对index,即通过元素在链表中的下标查找对应元素。基本思路是,首先找到index对应的数据所在的quicklistNode节点,之后调用ziplist的接口函数ziplistGet得到index对应的数据

简而言之就是:定位quicklistNode,再在quicklistNode中的ziplist中寻找目标节点

在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/qq_45966440/article/details/123454565