一 引入
Q1 正常情况下我们选择使用 Redis
就是为了提升查询速度,然而让人意外的是,Redis
当中却有一种比较有意思的数据结构,这种数据结构通过牺牲部分读写速度来达到节省内存的目的,这就是 ziplist
(压缩列表),Redis
为什么要这么做呢?难道真的是觉得自己的速度太快了,牺牲一点速度也不影响吗?
Q2 ziplist
作为一种列表,其和普通的双端列表,如 linkedlist
的最大区别就是 ziplist
并不存储前后节点的指针,而 linkedlist
一般每个节点都会维护一个指向前置节点和一个指向后置节点的指针。那么 ziplist
不维护前后节点的指针,它又是如何寻找前后节点的呢?
Q3 ziplist
类似于数组,ziplist在内存中是连续存储的。但是不同于数组,为了节省内存 ziplist的每个元素所占的内存大小可以不同(数组中叫元素,ziplist叫节点entry)。
普通数组的遍历是根据数组里存储的数据类型 找到下一个元素的,例如int类型的数组访问下一个元素时每次只需要移动一个sizeof(int)就行(实际上开发者只需让指针p+1就行,在这里引入sizeof(int)只是为了说明区别)。
上文说了,ziplist的每个节点的长度是可以不一样的,而我们面对不同长度的节点又不可能直接sizeof(entry),那么它是怎么访问下一个节点呢?
二 什么是压缩列表
压缩列表(ziplist
)是一种数据结构,是Redis为了节约内存而开发的。
ziplist
是由一系列特殊编码组成的连续内存块的顺序型数据结构。
每个节点可以用来存储一个整数或者一个字符串。
.ziplist
将一系列数据与其编码信息存储在一块连续的内存区域,这块内存物理上是连续的,逻辑上被分为多个组成部分,目的是在一定可控的时间复杂度条件下尽可能的减少不必要的内存开销(以时间换空间),从而达到节省内存的效果。
二 数据结构
2.1 压缩列表构成
《Redis设计与实现》
2.2 压缩列表节点构成
2.2.1 previous_entry_length(prevlen
)
prevlen
属性存储了前一个 entry
的长度,通过此属性能够从后到前遍历列表。
prevlen
属性的长度可能是 1
字节也可能是 5
字节:
当链表的前一个 entry
占用字节数小于 254
,此时 prevlen
只用 1
个字节进行表示。
<prevlen from 0 to 253> <encoding> <entry>
当链表的前一个 entry
占用字节数大于等于 254
,此时 prevlen
用 5
个字节来表示,其中第 1
个字节的值固定是 254
(相当于是一个标记,代表后面跟了一个更大的值),后面 4
个字节才是真正存储前一个 entry
的占用字节数。
0xFE <4 bytes unsigned little endian prevlen> <encoding> <entry>
注意:1
个字节完全你能存储 255
的大小,之所以只取到 254
是因为 zlend
就是固定的 255
,所以 255
这个数要用来判断是否是 ziplist
的结尾。
2.2.2 encoding
在Redis的压缩表中,设计精髓都在这里,为了节省压缩表占用的内存,Redis对Encoding进行了极致的设计。一般都是读取前8个字节,用来判断存储的数据是什么。
encoding
属性存储了当前 entry
所保存数据的类型以及长度。encoding
长度为 1
字节,2
字节或者 5
字节长。前面我们提到,每一个 entry
中可以保存字节数组和整数,而 encoding
属性的第 1
个字节就是用来确定当前 entry
存储的是整数还是字节数组。当存储整数时,第 1
个字节的前两位总是 11
,而存储字节数组时,则可能是 00
、01
和 10
三种中的一种。
当存储整数时,第 1
个字节的前 2
位固定为 11
,其他位则用来记录整数值的类型或者整数值(下表所示的编码中前两位均为 11
):
编码 | 长度 | entry保存的数据 |
---|---|---|
11000000 | 1字节 | int16_t类型整数 |
11010000 | 1字节 | int32_t类型整数 |
11100000 | 1字节 | int64_t类型整数 |
11110000 | 1字节 | 24位有符号整数 |
11111110 | 1字节 | 8位有符号整数 |
1111xxxx | 1字节 | xxxx 代表区间 0001-1101 ,存储了一个介于 0-12 之间的整数,此时 entry-data 属性被省略 |
注意:xxxx
四位编码范围是 0000-1111
,但是 0000
,1111
和 1110
已经被表格中前面表示的数据类型占用了,所以实际上的范围是 0001-1101
,此时能保存数据 1-13
,再减去 1
之后范围就是 0-12
。至于为什么要减去 1
是从使用习惯来说 0
是一个非常常用的数据,所以才会选择统一减去 1
来存储一个 0-12
的区间而不是直接存储 1-13
的区间。
当存储字节数组时,第 1
个字节的前 2
位为 00
、01
或者 10
,其他位则用来记录字节数组的长度:
编码 | 长度 | entry保存的数据 |
---|---|---|
00pppppp | 1字节 | 长度小于等于 63 字节(6 位)的字节数组 |
01pppppp qqqqqqqq | 2字节 | 长度小于等于 16383 字节(14 位)的字节数组 |
10000000 qqqqqqqq rrrrrrrr ssssssss tttttttt | 5字节 | 长度小于等于 2 的 32 次方减 1 (32 位)的字节数组,其中第 1 个字节的后 6 位设置为 0 ,暂时没有用到,后面的 32 位(4 个字节)存储了数据 |
2.2.3 content
2.3 查找
回答Q2的问题:
ziplist
虽然不维护前后节点的指针,但是它却维护了上一个节点的长度和当前节点的长度,然后每次通过长度来计算出前后节点的位置。既然涉及到了计算,那么相对于直接存储指针的方式肯定有性能上的损耗,这就是一种典型的用时间来换取空间的做法。因为每次读取前后节点都需要经过计算才能得到前后节点的位置,所以会消耗更多的时间,而在 Redis
中,一个指针是占了 8
个字节,但是大部分情况下,如果直接存储长度是达不到 8
个字节的,所以采用存储长度的设计方式在大部分场景下是可以节省内存空间的。
2.4 ziplist 连锁更新问题
上面提到 entry
中的 prevlen
属性可能是 1
个字节也可能是 5
个字节,那么我们来设想这么一种场景:假设一个 ziplist
中,连续多个 entry
的长度都是一个接近但是又不到 254
的值(介于 250~253
之间),那么这时候 ziplist
中每个节点都只用了 1
个字节来存储上一个节点的长度,假如这时候添加了一个新节点,如 entry1
,其长度大于 254
个字节,此时 entry1
的下一个节点 entry2
的 prelen
属性就必须要由 1
个字节变为 5
个字节,也就是需要执行空间重分配,而此时 entry2
因为增加了 4
个字节,导致长度又大于 254
个字节了,那么它的下一个节点 entry3
的 prelen
属性也会被改变为 5
个字节。依此类推,这种产生连续多次空间重分配的现象就称之为连锁更新。同样的,不仅仅是新增节点,执行删除节点操作同样可能会发生连锁更新现象。
2.5 性能
(1)在Redis中,数据结构压缩表是紧凑排列的,所以,我们每次查询都需要遍历整一个列表,才能查询到相关数据,因为ziplist存放的个数非常有限,所以性能的开销并不大。
(2)虽然 ziplist
可能会出现这种连锁更新的场景,但是一般如果只是发生在少数几个节点之间,那么并不会严重影响性能,而且这种场景发生的概率也比较低,所以实际使用时不用过于担心。
三 应用
压缩列表(ziplist)是列表键和哈希键的底层实现之一。
当一个列表键只包含少量列表项,并且每个列表项要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来做列表键的底层实现。
当一个哈希键只包含少量键值对,并且每个键值对的键和值要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来做哈希键的底层实现。
Redis一共支持五种数据结构类型,其中有三种数据结构在一定条件下会应用压缩列表。
四 问答
Q: Redis为什么使用压缩列表?使用压缩列表的好处是什么?压缩列表的应用对与我们使用内存有没有什么启发?
A: (1) 节约内存:
任何策略都有其应用场景,不同场景应用不同策略。
Redis是一款内存数据库软件,想办法尽可能减少内存的开销是Redis设计者一定要考虑的事情
压缩列表在一定N内可以容忍的时间复杂度,利用巧妙的编码技术除了存储内容尽可能的减少不必要的内存开销,将数据存储于连续的内存区域
(2) 减少内存碎片
将很多小的数据块存储在一个比较大的内存区域。
如果要存储的数据都是很小的条目,为每一个数据条目都单独的申请内存,结果是这些条目将有可能分散在内存的每一个角落,最终导致碎片增加,这是一件令人头疼的事情。