【Redis系列3】Redis列表对象之linkedlist(双端列表)和ziplist(压缩列表)及quicklick(快速列表)实现原理分析

前言

上一篇我们分析了字符串对象的底层存储结构SDS,那么这一篇我们继续分析Redis中5种常用数据类型的第2种基本数据类列表对象的底层存储结构。

列表对象

Redis3.2之前,列表对象其底层存储结构可以有两种,即:linkedlistziplist,而在Redis 3.2之后,列表对象底层存储结构优化成为了另一种:quicklist。而quicklist可以认为是linkedlistziplist的结合体。

列表内部使用哪一种类型也是通过编码来进行区分:

编码属性 描述 object encoding命令返回值
OBJ_ENCODING_LINKEDLIST 使用linkedlist实现列表对象 linkedlist
OBJ_ENCODING_ZIPLIST 使用ziplist实现列表对象 ziplist
OBJ_ENCODING_QUICKLIST 使用quicklist实现列表对象 quicklist

linkedlist

linkedlist是一个双向列表,每个节点都会存储指向上一个节点和指向下一个节点的指针。linkedlist因为每个节点的空间是不连续的,所以可能会造成过多的空间碎片。

linkedlist存储结构

链表中每一个节点都是一个listNode对象(源码adlist.h内),不过需要注意的是,列表中的value其实也是一个字符串对象,后面我们介绍的其他几种数据类型其内部最终也是会嵌套字符串对象:

typedef struct listNode {
    
    
    struct listNode *prev;//前一个节点
    struct listNode *next;//后一个节点
    void *value;//值(字符串对象)
} listNode;

然后会将其再进行封装成为一个list对象(源码adlist.h内):

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;

Redis中对linkedlist的访问是以NULL值为终点的,因为head节点的prev节点为NULL,tail节点的next节点为NULL。

所以,同样的,在Redis3.2之前我们可以得到如下简图:
在这里插入图片描述
PS:想要详细了解dictEntryredisObject对象以及编码相关知识的的可以点击这里

ziplist

ziplist是为了节省内存而开发的一种压缩列表数据结构,后面讲述的哈希数据类型底层也用到了ziplist

ziplist是由一系列特殊编码的连续内存块组成的顺序型数据结构,一个ziplist可以包含任意多个entry,而每一个entry又可以保存一个字节数组或者一个整数值。

ziplist和linkedlist最大的区别是ziplist不存储指向上一个节点和下一个节点的指针,存储的是上一个节点的长度和当前节点的长度,牺牲了部分读写性能来换取高效的内存利用率,是一种时间换空间的思想。

ziplist适用于字段个数少和字段值少的场景。

ziplist存储结构

ziplist的组成结构为:

<zlbytes> <zltail> <zllen> <entry> <entry> ... <entry> <zlend>

在这里插入图片描述

属性 类型 长度 说明
zlbytes uint32_t 4字节 记录压缩列表占用内存字节数(包括本身所占用的4个字节)
zltail uint32_t 4字节 记录压缩列表尾节点距离压缩列表的起始地址有多少个字节(通过这个值可以计算出尾节点的地址)
zllen uint16_t 2字节 记录压缩列表中包含的节点数量,当列表值超过可以存储的最大值(65535)时,次值固定存储216-1(65535),因此此时需要遍历整个压缩列表才能计算出真实节点数
entry 列表节点 - 压缩列表中的各个节点,长度由存储的实际数据决定
zlend uint8_t 1字节 特殊字符0xFF(十进制255),用来标记压缩列表的末端(其他正常的节点没有被标记为255的,因为255用来标识末尾,后面可以看到,正常节点都是标记为254)

entry存储结构

ziplist 中的每个 entry 都以包含两段信息的元数据作为前缀,每一个 entry 的组成结构为:

<prevlen> <encoding> <entry-data>
prevlen

prevlen属性存储了前一个entry的长度,以便能够从后到前遍历列表。 prevlen 的长度可能是1字节也可能是5字节:

  • 当链表的前一个entry占用字节数小于254,此时prevlen只用1个字节进行表示。
<prevlen from 0 to 253> <encoding> <entry>
  • 当链表的前一个entry占用字节数大于等于254,此时prevlen用5个字节来表示,其中第1个字节的值是254(相当于是一个标记,代表后面跟了一个更大的值),后面4个字节才是真正存储前一个entry的占用字节数。
0xFE <4 bytes unsigned little endian prevlen> <encoding> <entry>

PS:1个字节完全你能存储255,之所以只取到254是因为zlend就是固定的255,所以255这个数要用来判断是否是ziplist的结尾。

encoding

