8.Redis数据结构之ZipList


highlight: arduino-light

ZipList

数组存储的不是对象而是对象的引用

先看一段伪代码,伪代码如下 java List<Integer> list = new ArrayList<>(); ​ list.add(1); ​ Integer element = list.get(0); ​ list.remove(0); ​ System.out.println(element);

大致的操作就是先从list中使用get获取1个元素element,然后在list中remove这个元素element。

此时element还有值么?

然后看了下remove的源码,数组中的值确实被置为null了,那么element是去哪里拿值的呢?

后面就突然想到一个知识点,数组中存储的不是对象本身而是对象的引用

Integer element = list.get(0)

数组index为0的位置上存储的对象引用指向了element,数组remove了对应下标中的地址和我内存中的对象没有关系,自然element原先指向哪里现在还是指向哪里。

传统的数组

同之前的底层数据一样,压缩列表也是由Redis设计的一种数据存储结构。

他有点类似于数组,都是通过一片连续的内存空间来存储数据。但是和数组也有点区别:

数组是一组数据结构,用来存储同一类型值的集合,数组存储的元素都是定长。

数组存储不同长度的字符时,会选择最大的字符长度作为每个节点的内存大小

如下图,一共五个元素,每个元素的长度都是不一样的,这个时候选择最大值5作为每个元素的内存大小,如果选择小于5作为每个元素的内存大小,那么第一个元素hello,第二个元素world就不能完整存储,数据会丢失。

image.png

存在的问题:不等长元素

上面已经提到了需要用最大长度的字符串大小作为整个数组所有元素的内存大小,如果只有一个元素的长度超大,但是其他的元素长度都比较小,那么我们所有元素的内存都用超大的数字就会导致内存的浪费。

那么我们应该如何改进呢?

压缩列表:解决不等长

Redis引入了压缩列表的概念,即多大的元素使用多大的内存,一切从实际出发,拒绝浪费。

如下图,根据每个节点的实际存储的内容决定内存的大小,即第一个节点占用5个字节,第二个节点占用5个字节,第三个节点占用1个字节,第四个节点占用4个字节,第五个节点占用3个字节。

image.png

还有一个问题,我们在遍历的时候不知道每个元素的大小,无法准确计算出下一个节点的具体位置。

实际存储不会出现上图的横线,我们并不知道什么时候当前节点结束,什么时候到了下一个节点。所以在redis中添加length属性,用来记录前一个节点的长度。

如下图,如果需要从头开始遍历,取某个节点后面的数字,比如取“hello”的起始地址,但是不知道其结束地址在哪里,我们取后面数字5,即可知道"hello"占用了5个字节,即可顺利找到下一节点“world”的起始位置。 !

image.png

压缩列表图解分析

ziplist是由一系列特殊编码的连续内存块组成的顺序存储结构,类似于数组,ziplist在内存中是连续存储的,但是不同于数组,为了节省内存 ziplist的每个元素所占的内存大小可以不同(数组中叫元素,ziplist叫节点entry,下文都用“节点”),每个节点可以用来存储一个整数或者一个字符串。

ziplist使用连续的内存块,每一个节点(entry)都是连续存储的。

1.ziplist可以通过data header计算出当前entry的结束位置,也就能得到下一个entry的起始位置,正向遍历

2.ziplist可以通过length of previous entry计算出上一个entry的起始位置,反向遍历

image.png

image.png

表头

表头包括四个部分,分别是内存字节数zlbytes,尾节点距离起始地址的字节数zltail_offset,节点数量zllength,标志结束的记号zlend。

zlbytes:记录整个压缩列表占用的内存字节数。

zltail_offset:记录压缩列表尾节点距离压缩列表的起始地址的字节数(目的是为了直接定位到尾节点,方便反向查询)。

zllength:记录了压缩列表的节点数量。即在上图中节点数量为2。

zlend:保存一个常数255(0xFF),标记压缩列表的末端。

数据节点

数据节点包括三个部分,分别是前一个节点的长度prev_entry_len,当前数据类型和编码格式encoding,具体数据指针value。

  • prev_entry_len:记录前一个节点的长度。

    如果前一个节点长度小于254字节,那么prev_entry_len使用一个字节表示。

    如果前一个节点长度大于等于254字节,那么prev_entry_len使用五个字节表示。第一个字节为常数oxff,后面四位为真正的前一个节点的长度。

  • encoding 有两大类编码方式表示类型信息:

    • 第一字节的前两位是00,01,10时表示字符串类型,剩下的表示长度信息
    • 第一字节的前两位是11时表示整型类型,剩下的表示长度信息,例外:11111111表示end元素
  • value:存放具体的数据。

