Redis底层数据结构

289 阅读30分钟

又是美好的一天呀~ 个人博客地址: huanghong.top

本文预估阅读时长为1个小时左右~

C字符串

C语言使用长度为N+1的字符数组来表示长度为N的字符串,并且字符数组的最后一个元素总是空字符'\0'。

字符串长度计算

image-20230303103810035

  • 字符串长度计算需要从首字节索引开始遍历知道遇到代表字符串结尾的空字符位置,复杂度为O(n)。

  • 字符串以"\0"空字符作为结尾标识,当存储二进制数据的时候可能会出现单个字符串中有多个"\0"标识,会导致数据读取遍历不完整。

字符串拼接

image-20230303104514755
  • 进行字符串拼接的过程中可能会导致原已使用内存空间被覆盖

总结

  1. 字符串长度获取需要遍历整个字符串数组,遇到尾标识"\0"则停止计算,复杂度为O(n)。
  2. 字符串拼接过程中存在原已使用内存空间被覆盖(内存溢出)。
  3. 字符串以"\0"做为尾标识,存储以多个"\0"作为分隔符的字符串时会出现安全问题。

SDS

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

长度计算

image-20230303105205339

  • 存储字符串时维护了一个len属性用来存储字符串长度,调用STRLEN命令获取字符串长度,复杂度为O(1)。
  • 获取字符串内容时只需要根据len来进行截取数组中指定长度即可返回对应内容,不存在安全问题。

字符串拼接

image-20230303105529789
  • 空间预分配用于优化SDS 的字符串增长操作:当SDS的API对一个SDS进行修改,并且需要对SDS进行空间扩展的时候,程序不仅会为SDS分配修改所必须要的空间,还会为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数组的实际长度将为30 MB + 1MB +1byte。
  • 惰性空间释放用于优化SDS 的字符串缩短操作:当SDS的API需要缩短SDS保存的字符串时,程序并不立即使用内存重分配来回收缩短后多出来的字节,而是使用free属性将这些字节的数量记录起来,并等待将来使用。

对比总结

C字符串SDS
获取字符串长度的复杂度为O(N)获取字符串长度的复杂度为O(1)
API是不安全的,可能会造成缓冲区溢出API是安全的,不会造成缓冲区溢出
修改字符串长度N次必然需要执行N次内存重分配修改字符串长度N次最多需要执行N次内存重分配
只能保存文本数据可以保存文本或者二进制数据

双向链表

image-20230303114207166

链表节点结构

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

链表结构

//  adlist.h/list
struct list{
    //链表头结点
    struct listNode *head;
    //链表尾结点
    struct listNode *tail;
    //链表所包含节点数量
    unsigned long len;
    //节点值复制函数
    void *(*dup)(void *ptr);
    //节点值释放函数
    void *(*free)(void *ptr);
    //节点值对比函数
    void *(*match)(void *ptr,void *key);
} list;

总结

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

压缩列表ziplist

压缩列表是由一系列特殊编码的连续内存块组成的顺序型(sequential)数据结构(ziplist被设计成一种内存紧凑型的数据结构,占用一块连续的内存空间,以达到节省内存的目的)。一个压缩列表可以包含任意多个节点(entry),每个节点可以保存一个字节数组或者一个整数值。

image-20230305200405729

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

entry节点结构

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

其中,字节数组可以是以下三种长度的其中一种:

  1. 长度小于等于63(2的6次方-1)字节的字节数组;

  2. 长度小于等于16383 (2的14次方-1)字节的字节数组;

  3. 长度小于等于4294967295 (2的23次方-1)字节的字节数组;

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

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

节点结构

image-20230305202232930

  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属性所保存数据的类型以及长度。

  3. content

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

连锁更新

在大链表更新(新增、修改、删除)的过程中,由于previous_entry_length属性可以为1字节或5字节,导致操作元素previous_entry_length属性由原占用空间1字节变更成5字节,可能会使后续元素进行连锁更新previous_entry_length值。

总结

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

quicklist

在Redis 3.0之前,List对象的底层数据结构是双向链表或者压缩列表。然后在Redis 3.2的时候,List 对象的底层改由quicklist数据结构实现。

虽然压缩列表是通过紧凑型的内存布局节省了内存开销,但是因为它的结构设计,如果保存的元素数量增加或者元素变大了,压缩列表会有「连锁更新」的风险。—旦发生,会造成性能下降。

quicklist解决连锁更新的方案

通过控制每个链表节点中的压缩列表的大小或者元素个数,来规避连锁更新的问题。因为压缩列表元素越少或越小,连锁更新带来的影响就越小。从而提供了更好的访问性能。

quicklist结构

quicklist的结构体跟链表的结构体类似,都包含了表头和表尾,区别在于quicklist 的节点是quicklistNode。

image-20230305205341897

typedef struct quicklist {
    //头结点
    quicklistNode *head;
    //尾节点
    quicklistNode *tail;
    //所有压缩列表中的总元素个数
    unsigned long count;
    //quicklistNodes的个数
    unsigned long len;
    ...
} quicklist;

