Loading

《Redis设计与实现》01.数据结构

数据结构

简单动态字符串

​ C语言使用长度为N+1长度的字符数组来存放长度为N的字符串,且在字符数组的最后一个元素总是空字符"\0",在Redis中C字符串自会作为字符串字面量用在一些无需对字符串值进行修改的地方,例如打印日志:redislog(REDIS_WARNING, "redis is now reday to exit, bye bye ");。在Redis中自定义了一种名为简单动态字符串Simple Dynamic String,SDS)的抽象类型,并将SDS作为Redis的默认字符串表示。例如执行一下命令:

redis> RPUSH fruits "apple" "banana" "cherry"
(integer) 3

那么Redis将在数据库中创建一个新的键值对,其中:

  • 键值对的键是一个字符串对象,对象的底层实现是一个保存了字符串"fruits"的SDS
  • 键值对的值是一个列表对象,列表对象包含了三个字符串对象,这三个字符串对象分别是由SDS实现的"apple""banana""cherry"

SDS的定义

SDS是由sds.h/sdshdr结构定义的,在3.2版本之前的定义如下:

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

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

但是这样的定义方式的缺点是在大多数情况下我们SDS中保存的都是一些简单短小的字符串值,那么使用4字节存储的len和free属性就会造成内存浪费,所以在3.2版本,Redis将SDS按照存储字符串的长度分为五种类型,这样就可以根据字符串长度的不同使用不同的数据结构进行存储,有效的节省空间。这五种类型的定义如下:

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 */
    uint8_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    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[];
};

新类型中Redis增加了flags属性来存储类型,flags属性占用一个字节。其中sdshdr5中的buf数组用于存储实际内容,flags属性前三位用于存储字符串的类型,后五位用于存储字符串的长度,所以sdshdr5能存储的字符串长度最大为25-1=31,而对于长度大于31的字符串来说,5位的存储空间是肯定不够的,所以在sdshdr8及以上的类型定义里使用len属性存储字符串长度,使用alloc属性存储总长度,而flags属性前三位依然是存储类型,后五位则预留,所以sdshdr8能存储字符串的最大长度为28-1=255,以此类推

SDS获取字符串长度复杂度

​ 由于C字符串不记录自身的长度信息,所以获取C字符串的长度需遍历整个字符串,对每个字符进行计数,直到遇到代表C字符串结尾的空字符'/0'为止,所以获取C字符串长度的时间复杂度为O(N)。而SDS在属性len中记录了字符串长度信息,所以获取SDS字符串长长度的时间复杂度为O(1)。这确保了获取字符串长度的工作不会成为Redis的性能瓶颈,例如多次对一个长字符串使用STRLEN命令也不会影响Redis的性能

杜绝缓冲区溢出

​ C字符串另一个问题是容易造成缓冲区溢出(buffer overflow),例如<string.h>/strcat函数可以将一个字符串的内容拼接到目标字符串的末尾,但是如果目标字符串分配的内存空间不足就会产生缓冲区溢出,可能会覆盖掉别的内容。

​ 与C字符串不同的是,SDS在进行修改之前,APS会先检查SDS的空间是否满足修改所需的要求,如果不满足的话,API会自动将SDS的空间扩展到执行修改所需的大小,然后再执行修改操作。所以SDS的空间分配策略完全杜绝了发生缓冲区溢出的可能。

SDS空间分配策略

​ C字符串的底层实现总是为N+1长度的字符数组,所以对字符串进行修改长度操作,例如增加或缩短一个C字符串,程序总要对保存C字符串的数组进行一次内存重分配操作:

  • 如果是增长字符串操作,那么执行这个操作之前程序要先通过内存重分配来扩展底层字符数组的空间大小,如果忘记这一步骤就会造成缓冲区溢出
  • 如果是缩短字符串操作,那么执行这个操作之后程序要通过内存重分配来释放掉字符数组未被使用的空间,如果忘记这一步骤就会产生内存泄漏

