Redis数据结构之quicklist

699 阅读10分钟

Redis 优化设计数据结构来提升内存利用率的时候,提到可以使用压缩列表(ziplist)来保存数据。所以现在你应该也知道,ziplist 的最大特点,就是它被设计成一种内存紧凑型的数据结构,占用一块连续的内存空间,以达到节省内存的目的。

虽然 ziplist 节省了内存开销,可它也存在两个设计代价:一是不能保存过多的元素,否则访问性能会降低;二是不能保存过大的元素,否则容易导致内存重新分配,甚至可能引发连锁更新的问题。所谓的连锁更新,简单来说,就是 ziplist 中的每一项都要被重新分配内存空间,造成 ziplist 的性能降低。

因此,针对 ziplist 在设计上的不足,Redis 代码在开发演进的过程中,新增设计了两种数据结构:quicklist和listpack。这两种数据结构的设计目标,就是尽可能地保持 ziplist 节省内存的优势,同时避免 ziplist 潜在的性能下降问题。

今天详细介绍下 quicklist 和 listpack 的设计思想和实现思路,不过在具体讲解这两种数据结构之前,我想先带你来了解下为什么 ziplist 的设计会存在缺陷。这样一来,你在学习 quicklist 和 listpack 时,可以和 ziplist 的设计进行对比,进一步就能更加容易地掌握 quicklist 和 listpack 的设计考虑了。

ziplist不足

你已经知道,一个 ziplist 数据结构在内存中的布局,就是一块连续的内存空间。这块空间的起始部分是大小固定的 10 字节元数据,其中记录了 ziplist 的总字节数、最后一个元素的偏移量以及列表元素的数量,而这 10 字节后面的内存空间则保存了实际的列表数据。在 ziplist 的最后部分,是一个 1 字节的标识(固定为 255),用来表示 ziplist 的结束,如下图所示:

image.png

不过,虽然 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 变量,用于记录插入元素后所需的新增空间大小。

然后,ziplistInsert 函数会判断当前要插入的位置是否是列表末尾。如果不是末尾,那 么就需要获取位于当前插入位置的元素的 prevlen 和 prevlensize。

实际上,在 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 了。

image.png

因此,为了保证 ziplist 有足够的内存空间,来保存插入元素以及插入位置元素的 prevlen 信息,ziplistInsert 函数在获得插入位置元素的 prevlen 和 prevlensize 后,紧接着就 会计算插入元素的长度。

现在我们已知,一个 ziplist 元素包括了 prevlen、encoding 和实际数据 data 三个部分。所以,在计算插入元素的所需空间时,ziplistInsert 函数也会分别计算这三个部分的长度。这个计算过程一共可以分成四步来完成。

第一步,计算实际插入元素的长度。

首先你要知道,这个计算过程和插入元素是整数还是字符串有关。ziplistInsert 函数会先调用 zipTryEncoding 函数,这个函数会判断插入元素是否为整数。如果是整数,就按照不同的整数大小,计算 encoding 和实际数据 data 各自所需的空间;如果是字符串,那么就先把字符串长度记录为所需的新增空间大小。

第二步,调用 zipStorePrevEntryLength 函数,将插入位置元素的 prevlen 也计算到所需空间中。 这是因为在插入元素后,ziplistInsert 函数可能要为插入位置的元素分配新增空间。

第三步,调用 zipStoreEntryEncoding 函数,根据字符串的长度,计算相应 encoding 的大小。

到这里,ziplistInsert 函数就已经在 reqlen 变量中,记录了插入元素的 prevlen 长度、encoding 大小,以及实际数据 data 的长度。这样一来,插入元素的整体长度就有了,这也是插入位置元素的 prevlen 所要记录的大小。

第四步,调用 zipPrevLenByteDiff 函数,判断插入位置元素的 prevlen 和实际所需的 prevlen 大小。

ziplistResize 函数接收的参数分别是待重新分配的 ziplist 和重新分配的空间大小。而 ziplistInsert 函数传入的重新分配大小的参数,是三个长度之和。 那么是哪三个长度之和呢? 这三个长度分别是 ziplist 现有大小(curlen)、待插入元素自身所需的新增空间 (reqlen),以及插入位置元素 prevlen 所需的新增空间(nextdiff)。

进一步,那么 ziplistResize 函数在获得三个长度总和之后,具体是如何扩容呢? 我们可以进一步看下 ziplistResize 函数的实现,这个函数会调用 zrealloc 函数,来完成空 间的重新分配,而重新分配的空间大小就是由传入参数 len 决定的。这样,我们就了解到 了 ziplistResize 函数涉及到内存分配操作,因此如果我们往 ziplist 频繁插入过多数据的 话,就可能引起多次内存分配,从而会对 Redis 性能造成影响。

好了,到这里,我们就了解了 ziplist 在新插入元素时,会计算其所需的新增空间,并进行重新分配。而当新插入的元素较大时,就会引起插入位置的元素 prevlensize 增加,进而就会导致插入位置的元素所占空间也增加。

而如此一来,这种空间新增就会引起连锁更新的问题。 实际上,所谓的连锁更新,就是指当一个元素插入后,会引起当前位置元素新增 prevlensize 的空间。而当前位置元素的空间增加后,又会进一步引起该元素的后续元素, 其 prevlensize 所需空间的增加。

这样,一旦插入位置后续的所有元素,都会因为前序元素的 prevlenszie 增加,而引起自 身空间也要增加,这种每个元素的空间都需要增加的现象,就是连锁更新。连锁更新一旦发生,就会导致 ziplist 占用的内存空间要多次重新分配,这就会直接影响到 ziplist 的访问性能。 所以说,虽然 ziplist 紧凑型的内存布局能节省内存开销,但是如果保存的元素数量增加 了,或是元素变大了,ziplist 就会面临性能问题。那么,有没有什么方法可以避免 ziplist 的问题呢?

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;

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;

而也正因为 quicklist 采用了链表结构,所以当插入一个新的元素时,quicklist 首先就会检 查插入位置的 ziplist 是否能容纳该元素,这是通过 quicklistNodeAllowInsert 函数来 完成判断的。

quicklistNodeAllowInsert 函数会计算新插入元素后的大小(new_sz),这个大小等于 quicklistNode 的当前大小(node->sz)、插入元素的大小(sz),以及插入元素后 ziplist 的 prevlen 占用大小。

在计算完大小之后,quicklistNodeAllowInsert 函数会依次判断新插入的数据大小(sz) 是否满足要求,即单个 ziplist 是否不超过 8KB,或是单个 ziplist 里的元素个数是否满足 要求。

只要这里面的一个条件能满足,quicklist 就可以在当前的 quicklistNode 中插入新元素,否则 quicklist 就会新建一个 quicklistNode,以此来保存新插入的元素。

这样一来,quicklist 通过控制每个 quicklistNode 中,ziplist 的大小或是元素个数,就有效减少了在 ziplist 中新增或修改元素后,发生连锁更新的情况,从而提供了更好的访问性能。