quicklistNode结构

typedef struct quicklistNode {
    //前一个quicklistNode节点
    struct quicklistNode *prev;
    //下一个quicklistNode节点
    struct quicklistNode *next;
    //quicklistNode指向的压缩列表
    unsigned char *zl;
    //压缩列表的字节大小
    unsigned int sz;
    //压缩列表的元素个数
    unsigned int count : 16;
    ...
} quicklistNode;

总结

  1. 在向quicklist添加一个元素的时候,不会像普通的链表那样,直接新建一个链表节点。而是会检查插入位置的压缩列表是否能容纳该元素,如果能容纳就直接保存到quicklistNode结构里的压缩列表,如果不能容纳,才会新建一个新的quicklistNode 结构。
  2. quicklist 会控制quicklistNode结构里的压缩列表的大小或者元素个数,来规避潜在的连锁更新的风险,但是这并没有完全解决连锁更新的问题

listpack

quicklist 虽然通过控制quicklistNode结构里的压缩列表的大小或者元素个数,来减少连锁更新带来的性能影响,但是并没有完全解决连锁更新的问题。

因为quicklistNode还是用了压缩列表来保存元素,压缩列表连锁更新的问题,来源于它的结构设计,所以要想彻底解决这个问题,需要设计—个新的数据结构。

Redis 在5.0新设计一个数据结构叫 listpack,目的是替代压缩列表,它最大特点是listpack 中每个节点不再包含前一个节点的长度了,压缩列表每个节点正因为需要保存前一个节点的长度字段,就会有连锁更新的隐患

在最新6.2发行版本中,Redis Hash对象、Set对象的底层数据结构的压缩列表还未被替换成listpack,而Redis 的最新代码(还未发布版本)已经将所有用到压缩列表底层数据结构的Redis对象替换成listpack数据结构来实现,估计不久将来,Redis 就会发布一个将压缩列表为listpack的发行版本。

listpack结构

image-20230305210759684

entry结构

image-20230305211108413

  1. encoding:定义该元素的编码类型,会对不同长度的整数和字符串进行编码;
  2. data:实际存放的数据;
  3. len:encoding+data的总长度;

总结

listpack没有压缩列表中记录前一个节点长度的字段了,listpack 只记录当前节点的长度,当向listpack 加入一个新元素的时候,不会影响其他节点的长度字段的变化,从而避免了压缩列表的连锁更新问题。

字典

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

哈希表结构

image-20230305212615781

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

dictEntry结构

typedef struct dictEntry {
    //键
    void *key;
    //值
    union {
        void *val;
        uint64_tu64;
        int64_ts64;
    } v;
    //只想下一个哈希表节点,形成链表
    strcut dictEntry *next;
} dictEntry;

字典结构

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

image-20230305213535955

解决键冲突问题

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

rehash

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

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

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

哈希表的扩容与收缩

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

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

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

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

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

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

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

渐进式rehash

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

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

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

总结

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

整数集合

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

整数集合结构

image-20230306101343856

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

升级

image-20230306105614270

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

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

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

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

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

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

总结

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

跳表

跳跃表(skiplist)是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。跳跃表支持平均O(logN)、最坏O(N)复杂度的节点查找,还可以通过顺序性操作来批量处理节点。

在大部分情况下,跳跃表的效率可以和平衡树相媲美,并且因为跳跃表的实现比平衡树要来得更为简单,所以有不少程序都使用跳跃表来代替平衡树。

Redis只有在两个地方使用到跳跃表,一个是实现有序集合键,另一个是在集群节点中用作内部数据结构

跳表结构

image-20230306112705166

typedef struct zskiplist {
    //表头节点和表尾节点
    struct zskiplistNode *header,*tail;
    //表中节点的数量(表头节点的层数不计算在内)
    struct zskiplistNode *length;
    //表中层级最大的节点层数(表头节点不计算在内)
    int level;
} zskiplist;

header和 tail指针分别指向跳跃表的表头和表尾节点,通过这两个指针,程序定位表头节点和表尾节点的复杂度为O(1)。

通过使用length属性来记录节点的数量,程序可以在O(1)复杂度内返回跳跃表的长度。level属性则用于在O(1)复杂度内获取跳跃表中层高最大的那个节点的层数量,注意表头节点的层高并不计算在内

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

  1. 层(level) :节点中用L1、L2、L3等字样标记节点的各个层,L1代表第一层,L2代表第二层,以此类推。每个层都带有两个属性:前进指针和跨度。前进指针用于访问位于表尾方向的其他节点,而跨度则记录了前进指针所指向节点和当前节点的距离。在上面的图片中,连线上带有数字的箭头就代表前进指针,而那个数字就是跨度。当程序从表头向表尾进行遍历时,访问会沿着层的前进指针进行。
  2. 后退(backward)指针:节点中用Bw字样标记节点的后退指针,它指向位于当前节点的前一个节点。后退指针在程序从表尾向表头遍历时使用。
  3. 分值(score):各个节点中的1.0、2.0 和3.0是节点所保存的分值。在跳跃表中,节点按各自所保存的分值从小到大排列。
  4. 成员对象(obj):各个节点中的o1、o2和 o3是节点所保存的成员对象。 注意表头节点和其他节点的构造是一样的:表头节点也有后退指针、分值和成员对象,不过表头节点的这些属性都不会被用到,所以图中省略了这些部分,只显示了表头节点的各个层

