2、认识redis的list


#quicklist

Redis List 的底层逻辑类似于 Java 里面的 LinkedList。C 语言里面呢,并没有现成的链表结构, 所以 Redis 自己做了一个 quicklist,用来表示双端链表

quicklist 的结构大概就是下图这样:
在这里插入图片描述

可以看到,quicklist 有头指针尾指针,分别指向了链表的首节点和尾节点;在每个节点里面,都有 next、prev 两个指针,next 指针指向下一个节点,prev 指针指向前一个节点。除此之外,每个 Node 里面还有一个 Value 指针,指向这个节点存储的具体数据。

如果按照这个连表结构存储数据的话会有如下几个问题

  1. 如果每个 Node 节点里面 Value 指针指向的数据很小,比如只存储了一个 int 值,那 next、prev 指针占了 Node 节点的绝大部分空间,真正存储数据的有效负载就比较低。
  2. 链表节点分配很多的话,就会出现很多不连续的内存碎片
  3. 链表查询数据的时候,需要顺着 next 或者 prev 指针链表的一端开始查找,不像数组那样,可以按照下标随机访问,所以说双端链表的查找效率比较低。

Redis7.0之前的版本, Value指向了一个ziplist实例, ziplist 是一块连续的空间,里面可以存储多个元素。简单来说,ziplist 实际上就是用一块连续的内存区域,实现了类似链表的效果

这样的话,quicklist 和简单的链表结构相比呢,Node 节点数量会更少,内存碎片也就更少了,而且一个 Node 里面存放了多个元素,next、prev 指针占的空间就几乎可以忽略了,有效负载也高了。

ziplist 虽然是一块连续的空间,但是呢,它还是不能像数组那样随机访问。查找元素的时候,也是要从头开始扫描,听上去查找方面并没有什么提升。但是也正是 ziplist 是一块连续的内存空间,扫描的时候,不会像 Node 那样有很多指针解析的开销,数据量少的时候,迭代起来还是挺快的。

ziplist 结构分析

数据结构如下图
在这里插入图片描述

属性类型长度用途
zlbytesuint32_t4字节是一个无符号整数,表示当前ZipList占用的总字节数。
zltailuint32_t4字节记录压缩列表表尾节点距离压缩列表的起始地址有多少字节,通过该偏移量可以确定表尾节点的地址。主要是为了实现逆序遍历。如果我们已知一个 ziplist 的首地址,就可以结合它的 zltail 值,计算出最后一个 entry 的地址,对吧?而在 entry 里面呢,会记录前一个 entry 的长度,这样的话,我们就可以找到前一个 entry 的地址,于是一个个 entry 反着找,就能实现 ziplist 的逆序遍历了。
zllenuint16_t2字节指Ziplist中entry的数量(也就是元素的个数)。最大值为UINT16_MAX(65534),如果超过这个值,此处会记录为65535,但节点的真实数量需要遍历整个压缩列表才能计算得出。
entry列表节点不定用来存放具体的数据项(score和member),长度不定,可以是字节数组或整数,entry会根据成员的数量自动扩容。
zlenduint8_t1字节是一个单字节的特殊值,值是0xFF(十进制255),起到标识ZipList内存结束点的作用。

entity的结构

在这里插入图片描述

一个entity里面分了3个部分

第一部分是 prevlen,它记录了前一个 entry 节点占了多少个字节。前面说过 ziplist 逆序遍历的过程,这里面就是靠 prevlen 字段确定往前移动多少个字节,就能拿到前一个 entry 首地址。

prevlen 这部分会根据前一个 entry 的长度进行变长编码,怎么个“变长编码”呢?

  • 如果前一个 entry 的长度小于 254 字节的话,那么当前 entry 中的 prevlen 部分,只用一个字节就能存得下(1个字节有8位, 可以表示0-255, 例如前一个数的长度为5可以用十六进制0x05表示, 那么prevlen 就是0x05)
  • 如果前一个 entry 的长度大于等于 254 字节的话,那么当前的 prevlen 需要 5 个字节。这 5 个字节里面,第 1 个字节存一个固定值 254(十六进制是0xFE),作为一个标识,之所以不用 255 做标记是为了防止与 zlend(占一个字节) 冲突。然后,后面 4 个字节组成一个 32 位的 int 值,用来存前一个 entry 的长度。

第二部分是len

它记录了当前这个 entry 节点里面 data 部分的长度。len 呢,也是变长编码,len 的变长编码逻辑有点复杂。

