Redis 源码分析和存储原理 (三)

1 . SDS

Redis 没有直接使用c语言传统的字符串表示,而是自己构建一套一种名为简单动态字符串的,简称SDS.

SDS的定义

/* Note: sdshdr5 is never used, we just access the flags byte directly.
 * However is here to document the layout of type 5 SDS strings. */
struct __attribute__ ((__packed__)) sdshdr5 {
    unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len; /* used */  // 记录buf数组中已使用字节的数量
    uint8_t alloc; /* excluding the header and null terminator */ //记录buf中总分配的大小
    unsigned char flags; /* 3 lsb of type, 5 unused bits */ //判断是哪一种类型8, 16, 32, 64
    char buf[]; //字节数组,用于保存字符串
};
struct __attribute__ ((__packed__)) sdshdr16 {
    uint16_t len; /* used */
    uint16_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
    uint32_t len; /* used */
    uint32_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
    uint64_t len; /* used */
    uint64_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};

根据传统,C语言使用长度为N+1的字符数组来表示长度为N的字符串,并且字符数组的最后一个元素总是空字符’\0’。C语言使用的这种简单的字符串表示方式,并不能满足Redis对字符串在安全性、效率以及功能方面的要求。而Redisz使用结构体中的alloc来分配和计算长度,保证了字符串如果中间有结束字符,并不会结束。通过使用SDS而不是C字符串,Redis将获取字符串长度所需的复杂度从O(N)降低到了O(1),这确保了获取字符串长度的工作不会成为Redis的性能瓶颈。例如,因为字符串键在底层使用SDS来实现,所以即使我们对一个非常长的字符串键反复执行STRLEN命令,也不会对系统性能造成任何影响,因为STRLEN命令的复杂度仅为O(1)。

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

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

而SDS实现了空间预分配和惰性空间释放两种优化策略:

空间预分配

  • 如果对SDS进行修改之后,SDS的长度(也即是len属性的值)将小于1MB,那么程序分配和len属性同样大小的未使用空间,这时SDS len属性的值将和free属性的值相同。举个例子,如果进行修改之后,SDS的len将变成13字节,那么程序也会分配13字节的未使用空间,SDS的buf数组的实际长度将变成13+13+1=27字节(额外的一字节用于保存空字符)。
  • 如果对SDS进行修改之后,SDS的长度将大于等于1MB,那么程序会分配1MB的未使用空间。举个例子,如果进行修改之后,SDS的len将变成30MB,那么程序会分配1MB的未使用空间,SDS的buf数组的实际长度将为30MB+1MB+1byte

惰性空间释放:

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

总结和C字符串的区别

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-0XJEARjL-1651485927188)(C:\Users\zhen\AppData\Roaming\Typora\typora-user-images\image-20220502153027135.png)]

2. 链表

链表提供了高效的节点重排能力,以及顺序性的节点访问方式,并且可以通过增删节点来灵活地调整链表的长度。作为一种常用数据结构,链表内置在很多高级的编程语言里面,因为Redis使用的C语言并没有内置这种数据结构,所以Redis构建了自己的链表实现。

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

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ptyoGX6W-1651485927188)(C:\Users\zhen\AppData\Roaming\Typora\typora-user-images\image-20220502153325564.png)]

虽然仅仅使用多个listNode结构就可以组成链表,但使用adlist.h/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,而dup、free和match成员则是用于实现多态链表所需的类型特定函数:

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

下图是由一个list结构和三个listNode结构组成的链表。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SH0S72hw-1651485927189)(C:\Users\zhen\AppData\Roaming\Typora\typora-user-images\image-20220502164448173.png)]

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

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

3. 字典(dict)

字典,又称为符号表(symbol table)、关联数组(associative array)或映射(map),是一种用于保存键值对(key-value pair)的抽象数据结构。在字典中,一个键(key)可以和一个值(value)进行关联(或者说将键映射为值),这些关联的键和值就称为键值对。字典中的每个键都是独一无二的,程序可以在字典中根据键查找与之关联的值,或者通过键来更新值,又或者根据键来删除整个键值对,等等。字典在Redis中的应用相当广泛,比如Redis的数据库就是使用字典来作为底层实现的,对数据库的增、删、查、改操作也是构建在对字典的操作之上的。

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

/* This is our hash table structure. Every dictionary has two of this as we
 * implement incremental rehashing, for the old to the new table. */
typedef struct dictht {
    dictEntry **table;  //哈希表数组
    unsigned long size; //哈希表大小
    unsigned long sizemask; //哈希表大小掩码,用于计算索引值,总是等于size-1
    unsigned long used; //哈希表已有节点的数量
} dictht;

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7l1Jjags-1651485927189)(C:\Users\zhen\AppData\Roaming\Typora\typora-user-images\image-20220502164831033.png)]