因为内存重分配涉及到非常复杂的算法,并且可能执行系统调用,所以通常是一个比较耗时的操作。在一般的程序中如果修改字符串的情况不经常出现,那么每次修改字符串进行一次内存重分配还是可以接受的。但是Redis作为以性能著称的数据库,经常被用于速度要求高,修改次数多的场景,如果每次修改都进行一次内存重分配那肯定是不合适的。

​ 所以为了避免这种情况出现,SDS通过未使用空间这一特性解除了字符串长度和字符数组长度之间的关联。而针对未使用空间这一特性,SDS实现了空间预分配和惰性空间释放两种优化策略:

空间预分配:空间预分配用于优化SDS的字符串增长操作,当SDS需要进行空间扩展时,API不仅会为SDS分配修改所需要的内存空间,还会为SDS分配额外的未使用空间

额外分配的未使用空间分配策略如下:

  • 修改之后SDS中len的属性值小于1MB,那么程序分配和len属性同样大小的未使用空间;修改之后SDS中len的属性值大于等于1MB,那么程序会分配1MB的未使用空间

通过空间预分配策略,Redis可以减少连续执行字符串增长操作时所需的内存重分配次数,在扩展SDS内存空间之前,API会先检查未使用空间是否足够,如果足够的话API就会使用未分配空间,而无需进行内存重分配。

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

通过惰性空间释放策略,SDS避免了缩短字符串时所需的内存重分配操作,并为以后可能发生的增长字符串操作提供了优化。SDS也提供了手动释放未使用空间的API。

二进制安全

C字符串中的字符必须符合某种编码(例如ASCII),并且除了字符串末尾以外不能包含空字符,否则空字符将会被认定为字符串结尾。这些限制使得C字符串只能保存文本数据而不能保存图片,视频等二进制数据。而SDS的API都是二进制安全的,API会以处理二进制的方式来处理SDS中的字符数组里的数据,这也是我们将SDS中的数组称为字节数组的原因

例如SDS中保存了带空字符的字符串也是能够读取成功的,因为SDS使用len属性值而不是空字符来判断字符串是否结束

兼容部分C字符串函数

虽然SDS是二进制安全的,但是它们和C字符串一样遵循这空字符结尾的惯例,这样SDS就可以重用C字符串中的部分函数

链表

​ 链表提供高效的节点重排能力,以及顺序性的节点访问方式,并且可以灵活的增删节点来调节链表的长度。链表在Redis中的应用非常广泛,比如列表的底层实现之一就是链表,当一个链表包含了数量较多的元素或链表中包含了较长的字符串时,列表就会使用链表作为底层实现

​ 除了列表外,发布与订阅、慢查询、监视器等功能也用到了链表,Redis服务器本身还用链表来保存多个客户端的状态信息,以及使用链表来构建客户端输出缓冲区(output buffer)

链表及链表节点的定义

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

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

多个链表节点通过prevnext指针连接成双端链表,链表使用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 *value);
}list;

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

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

总结

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

字典

​ 字典又称符号表(symbol table)、关联数组(associative array)或映射(map),是一种用于保存键值对的抽象数据结构。字典在Redis中的应用相当广泛,比如Redis的数据库就是使用字典来作为底层实现的,对数据库的增删改查操作也是构建在对字典的操作之上

​ 除了用来表示数据库以外,字典还是哈希值的底层实现之一,当一个哈希值包含的键值对比较多或者键值对中的元素都是比较长的字符串时,哈希值会使用字段作为其底层实现

字典的定义

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

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

typedef  struct dictEntry{
    // 键
    void *key;
    
    // 值
    union{
        void *val;
        uint64_t u64;
        int64_t s64;
    }v;
    
    // 指向下一节点的指针,在发生哈希冲突时形成链表
    struct dictEntry *next;
}dictEntry;