1、如果data存储的是字符串

  • 如果data的长度0-63(2^6-1)之间, 那么len只占一位, 最高位两位是00, 后面6位用来记录data的长度
    在这里插入图片描述

  • 如果data的长度小于等于 2^14-1(16k-1), 那么len占两位, 第1个字节的前2位是01, 后6位与第2个字节的8位(总共14位), 用来记录data的长度, 也就是(2^14-1)
    在这里插入图片描述

  • 如果data的长度小于等于(2^32-1)(4G), 那么len占5个字节, 第1个字节的前2位是10, 后面的6位保留不用, 剩余的4个字节组成的32位用来表示data的长度, 也就是2^32-1
    在这里插入图片描述

2、如果data存储的是整数

len占一个字节, 最高两位始终都是11。剩余的6位用来表示data存的数字占了多少字节

  • 如果data中数据是int16_t 类型,类似于 Java 里面的 short(占 2 个字节)。那么len的剩余6位全部为0

在这里插入图片描述

  • 如果data中数据是int32_t 类型,类似 Java 里面的 int(占用 4 个字节)。那么len的剩余6位为01 0000

在这里插入图片描述

  • 如果data中数据是int64_t类型,类似 Java 里面的 long(占用 8 个字节)。那么len的剩余6位为10 0000 在这里插入图片描述

  • 如果data中数据是占用3字节的整数(2^24-1),java中没有对应的类型。那么len的剩余6位为11 0000
    在这里插入图片描述

  • 如果data中数据是占用1字节的整数(255),就和 Java 里面的 byte 一样。那么len的剩余6位为11 1110
    在这里插入图片描述

  • 如果存储的数据在0-12闭区间之间, 那么将没有data部分, 数据内容会存在len的低4位, len的高4位都是1。

    需要注意的是:由于低 4 位表示的 13 个值并不是 1 ~ 13,而是 0 ~ 12,也就是 0001 表示 0,1101 表示 12,也就是说,xxxx 表示的真实值是 xxxx -1。
    在这里插入图片描述

listpark

listpack是一种元素间有顺序的,分布在一块连续的内存空间。在redis中作为list、hash、set、zset类型的底层实现之一。

listpack和ziplist一样都是为了节约内存,区别在于listpack不存在ziplist的连锁更新问题。 Redis 5.0 就引入了 listpack 这个更简单的结构来对标 ziplist,从 Redis 7.0 开始,完全使用 listpack 替换 ziplist 了

下面这张图是 listpack 结构,它和 ziplist 的结构很类似,也是分为头、中、尾三部分。先来看头、尾两部分:头部里面的

  • tot-bytes 占了 4 个字节,存了 listpack 占用的总字节数,包括头部以及尾部

  • num-elements 占用 2 个字节,存了这个 listpack 里面的元素个数;

  • element: 具体的每个元素

  • 尾部是一个字节的 end-byte,它的值始终是 0xFF(255), 占一个字节。
    在这里插入图片描述

接下来我们就一起看下 listpack 里面 element 的结构,里面分了 encoding-type、data、backlen 三部分。

listpark中的数据元素

encoding-type 存的是 element-data 部分的编码类型和长度。和 ziplist entry 的类似,encoding-type 也是个变长的结构。

a. 单字节编码

  • 0xxx xxxx:1个字节, encoding-type 第一位是 0 的话,data 就是一个 7 位的无符号整数, encoding-type 和 data 会合并成一个字节,这个字节的后 7 位存的就是 data 部分
  • 10xx xxxx:1个字节, encoding-type 前 2 位是 10 的话,表示 data 是一个字符串,encoding-type 的低 6 位表示字符串的字节数,也就是说,data 字符串最长为 63 个字节。紧跟在 encoding-type 字节之后的,就是 data 字符串。

b. 多字节编码

  • 110x xxxx:2个字节, encoding-type前 3 位是 110 的话,表示 data 是一个整数,encoding-type 的低 5 位以及下一个字节的 8 位,总共 13 位,共同来表示 data 这个整数值。
  • 1110 xxxx:2个字节, encoding-type 前 4 位是 1110 的话,表示 data 是一个字符串,encoding-type 的低 4 位以及下一个字节的 8 位共同表示字符串的长度,也就是说,data 字符串最长就是 4095 个字节了。
  • 1111 0000:5个字节, encoding-type 的值是 0xF0 的话,表示后面跟的 4 个字节表示字符串的长度,data 字符串最长 2^32 -1 个字节。紧跟字符串长度之后的部分,就是 data 字符串了。
  • 1111 0001:3个字节, encoding-type 是 0xF1 话,表示 data 是一个 16 位的整型,encoding-type 后面紧跟的 2 个字节用来存 data 的具体值。
  • 1111 0010:4个字节,encoding-type 是 0xF2 话,表示 data 是一个 24 位的整型,encoding-type 后面紧跟的 3 个字节用来存 data 的具体值。
  • 1111 0011:5个字节, encoding-type 是 0xF3 话,表示 data 是一个 32 位的整型,encoding-type 后面紧跟的 4 个字节用来存 data 的具体值。
  • 1111 0100:9个字节, encoding-type 是 0xF4 话,表示 data 是一个 64 位的整型,encoding-type 后面紧跟的 8 个字节用来存 data 的具体值。
  • 11110101-11111110:未使用。
  • 11111111:用来表示listpack结尾。

