详解Redis中的List是如何实现的
目录
- 回答
- 扩展
- List 介绍
- List 的底层实现
- zipList
- linkedList
- quicklist
回答
Redis 中的 List 数据结构是一个双向链表,用于存储一个序列的数据,它类似于 Java 中的数组或列表,其底层实现分为两个版本:
3.2 版本以前使用
linkedlist
+ziplist
- 当列表中元素的⻓度较⼩或者数量较少时,通常采⽤
zipList
来存储。原因是因为zipList
是一个紧凑的数据结构,能够有效地减少内存占用。但是,在列表中元素较多或者元素较大时,zipList
的性能会下降,因为在列表的头部或尾部添加或删除元素时,可能需要重新分配并复制整个ziplist
。所以,zipList
非常适合少量的小数据存储。同时 zipList 还有一个“连锁更新”的问题,也会严重影响ziplist
的性能。 - 当列表元素大于 512 或者元素的长度大于 64 字节时,List 底层由
zipList
转换为linkedlist
。linkedlist
是一个双向链表,能够快速支持列表的删除和插入操作,但是内存利用率较低,因为它需要额外的内存空间来维护链表结构。
- 当列表中元素的⻓度较⼩或者数量较少时,通常采⽤
3.2 版本以后使用
quicklist
- 从 3.2 版本开始后,List 的底层实现采用
quicklist
。quicklist
是将zipList
作为节点嵌入linkedList
的节点中,它结合了两者的优点,也具备两者的优点。具体来说,quicklist
是由多个ziplist
节点组成的linkedList
链表,每个ziplist
节点可以存储多个列表元素。
- 从 3.2 版本开始后,List 的底层实现采用
扩展
List 介绍
List 的 Redis 中的 5 种主要数据结构之一,它是一种序列集合,可以存储一个有序的字符串列表,顺序是插入的顺序。我们可以使用相关命令添加一个字符串元素到 List 的头部(左边)或者尾部。
**List 的最大长度为 2^31 - 1,即每个 List 支持超过 40 亿个元素。**主要特点如下:
- 有序性:List 中的元素按照插入顺序排序,可以在列表的头部或尾部添加元素。
- 双向链表:List 内部通过双向链表实现,使得在列表的两端操作(如插入和删除)都非常快,时间复杂度为 O(1)。
- 元素重复性:List 允许重复的元素。
- 元素访问:List 支持通过索引访问元素(如 LINDEX 命令),但这是通过遍历实现的,因此其时间复杂度为 O(N)。若 List 元素较多,则访问效率较低
List 常用的命令如下:
LPUSH key value1 [value2]
:将一个或多个元素插入到列表头部。如果 key 不存在,一个空列表会被创建并执行LPUSH
操作。返回值是操作后列表的长度。RPUSH key value1 [value2]
:将一个或多个值插入到列表尾部。类似于 LPUSHLPOP key
:移除并返回列表的第一个元素。如果列表为空,返回 nil。RPOP key
:移除并返回列表的最后一个元素。如果列表为空,返回 nil。LLEN key
:返回列表的长度,如果 key 不存在 返回 0。LINDEX key index
:获取列表在 index 位置的元素。index 是基于 0 的,也可以是负数,-1 表示最后一个元素,-2 表示倒数第二个元素等。LSET key index value
:将列表的 index 位置的编程值设置为 value。如果 index 超出范围,操作会返回一个错误。LRANGE key start stop
:获取列表指定范围内的元素。start 和 stop 都是基于 0 的索引,可以使用负数索引指定位置。LREM key count value
:根据参数 count 的值,移除与参数 value 相等的元素。count 的值可以是以下几种:- count > 0:从头到尾,移除最多 count 个 value 相等的元素。
- count < 0:从尾到头,移除最多 -count 个 value 相等的元素。
- count = 0:移除所有 value 相等的元素。
LTRIM key start stop
:对一个列表进行修剪(trim),就是说,让列表只保留指定区间内的元素,不在指定区间之内的元素都将被删除。
下面是 List 常用命令的演示:
需要注意的是,List 设置过期时间,只能给整个 List 设置过期时间,不能单独给某一个元素设置。
List 的底层实现
List 的底层实现有三种:zipList
、linkedList
和 quickList
。他们的使用情况如下:
- 当 List 存储的元素较少且每个元素的大小也较小时,Redis 会选择使用
zipList
来存储数据,以节省内存空间。 - 当列表元素大于 512 或者元素的长度大于 64 字节时,Redis 则转换为使用
linkedList
来存储数据,以优化操作的性能。
zipList
当 Lisandroidt 中的元素比较少或者每个元素的大小也小时,Redis 选择 zipList
来存储数据。zipList
通过紧凑的内存布局存储一系列的 entry,每个 entry 可以代表一个字符串或者整数。
zipList
的内部结构主要分为三个部分:表头(header)、条目(entries)和表尾(end),如下图:
表头(header)
zlbytes
:4 个字节,记录整个ziplist
占用的总字节数。zltail
:4 个字节,记录到ziplist
最后一个元素的偏移量,这样就可以快速定位到最后一个元素,提高从列表尾部添加或者访问元素的效率。zllen
:2 个字节,记录ziplist
中元素的个数。
条目(entries)
- 列表元素
entry
。每个entry
可以存储字符串或者整数。entry
的结构取决于它所存储的数据类型和大小。
- 列表元素
表尾(end)
- 一个字节的特殊值
0xFF
,用于标记压缩列表的结束。
- 一个字节的特殊值
entry
的结构与其存储的数据类型相关,如下:
存储字符串时,有三个部分,而整数只有两个部分,主要是因为整数是以最紧凑的格式存储,没有使用任何额外的标记或填充字节,所以在 zipList
中,整数值的编码同时包含了类型信息和实际的整数值。
- prevlen
记录前一个 entry
占用字节数,zipList
能实现逆序遍历就是靠这个字段确定往前移动多少字节拿到上一个 entry
首地址。
若前一个 entry 长度小于 254 个字节时,则 prevlen 占用 1 个字节。若前一个 entry 长度大于等于 254 个字节,则 prevlen 占用 5 个字节,第一个字节设为0xFE
,接下来的4字节用于存储实际的长度值。
- encoding
用于表示当前 entry 的类型和长度,当前 entry 的长度和值是根据保存的是整数还是字符串以及数据的长度共同来决定。
前两位用于表示类型,当前两位值为 “11” 则表示 entry 存放的是整数类型数据,其他表示存储的是字符串。
- entry-data
实际存储数据的位置。如果 entry 存储整数类型,则没有这个 entry-data,它会合并到 encoding 中。
zipList
适合较少元素的存储,如果元素过多的话,则查询效率会大打折扣,时间复杂度为 O(N)
。除了查询效率较低外,zipList 还有一个问题:“连锁更新问题”。那什么是连锁更新问题呢?
zipList
是一个紧凑的序列数据结构,它每个 entry 都有一个prevlen
字段来记录前一个 entry 的大小,当我们插入一个较大元素或者修改一个元素较大时,会引发后续 entry 的prevlen
占用空间发生变化,从而导致该 entry 的存储空间大小超过 254 bytes,进一步引发后续 entry 的存储空间,导致一连串的 entry 存储空间发生变化,从而引发“连锁更新”问题。
“连锁更新”通常发生在以下情况:
- 当
zipList
中的一个 entry 被更新(或者插入一个新的 entry),导致该 entry 的存储空间增加,并且这个存储空间增加会导致后续 entry 的存储空间发生变化。 - 如果前一个 entry 的存储空间需要使用更多的字节来表示(例如,从1字节增加到5字节),那么这种存储空间的增加可能会影响到下一个 entry ,因为下一个 entry需要更新它存储的前一个 entry 的存储空间
prevlen
字段,它由1 字节变成 5 字节。 - 如果这个更新又导致下一个 entry的存储空间表示也不足,那么这个过程就会继续传播,形成一个连锁反应,直到找到一个 entry,其长度表示不需要改变为止。
举一个例子:假如我们有一个 zipList ,它有多个 entry 大小在 250~253字节之间,他们的 prevlen
都是 1 个字节:
现在我们插入一个新的 entry,长度为 260 bytes,则是 e1
的 prevlen
就会由 1 bytes 增加到 5 bytes,导致 e1
存储空间由 252 bytes 增加到 256 bytes:
e1
由 252 bytes 增加到 256 bytes,那么 e2
的 prevlen
就会由 1 bytes 增加到 5 bytes,从而导致 e2
的存储空间由 252 bytes 增加到 256 bytes:
e2 的存储空间增加会导致 e3 的增加,e3 又会导致 e4,e4 又会延伸到 e5,但是 e5 的存储空间由 100bytes 增加到 104 bytes,小于 254 bytes,所以不会导致 e6 的 prevlen
值增大,至此 “连锁更新” 结束:
“连锁更新”会对 zipList
的性能有影响,因为它会导致大量的内存重新分配和数据复制。而且在极端情况下,即使只是修改了一个很小的 entry,也可能会导致整个 zipList
被复制和更新,严重影响 zipList
的性能。
总结:当列表中元素的⻓度较⼩或者数量较少时,通常采⽤
zipList
来存储。原因是因为zipList
是一个紧凑的数据结构,能够有效地减少内存占用。但是,在列表中元素较多或者元素较大时,zipList
的性能会下降,因为在列表的头部或尾部添加或删除元素时,可能需要重新分配并复制整个ziplist
。所以,zipList
非常适合少量的小数据存储。同时 zipList 还有一个“连锁更新”的问题,也会严重影响ziplist
的性能。
linkedList
linkedList
是一个由一个个节点组成的双向链表,数据结构定义如下:
typedef struct list { // 头指针 listNode *head; // 尾指针 listNode *tail; // 节点值的复制函数 void *(*dup)(void *ptr); // 节点值释放函数 void (*free)(void *ptr); // 节点值比对是否相等 int (*match)(void *ptr, void *key); // 链表的节点数量 unsigned long z; } list;
list
结构体代表整个链表,包含指向链表头节点、尾节点的指针,以及链表的长度等信息。listNode
代表双向链表的一个节点,定义如下:
typedef struct listNode { // 前驱节点指针 struct listNode *prev; // 后驱节点指针 struct listNode *next; // 指向节点的值 void *value; } listNode;
整体结构如下:
linkedList
是一个双向链表,所以在执行两端操作时(如 LPUSH
、RPUSH
、LPOP
、RPOP
),时间复杂度是 O(1)
,效率非常快。但是如果它要执行索引类操作(如 LINDEX
、LSET
)时,则需要遍历列表,所以效率会低些。同时,linkedList
与 zipList
相比,它的内存使用率较高,每个节点除了存储值本身外,还需要额外空间存储前向和后向的指针。但是,对于大型列表,这种额外的内存开销是合理的,因为它提供了更佳的操作性能。所以 linkedList
比较适合列表元素数量较多,或者列表中包含大型元素的场景。
quicklist
在 quicklist
出现之前,List 使用 zipList
和 linkedList
来存储数据。
zipList
:是一个紧凑的数据结构,它能够有效地减少内存占用,但是当列表中元素较多或者元素本身较大时,zipLisjavascriptt
的性能会下降,因为在列表的头部或尾部添加或删除元素时,可能需要重新分配并复制整个ziplist
。所以,zipList
非常适合少量的小数据存储。linkedList
:一个双向链表,能够快速支持插入和删除操作,但是内存利用率较低,因为它需要额外的内存空间来维护链表结构。
zipList
和 linkedList
都存在这样或那样的缺陷,所以www.devze.com Redis 在 3.2 版本采用 quicklipythonst
来取代zipList
和 linkedList
。
quicklist
是将 zipList
作为节点嵌入 linkedList
的节点中,它结合了两者的优点。具体来说,quicklist
是由多个 ziplist
节点组成的 linkedList
链表,每个 ziplist
节点可以存储多个列表元素。
- 内存利用率:通过使用
ziplist
,quicklist
可以像使用ziplist
那样高效地存储小数据元素,提供内存利用率。 - 操作性能:由于
quicklist
是基于双向链表的,它可以快速地在两端添加或删除元素,而不需要像单一ziplist
那样可能会发生重新分配和复制整个数据结构的情况。对于列表中间的插入和删除操作,Redis 会先找到对应的ziplist
节点,然后在这个ziplist
中进行操作,这样可以保持较高的操作效率。
quicklist
表头结构:
typedef struct quicklist { // 链表头部节点指针 quicklistNode *head; // 链表头部节点指针 quicklistNode *tail; // 所有 ziplist 的总 entry 个数 unsigned long count; // quicklistNode 个数 unsigned long len; /* number of quicklistNodes */ signed int fill : QL_FILL_BITS; /* fill factor for individual nodes */ unsigned int compress : QL_COMP_BITS; /* depth of end nodes not to compress;0=off */ unsigned int bookmark_count: QL_BM_BITS; quicklistBookmark bookmarks[]; } quicklist;
quicklist
节点结构:
typedef struct quicklistNode { // 前驱节点指针 struct quicklistNode *prev; // 后继节点指针 struct quicklistNode *next; // 指向 ziplist 的指针 unsigned char *zl; // ziplist 字节大小 unsigned int sz; // ziplst 元素个数 unsigned int count : 16; // 编码格式,1 = RAW 代表未压缩原生ziplist,2=LZF 压缩存储 unsigned int encoding : 2; // 节点持有的数据类型,默认值 = 2 表示是 ziplist unsigned int container : 2; // 节点持有的 ziplist 是否经过解压, 1 表示已经解压过,下一次操作需要重新压缩。 unsigned int recompress : 1; // ziplist 数据是否可压缩,太小数据不需要压缩 unsigned int attempted_compress : 1; // 预留字段 unsigned int extra : 10; } quicklistNode;
结合 quicklist
和 quicklistNode
,quicklist
的结构如下图:
使用 quicklist
关键点就在于我们如何平衡好每个 ziplist
的大小或者元素个数,平衡内存得使用和操作性能。
quicklistNode
的ziplist
的越小,内存使用率就会越低,极端情况下,每个ziplist
只有一个元素,这样quicklist
退化为了linkedList
。quicklistNode
的ziplist
的越大,内存使用率就越高,但这样就越不利于操作性能了,极端情况下,所有元素都几种在ziplist
中,quicklist
就退化为了ziplist
。
所以,我们需要通过配置来平衡每个 ziplist
的大小或者元素个数。Redis 提供了参数 list-max-ziplist-size
和 list-compress-depth
来配置 quicklist
。
list-max-ziplist-size
控制每个 quicklist
节点内部的 ziplist
可以包含的最大元素数量或字节大小。
- 为负数时表示
ziplist
节点的字节大小上限。 - 为正数时表示
ziplist
节点中元素的数量上限。
当一个 ziplist
中的元素达到配置的阈值时,如果有新元素添加到列表中时,Redis 会创建一个新的 ziplist
节点来存储这个元素。该值较大(绝对值)时,单个 ziplist
存储的元素就越多,内存利用率就越高,但是会牺牲列表的操作性能,如果较小,则有利于列表的操作性能,但牺牲了内存的利用率。所以,在实际生产情况下我们需要根据实际情况配置一个适中的值,来平衡列表操作的内存效率和性能。
Redis 默认 list-max-ziplist-size
为 -2
,限制 ziplist
节点大小为 2KB。
list-compress-depth
用于配置 quicklist
中节点的压缩。list-compress-depth
决定了在 quicklist
中,距离首尾元素多远的中间节点应该被压缩存储,该参数影响着内存使用和访问这些被压缩节点数据时的性能。注意,为了 push/pop 操作的高效性,quicklist
的头和尾节点永远都不会被压缩。
0
:表示不对quicklist
中的任何节点进行压缩。访问性能最佳,但是内存利用率最高。1
:表示对quicklist
的首尾节点不之外的节点进行压缩。这样可以最大限度地提供内存的利用率,但是不利于这些元素的访问,因为需要进行解压操作。> 1
:指定了列表首尾节点各有多少个节点不被压缩。如,list-compress-depth 2
表明quicklist
的首尾各两个节点不会被压缩,其余中间的节点都会被压缩。随着这个值的增加,被压缩的节点数量减少,内存使用会增加,但访问这些较靠近列表首尾的元素的速度会更快。
以上就是详解Redis中的List是如何实现的的详细内容,更多关于Redis List实现的资料请关注编程客栈(www.devze.com)其它相关文章!
精彩评论