dictEntry结构中key属性保存键值对中的键,而v属性则保存键值对中的值,其中键值对中的值可以是一个指针,也可以是一个uint64_t整数,又或者是一个int64_t整数,next属性是指向另一个哈希表节点的指针,这个指针可以将多个哈希值相等的键值对连接在一起组成链表,以解决哈希冲突问题

Redis中哈希表是由dict.h/dictht结构定义:

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

table属性是一个数组,数组中的每一个元素都是指向dictEntry结构的指针;size属性记录了哈希表的大小,也就是table数组的大小;used记录了哈希表目前已有的节点数量;sizemask属性和哈希值一起决定键值对在table数组中的索引

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

typedef struct dict{
    // 类型特定函数
    dictType *type;
    
    // 私有数据
    void *privdata;
    
    // 哈希表
    dictht ht[2];
    
    // rehash索引,不在rehash阶段时值为-1
    int rehashidx;
}dict;

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

  • type属性是一个指向dictType结构的指针,每个dictType结构保存了一簇用于操作特定类型键值对的函数,Redis会为用途不同的字典设置不同的类型特定函数
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;
  • privdata属性保存了需要传给类型特定函数的可选参数

ht属性是一个包含两个项的数组,数组中的每一个项都是一个dictht哈希表,一般情况下,字典只使用ht[0]哈希表,ht[1]哈希表在对ht[0]进行rehash的时候使用;rehashinx属性记录了rehash的进度,如果没有正在进行的rehash那么rehashidx的值为-1。

哈希算法

当要将一个新的键值对添加到字典中时,程序会先计算出键值对中键的哈希值,然后根据哈希值哈希表大小掩码sizemask进行与运算来计算出该键值对在哈希表数组的索引:

# 使用字典设置的哈希函数来计算key的哈希值
hash = dict -> typr -> hashFunction(key);
# 使用哈希表的sizemask属性和哈希值进行与运算计算出索引
# 根据情况不同,ht[x]可能是ht[0]也可能是ht[1]
index = hash & dict -> ht[x].sizemask;

当有两个或以上的键值对分配到同一个索引上时,我们称这些键发生了冲突,即哈希冲突;Redis的哈希表使用链表法来解决哈希冲突,每个哈希表节点都有一个next指针,发生哈希冲突的键值对通过next指针构成一个单向链表,由于dictEntry组成的链表没有指向表头表尾的指针,所以为了速度考虑,程序总是将新节点添加到链表的头位置(复杂度为O(1)),如果放在表尾则需要通过哈希表数组索引上的dictEntry遍历整个链表,复杂度为O(N)

rehash

​ 随着操作的不断执行,哈希表保存的键值对会逐渐的增多或减少,为了让哈希表的加载因子(load factor)保持在一个合理的范围之内,当哈希表中的键值对数量太多或太少时,程序需要对哈希表的大小进行相应的扩展和伸缩

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

  • 服务器没有在执行BGSAVEBGREWRITEAOF命令且哈希表的加载因子大于等于1
  • 服务器正在执行BGSAVEBGREWRITEAOF命令且哈希表的加载因子大于等于5

哈希表的加载因子计算方法:

# 加载因子 = 哈希表已保存节点数量 / 哈希表数组大小
load_factor = ht[0].used / ht[0].size

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

而当哈希表的加载因子小于0.1时,程序开始自动执行哈希表的收缩操作

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

  1. 为字典的ht[1]哈希表分配空间,分配空间的大小取决于要执行的操作以及ht[0]中包含的键值对数量
    1. 如果执行的是扩展操作,那么ht[1]的大小是第一个大于等于ht[0].used*2的2的N次幂
    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[1]设置为ht[0],并新创建一个空白哈希表作为ht[1],为下次rehash做准备

渐进式rehash

​ 为了避免哈希表数组中键值对数量过多导致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+1
  4. 当ht[0]上的所有键值对都rehash到ht[1]上之后,程序会将rehashidx设为-1,表示rehash操作已完成

渐进式rehash的好处是它采取分而治之的方式,将rehash键值对所需的计算工作均摊到对字典的每个增删改查操作上,从而避免了集中式rehash带来的巨大计算量对服务的影响