backlen

它存了当前这个 element 的总长度,这个总长度包含了的是 encoding-type 长度加上 data 部分的长度,不包含 backlen 自身的长度

之所以有这个 backlen 存在,就和 ziplist 里面的 prevlen 一样,主要是为了支持从后向前遍历 listpack 。backlen 是一个变长的部分,最多占用 5 个字节。backlen 里面每个字节的第一位都是标识符,如果是 0 的话,就表示这个字节是 backlen 部分的最后一个字节;如果是 1 的话,就不是。也就是说,每个字节的剩余 7 位才携带有效信息

举个例子,我要在 backlen 里面存 500 这个值 ,其对应的二进制是这个值 0000 0001 1111 0100,将每 7 位切分一次,得到这个值 x0000011 x1110100,然后最高位加个 x,表示的是该字节第一位的标识符。因为 backlen 是从右向左读取的,所以高位字节应该在右边,我们给它翻转一下,就是这个值了 x1110100 x0000011,最后,补全各个字节中的标识符,得到完整的 backlen 值 11110100 00000011。

其实呢,backlen 就是 listpack 和 ziplist 不一样的地方,虽然都是为了逆序遍历,但是 backlen 记录的是 element 自身的长度,prevlen 记录的是前一个 element 的长度。很明显,backlen 这种设计把变化封装到了 element 里面,一个 element 无论怎么变,也不会影响邻居,也就不会出现连锁更新。再看 prevlen,就会发现 entry 内部没有兜住自身的变化

假设要用listpack结构存放字符串"hello"以及整数1024,表示如下图:
在这里插入图片描述

  • hello长度是6 小于63个字节, 是单字节编码10xx xxxx格式, 后六位000101是hello的长度, data部分是hello字符串, backlen是encoding-type+data的长度表示的字节数110也就是6
  • 1024是下与7位的整数, 是多字节编码110x xxxx格式, 占2个字节, 1024的二进制是(000100 00000000), backlen是2个字节(010)

总结下quicklist

quicklist 是一个类似于 Java 里面 LinkedList 的双向链表,大概结构如下图所示:
在这里插入图片描述

quicklist 里面的节点是 quicklistNode 类型,quicklistNode 里面维护了 next、prev 指针,指向前后两个 quicklistNode 节点;然后还有一个 entry 指针,指向了一个 listpack 实例。真正的元素是存储在这个 listpack 里面的,那就是说,多个元素存储在一个 quicklistNode 里面。

现在看 quicklist 的结构,是不是有种两层的感觉?比如说,我们执行 RPUSH 命令,往 quicklist 的队尾插入一个元素,实际上是先走 quicklist,找到最后一个 quicklistNode 节点,这是第一层。然后,用这个 quicklistNode 节点的 entry 指针,找到里面的 listpack,最后将数据写入到该 listpack 头部。查找数据也是类似的操作,先找 quicklistNode 节点,然后查找 listpack 得到真正的数据。

正如上文描述的这样,quicklist 同时使用双向链表结构和 listpack 连续内存空间,主要是为了达到空间和时间上的折中

  • 双向链表在首尾操作的场景里面,是 O(1) 的时间复杂度。但是,每个 quicklistNode 都保存了 prev、next 指针,要是每一个元素都比较短的话,内存的有效负载就会降;再加上 quicklistNode 节点所占的内存空间不连续,很容易产生内存碎片。而 listpack 是一块连续内存,既没有内存碎片,也没有指针这种无效负载,并且 entry 会使用变长编码,有效负载比双向链表高很多。

  • 修改、新增、删除元素的时候,listpack 就会比双端链表的性能差。主要是因为 listpack 在这些场景里面,会有内存拷贝之类的事情发生,特别是在 listpack 这块连续空间比较大的时候,一次内存拷贝可能会涉及到大量的数据。

Redis 为了同时使用双向链表和 listpack 的优点、规避两者的缺点,就出现了 quicklist 这种数据结构。比如说,每个 quicklistNode 里面的 listpack 都是比较小 listpack,当 listpack 超过阈值,就会分裂,防止大 listpack 出现;也正是 listpack 里面存了多个元素,可以减少内存碎片。

个人公众号: 行云代码

参考文章

说透 Redis 7

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

uncleqiao

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值