大家好,我是袁庭新。
在数据库的世界里,Redis 以其高效和灵活备受瞩目。而其中的 ZipList 和 QuickList 数据结构更是独具魅力。它们在内存管理和数据存储方面有着独特的设计理念,深入探究这些结构,能让我们更好地理解 Redis 的强大之处。这篇文章我给大家系统总结了Redis中ZipList和QuickList两种数据结构的原理。
1.ZipList
1.1 ZipList介绍
ZipList是一种特殊的"双端链表" ,由一系列特殊编码的连续内存块组成。可以在任意一端进行压入或弹出操作, 并且该操作的时间复杂度为O(1)。ZipList数据结构如下图所示。
现对ZipList数据结构中的属性做如下的说明:
|---------|----------|-----|---------------------------------------------------------------------------------------|
| 属性 | 类型 | 长度 | 用途 |
| zlbytes | uint32_t | 4字节 | 是一个无符号整数,表示当前ZipList占用的总字节数。 |
| zltail | uint32_t | 4字节 | 记录压缩列表表尾节点距离压缩列表的起始地址有多少字节,通过该偏移量可以确定表尾节点的地址。 |
| zllen | uint16_t | 2字节 | 指Ziplist中entry的数量。最大值为UINT16_MAX(65534),如果超过这个值,此处会记录为65535,但节点的真实数量需要遍历整个压缩列表才能计算得出。 |
| entry | 列表节点 | 不定 | 用来存放具体的数据项(score和member),长度不定,可以是字节数组或整数,entry会根据成员的数量自动扩容。 |
| zlend | uint8_t | 1字节 | 是一个单字节的特殊值,值是0xFF(十进制255),起到标识ZipList内存结束点的作用。 |
1.2 ZipListEntry
1.2.1 ZipListEntry介绍
在ZipList中,Entry并不像传统链表节点那样需要存储指向前一个和后一个节点的指针,因为这样做会由于每个节点需维护两个指针而占用多达16个字节的内存,从而造成内存浪费。Entry是采用了下图的结构。
(1) previous_entry_length:表示前一个节点的长度,占1个或5个字节。
- 如果前一个节点的长度小于254个字节,则采用1个字节来保存这个长度值。
- 如果前一个节点的长度大于等于254个字节,则采用5个字节来保存这个长度值,第一个字节为0xfe,后四个字节才是真实长度数据。
(2) encoding:表示编码属性,记录content的数据类型(字符串还是整数)以及长度,占用1个、2个或5个字节。
(3) contents:负责保存节点的数据,可以是字符串或整数。
这里需要说明的是,在ZipList中所有存储长度的数值均采用小端字节序,即低位字节在前,高位字节在后。例如数值"0x1234",采用小端字节序后实际存储的值为"0x3412"。
1.2.2 ZipListEntry编码
ZipListEntry中的encoding编码分为字符串和整数两种,下面分别对这两种类型进行详细介绍。
第一种字符串,如果encoding是以"00"、"01"或者"10"开头,则证明content是字符串。
|------------------------------------------------------|----------|----------------------|
| 编码 | 编码长度 | 字符串大小 |
| |00pppppp| | 1 bytes | <= 63 bytes |
| |01pppppp|qqqqqqqq| | 2 bytes | <= 16383 bytes |
| |10000000|qqqqqqqq|rrrrrrrr|ssssssss|tttttttt| | 5 bytes | <= 4294967295 bytes |
例如,我们要在ZipList中保存字符串"ab"和"bc"。
第二种整数,如果encoding是以"11"开始,则证明content是整数,且encoding固定只占用1个字节。
|----------|------|----------------------------------------------|
| 编码 | 编码长度 | 整数类型 |
| 11000000 | 1 | int16_t(2 bytes) |
| 11010000 | 1 | int32_t(4 bytes) |
| 11100000 | 1 | int64_t(8 bytes) |
| 11110000 | 1 | 24位有符整数(3 bytes) |
| 11111110 | 1 | 8位有符整数(1 bytes) |
| 1111xxxx | 1 | 直接在xxxx位置保存数值,范围从0001~1101(即1~13),减1后结果为实际值 |
例如,一个ZipList中包含两个整数值2和5。
1.2.3 ZipList的连锁更新问题
在ZipList中,每个Entry都包含一个名为previous_entry_length的字段,用来记录上一个节点的大小,该字段的长度可以是1个字节或者5个字节。
- 如果前一个节点的长度小于254个字节,则采用1个字节来保存这个长度值。
- 如果前一个节点的长度大于等于254个字节,则采用5个字节来保存这个长度值,第一个字节为0xfe,后四个字节才是真实长度数据。
假设存在N个连续的entry,它们的长度均在250至253字节之间,因此,这些entry的previous_entry_length属性仅需1个字节即表示。现在结构就变成如下图所示的样子。
假如现在我们向ZipList列表的首部插入一个长度为254字节的entry节点,此时ZipList的结构又会有怎样的变化呢?通过下图来观察。
在ZipList中,当发生连续多次的空间扩展操作时,这种特殊情况被称为连锁更新(Cascade Update)。无论是新增还是删除操作,都有可能触发这种连锁更新。
最后我们对ZipList做如下的总结:
- 压缩列表ZipList可以看做是一种连续内存空间的"双向链表";
- ZipList列表的节点之间不是通过指针连接,而是记录上一节点和本节点长度来寻址,内存占用较低;
- 如果ZipList列表数据过多,会导致链表过长,可能影响查询性能;
- 在ZipList中增或删较大数据时,有可能会发生连续更新问题。
2.QuickList
2.1 QuickList介绍
虽然ZipList能够节省内存,但它要求申请的内存空间必须是连续的,当内存占用较高时,这会导致申请内存的效率变得很低。如何解决这一问题?我们可以考虑通过限制ZipList的长度和单个entry的大小来减轻对连续大块内存的需求,从而优化内存申请过程。
当需要存储的数据量超出ZipList的最佳承载上限时,我们又该如何应对呢?一种有效的策略是创建多个ZipList,将数据分片存储在这些不同的ZipList中。
数据拆分存储在多个ZipList中后会变得很分散,从而会增加管理和查找的难度,同时这多个ZipList又如何建立联系呢?为了解决这个问题,Redis在3.2版本中引入了一种新的数据结构------QuickList。QuickList实际上是一个双端链表,但其独特之处在于链表中的每一个节点都采用了ZipList来存储数据,这样既保持了ZipList节省内存的优势,又通过链表的结构巧妙地将多个ZipList组织起来,同时还达到了数据的分片存储的目的,便于统一管理和高效查找。
Redis提供了一个名为"list-max-ziplist-size"的配置选项,旨在控制QuickList中各个ZipList所能包含的entry数量,以防止其过多。该配置选项的作用具体阐述如下:
(1) 当"list-max-ziplist-size"被设置为正数时,它直接限定了ZipList能够容纳的entry的最大数量。
(2) 若"list-max-ziplist-size"的值为一个负数,则转而以内存大小为标准来限制ZipList的规模。具体细分为5种情况,介绍见下表。
|-----|---------------------------|
| 参数值 | 作用 |
| -1 | 每个ZipList的内存占用不能超过4kb |
| -2 | 默认值,每个ZipList的内存占用不能超过8kb |
| -3 | 每个ZipList的内存占用不能超过16kb |
| -4 | 每个ZipList的内存占用不能超过32kb |
| -5 | 每个ZipList的内存占用不能超过64kb |
下面通过"config get"命令来查看list-max-ziplist-size选项的参数值。
bash
127.0.0.1:6379> config get list-max-ziplist-size
1) "list-max-ziplist-size"
2) "-2"
除了控制ZipList的大小,QuickList还可以对节点的ZipList做压缩。通过配置项"list-compress-depth"来控制。因为链表一般都是从首尾访问较多,所以首尾是不压缩的。这个参数是控制首尾不压缩的节点个数的。具体介绍见下表。
QuickList不仅提供了对ZipList大小的控制,还允许对节点中的ZipList进行压缩,这一功能通过配置项"list-compress-depth"来实现。考虑到链表通常更多地从首尾两端进行访问,因此通常建议首尾节点不作压缩。该配置项具体决定了首尾不压缩节点的数量,其工作机制如下表所述。
|-----|-----------------------------------------------------|
| 参数值 | 作用 |
| 0 | 表示不进行压缩,这是默认设置。 |
| 1 | 表示QuickList的首尾各有1个节点保持不压缩状态,而中间的节点则会被压缩。 |
| 2 | 表示QuickList的首尾各有2个节点不会被压缩,其余中间节点则进行压缩。 |
| N | 表示QuickList首尾将各有N个节点保持原始状态,不进行压缩,而位于它们之间的节点则会被压缩处理。 |
下面通过"config get"命令来查看list-compress-depth选项的参数值。
bash
127.0.0.1:6379> config get list-compress-depth
1) "list-compress-depth"
2) "0"
2.2 QuickList原理
打开redis-7.2.5/src/quicklist.h文件,查看quicklist和quicklistNode结构体的源码,核心代码如下。
cpp
/* quicklist是一个40字节的结构体(在64位系统上),用于描述一个快速列表。 */
typedef struct quicklist {
quicklistNode *head; /* 快速列表的头节点 */
quicklistNode *tail; /* 快速列表的尾节点 */
unsigned long count; /* 所有ziplist的entry的总数量 */
unsigned long len; /* 快速列表节点的数量,即ziplist的总数量 */
/* 单个节点的填充因子,即ziplist的entry上限,默认值是-2 */
signed int fill : QL_FILL_BITS;
/* 快速列表两端不压缩的节点数量;0表示禁用压缩 */
unsigned int compress : QL_COMP_BITS;
/* 书签的数量,一般用不到 */
unsigned int bookmark_count: QL_BM_BITS;
/* 可选的书签数组,用于标记特定位置 */
quicklistBookmark bookmarks[];
} quicklist;
/*
* quicklistNode是一个32字节的结构体,用于描述快速列表中的一个listpack。
* 我们使用位字段来确保quicklistNode的大小为32字节。
* count: 16位,最大值为65536(listpack的最大字节数为65K,因此实际的最大计数小于32K)。
* encoding: 2位,RAW=1表示未压缩,LZF=2表示使用LZF压缩。
* container: 2位,PLAIN=1表示单个项作为字符数组,PACKED=2表示包含多个项的listpack。
* recompress: 1位,布尔值,表示节点是否临时解压以供使用。
* attempted_compress: 1位,布尔值,用于测试验证。
* extra: 10位,预留用于未来使用,填充剩余的32位。
*/
typedef struct quicklistNode {
struct quicklistNode *prev; /* 指向前一个节点的指针 */
struct quicklistNode *next; /* 指向后一个节点的指针 */
unsigned char *entry; /* 指向节点数据的指针 */
size_t sz; /* 节点数据的大小(字节) */
unsigned int count : 16; /* 节点中listpack的条目数量 */
unsigned int encoding : 2; /* 节点数据的编码方式:RAW=1或LZF=2 */
unsigned int container : 2; /* 节点数据的容器类型:PLAIN=1或PACKED=2 */
unsigned int recompress : 1; /* 节点是否之前被压缩过,现在临时解压 */
unsigned int attempted_compress : 1; /* 节点无法压缩;太小了 */
unsigned int dont_compress : 1; /* 防止压缩将在稍后使用的节点数据 */
unsigned int extra : 9; /* 预留位,用于未来扩展 */
} quicklistNode;
接下来,我们使用一副流程图来直观地展示当前的QuickList的数据结构,如下图所示。
QuickList的特点概述如下:
- 双端链表结构:QuickList构建于一个创新的双端链表之上,其节点采用了ZipList形式。
- 内存高效利用:通过采用ZipList作为节点,QuickList有效解决了传统链表因指针众多而导致的内存占用过高问题。
- 优化内存分配:QuickList对ZipList的大小进行了精心控制,从而避免了大规模连续内存空间申请所带来的效率瓶颈。
- 中间节点压缩:更进一步地,QuickList支持对中间节点进行压缩,这一特性极大地促进了内存资源的节省。
3.总结
最后做一个总结,本文新哥主要给大家介绍了 Redis 中的 ZipList 和 QuickList 两种数据结构。
ZipList:是一种特殊 "双端链表",由连续内存块组成。其属性包括 zlbytes、zltail、zllen、entry 和 zlend 等,分别用于记录总字节数、尾节点偏移量、节点数量、数据项和结束标识。Entry 结构独特,通过 previous_entry_length、encoding 和 content 存储数据,编码分字符串和整数两种。但存在连锁更新问题,如插入大节点可能引发连续更新,影响性能,且数据过多时查询性能会受影响。
QuickList:为解决 ZipList 内存申请及管理问题而生。它是双端链表,节点为 ZipList。通过 "list-max-ziplist-size" 控制 ZipList 中 entry 数量(正数限制数量,负数按内存大小限制),"list-compress-depth" 控制节点压缩(0 不压缩,正数决定首尾不压缩节点数)。其结构体 quicklist 和 quicklistNode 定义了链表及节点属性。具有双端链表结构、内存高效利用、优化内存分配和中间节点可压缩等特点,有效提升了存储和管理效率。