encoding属性存储了当前entry所保存数据的类型以及长度。encoding长度为1字节,2字节或者5字节长。前面我们提到,每一个entry中可以保存字节数组和整数,而encoding属性的第1个字节就是用来确定当前entry存储的是整数还是字节数组。当存储整数时,第1个字节的前两位总是11,而存储字节数组时,则可能是00、01和10。

  • 当存储整数时,第1个字节的前2位固定为11,其他位则用来记录整数值的类型或者整数值:
编码 长度 entry保存的数据
11000000 1字节 int16_t类型整数
11010000 1字节 int32_t类型整数
11100000 1字节 int64_t类型整数
11110000 1字节 24位有符号整数
11111110 1字节 8位有符号整数
1111xxxx 1字节 xxxx代表区间0001-1101,存储了一个介于0-12之间的整数,此时无需entry-data属性

PS:xxxx四位编码范围是0000-1111,但是0000,1111和1110已经被占用了,所以实际上的范围是0001-1101,此时能保存数据1-13,再减去1之后范围就是0-12。至于为什么要减去1个人的理解是从使用概率上来说0是很常用的一个数字,所以才会选择统一减去1来存储一个0-12的区间而不是直接存储1-13的区间。

  • 当存储字节数组时,第1个字节的前2位为00、01或者10,其他位则用来记录字节数组的长度:
编码 长度 entry保存的数据
00pppppp 1字节 长度小于等于63字节(6位)的字节数组
01pppppp qqqqqqqq 2字节 长度小于等于16383字节(14位)的字节数组
10000000 qqqqqqqq rrrrrrrr ssssssss tttttttt 5字节 长度小于等于232-1字节(32位)的字节数组,其中第1个字节的后6位设置为0,暂时没有用到,后面的32位(4个字节)存储了数据
entry-data

entry-data:具体数据。当存储小整数时,encoding就是数据本身,此时没有entry-data部分,没有entry-data部分之后的ziplist结构如下:

<prevlen> <encoding>

压缩列表中entry的数据结构定义如下(源码ziplist.c内),当然这个代码注释写了实际存储并没有用到这个结构,这个结构只是用来接收数据,所以了解一下就可以了:

typedef struct zlentry {
    
    
    unsigned int prevrawlensize;//存储prevrawlen所占用的字节数
    unsigned int prevrawlen;//存储上一个链表节点需要的字节数
    unsigned int lensize;//存储len所占用的字节数
    unsigned int len;//存储链表当前节点的字节数
    unsigned int headersize;//当前链表节点的头部大小(prevrawlensize + lensize)即非数据域的大小
    unsigned char encoding;//编码方式
    unsigned char *p;//指向当前节点的起始位置(因为列表内的数据也是一个字符串对象)
} zlentry;

ziplist数据示例

上面讲解了这么多,听起来非常复杂,下面我们就通过一个ziplist存储整数为例子来分析一下。

[0f 00 00 00] [0c 00 00 00] [02 00] [00 f3] [02 f6] [ff]
      |             |          |       |       |     |
   zlbytes        zltail     zllen    "2"     "5"   end

1、第一组4个字节代表zlbytes,0f转成二进制就是1111也就是15,也就是这整个ziplist长度是15个字节。
2、第二组4个字节zltail,0c转成二进制就是1100也就是12,就是说[02 f6]这个尾节点距离起始位置有12个字节。
3、第三组2个字节就是记录了当前ziplistentry的数量,02转成二进制就是10,也就是说当前ziplist有2个节点
4、[00 f3]就是第一个entry,00表示0,因为这是第1个节点,所以前一个节点长度为0,f3转成二进制就是11110011,刚好对应了编码1111xxxx,所以后面四位就是存储了一个0-12的整数。0011转成十进制就是3,减去1得到2,所以第一个entry存储的数据就是2。后面[02 f6]一样的算法可以得出就是5。
5、最后一组1个字节[ff]转成二进制就是11111111,代表这是整个ziplist的结尾。

假如这时候又添加了一个Hello World到列表中,那么就会新增一个entry

[02] [0b] [48 65 6c 6c 6f 20 57 6f 72 6c 64]

1、第一个字节02转成十进制就是2表示前一个节点长度是2.
2、第2个字节0b转成二进制为00001011,以00开头,符合编码00pppppp,而除掉最开始的两位00计算之后得到十进制11,这就说明后面字节数组的长度是11.
3、第三部分的11个字节就是存储了Hello World的字节数组

ziplist连锁更新问题

前面提到entry中的prevlen属性可能是1个字节也可能是5个字节,我们设想这么一种场景:假设一个ziplist中,连续多个entry的长度都是一个接近但是又不到254的值(介于250~253之间),假如这时候新增一个entry1长度增加到大于254个字节,那么此时entry2prelen属性就必须要由1个字节变为5个字节,也就是需要执行空间重分配,而此时因为entry2长度也增加了4个字节,又大于254个字节了了,那么entry3prelen属性也会被迫变为5个字节,依此类推,这种产生连续多次空间重分配的现象就称之为连锁更新

