You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

425 lines
30 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 06 | 从ziplist到quicklist再到listpack的启发
你好,我是蒋德钧。
在前面的[第4讲](https://time.geekbang.org/column/article/402223)我介绍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的结束如下图所示
![](https://static001.geekbang.org/resource/image/08/6d/08fe01427f264234c59951c8293d466d.jpg?wh=2000x795)
不过虽然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了。
我画了下面这张图,以便于你理解数据插入过程对插入位置元素的影响。
![](https://static001.geekbang.org/resource/image/de/45/de43202b0afb4e5394c5323fc9f93a45.jpg?wh=2000x1125)
因此为了保证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增加而引起自身空间也要增加这种每个元素的空间都需要增加的现象就是连锁更新。我画了下面这张图你可以看下。
![](https://static001.geekbang.org/resource/image/b7/4c/b7f75261e8e72832220c98bf73a0eb4c.jpg?wh=2000x1125)
连锁更新一旦发生就会导致ziplist占用的内存空间要多次重新分配这就会直接影响到ziplist的访问性能。
所以说虽然ziplist紧凑型的内存布局能节省内存开销但是如果保存的元素数量增加了或是元素变大了ziplist就会面临性能问题。那么有没有什么方法可以避免ziplist的问题呢
这就是接下来我要给你介绍的quicklist和listpack这两种数据结构的设计思想了。
## quicklist设计与实现
我们先来学习下quicklist的实现思路。
quicklist的设计其实是结合了链表和ziplist各自的优势。简单来说**一个quicklist就是一个链表而链表中的每个元素又是一个ziplist。**
我们来看下quicklist的数据结构这是在[quicklist.h](https://github.com/redis/redis/tree/5.0/src/quicklist.h)文件中定义的而quicklist的具体实现是在[quicklist.c](https://github.com/redis/redis/tree/5.0/src/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的示意图。
![](https://static001.geekbang.org/resource/image/bc/0e/bc725a19b5c1fd25ba7740bab5f9220e.jpg?wh=2000x890)
而也正因为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](https://github.com/redis/redis/tree/5.0/src/listpack.c),头文件包括[listpack.h](https://github.com/redis/redis/tree/5.0/src/listpack.h)和[listpack\_malloc.h](https://github.com/redis/redis/tree/5.0/src/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头和尾之间。
![](https://static001.geekbang.org/resource/image/d6/60/d6ef170068fc14c7d901b9ff4935yy60.jpg?wh=2000x562)
好了了解了listpack的整体结构后我们再来看下listpack列表项的设计。
和ziplist列表项类似listpack列表项也包含了元数据信息和数据本身。不过为了避免ziplist引起的连锁更新问题listpack中的每个列表项不再像ziplist列表项那样保存其前一个列表项的长度**它只会包含三个方面内容**分别是当前元素的编码类型entry-encoding、元素数据(entry-data),以及编码类型和元素数据这两部分的长度(entry-len),如下图所示。
![](https://static001.geekbang.org/resource/image/60/27/60833af3db19ccf12957cfe6467e9227.jpg?wh=2000x786)
这里关于listpack列表项的设计你需要重点掌握两方面的要点分别是列表项元素的编码类型以及列表项避免连锁更新的方法。下面我就带你具体了解下。
### listpack列表项编码方法
我们先来看下listpack元素的编码类型。如果你看了listpack.c文件你会发现该文件中有大量类似LP\_ENCODING\_\_XX\_BIT\_INT和LP\_ENCODING\_\_XX\_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的无符号整数如下图所示
![](https://static001.geekbang.org/resource/image/8c/fb/8c4bd520d3953f7e70b6e3f08543c6fb.jpg?wh=1752x710)
而当编码类型为LP\_ENCODING\_13BIT\_INT时这表示元素的实际数据是13 bit的整数。同时因为LP\_ENCODING\_13BIT\_INT的宏定义值为0xC0转换为二进制值是1100 0000所以这个二进制值中的后5位和后续的1个字节共13位会用来保存13bit的整数。而该二进制值中的前3位110则用来表示当前的编码类型。我画了下面这张图你可以看下。
![](https://static001.geekbang.org/resource/image/3e/d7/3ecbb8412d41d0a36587dfdaf49714d7.jpg?wh=1897x712)
在了解了LP\_ENCODING\_7BIT\_UINT和LP\_ENCODING\_13BIT\_INT这两种编码类型后剩下的LP\_ENCODING\_16BIT\_INT、LP\_ENCODING\_24BIT\_INT、LP\_ENCODING\_32BIT\_INT和LP\_ENCODING\_64BIT\_INT你应该也就能知道它们的编码方式了。
这四种类型是分别用2字节16 bit、3字节24 bit、4字节32 bit和8字节64 bit来保存整数数据。同时它们的编码类型本身占1字节编码类型值分别是它们的宏定义值。
然后,对于**字符串编码**来说一共有三种类型分别是LP\_ENCODING\_6BIT\_STR、LP\_ENCODING\_12BIT\_STR和LP\_ENCODING\_32BIT\_STR。从刚才的介绍中你可以看到整数编码类型名称中BIT前面的数字表示的是整数的长度。因此类似的字符串编码类型名称中BIT前的数字表示的就是字符串的长度。
比如当编码类型为LP\_ENCODING\_6BIT\_STR时编码类型占1字节。该类型的宏定义值是0x80对应的二进制值是1000 0000这其中的前2位是用来标识编码类型本身而后6位保存的是字符串长度。然后列表项中的数据部分保存了实际的字符串。
下面的图展示了三种字符串编码类型和数据的布局,你可以看下。
![](https://static001.geekbang.org/resource/image/9c/25/9c17c0be0519100c509e2378acd6e125.jpg?wh=2000x1125)
### 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的基本过程你可以再回顾下。
![](https://static001.geekbang.org/resource/image/a9/be/a9e7c837959f8d01bff8321135c484be.jpg?wh=2000x1125)
了解了从左向右正向查询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这种特别的编码方式你可以看下。
![](https://static001.geekbang.org/resource/image/4a/5c/4ae6140ca6b08f35b9eb245c4627245c.jpg?wh=2000x1125)
实际上正是因为有了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版本中设计实现了quicklist。quicklist结构在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能支持的最大整数是多大吗
欢迎在留言区分享你的答案和思考过程,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。