哈希表节点使用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)的问题。

下图就展示了如何通过next指针,将两个索引值相同的键k1和k0连接在一起。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wApw59jC-1651485927189)(C:\Users\zhen\AppData\Roaming\Typora\typora-user-images\image-20220502165040780.png)]

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

typedef struct dict {
    dictType *type;  //类型特定函数
    void *privdata; //私有数据
    dictht ht[2]; //哈希表
    long rehashidx; /* rehashing not in progress if rehashidx == -1 */
    int16_t pauserehash; /* If >0 rehashing is paused (<0 indicates coding error) */
} 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);
    int (*expandAllowed)(size_t moreMem, double usedRatio);
} dictType;

ht属性是一个包含两个项的数组,数组中的每个项都是一个dictht哈希表,一般情况下,字典只使用ht[0]哈希表,ht[1]哈希表只会在对ht[0]哈希表进行rehash时使用。除了ht[1]之外,另一个和rehash有关的属性就是rehashidx,它记录了rehash目前的进度,如果目前没有在进行rehash,那么它的值为-1。

下图展示了一个普通状态下(没有进行rehash)的字典。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wiUpuj3O-1651485927189)(C:\Users\zhen\AppData\Roaming\Typora\typora-user-images\image-20220502165620986.png)]

解决键冲突:

当有两个或以上数量的键被分配到了哈希表数组的同一个索引上面时,我们称这些键发生了冲突(collision)。Redis的哈希表使用链地址法(separate chaining)来解决键冲突,每个哈希表节点都有一个next指针,多个哈希表节点可以用next指针构成一个单向链表,被分配到同一个索引上的多个节点可以用这个单向链表连接起来,这就解决了键冲突的问题。举个例子,假设程序要将键值对k2和v2添加到图所示的哈希表里面,并且计算得出k2的索引值为2,那么键k1和k2将产生冲突,而解决冲突的办法就是使用next指针将键k2和k1所在的节点连接起来,如图4-7所示。因为dictEntry节点组成的链表没有指向链表表尾的指针,所以为了速度考虑,程序总是将新节点添加到链表的表头位置(复杂度为O(1)),排在其他已有节点的前面。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-S1SrIxdp-1651485927190)(C:\Users\zhen\AppData\Roaming\Typora\typora-user-images\image-20220502165814623.png)]

rehash

随着操作的不断执行,哈希表保存的键值对会逐渐地增多或者减少,为了让哈希表的负载因子(loadfactor)维持在一个合理的范围之内,当哈希表保存的键值对数量太多或者太少时,程序需要对哈希表的大小进行相应的扩展或者收缩,扩展和收缩哈希表的工作可以通过执行rehash(重新散列)操作来完成,Redis对字典的哈希表执行rehash的步骤如下:

  1. 为字典的ht[1]哈希表分配空间,这个哈希表的空间大小取决于要执行的操作,以及ht[0]当前包含的键值对数量(也即是ht[0].used属性的值):
    • 如果执行的是扩展操作,那么ht[1]的大小为第一个大于等于ht[0].used*2的 2 n 2^n 2n
    • 如果执行的是收缩操作,那么ht[1]的大小为第一个大于等于ht[0].used的 2 n 2^n 2n
  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做准备。

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-AAyGeElB-1651485927190)(C:\Users\zhen\AppData\Roaming\Typora\typora-user-images\image-20220502170105487.png)]

  1. ht[0].used当前的值为4,4*2=8,而8( 2 3 2^3 23)恰好是第一个大于等于4的2的n次方,所以程序会将ht[1]哈希表的大小设置为8。下图展示了ht[1]在分配空间之后,字典的样子。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gDPYvffd-1651485927190)(C:\Users\zhen\AppData\Roaming\Typora\typora-user-images\image-20220502170237230.png)]

  1. 将ht[0]包含的四个键值对都rehash到ht[1],如图所示

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-tjAPWdbc-1651485927190)(C:\Users\zhen\AppData\Roaming\Typora\typora-user-images\image-20220502170330699.png)]

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-YEEWFGvB-1651485927190)(C:\Users\zhen\AppData\Roaming\Typora\typora-user-images\image-20220502170413772.png)]

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

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

其中哈希表的负载因子可以通过公式:

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

load_factor = ht[0].used / ht[0].size

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

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

但是,这个rehash动作并不是一次性、集中式地完成的,而是分多次、渐进式地完成的。这样做的原因在于,如果ht[0]里只保存着四个键值对,那么服务器可以在瞬间就将这些键值对全部rehash到ht[1];但是,如果哈希表里保存的键值对数量不是四个,而是四百万、四千万甚至四亿个键值对,那么要一次性将这些键值对全部rehash到ht[1]的话,庞大的计算量可能会导致服务器在一段时间内停止服务。因此,为了避免rehash对服务器性能造成影响,服务器不是一次性将ht[0]里面的所有键值对全部rehash到ht[1],而是分多次、渐进式地将ht[0]里面的键值对慢慢地rehash到ht[1]。