entry结构

image-20230306111246000

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

总结

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

Redis对象

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

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

Redis对象结构

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

type

对象对象type属性的值TYPE命令输出
字符串对象REDIS_STRINGstring
列表对象REDIS_LISTlist
哈希对象REDIS_HASHhash
集合对象REDIS_SETset
有序集合对象REDIS_ZSETzset

encoding

类型编码对象
REDIS_STRINGREDIS_ENCODING_INT使用整数值实现的字符串对象
REDIS_STRINGREDIS_ENCODING_EMBSTR使用embstr编码的简单动态字符串实现的字符串对象
REDIS_STRINGREDIS_ENCODING_RAW使用简单动态字符串实现的字符串对象
REDIS_LISTREDIS_ENCODING_ZIPLIST使用压缩列表实现的列表对象
REDIS_LISTREDIS_ENCODING_LINKEDLIST使用双端链表实现的列表对象
REDIS_HASHREDIS_ENCODING_ZIPLIST使用压缩列表实现的哈希对象
REDIS_HASHREDIS_ENCODING_HT使用字典实现的哈希对象
REDIS_SETREDIS_ENCODING_INTSET使用整数集合实现的集合对象
REDIS_SETREDIS_ENCODING_HT使用字典实现的集合对象
REDIS_ZSETREDIS_ENCODING_ZIPLIST使用压缩列表实现的有序集合对象
REDIS_ZSETREDIS_ENCODING_SKIPLIST使用跳跃表和字典实现的有序集合对象

String

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

image-20230306124517650

编码转换

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

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

    • embstr编码将创建字符串对象所需的内存分配次数从raw编码的两次降低为一次。释放embstr编码的字符串对象只需要调用一次内存释放函数,而释放raw编码的字符串对象需要调用两次内存释放函数。
    • 因为embstr编码的字符串对象的所有数据都保存在一块连续的内存里面,所以这种编码的字符串对象比起raw编码的字符串对象能够更好地利用缓存带来的优势。
  3. 如果字符串对象保存的是一个字符串值,并且这个字符串值的长度大于32字节,那么字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串值,并将对象的编码设置为raw。

    image-20230306123929496

List

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

ziplist

image-20230306125705434

linkedlist

image-20230306125728233

编码转换

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

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

可以修改redis.conf中list-max-ziplist-entrieslist-max-ziplist-value配置项更改对应条件

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

Redis6.0移除了ziplist或者linkedlist作为列表对象的实现,底层实现为quicklist。

Hash

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

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

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

ziplist

image-20230306130909528

hashtable

image-20230306130927493

编码转换

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

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

可以修改redis.conf中hash-max-ziplist-entrieshash-max-ziplist-value配置项更改对应条件

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

image-20230306131959105

Set

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

intset

image-20230306132139043

hashtable

image-20230306132148773

编码转换

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

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

可以修改redis.conf中set-max-intset-entries配置项更改对应条件

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

image-20230306132313625

Zset

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

Zset结构

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

ziplist

image-20230306135352891

skiplist

image-20230306135417114

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

  1. zset结构中的zsl跳跃表按分值从小到大保存了所有集合元素,每个跳跃表节点都保存了一个集合元素:跳跃表节点的object属性保存了元素的成员,而跳跃表节点的score属性则保存了元素的分值。通过这个跳跃表,程序可以对有序集合进行范围型操作,比如ZRANK、ZRANGE等命令就是基于跳跃表API来实现的。
  2. zset 结构中的dict字典为有序集合创建了一个从成员到分值的映射,字典中的每个键值对都保存了一个集合元素:字典的键保存了元素的成员,而字典的值则保存了元素的分值。通过这个字典,程序可以用O(1)复杂度查找给定成员的分值,ZSCORE命令就是根据这一特性实现的,而很多其他有序集合命令都在实现的内部用到了这一特性。
  3. 有序集合每个元素的成员都是一个字符串对象,而每个元素的分值都是一个double类型的浮点数。值得一提的是,虽然zset结构同时使用跳跃表和字典来保存有序集合元素,但这两种数据结构都会通过指针来共享相同元素的成员和分值,所以同时使用跳跃表和字典来保存集合元素不会产生任何重复成员或者分值,也不会因此而浪费额外的内存。

编码转换

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

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

可以修改redis.conf中zset-max-ziplist-entrieszset-max-ziplist-value配置项更改对应条件

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

image-20230306140439147

感谢阅读完本篇文章!!! 个人博客地址: huanghong.top