Ziplists 示例

一个包含字符串"2"和"5"的ziplist,编码结构如下图

image.png

zlbytes : 小端模式,二进制形式0f 00 00 00,表示15(4+4+2+2+2+1=15)

zltail: 小端模式,二进制形式0c 00 00 00 ,表示12,即最后一个元素entry2的偏移地址4+4+2+2 = 12

zllen: 小端模式,二进制形式 02 00 ,表示2,总共有两个entry

entry1 : 00 f3,00 表示前一个entry的长度是0, 整数2的二进制表示为11110011也就是f3

entry2: 02 f6, 由于entry1 的长度是2字节,所以前两字节是02,f6 表示整数5的二进制编码

这两个entry,每个数据结构只有两个字段,即 ,用encoding 存储类型和数据

如果要在元素 “5” 后边加入"Hello World",则"Hello World"对应的entry的编码如下: [02] [0b] [48 65 6c 6c 6f 20 57 6f 72 6c 64]

第一个字节02,表示前边一个entry长度是2字节 第二个字节0b,表示类型和长度,即0000 1011,也就是长度为0b1011=0x0b=11的字符串 entry-data字段,存储"Hello World"的ascii值

压缩列表的缺点:连锁更新

压缩列表ziplist结构的缺点是:每次插入或删除一个元素时,都需要进行频繁的调用realloc()函数进行内存的扩展或减小,然后进行数据”搬移”,甚至可能引发连锁更新,造成严重效率的损失。

因为压缩表是紧凑存储的,没有多余的空间。这就意味着插入一个新的元素就需要调用函数扩展内存。过程中可能需要重新分配新的内存空间,并将之前的内容一次性拷贝到新的地址。

如果数据量太多,重新分配内存和拷贝数据会有很大的消耗。

所以压缩表不适合存储大型字符串,并且数据元素不能太多。

压缩列表的连锁更新过程图解(重点)

前面提到每个节点entry都会有一个prevlen字段存储前一个节点entry的长度,如果内容小于254,prevlen用一个字节存储,如果大于254,就用五个字节存储。这意味着如果某个entry经过操作从253字节变成了254字节,那么他的下一个节点entry的pervlen字段就要更新,从1个字节扩展到5个字节;如果这个entry的长度本来也是253字节,那么后面entry的prevlen字段还得继续更新。

如果每个节点entry都存储的253个字节的内容,那么第一个entry修改后会导致后续所有的entry的级联更新,这是一个比较损耗资源的操作。

所以,发生级联更新的前提是有连续的250-253字节长度的节点。

步骤一

比如一开始的压缩表呈现下图所示(XXXX表示字符串),现在想要把第二个数据的改大点,哪个时候就会发生级联更新了。

image.png

步骤二

我们想要分配四个长度的大小给第三个数据的prevlen,因为第二个元素的prevlen字段是表示他前一个元素的大小。

image.png

步骤三

调整完发现第三个元素的长度增加了,所以第四个元素的prevlen字段也需要修改。

image.png

步骤四

调整完发现第四个元素的长度增加了,所以把第五个元素的prevlen字段也需要修改。

ziplist与linkedlist对比

ziplist与双端linkedlist非常相似,但是在存储数据时没有使用指针

双端linkedlist 中的每个节点有3个指针,前向指针,后向指针,数据指针。在64位系统中指针要占据8字节的内存,对于小对象列表来说,效率很低。ziplist中Entry之间没有指针,而是使用数组,节省空间。

ziplist是CPU和内存妥协的产物,ziplist有效的节省内存,但是会比linkedlist消耗CPU,ziplist插入新的元素需要重新分配内存。所以redis在小对象时才使用ziplist

ziplist 是一个特殊的双向链表

特殊之处在于:没有维护双向指针:prev next;而是存储上一个 entry的长度和 当前entry的长度,通过长度推算下一个元素在什么地方。

牺牲读取的性能,获得高效的存储空间,因为(简短字符串的情况)存储指针比存储entry长度 更费内存。这是典型的“时间换空间”。