在渐进式rehash操作执行期间,对字典进行删除、更新、查询操作会在两个哈希表上进行,例如查找会先在ht[0]查找,如果没有找到再到ht[1]上进行查找。而在渐进式rehash执行期间对字典进行增加操作则只会在ht[1]上进行,这一措施保证了ht[0]中的键值对只增不减,并随着rehash的操作最终变成空表

总结

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

4.跳跃表

《深入理解Redis-跳跃表》

《深入理解Redis跳跃表的基本实现和特性》一角钱技术

​ 跳跃表(skip list)是一种有序的数据结构,它通过在节点中维持多个指向其他节点的指针从而达到快速访问的目的。大部分情况下,跳跃表和平衡树的效率相近,但是跳跃表的实现要相对来说简单很多

​ Redis使用跳跃表来作为有序集合的实现之一,如果一个有序集合里包含的元素较多或者元素成员是较长字符串时,有序集合就会使用跳跃表作为其底层实现。除了实现有序集合外,跳跃表的另一个作用是在集群节点中用作内部结构

跳跃表的定义

跳跃表是一种典型的以空间换时间的数据结构

对一个单链表来讲,即使存储的数据是有序的,我们想要查找其中一个数据也要从头到尾遍历链表,这样查找效率很低,时间复杂度是O(N),如下图所示:

单链表示意图

如果我们想要提高效率,可以考虑建立一个索引,即把单链表中每两个节点提取出一个节点再建立一个单链表,抽取出来的单链表我们当作索引,如下图所示:

一级索引

这个时候我们查找节点就可以先在索引层查找,如果索引层有这个数据证明这个数据是存在的,如果没有则找到索引中查找节点所在的区间继续去底层链表查询

在原有链表上加了一层索引。查找一个节点所需遍历的节点减少了,也就是查询效率提高了,那当我们有大量数据时,我们可以增加多级索引,像这种链表加多级索引的结构就叫做跳跃表,如下图所示:

跳跃表

从图中可以看出,跳跃表主要包含以下特点:

  • 表头(head):负责维护跳跃表的节点指针
  • 跳跃表节点:保存着元素值,以及多个层
  • :保存着指向其他元素的指针,高层的指针越过的元素数量大于低层的指针。为了提高查询效率,程序总是从高层开始先访问,然后随着元素值范围的缩小,慢慢降低层次
  • 表尾:全部由null组成,表示跳跃表的结尾

Redis中跳跃表的实现

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

zskiplistNode的定义如下:

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

zskiplistNode结构定义的跳跃表节点特点如下:

  • :跳跃表节点中的level数组又叫做层数组,可以包含多个层即zskiplistLevel元素,每个元素又包含一个前进指针和跨度值。程序可以通过这些层的访问来提高查询效率,每次创建一个新的zskiplistNode跳跃表节点时,程序都会根据幂次定律随机生成一个介于132之间的值作为level数组的大小,这个大小就是层的高度。跳跃表表头节点固定层高为32
    • 前进指针:每个层都有一个指向表尾方向的前进指针,用于从表头向表尾方向查询节点
    • 跨度:层的跨度用于记录两个节点之间的距离,指向null的所有层的跨度都为0。跨度是用来计算节点的排位的,再查找某个节点的过程中,将沿途所有访问过的节点跨度累加起来,结果就是目标节点在跳跃表中的排位
  • 后退指针:节点的后退指针用于从表尾向表头的遍历操作,因为每个节点只有一个后退指针,所以一次只能后退到前一个节点
  • 分值:节点的分值是一个double类型的浮点数,跳跃表中的所有节点都按照分值进行排序,分值相同的节点将按照成员对象在字典序中的大小来排序
  • 成员对象:节点的成员对象是一个指针,它指向一个SDS字符串对象

zskiplist结构的定义如下:

