learn-tech/专栏/Redis源码剖析与实战/06从ziplist到quicklist,再到listpack的启发.md
2024-10-16 06:37:41 +08:00

408 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

因收到Google相关通知网站将会择期关闭。相关通知内容
06 从ziplist到quicklist再到listpack的启发
在前面的【第 4 讲】,我介绍 Redis 优化设计数据结构来提升内存利用率的时候提到可以使用压缩列表ziplist来保存数据。所以现在你应该也知道ziplist 的最大特点,就是它被设计成一种内存紧凑型的数据结构,占用一块连续的内存空间,以达到节省内存的目的。
但是,在计算机系统中,任何一个设计都是有利有弊的。对于 ziplist 来说,这个道理同样成立。
虽然 ziplist 节省了内存开销,可它也存在两个设计代价:一是不能保存过多的元素,否则访问性能会降低;二是不能保存过大的元素,否则容易导致内存重新分配,甚至可能引发连锁更新的问题。所谓的连锁更新,简单来说,就是 ziplist 中的每一项都要被重新分配内存空间,造成 ziplist 的性能降低。
因此,针对 ziplist 在设计上的不足Redis 代码在开发演进的过程中新增设计了两种数据结构quicklist 和 listpack。这两种数据结构的设计目标就是尽可能地保持 ziplist 节省内存的优势,同时避免 ziplist 潜在的性能下降问题。
今天这节课,我就来给你详细介绍下 quicklist 和 listpack 的设计思想和实现思路,不过在具体讲解这两种数据结构之前,我想先带你来了解下为什么 ziplist 的设计会存在缺陷。这样一来,你在学习 quicklist 和 listpack 时,可以和 ziplist 的设计进行对比,进一步就能更加容易地掌握 quicklist 和 listpack 的设计考虑了。
而且ziplist 和 quicklist 的区别,也是经常被问到的面试题,而 listpack 数据结构因为比较新,你对它的设计实现可能了解得并不多。那在学完了这节课之后,你其实就可以很轻松地应对这三种数据结构的使用问题了。此外,你还可以从这三种数据结构的逐步优化设计中,学习到 Redis 数据结构在内存开销和访问性能之间,采取的设计取舍思想。如果你需要开发高效的数据结构,你就可以把这种设计思想应用起来。
好,那么接下来,我们就先来了解下 ziplist 在设计与实现上存在的缺陷。
ziplist 的不足
你已经知道,一个 ziplist 数据结构在内存中的布局,就是一块连续的内存空间。这块空间的起始部分是大小固定的 10 字节元数据,其中记录了 ziplist 的总字节数、最后一个元素的偏移量以及列表元素的数量,而这 10 字节后面的内存空间则保存了实际的列表数据。在 ziplist 的最后部分,是一个 1 字节的标识(固定为 255用来表示 ziplist 的结束,如下图所示:
不过,虽然 ziplist 通过紧凑的内存布局来保存数据,节省了内存空间,但是 ziplist 也面临着随之而来的两个不足:查找复杂度高和潜在的连锁更新风险。那么下面,我们就分别来了解下这两个问题。
查找复杂度高
因为 ziplist 头尾元数据的大小是固定的,并且在 ziplist 头部记录了最后一个元素的位置,所以,当在 ziplist 中查找第一个或最后一个元素的时候,就可以很快找到。
但问题是当要查找列表中间的元素时ziplist 就得从列表头或列表尾遍历才行。而当 ziplist 保存的元素过多时,查找中间数据的复杂度就增加了。更糟糕的是,如果 ziplist 里面保存的是字符串ziplist 在查找某个元素时,还需要逐一判断元素的每个字符,这样又进一步增加了复杂度。
也正因为如此,我们在使用 ziplist 保存 Hash 或 Sorted Set 数据时,都会在 redis.conf 文件中,通过 hash-max-ziplist-entries 和 zset-max-ziplist-entries 两个参数,来控制保存在 ziplist 中的元素个数。
不仅如此除了查找复杂度高以外ziplist 在插入元素时如果内存空间不够了ziplist 还需要重新分配一块连续的内存空间,而这还会进一步引发连锁更新的问题。
连锁更新风险
我们知道,因为 ziplist 必须使用一块连续的内存空间来保存数据所以当新插入一个元素时ziplist 就需要计算其所需的空间大小,并申请相应的内存空间。这一系列操作,我们可以从 ziplist 的元素插入函数 __ziplistInsert 中看到。
__ziplistInsert 函数首先会计算获得当前 ziplist 的长度,这个步骤通过 ZIPLIST_BYTES 宏定义就可以完成,如下所示。同时,该函数还声明了 reqlen 变量,用于记录插入元素后所需的新增空间大小。
//获取当前ziplist长度curlen声明reqlen变量用来记录新插入元素所需的长度
size_t curlen = intrev32ifbe(ZIPLIST_BYTES(zl)), reqlen;
然后__ziplistInsert 函数会判断当前要插入的位置是否是列表末尾。如果不是末尾,那么就需要获取位于当前插入位置的元素的 prevlen 和 prevlensize。这部分代码如下所示
//如果插入的位置不是ziplist末尾则获取前一项长度
if (p[0] != ZIP_END) {
ZIP_DECODE_PREVLEN(p, prevlensize, prevlen);
} else {
}
实际上,在 ziplist 中,每一个元素都会记录其前一项的长度,也就是 prevlen。然后为了节省内存开销ziplist 会使用不同的空间记录 prevlen这个 prevlen 空间大小就是 prevlensize。
举个简单的例子,当在一个元素 A 前插入一个新的元素 B 时A 的 prevlen 和 prevlensize 都要根据 B 的长度进行相应的变化。
那么现在,我们假设 A 的 prevlen 原本只占用 1 字节(也就是 prevlensize 等于 1而能记录的前一项长度最大为 253 字节。此时,如果 B 的长度超过了 253 字节A 的 prevlen 就需要使用 5 个字节来记录prevlen 具体的编码方式,你可以复习回顾下第 4 讲),这样就需要申请额外的 4 字节空间了。不过,如果元素 B 的插入位置是列表末尾,那么插入元素 B 时,我们就不用考虑后面元素的 prevlen 了。
我画了下面这张图,以便于你理解数据插入过程对插入位置元素的影响。
因此,为了保证 ziplist 有足够的内存空间,来保存插入元素以及插入位置元素的 prevlen 信息__ziplistInsert 函数在获得插入位置元素的 prevlen 和 prevlensize 后,紧接着就会计算插入元素的长度。
现在我们已知,一个 ziplist 元素包括了 prevlen、encoding 和实际数据 data 三个部分。所以在计算插入元素的所需空间时__ziplistInsert 函数也会分别计算这三个部分的长度。这个计算过程一共可以分成四步来完成。
第一步,计算实际插入元素的长度。
首先你要知道这个计算过程和插入元素是整数还是字符串有关。__ziplistInsert 函数会先调用 zipTryEncoding 函数,这个函数会判断插入元素是否为整数。如果是整数,就按照不同的整数大小,计算 encoding 和实际数据 data 各自所需的空间;如果是字符串,那么就先把字符串长度记录为所需的新增空间大小。这一过程的代码如下所示:
if (zipTryEncoding(s,slen,&value,&encoding)) {
reqlen = zipIntSize(encoding);
} else {
reqlen = slen;
}
第二步,调用 zipStorePrevEntryLength 函数,将插入位置元素的 prevlen 也计算到所需空间中。
这是因为在插入元素后__ziplistInsert 函数可能要为插入位置的元素分配新增空间。这部分代码如下所示:
reqlen += zipStorePrevEntryLength(NULL,prevlen);
第三步,调用 zipStoreEntryEncoding 函数,根据字符串的长度,计算相应 encoding 的大小。
在刚才的第一步中ziplistInsert 函数对于字符串数据只是记录了字符串本身的长度所以在第三步中ziplistInsert 函数还会调用 zipStoreEntryEncoding 函数,根据字符串的长度来计算相应的 encoding 大小,如下所示:
reqlen += zipStoreEntryEncoding(NULL,encoding,slen);
好了到这里__ziplistInsert 函数就已经在 reqlen 变量中,记录了插入元素的 prevlen 长度、encoding 大小,以及实际数据 data 的长度。这样一来,插入元素的整体长度就有了,这也是插入位置元素的 prevlen 所要记录的大小。
第四步,调用 zipPrevLenByteDiff 函数,判断插入位置元素的 prevlen 和实际所需的 prevlen 大小。
最后__ziplistInsert 函数会调用 zipPrevLenByteDiff 函数,用来判断插入位置元素的 prevlen 和实际所需的 prevlen这两者间的大小差别。这部分代码如下所示prevlen 的大小差别是使用 nextdiff 来记录的:
nextdiff = (p[0] != ZIP_END) ? zipPrevLenByteDiff(p,reqlen) : 0;
那么在这里,如果 nextdiff 大于 0就表明插入位置元素的空间不够需要新增 nextdiff 大小的空间,以便能保存新的 prevlen。然后__ziplistInsert 函数在新增空间时,就会调用 ziplistResize 函数,来重新分配 ziplist 所需的空间。
ziplistResize 函数接收的参数分别是待重新分配的 ziplist 和重新分配的空间大小。而 __ziplistInsert 函数传入的重新分配大小的参数,是三个长度之和。
那么是哪三个长度之和呢?
这三个长度分别是 ziplist 现有大小curlen、待插入元素自身所需的新增空间reqlen以及插入位置元素 prevlen 所需的新增空间nextdiff。下面的代码显示了 ziplistResize 函数的调用和参数传递逻辑:
zl = ziplistResize(zl,curlen+reqlen+nextdiff);
进一步,那么 ziplistResize 函数在获得三个长度总和之后,具体是如何扩容呢?
我们可以进一步看下 ziplistResize 函数的实现,这个函数会调用 zrealloc 函数,来完成空间的重新分配,而重新分配的空间大小就是由传入参数 len 决定的。这样,我们就了解到了 ziplistResize 函数涉及到内存分配操作,因此如果我们往 ziplist 频繁插入过多数据的话,就可能引起多次内存分配,从而会对 Redis 性能造成影响。
下面的代码显示了 ziplistResize 函数的部分实现,你可以看下。
unsigned char *ziplistResize(unsigned char *zl, unsigned int len) {
//对zl进行重新内存空间分配重新分配的大小是len
zl = zrealloc(zl,len);
zl[len-1] = ZIP_END;
return zl;
}
好了,到这里,我们就了解了 ziplist 在新插入元素时,会计算其所需的新增空间,并进行重新分配。而当新插入的元素较大时,就会引起插入位置的元素 prevlensize 增加,进而就会导致插入位置的元素所占空间也增加。
而如此一来,这种空间新增就会引起连锁更新的问题。
实际上,所谓的连锁更新,就是指当一个元素插入后,会引起当前位置元素新增 prevlensize 的空间。而当前位置元素的空间增加后,又会进一步引起该元素的后续元素,其 prevlensize 所需空间的增加。
这样,一旦插入位置后续的所有元素,都会因为前序元素的 prevlenszie 增加,而引起自身空间也要增加,这种每个元素的空间都需要增加的现象,就是连锁更新。我画了下面这张图,你可以看下。
连锁更新一旦发生,就会导致 ziplist 占用的内存空间要多次重新分配,这就会直接影响到 ziplist 的访问性能。
所以说,虽然 ziplist 紧凑型的内存布局能节省内存开销但是如果保存的元素数量增加了或是元素变大了ziplist 就会面临性能问题。那么,有没有什么方法可以避免 ziplist 的问题呢?
这就是接下来我要给你介绍的 quicklist 和 listpack这两种数据结构的设计思想了。
quicklist 设计与实现
我们先来学习下 quicklist 的实现思路。
quicklist 的设计,其实是结合了链表和 ziplist 各自的优势。简单来说,一个 quicklist 就是一个链表,而链表中的每个元素又是一个 ziplist。
我们来看下 quicklist 的数据结构这是在quicklist.h文件中定义的而 quicklist 的具体实现是在quicklist.c文件中。
首先quicklist 元素的定义,也就是 quicklistNode。因为 quicklist 是一个链表,所以每个 quicklistNode 中,都包含了分别指向它前序和后序节点的指针*prev和*next。同时每个 quicklistNode 又是一个 ziplist所以在 quicklistNode 的结构体中,还有指向 ziplist 的指针*zl。
此外quicklistNode 结构体中还定义了一些属性,比如 ziplist 的字节大小、包含的元素个数、编码格式、存储方式等。下面的代码显示了 quicklistNode 的结构体定义,你可以看下。
typedef struct quicklistNode {
struct quicklistNode *prev; //前一个quicklistNode
struct quicklistNode *next; //后一个quicklistNode
unsigned char *zl; //quicklistNode指向的ziplist
unsigned int sz; //ziplist的字节大小
unsigned int count : 16; //ziplist中的元素个数
unsigned int encoding : 2; //编码格式,原生字节数组或压缩存储
unsigned int container : 2; //存储方式
unsigned int recompress : 1; //数据是否被压缩
unsigned int attempted_compress : 1; //数据能否被压缩
unsigned int extra : 10; //预留的bit位
} quicklistNode;
了解了 quicklistNode 的定义,我们再来看下 quicklist 的结构体定义。
quicklist 作为一个链表结构,在它的数据结构中,是定义了整个 quicklist 的头、尾指针,这样一来,我们就可以通过 quicklist 的数据结构,来快速定位到 quicklist 的链表头和链表尾。
此外quicklist 中还定义了 quicklistNode 的个数、所有 ziplist 的总元素个数等属性。quicklist 的结构定义如下所示:
typedef struct quicklist {
quicklistNode *head; //quicklist的链表头
quicklistNode *tail; //quicklist的链表尾
unsigned long count; //所有ziplist中的总元素个数
unsigned long len; //quicklistNodes的个数
...
} quicklist;
然后,从 quicklistNode 和 quicklist 的结构体定义中,我们就能画出下面这张 quicklist 的示意图。
而也正因为 quicklist 采用了链表结构所以当插入一个新的元素时quicklist 首先就会检查插入位置的 ziplist 是否能容纳该元素,这是通过 _quicklistNodeAllowInsert 函数来完成判断的。
_quicklistNodeAllowInsert 函数会计算新插入元素后的大小new_sz这个大小等于 quicklistNode 的当前大小node->sz、插入元素的大小sz以及插入元素后 ziplist 的 prevlen 占用大小。
在计算完大小之后_quicklistNodeAllowInsert 函数会依次判断新插入的数据大小sz是否满足要求即单个 ziplist 是否不超过 8KB或是单个 ziplist 里的元素个数是否满足要求。
只要这里面的一个条件能满足quicklist 就可以在当前的 quicklistNode 中插入新元素,否则 quicklist 就会新建一个 quicklistNode以此来保存新插入的元素。
下面代码显示了是否允许在当前 quicklistNode 插入数据的判断逻辑,你可以看下。
unsigned int new_sz = node->sz + sz + ziplist_overhead;
if (likely(_quicklistNodeSizeMeetsOptimizationRequirement(new_sz, fill)))
return 1;
else if (!sizeMeetsSafetyLimit(new_sz))
return 0;
else if ((int)node->count < fill)
return 1;
else
return 0;
这样一来quicklist 通过控制每个 quicklistNode ziplist 的大小或是元素个数就有效减少了在 ziplist 中新增或修改元素后发生连锁更新的情况从而提供了更好的访问性能
Redis 除了设计了 quicklist 结构来应对 ziplist 的问题以外还在 5.0 版本中新增了 listpack 数据结构用来彻底避免连锁更新下面我们就继续来学习下它的设计实现思路
listpack 设计与实现
listpack 也叫紧凑列表它的特点就是用一块连续的内存空间来紧凑地保存数据同时为了节省内存空间listpack 列表项使用了多种编码方式来表示不同长度的数据这些数据包括整数和字符串
listpack 相关的实现文件是listpack.c头文件包括listpack.h和listpack_malloc.h我们先来看下 listpack 的创建函数 lpNew因为从这个函数的代码逻辑中我们可以了解到 listpack 的整体结构
lpNew 函数创建了一个空的 listpack一开始分配的大小是 LP_HDR_SIZE 再加 1 个字节LP_HDR_SIZE 宏定义是在 listpack.c 它默认是 6 个字节其中 4 个字节是记录 listpack 的总字节数2 个字节是记录 listpack 的元素数量
此外listpack 的最后一个字节是用来标识 listpack 的结束其默认值是宏定义 LP_EOF ziplist 列表项的结束标记一样LP_EOF 的值也是 255
unsigned char *lpNew(void) {
//分配LP_HRD_SIZE+1
unsigned char *lp = lp_malloc(LP_HDR_SIZE+1);
if (lp == NULL) return NULL;
//设置listpack的大小
lpSetTotalBytes(lp,LP_HDR_SIZE+1);
//设置listpack的元素个数初始值为0
lpSetNumElements(lp,0);
//设置listpack的结尾标识为LP_EOF值为255
lp[LP_HDR_SIZE] = LP_EOF;
return lp;
}
你可以看看下面这张图展示的就是大小为 LP_HDR_SIZE listpack 头和值为 255 listpack 当有新元素插入时该元素会被插在 listpack 头和尾之间
好了了解了 listpack 的整体结构后我们再来看下 listpack 列表项的设计
ziplist 列表项类似listpack 列表项也包含了元数据信息和数据本身不过为了避免 ziplist 引起的连锁更新问题listpack 中的每个列表项不再像 ziplist 列表项那样保存其前一个列表项的长度它只会包含三个方面内容分别是当前元素的编码类型entry-encoding)、元素数据 (entry-data)以及编码类型和元素数据这两部分的长度 (entry-len)如下图所示
这里关于 listpack 列表项的设计你需要重点掌握两方面的要点分别是列表项元素的编码类型以及列表项避免连锁更新的方法下面我就带你具体了解下
listpack 列表项编码方法
我们先来看下 listpack 元素的编码类型如果你看了 listpack.c 文件你会发现该文件中有大量类似 LP_ENCODINGXX_BIT_INT LP_ENCODINGXX_BIT_STR 的宏定义如下所示
#define LP_ENCODING_7BIT_UINT 0
#define LP_ENCODING_6BIT_STR 0x80
#define LP_ENCODING_13BIT_INT 0xC0
...
#define LP_ENCODING_64BIT_INT 0xF4
#define LP_ENCODING_32BIT_STR 0xF0
这些宏定义其实就对应了 listpack 的元素编码类型具体来说listpack 元素会对不同长度的整数和字符串进行编码这里我们分别来看下
首先对于整数编码来说 listpack 元素的编码类型为 LP_ENCODING_7BIT_UINT 表示元素的实际数据是一个 7 bit 的无符号整数又因为 LP_ENCODING_7BIT_UINT 本身的宏定义值为 0所以编码类型的值也相应为 0 1 bit
此时编码类型和元素实际数据共用 1 个字节这个字节的最高位为 0表示编码类型后续的 7 位用来存储 7 bit 的无符号整数如下图所示
而当编码类型为 LP_ENCODING_13BIT_INT 这表示元素的实际数据是 13 bit 的整数同时因为 LP_ENCODING_13BIT_INT 的宏定义值为 0xC0转换为二进制值是 1100 0000所以这个二进制值中的后 5 位和后续的 1 个字节 13 会用来保存 13bit 的整数而该二进制值中的前 3 110则用来表示当前的编码类型我画了下面这张图你可以看下
在了解了 LP_ENCODING_7BIT_UINT LP_ENCODING_13BIT_INT 这两种编码类型后剩下的 LP_ENCODING_16BIT_INTLP_ENCODING_24BIT_INTLP_ENCODING_32BIT_INT LP_ENCODING_64BIT_INT你应该也就能知道它们的编码方式了
这四种类型是分别用 2 字节16 bit)、3 字节24 bit)、4 字节32 bit 8 字节64 bit来保存整数数据同时它们的编码类型本身占 1 字节编码类型值分别是它们的宏定义值
然后对于字符串编码来说一共有三种类型分别是 LP_ENCODING_6BIT_STRLP_ENCODING_12BIT_STR LP_ENCODING_32BIT_STR从刚才的介绍中你可以看到整数编码类型名称中 BIT 前面的数字表示的是整数的长度因此类似的字符串编码类型名称中 BIT 前的数字表示的就是字符串的长度
比如当编码类型为 LP_ENCODING_6BIT_STR 编码类型占 1 字节该类型的宏定义值是 0x80对应的二进制值是 1000 0000这其中的前 2 位是用来标识编码类型本身而后 6 位保存的是字符串长度然后列表项中的数据部分保存了实际的字符串
下面的图展示了三种字符串编码类型和数据的布局你可以看下
listpack 避免连锁更新的实现方式
最后我们再来了解下 listpack 列表项是如何避免连锁更新的
listpack 因为每个列表项只记录自己的长度而不会像 ziplist 中的列表项那样会记录前一项的长度所以当我们在 listpack 中新增或修改元素时实际上只会涉及每个列表项自己的操作而不会影响后续列表项的长度变化这就避免了连锁更新
不过你可能会有疑问如果 listpack 列表项只记录当前项的长度那么 listpack 支持从左向右正向查询列表或是从右向左反向查询列表吗
其实listpack 是能支持正反向查询列表的
当应用程序从左向右正向查询 listpack 我们可以先调用 lpFirst 函数该函数的参数是指向 listpack 头的指针它在执行时会让指针向右偏移 LP_HDR_SIZE 大小也就是跳过 listpack 你可以看下 lpFirst 函数的代码如下所示
unsigned char *lpFirst(unsigned char *lp) {
lp += LP_HDR_SIZE; //跳过listpack头部6个字节
if (lp[0] == LP_EOF) return NULL; //如果已经是listpack的末尾结束字节则返回NULL
return lp;
}
然后再调用 lpNext 函数该函数的参数包括了指向 listpack 某个列表项的指针lpNext 函数会进一步调用 lpSkip 函数并传入当前列表项的指针如下所示
unsigned char *lpNext(unsigned char *lp, unsigned char *p) {
...
p = lpSkip(p); //调用lpSkip函数偏移指针指向下一个列表项
if (p[0] == LP_EOF) return NULL;
return p;
}
最后lpSkip 函数会先后调用 lpCurrentEncodedSize lpEncodeBacklen 这两个函数
lpCurrentEncodedSize 函数是根据当前列表项第 1 个字节的取值来计算当前项的编码类型并根据编码类型计算当前项编码类型和实际数据的总长度然后lpEncodeBacklen 函数会根据编码类型和实际数据的长度之和进一步计算列表项最后一部分 entry-len 本身的长度
这样一来lpSkip 函数就知道当前项的编码类型实际数据和 entry-len 的总长度了也就可以将当前项指针向右偏移相应的长度从而实现查到下一个列表项的目的
下面代码展示了 lpEncodeBacklen 函数的基本计算逻辑你可以看下
unsigned long lpEncodeBacklen(unsigned char *buf, uint64_t l) {
//编码类型和实际数据的总长度小于等于127entry-len长度为1字节
if (l <= 127) {
...
return 1;
} else if (l < 16383) { //编码类型和实际数据的总长度大于127但小于16383entry-len长度为2字节
...
return 2;
} else if (l < 2097151) {//编码类型和实际数据的总长度大于16383但小于2097151entry-len长度为3字节
...
return 3;
} else if (l < 268435455) { //编码类型和实际数据的总长度大于2097151但小于268435455entry-len长度为4字节
...
return 4;
} else { //否则entry-len长度为5字节
...
return 5;
}
}
我也画了一张图展示了从左向右遍历 listpack 的基本过程你可以再回顾下
了解了从左向右正向查询 listpack我们再来看下从右向左反向查询 listpack
首先我们根据 listpack 头中记录的 listpack 总长度就可以直接定位到 listapck 的尾部结束标记然后我们可以调用 lpPrev 函数该函数的参数包括指向某个列表项的指针并返回指向当前列表项前一项的指针
lpPrev 函数中的关键一步就是调用 lpDecodeBacklen 函数lpDecodeBacklen 函数会从右向左逐个字节地读取当前列表项的 entry-len
那么lpDecodeBacklen 函数如何判断 entry-len 是否结束了呢
这就依赖于 entry-len 的编码方式了entry-len 每个字节的最高位是用来表示当前字节是否为 entry-len 的最后一个字节这里存在两种情况分别是
最高位为 1表示 entry-len 还没有结束当前字节的左边字节仍然表示 entry-len 的内容
最高位为 0表示当前字节已经是 entry-len 最后一个字节了
entry-len 每个字节的低 7 则记录了实际的长度信息这里你需要注意的是entry-len 每个字节的低 7 位采用了大端模式存储也就是说entry-len 的低位字节保存在内存高地址上
我画了下面这张图展示了 entry-len 这种特别的编码方式你可以看下
实际上正是因为有了 entry-len 的特别编码方式lpDecodeBacklen 函数就可以从当前列表项起始位置的指针开始向左逐个字节解析得到前一项的 entry-len 这也是 lpDecodeBacklen 函数的返回值而从刚才的介绍中我们知道 entry-len 记录了编码类型和实际数据的长度之和
因此lpPrev 函数会再调用 lpEncodeBacklen 函数来计算得到 entry-len 本身长度这样一来我们就可以得到前一项的总长度 lpPrev 函数也就可以将指针指向前一项的起始位置了所以按照这个方法listpack 就实现了从右向左的查询功能
小结
这节课我从 ziplist 的设计不足出发依次给你介绍了 quicklist listpack 的设计思想
你要知道ziplist 的不足主要在于一旦 ziplist 中元素个数多了它的查找效率就会降低而且如果在 ziplist 里新增或修改数据ziplist 占用的内存空间还需要重新分配更糟糕的是ziplist 新增某个元素或修改某个元素时可能会导致后续元素的 prevlen 占用空间都发生变化从而引起连锁更新问题导致每个元素的空间都要重新分配这就会导致 ziplist 的访问性能下降
所以为了应对 ziplist 的问题Redis 先是在 3.0 版本中设计实现了 quicklistquicklist 结构在 ziplist 基础上使用链表将 ziplist 串联起来链表的每个元素就是一个 ziplist这种设计减少了数据插入时内存空间的重新分配以及内存数据的拷贝同时quicklist 限制了每个节点上 ziplist 的大小一旦一个 ziplist 过大就会采用新增 quicklist 节点的方法
不过又因为 quicklist 使用 quicklistNode 结构指向每个 ziplist无疑增加了内存开销为了减少内存开销并进一步避免 ziplist 连锁更新问题Redis 5.0 版本中就设计实现了 listpack 结构listpack 结构沿用了 ziplist 紧凑型的内存布局把每个元素都紧挨着放置
listpack 中每个列表项不再包含前一项的长度了因此当某个列表项中的数据发生变化导致列表项长度变化时其他列表项的长度是不会受影响的因而这就避免了 ziplist 面临的连锁更新问题
总而言之Redis 在内存紧凑型列表的设计与实现上 ziplist quicklist再到 listpack你可以看到 Redis 在内存空间开销和访问性能之间的设计取舍这一系列的设计变化是非常值得你学习的
每课一问
ziplist 会使用 zipTryEncoding 函数计算插入元素所需的新增内存空间假设插入的一个元素是整数你知道 ziplist 能支持的最大整数是多大吗
欢迎在留言区分享你的答案和思考过程如果觉得有收获也欢迎你把今天的内容分享给更多的朋友