PS:不仅仅是新增节点,执行删除节点操作同样可能会发生连锁更新现象。

linkedlist和ziplist的选择

在Redis3.2之前,linkedlistziplist两种编码可以进选择切换,如果需要列表使用ziplist编码进行存储,则必须满足以下两个条件:

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

一旦不满足这两个条件的任意一个,则会使用linkedlist编码进行存储。

PS:这两个条件可以通过参数list-max-ziplist-valuelist-max-ziplist-entries进行修改。

quicklist

在Redis3.2之后,统一用quicklist来存储列表对象,quicklist存储了一个双向列表,每个列表的节点是一个ziplist,所以实际上quicklist就是linkedlistziplist的结合。

quicklist内部存储结构

quicklist中每一个节点都是一个quicklistNode对象,其数据结构定义为:

typedef struct quicklistNode {
    
    
    struct quicklistNode *prev;//前一个节点
    struct quicklistNode *next;//后一个节点
    unsigned char *zl;//当前指向的ziplist或者quicklistLZF
    unsigned int sz;//当前ziplist占用字节
    unsigned int count : 16;//ziplist中存储的元素个数,16字节(最大65535个)
    unsigned int encoding : 2; //是否采用了LZF压缩算法压缩节点 1:RAW 2:LZF
    unsigned int container : 2; //存储结构,NONE=1, ZIPLIST=2
    unsigned int recompress : 1; //当前ziplist是否需要再次压缩(如果前面被解压过则为true,表示需要再次被压缩)
    unsigned int attempted_compress : 1;//测试用 
    unsigned int extra : 10; //后期留用
} quicklistNode;

然后各个quicklistNode就构成了一个列表quicklist

typedef struct quicklist {
    
    
    quicklistNode *head;//列表头节点
    quicklistNode *tail;//列表尾节点
    unsigned long count;//ziplist中一共存储了多少元素,即:每一个quicklistNode内的count相加
    unsigned long len; //双向链表的长度,即quicklistNode的数量
    int fill : 16;//填充因子
    unsigned int compress : 16;//压缩深度 0-不压缩
} quicklist;

根据这两个结构,我们可以得到Redis3.2之后的列表对象的一个简图:
在这里插入图片描述

quicklist的compress属性

compress表示压缩深度,可以通过参数list-compress-depth控制:

  • 0:不压缩(默认值)
  • 1:首尾第1个元素不压缩
  • 2:首位前2个元素不压缩
  • 3:首尾前3个元素不压缩
  • 以此类推

PS:之所以采取这种方式去控制是因为很多场景都是两端的元素访问率较高,而中间元素访问率相对较低。

quicklistNode的zl指针

zl指针默认指向了ziplist,sz属性记录了当前ziplist占用的字节,不过这仅仅限于当前节点没有被压缩(LZF压缩算法)的情况,如果当前节点被压缩了,那么zl指针会指向另一个对象quicklistLZFquicklistLZF是一个4+N字节的结构:

typedef struct quicklistLZF {
    
    
    unsigned int sz;// LZF大小
    char compressed[];//被压缩的内容
} quicklistLZF;

quicklist对比原始两种列表的改进

quicklist同样采用了linkedlist的双端列表特性,然后quicklist中的每个节点又是一个ziplist,所以quicklist就是综合平衡考虑了空间碎片和读写性能两个维度。使用quicklist需要注意以下2点:

  • 1、如果ziplist中的entry个数过少,极端情况就是只有1个entry,此时就相当于退化成了一个普通的linkedlist
  • 2、如果ziplist中的entry过多,那么也会导致一次性需要申请的内存空间过大,而且因为ziplist本身的就是以时间换空间,所以会过多entry也会影响到列表对象的读写性能。

ziplist中的entry个数可以通过参数list-max-ziplist-size来控制:

list-max-ziplist-size 1

注意:这个参数可以配置正数也可以配置负数。正数表示限制每个节点中的entry数量,如果是负数则只能为-1~-5

  • -1:每个ziplist最多只能为4KB
  • -2:每个ziplist最多只能为8KB
  • -3:每个ziplist最多只能为16KB
  • -4:每个ziplist最多只能为32KB
  • -5:每个ziplist最多只能为64KB

总结

本文主要介绍了Redis中5种常用数据类型中的列表类型底层的存储结构,并分别对其两种底层数据linkedlistziplist进行了分析对比,最后分析了Redis3.2之后的底层数据类型quicklist的存储原理。

下一篇,我们将分析Redis中5种常用数据类型中的第3种哈希对象的底层存储结构。

请关注我,和孤狼一起学习进步

猜你喜欢

转载自blog.csdn.net/zwx900102/article/details/109595508