typedef struct zskiplist {
    // 表头节点和表尾节点
    struct zskiplistNode *head, *tail;
    // 表中节点数量
    unsigned long length;
    //表中层数最大节点的层数
	int level;
} zskiplist;

zskiplist定义的跳跃表的特点如下:

  • head指针和tail指针分别指向表头节点和表尾节点,所以跳跃表中获取表头节点和表尾节点的时间复杂度为O(1)
  • length属性记录跳跃表中节点的数量,所以获取跳跃表的长度的时间复杂度为O(1)不包括表头节点
  • level属性记录跳跃表中层高最大的那个节点的层级数量,但不包括表头节点

总结

  • 跳跃表是有序集合的底层实现之一
  • Redis跳跃表实现是由zskiplistzskiplistNode两个结构组成,其中zskiplist结构用于保存跳跃表的信息,例如头尾节点,表中节点数量等;而zskiplistNode则用于表示跳跃表节点
  • 每个跳跃表节点的的层高都是132之间的随机数,表头节点层高固定为32
  • 表头节点和其他节点的构造是一样的,也有后退指针、分值和成员对象,不过表头节点的这些属性不会被用到
  • 在同一个跳跃表中,多个跳跃表节点可以具有相同的分值,当跳跃表节点分值相等时则按照成员对象在字典序中的大小来排序
  • Redis中有序集合采用跳跃表实现而不是平衡树的原因是:平衡树的删除和插入操作逻辑复杂,有序集合经常会执行ZRANGEZREVRANGE操作,所以跳跃表里面的双向链表十分方便,且在查询效率相近的情况下,跳跃表的实现要更为简单

5.整数集合

整数集合intset)是集合的底层实现之一,当集合中只包含整数值元素且元素数量不多时,Redis就会使用整数集合来实现集合

整数集合的定义

​ 整数集合是Redis用于保存整数值的集合抽象数据结构,它可以保存int16_tint32_tint64_t的整数值,并且保证集合中不会出现重复元素。整数集合由intset.h/intset结构定义:

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

length属性记录了整数集合中元素的数量,也就是contents数组的长度;contents数组是整数集合的底层实现,整数集合中存储的值按照大小有序的在此数组里面排列存放。虽然contents属性声明为int8_t类型的数组,但实际上contents属性并不保存任何int8_t的值,contents数组的真正类型取决于encoding属性的值:

  • 如果encoding属性的值为INTSET_ENC_INT16,那么contents就是int16_t类型的数组,则一个元素占16位空间
  • 如果encoding属性的值为INTSET_ENC_INT32,那么contents就是int32_t类型的数组,则一个元素占32位空间
  • 如果encoding属性的值为INTSET_ENC_INT64,那么contents就是int64_t类型的数组,则一个元素占64位空间

升级和降级

​ 当我们要将一个新元素添加到整数集合里面,且新元素的类型比整数集合里面元素类型要长的时候,整数集合会先进行升级,然后再将新元素添加到整数集合里面,升级的步骤如下:

  1. 根据新元素类型,扩展整数集合底层数组的空间大小,并为新元素分配空间
  2. 将底层数组所有元素都转换为和新元素相同的类型,并将转换后的原有元素按照原有顺序放置到正确的位上
  3. 将新元素添加到底层数组里面,因为引发升级的新元素的长度总是比现有元素的长度都大,所以这个新元素要么大于最大的原有元素,要么小于最小的原有元素,所以新元素要么放在底层数组的开头,要么放在底层数组的结尾

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

​ 整数集合的升级策略有两个好处,一个是提升整数集合的灵活性,一个是尽可能的节约内存

  • 提升整数集合的灵活性:C语言是静态语言,为了避免类型错误,我们通常不会将两种不同类型的值放在同一个数据结构里。由于整数集合的升级策略我们可以随意的将int16_t、int32_t和int64_t类型的整数添加到整数集合中,而不必担心出现类型错误
  • 节约内存:要让一个数组同时保存int16_t、int32_t和int64_t类型的整数,最简单的方法就是定义一个int64_t类型的数组,但这样势必会出现浪费内存的情况,而整数集合现在的做法既可以让集合能够保存三种不同类型的元素,也能尽可能的节约内存

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

