Redis数据结构ZipList和QuickList原理解析

大家好,我是袁庭新。

在数据库的世界里,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 定义了链表及节点属性。具有双端链表结构、内存高效利用、优化内存分配和中间节点可压缩等特点,有效提升了存储和管理效率。

相关推荐
AI多Agent协作实战派1 小时前
AI多Agent协作系统实战(十七):凌晨4点,我的AI系统在“假装工作“——3个bug同时爆炸的5小时
java·前端·bug
gaolei_eit1 小时前
Java+Ai+vue
java·spring·maven
qq_2518364571 小时前
基于java Web 动漫视频网站毕业论文
java·开发语言·前端
LayZhangStrive1 小时前
JUC相关的函数、注解、变量杂记
java·面试·多线程·juc
未秃头的程序猿1 小时前
给公司做了个AI客服Agent,用的Spring AI 1.0,3天上线领导拍板了
java·后端·ai编程
来一碗刘肉面1 小时前
栈的应用(表达式求值)
数据结构·算法
曹牧1 小时前
Eclipse 批量文本替换
java·ide·eclipse
程序员清风2 小时前
OpenAI官方发布最新提示词技巧!
java·后端·面试
梅头脑2 小时前
写了3年CRUD,volatile和synchronized的区别还是答不清——直到我画出了这张三性两锁边界图
java
暮暮祈安2 小时前
Celery 新手入门指南
java·数据库·python·flask·httpx