因此,为了避免rehash对服务器性能造成影响,服务器不是一次性将ht[0]里面的所有键值对全部rehash到ht[1],而是分多次、渐进式地将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操作已完成。

渐进式rehash的好处在于它采取分而治之的方式,将rehash键值对所需的计算工作均摊到对字典的每个添加、删除、查找和更新操作上,从而避免了集中式rehash而带来的庞大计算量。

大KEY

在redis实例中形成了很大的对象,比如一个很大的hash或很大的zset,这样的对象在扩容的时候,会一次性申请更大的一块内存,这会导致卡顿;如果这个大key被删除,内存会一次性回收,卡顿现象会再次产生;如果观察到redis的内存大起大落,极有可能因为大key导致的;

# 每隔0.1秒 执行100条scan命令
redis-cli -h 127.0.0.1 --bigkeys -i 0.1

4. 跳表(skiplist)

跳表(多层级有序链表)结构用来实现有序集合;鉴于redis需要实现 zrange 以及 zrevrange功能;需要节点间最好能直接相连并且增删改操作后结构依然有序;B+树时间复杂度为 h ∗ O ( l o g 2 n ) h * O(log_2{n}) hO(log2n)鉴; 于B+复杂的节点分裂操作;考虑其他数据结构;

有序数组通过二分查找能获得 O ( l o g 2 n ) O(log_2{n}) O(log2n)时间复杂度, 平衡二叉树也能获得 O ( l o g 2 n ) O(log_2{n}) O(log2n)时间复杂度;

在大部分情况下,跳跃表的效率可以和平衡树相媲美,并且因为跳跃表的实现比平衡树要来得更为简单,所以有不少程序都使用跳跃表来代替平衡树。Redis使用跳跃表作为有序集合键的底层实现之一,如果一个有序集合包含的元素数量比较多,又或者有序集合中元素的成员(member)是比较长的字符串时,Redis就会使用跳跃表来作为有序集合键的底层实现。

和链表、字典等数据结构被广泛地应用在Redis内部不同,Redis只在两个地方用到了跳跃表,一个是实现有序集合键,另一个是在集群节点中用作内部数据结构,除此之外,跳跃表在Redis里面没有其他用途。

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

从节约内存出发,redis 考虑牺牲一点时间复杂度让跳表结构更加变扁平,就像二叉堆改成四叉堆
结构;并且redis 还限制了跳表的最高层级为 32 ;节点数量大于 128 或者有一个字符串长度大于 64 ,则使用跳表( skiplist );

/* ZSETs use a specialized version of Skiplists */
typedef struct zskiplistNode {
    sds ele;    //成员对象,以sds实现
    double score; // 节点按各自所保存的分值从小到大排列。
    struct zskiplistNode *backward;  // 从后向前遍历的指针
    struct zskiplistLevel {
        struct zskiplistNode *forward; // 前进指针
        unsigned long span; // 跨度
    } level[];
} zskiplistNode;

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-bUVSvl9u-1651485927191)(C:\Users\zhen\AppData\Roaming\Typora\typora-user-images\image-20220502171657497.png)]

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

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

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

  • 层(level):节点中用L1、L2、L3等字样标记节点的各个层,L1代表第一层,L2代表第二层,以此类推。每个层都带有两个属性:前进指针和跨度。前进指针用于访问位于表尾方向的其他节点,而跨度则记录了前进指针所指向节点和当前节点的距离。在上面的图片中,连线上带有数字的箭头就代表前进指针,而那个数字就是跨度。当程序从表头向表尾进行遍历时,访问会沿着层的前进指针进行。

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

  • 分值(score):各个节点中的1.0、2.0和3.0是节点所保存的分值。在跳跃表中,节点按各自所保存的分值从小到大排列。在同一个跳跃表中,各个节点保存的成员对象必须是唯一的,但是多个节点保存的分值却可以是相同的:分值相同的节点将按照成员对象在字典序中的大小来进行排序,成员对象较小的节点会排在前面(靠近表头的方向),而成员对象较大的节点则会排在后面(靠近表尾的方向)

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

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

    • 两个节点之间的跨度越大,它们相距得就越远。
    • 指向NULL的所有前进指针的跨度都为0,因为它们没有连向任何节点。
    • 初看上去,很容易以为跨度和遍历操作有关,但实际上并不是这样,遍历操作只使用前进指针就可以完成了,跨度实际上是用来计算排位(rank)的:在查找某个节点的过程中,将沿途访问过的所有层的跨度累计起来,得到的结果就是目标节点在跳跃表中的排位。
    • 举个例子,图5-4用虚线标记了在跳跃表中查找分值为3.0、成员对象为o3的节点时,沿途经历的层:查找的过程只经过了一个层,并且层的跨度为3,所以目标节点在跳跃表中的排位为3
      - [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-L5bE35kn-1651485927191)(C:\Users\zhen\AppData\Roaming\Typora\typora-user-images\image-20220502172329704.png)]