总结

  • 整数集合集合的底层实现之一
  • 整数集合的底层实现为数组,这个数组以有序无重复元素的方式保存着集合元素。在有需要的时候程序会根据新添加元素的类型,改变这个数组的类型,即升级
  • 升级操作为整数集合带来操作上的灵活性,以及尽可能的节约内存
  • 整数集合只支持升级操作,不支持降级操作
6.压缩列表

压缩列表是Redis为了节省内存而开发的数据结构,它是由一系列特殊编码的连续内存块组成的顺序性数据结构

压缩列表的构成

一个压缩列表可以包含任意多个节点(entry),每个节点可以保存一个字节数组和一个整数值。下图展示了压缩列表的各个组成部分:

压缩列表的各个组成部分含义如下:

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

压缩列表节点的构成

节点的结构一般是:<prevlen><encoding><content>,各个属性的特点如下:

  • prevlen:用于记录前一个节点所占的字节长度,此属性的作用是通过当前节点起始位置减去前一节点所占的长度可以获得前一节点的起始位置,用于支持压缩列表从表尾到表头的遍历操作。此属性编码长度可以是1字节或5字节
    • 当前一节点长度小于254时,当前节点的prevlen属性长度为1字节
    • 当前一节点长度大于等于254时,当前节点的prevlen属性长度为5字节,其中属性的第一个字节会被设置成0xFE(254),之后的四个字节用于保存前一个节点的长度
  • encoding:记录了节点的content属性所保存的数据类型以及长度
  • content:节点的content属性用于保存节点的值,节点值可以是一个字节数组或者整数,值的类型和长度由encoding属性决定

连锁更新

通过上面的分析,我们知道:

  • 前个节点的长度小于 254 的时候,用 1 个字节保存 prevlen
  • 前个字节的长度大于等于 254 的时候,用 5 个字节保存 prevlen

现在我们来考虑一种情况:假设一个压缩列表中,有多个长度 250 ~ 253 的节点,假设是 entry1 ~ entryN。
因为都是小于 254,所以都是用 1 个字节保存 prevlen。
如果此时,在压缩列表最前面,插入一个 254 长度的节点,此时它的长度需要 5 个字节
也就是说 entry1.prevlen 会从 1 个字节变为 5 个字节,因为 prevlen 变长,entry1 的长度超过 254 了。
这下就糟糕了,entry2.prevlen 也会因为 entry1 而变长,entry2 长度也会超过 254 了。
然后接着 entry3 也会连锁更新。。。直到节点不超过 254, 噩梦终止。。。

这种由于一个节点的增删,后续节点变长而导致的连续重新分配内存的现象,就是连锁更新。最坏情况下,会导致整个压缩列表的所有节点都重新分配内存。

每次分配空间的最坏时间复杂度是 O(N),所以连锁更新的最坏时间复杂度高达 O(N^2)!

虽然说,连锁更新的时间复杂度高,但是它造成大的性能影响的概率很低,原因如下:

  1. 压缩列表中需要需要有连续多个长度刚好为 250 ~ 253 的节点,才有可能发生连锁更新。实际上,这种情况并不多见。
  2. 即使有连续多个长度刚好为 250 ~ 253 的节点,连续的个数也不多,不会对性能造成很大影响

因此,压缩列表插入操作,平均复杂度还是 O(n).

总结

  • 压缩列表是一种为了节约内存而开发的顺序性数据结构
  • 压缩列表被用作有序列表哈希键的底层实现之一
  • 压缩列表可以包含多个节点,每个节点可以存储一个字节数组或者整数值
  • 添加节点或者删除节点可能会导致压缩列表发生连锁更新操作,但这种操作出现的频率并不高
posted @ 2023-02-26 14:06  edws  阅读(37)  评论(0编辑  收藏  举报