ziplist使用局限性

字段、值比较小,才会用ziplist。

``` Redis是基于内存的nosql,有些场景下为了节省内存,redis会用“时间”换“空间”。 ziplist就是很典型的例子。 ​ ziplist是list键、hash键以及zset键的底层实现之一(3.0之后list键已经不直接用ziplist和linkedlist作为底层实现了,取而代之的是quicklist) 这些键的常规底层实现如下: ​ list键:双向链表 hash键:字典dict zset键:跳跃表zskiplist 但是当list键里包含的元素较少、并且每个元素要么是小整数要么是长度较小的字符串时,redis将会用ziplist作为list键的底层实现。同理hash和zset在这种场景下也会使用ziplist。 ​ 既然已有底层结构可以实现list、hash、zset键,为什么还要用ziplist呢? 当然是为了节省内存空间 我们先来看看ziplist是如何压缩的

```

zlbytes: ziplist的长度(单位: 字节),是一个32位无符号整数
zltail: ziplist最后一个节点的偏移量,反向遍历ziplist或者pop尾部节点的时候有用。
zllen: ziplist的节点(entry)个数
entry: 节点
zlend: 值为0xFF,用于标记ziplist的结尾
普通数组的遍历是根据数组里存储的数据类型 找到下一个元素的,例如int类型的数组访问下一个元素时每次只需要移动一个sizeof(int)就行(实际上开发者只需让指针p+1就行,在这里引入sizeof(int)只是为了说明区别)。
上文说了,ziplist的每个节点的长度是可以不一样的,而我们面对不同长度的节点又不可能直接sizeof(entry),那么它是怎么访问下一个节点呢?
ziplist将一些必要的偏移量信息记录在了每一个节点里,使之能跳到上一个节点或下一个节点。
接下来我们看看节点的布局
​
节点的布局(entry)
​
每个节点由三部分组成:prevlength、encoding、data
​
prevlengh: 记录上一个节点的长度,为了方便反向遍历ziplist
为了节省内存,根据上一个节点的长度prevlength 可以将ziplist节点分为两类:
entry的前8位小于254,则这8位就表示上一个节点的长度
entry的前8位等于254,则意味着上一个节点的长度无法用8位表示,后面32位才是真实的prevlength。用254 不用255(11111111)作为分界是因为255是zlend的值,它用于判断ziplist是否到达尾部。
​
encoding: 当前节点的编码规则,下文会详细说
data: 当前节点的值,可以是数字或字符串

image.png

为什么ziplist节省内存

没有指针
连续空间,减少内存碎片
64位系统双向链表一个节点prev和next指针就16字节了,还有1个数据指针。
ziplist主要省的内存就在这儿了
压缩链表都在一块连续的空间上创建,碎片化的空间小了,分配大块内存减小内存碎片

quickList

image.png

在版本3.2之后,Redis 列表list使用quicklist作为底层实现,ziplist被quicklist替代。

但是仍然是zset底层实现之一。

可以认为quickList,是ziplist和linkedlist二者的结合;quickList将二者的优点结合起来。

quicklist是由ziplist组成的双向链表,链表中的每一个节点都以压缩列表ziplist的结构保存着数据,而ziplist有多个entry节点,保存着数据。

相当于一个quicklist节点保存的是一片数据,而不再是所有的数据

这样做的目的是防止连续的连锁更新发生。

quicklist 内部默认单个 ziplist 长度为 8k 字节,超出了这个字节数,就会新起一个 ziplist。

ziplist 的长度由配置参数 list-max-ziplist-size决定。

根据以上描述,总结出一下quicklist的特点:

quicklist宏观上是一个双向链表,因此,它具有一个双向链表的有点,进行插入或删除操作时非常方便,虽然复杂度为O(n),但是不需要内存的复制,提高了效率,而且访问两端元素复杂度为O(1)。

quicklist微观上是一片片entry节点,每一片entry节点内存连续且顺序存储,可以通过二分查找以 log2(n) 的复杂度进行定位。

quickList就是一个标准的双向链表的配置,有head 有tail; 每一个节点是一个quicklistNode,包含prev和next指针。

每一个quicklistNode 包含 一个ziplist,zip压缩链表里存储键值。 所以quicklist是对ziplist进行一次封装,使用小块的ziplist来既保证了少使用内存,也保证了性能。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值