仅靠多个跳跃表节点就可以组成一个跳跃表,如图所示。但通过使用一个zskiplist结构来持有这些节点,程序可以更方便地对整个跳跃表进行处理,比如快速访问跳跃表的表头节点和表尾节点,或者快速地获取跳跃表节点的数量(也即是跳跃表的长度)等信息,如图所示。

typedef struct zskiplist {
    struct zskiplistNode *header, *tail;
    unsigned long length;
    int level;
} zskiplist;

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-L8HK5ywd-1651485927191)(C:\Users\zhen\AppData\Roaming\Typora\typora-user-images\image-20220502172552048.png)]

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

5. 整数集合

整数集合(intset)是集合键的底层实现之一,当一个集合只包含整数值元素,并且这个集合的元素数量不多时,Redis就会使用整数集合作为集合键的底层实现。如果我们创建一个集合键,并且集合中的所有元素都是整数值,那么这个集合键的底层实现就会是整数集合:

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

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

contents数组是整数集合的底层实现:整数集合的每个元素都是contents数组的一个数组项(item),各个项在数组中按值的大小从小到大有序地排列,并且数组中不包含任何重复项。length属性记录了整数集合包含的元素数量,也即是contents数组的长度。虽然intset结构将contents属性声明为int8_t类型的数组,但实际上contents数组并不保存任何int8_t类型的值,contents数组的真正类型取决于encoding属性的值

  • 如果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)。

展示了一个整数集合示例:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-LExCGirG-1651485927191)(C:\Users\zhen\AppData\Roaming\Typora\typora-user-images\image-20220502175543537.png)]

  • encoding属性的值为INTSET_ENC_INT16,表示整数集合的底层实现为int16_t类型的数组,而集合保存的都是int16_t类型的整数值。
  • length属性的值为5,表示整数集合包含五个元素。
  • contents数组按从小到大的顺序保存着集合中的五个元素。
  • 因为每个集合元素都是int16_t类型的整数值,所以contents数组的大小等于sizeof(int16_t)*5=16*5=80位。

6. 压缩列表(ziplist)

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

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

/* Each entry in the ziplist is either a string or an integer. */
typedef struct {
    /* When string is used, it is provided with the length (slen). */
    unsigned char *sval;
    unsigned int slen;
    /* When integer is used, 'sval' is NULL, and lval holds the value. */
    long long lval;
} ziplistEntry;

每个压缩列表节点可以保存一个字节数组或者一个整数值,其中,字节数组可以是以下三种长度的其中一种:

  • 长度小于等于63( 2 6 – 1 2^6–1 261)字节的字节数组;
  • 长度小于等于16383( 2 14 – 1 2^{14}–1 2141)字节的字节数组;
  • 长度小于等于4294967295( 2 32 – 1 2^{32}–1 2321)字节的字节数组

而整数值则可以是以下六种长度的其中一种

  • 4位长,介于0至12之间的无符号整数;
  • 1字节长的有符号整数;
  • 3字节长的有符号整数;
  • int16_t类型整数;
  • int32_t类型整数;
  • int64_t类型整数

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Y7HE1PZU-1651485927191)(C:\Users\zhen\AppData\Roaming\Typora\typora-user-images\image-20220502180256068.png)]

  • previous_entry_length: 节点的previous_entry_length属性以字节为单位,记录了压缩列表中前一个节点的长度。previous_entry_length属性的长度可以是1字节或者5字节:
    • 如果前一节点的长度小于254字节,那么previous_entry_length属性的长度为1字节:前一节点的长度就保存在这一个字节里面。
    • 如果前一节点的长度大于等于254字节,那么previous_entry_length属性的长度为5字节:其中属性的第一字节会被设置为0xFE(十进制值254),而之后的四个字节则用于保存前一节点的长度。
  • 节点的encoding属性记录了节点的content属性所保存数据的类型以及长度:
    • 一字节、两字节或者五字节长,值的最高位为00、01或者10的是字节数组编码:这种编码表示节点的content属性保存着字节数组,数组的长度由编码除去最高两位之后的其他位记录;
    • 一字节长,值的最高位以11开头的是整数编码:这种编码表示节点的content属性保存着整数值,整数值的类型和长度由编码除去最高两位之后的其他位记录;
  • 节点的content属性负责保存节点的值,节点值可以是一个字节数组或者整数,值的类型和长度由节点的encoding属性决定。

参考:
Redis设计与实现

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值