无题
我们前面第 6 讲《实战应用篇:List 命令详解与实战(上)》中说过,Redis List 的底层逻辑类似于 Java 里面的 LinkedList。C 语言里面呢,并没有现成的链表结构, 所以 Redis 自己做了一个 quicklist,用来表示双端链表。
quicklist 的结构大概就是下图这样:
可以看到,quicklist 有头指针和尾指针,分别指向了链表的首节点和尾节点;在每个节点里面,都有 next、prev 两个指针,next 指针指向下一个节点,prev 指针指向前一个节点。除此之外,每个 Node 里面还有一个 Value 指针,指向这个节点存储的具体数据。
如果单纯使用双端链表的话,会出现一些问题。
- 如果每个 Node 节点里面 Value 指针指向的数据很小,比如只存储了一个 int 值,那 next、prev 指针占了 Node 节点的绝大部分空间,真正存储数据的有效负载就比较低。
- 链表节点分配很多的话,就会出现很多不连续的内存碎片。
- 还有就是,链表查询数据的时候,需要顺着 next 或者 prev 指针链表的一端开始查找,不像数组那样,可以按照下标随机访问,所以说双端链表的查找效率比较低。
Redis 7.0 之前的版本,为了优化上面的问题,把 Node 节点的 Value 指针,指向了一个 ziplist 实例,ziplist 是一块连续的空间,里面可以存储多个 List 元素。简单来说,ziplist 实际上就是用一块连续的内存区域,实现了类似链表的效果。
这样的话,quicklist 和简单的链表结构相比呢,Node 节点数量会更少,内存碎片也就更少了,而且一个 Node 里面存放了多个元素,next、prev 指针占的空间就几乎可以忽略了,有效负载也高了。
ziplist 虽然是一块连续的空间,但是呢,它还是不能像数组那样随机访问。查找元素的时候,也是要从头开始扫描,听上去查找方面并没有什么提升。但是也正是 ziplist 是一块连续的内存空间,扫描的时候,不会像 Node 那样有很多指针解析的开销,数据量少的时候,迭代起来还是挺快的。
不过需要说明的是:
从 7.0 开始,Redis 就用 listpack 这个结构替换了 ziplist 结构,但是现在生产环境用的主要还是 Redis 6,Redis 7.0 Release 也是刚发,所以 ziplist 这个结构还是要介绍。程序员知识迭代嘛,没办法的。
ziplist 结构分析
接下来呢,我们就来深入说一下 ziplist 的内存结构,看看它是怎么存储数据的。
来,看下面这张图哈,ziplist 分成了队头、队尾、还有中间的数据三部分(我用不同颜色标了一下),ziplist 里面这一个个的 entry 是真正存储数据的地方,ziplist 里面结构比较复杂的部分在队头这个地方。
首先来看队头里面的 zlbytes 值,它里面存了一个 int 值,占了 4 个字节,里面存的是整个 ziplist 占的总字节数。
然后是队头的第二部分 zltail 值,也是一个 int 值,占了 4 字节,记录了最后一个 entry 在 ziplist 里面的偏移字节数。为什么要存 zltail 这个值呢?主要是为了
实现逆序遍历。如果我们已知一个 ziplist 的首地址,就可以结合它的 zltail 值,计算出最后一个 entry 的地址,对吧?而在 entry 里面呢,会记录前一个 entry 的长度,这样的话,我们就可以找到前一个 entry 的地址,于是一个个 entry 反着找,就能实现 ziplist 的逆序遍历了。(至于 entry 的具体结构呢,马上就会说到,咱们一步一步来哈。)队头的第三部分是 zllen 值,它是一个 2 字节的整数,记录了整个 ziplist 中的 entry 个数,也就是元素个数哈。所以,我们说一个 ziplist 最多存储 2^16-1 个元素,没问题吧?其实呢,如果元素个数超过了 2^16 -1,ziplist 依旧能存得下,但是这个场景就比较低效了。在 zllen 值变成全 1 的时候呢,Redis 就不再认为 zllen 表示的是元素个数,而是把它当成一个溢出的标识,这个时候要获取元素个数的话,就需要遍历整个 ziplist。
接下来看 ziplist 的结尾,也就是图里面的 zlend 部分,它占了 1 个字节,而且它的值始终是 255,用来标识 ziplist 结束。这就类似于 C 字符串的
\0结束符,这只是个类比而已哦,ziplist 已经明确地记录了整个 ziplist 长度,所以它本身就是二进制安全的。
说完了 ziplist 首尾两部分的结构之后呢,我们再来展开说说 ziplist 里面真正存储数据的 entry 部分。这张图展示了一个 entry 的结构:
可以看到,一个 entry 里面分了 3 部分。
第一部分是 prevlen,它记录了前一个 entry 节点占了多少个字节。前面说过 ziplist 逆序遍历的过程,这里面就是靠 prevlen 字段确定往前移动多少个字节,就能拿到前一个 entry 首地址。
prevlen 这部分会根据前一个 entry 的长度进行变长编码,怎么个“变长编码”呢?
- 如果前一个 entry 的长度小于 254 字节的话,那么当前 entry 中的 prevlen 部分,只用一个字节就能存得下。
- 如果前一个 entry 的长度大于等于 254 字节的话,那么当前的 prevlen 需要 5 个字节。这 5 个字节里面,第 1 个字节存一个固定值 254,作为一个标识,之所以不用 255 做标记是为了防止与 zlend 冲突。然后,后面 4 个字节组成一个 32 位的 int 值,用来存前一个 entry 的长度。
entry 的第二部分是 len,它记录了当前这个 entry 节点里面 data 部分的长度。len 呢,也是变长编码,len 的变长编码逻辑有点复杂。
entry 的第三部分是 data,其中存储了真正的数据。我们先来说 data 存的是字符串的场景:
- 第一个场景里面,len 的第 1 个字节最高两位是 00,这个时候的 len 只有 1 个字节,剩余的 6 位用来记录 data 的长度,也就是说,data 的长度上限是 2^6-1。
- 第二个场景里面,len 的第 1 个字节最高两位是 01,len 占用 2 个字节,然后第 1 个字节剩余的 6 位以及第 2 个字节 8 位,组合起来,总共有 14 位,用来记录 data 的长度。这样的话,data 的长度上限是 2^14-1。
- 第三个场景里面,len 的第 1 个字节最高两位是 10,len 占用 5 个字节,第 1 个字节里面剩余的 6 位保留不用,只使用后面 4 字节,组成 32 位的整型来记录 data 长度。这样的话,最多能存储 2^32-1 个字节的数据。
上面这三种场景里面呢,data 部分存储的都是字符串。如果 data 里面存储的是整数,那 len 第一个字节的最高两位始终为 11。 len 只占一个字节,然后呢,第一个字节的剩余 6 位,来标识 data 存的数字占了多少字节,比如这张图里面:
- len 第一个字节的剩余 6 位全部为 0,表示 data 为 int16_t 类型,类似于 Java 里面的 short,占 2 个字节。
- len 第一个字节的剩余 6 位为 01 0000,表示 data 为 int32_t 类型,类似 Java 里面的 int,占用 4 个字节。
- len 第一个字节的剩余 6 位为 10 0000,表示 data 为 int64_t 类型,类似 Java 里面的 long,占用 8 个字节。
- len 第一个字节的剩余 6 位为 11 0000,表示 data 为占用 3 字节的整数,在 Java 里面没有对标的类型。
- len 第一个字节的剩余 6 位为 11 1110,表示 data 为占用 1 字节的整数,就和 Java 里面的 byte 一样。
- len 第一个字节的剩余 6 位为 11 开头,并且低 4 位的取值在 0001 和 1101 之间,低 4 位就表示真正的数据值,这样的话,就不再需要一个单独的 data 部分了。
需要注意的是:由于低 4 位表示的 13 个值并不是 1 ~ 13,而是 0 ~ 12,也就是 0001 表示 0,1101 表示 12,也就是说,xxxx 表示的真实值是 xxxx -1。
总结
本节课程中,我们重点介绍了 Redis 中 ziplist 这种数据结构的核心结构。ziplist 是一块连续的内存空间,但是逻辑上它表示的是一个链表,之所以不用指针+节点的常规链表方式实现,就是为了节省指针的空间。从这个角度看,Redis 是不是把节省内存这件事情做到了极致呢?
ziplist 结构本身的规则有点复杂,通过本节的讲解之后,小伙伴们再阅读 ziplist 代码的时候,有没有豁然开朗的感觉呢?ziplist 的复杂设计,也是 Redis 7 中用 listpack 结构替换 ziplist 结构的原因之一。
分析完 ziplist 的核心结构之后,在下一节我们将聚焦 Redis 是如何向 ziplist 中写入数据的。
