1、压缩列表的优缺点
压缩列表被设计成一种内存紧凑型的数据结构,这样有两个好处:
内存空间连续,可以利用CPU缓存 可以针对不同的数据长度来分配头结构的长度,节省数据结构带来的内存开销
压缩列表的缺点:
- 如果存储的元素过多,查询效率就会很低,因为查找的方式是挨个遍历
- 新增或修改某个元素时,压缩列表占用的内存空间需要重新分配,甚至可能引发连锁更新的问题
因此,Redis 对象(List 对象、Hash 对象、Zset 对象)包含的元素数量较少或者元素值不大时,才会使用压缩列表作为底层数据结构。
2、压缩列表的结构设计
1、压缩列表结构
头部有三个字段:
- zlbytes:整个压缩列表占用的内存字节数
- zltail:列表的起始地址到最后一个节点的字节数,可以快速找到列表的最后一个节点,用于逆序遍历
- zllen:记录列表内元素的个数
在尾部有一个字段:
- zlend:标记压缩列表的结束点。固定值是 0xFF(十进制255)
查找效率
这样的设计,使得查找列表中第一个元素、最后一个元素,效率很高,是O(1),因为可以根据表头的三个字段直接定位。
但是查找列表中间的元素就只能靠遍历了,所以不适合存储过多的元素,因为查找效率低。
2、压缩列表节点结构
- prevlen:记录“前一个节点”的长度
- encoding:记录当前节点的实际数据类型和长度
- data:保存实际数据
为什么要设计这个prevlen?
这是为了逆序遍历压缩列表 !从列表头部的zltail字段可以快速找到最后一个节点,之后就可以依靠当前节点存储的prevlen来找到前一个节点。
每个节点占用的字节数都不一样,所以必须设计一个字段来表示每个节点的长度。
prevlen占用的长度:
- 如果前一个节点的长度小于254字节,那么prevlen使用1字节来保存
- 如果前一个节点的长度大于254字节,那么prevlen直接使用5字节来保存,而且第一个字节是固定的标志0xFE,只用后面4字节表示长度
为什么这么设计?
因为这样可以通过判断prevlen的第一个字节是否为0xFE,来得知prevlen的长度是1字节还是5字节。
为什么要设计这个encoding?
这是为了正序遍历压缩列表,同时也起到标识节点数据类型的作用。
- 如果数据是整数,encoding就占1个字节
- 如果数据是字符串,encoding就根据字符串的长度,可以占1、2、5个字节
具体的逻辑是:
- encoding的前两位用来表示类型(11整数,01、10、11表示不同长度的字符串)
encoding的其他位用来存储当前数据的长度
为什么必须要保存类型?它有什么作用?
通过判断encoding的前两位,就可以得知encoding的长度。
总结
prevlen和encoding虽然都用于遍历,但存储的内容还是有区别:
- prevlen存储的是前一个节点的整体长度,包括结构字段(prevlen、encoding)长度和数据长度
- encoding的后几位存储的只是数据长度
这是因为想要实现正序遍历和逆序遍历。
3、如何找到一个节点的数据
我们可以顺着压缩列表头开始:
- 列表头的三个字段长度都是确定的,所以可以直接找到列表的第一个节点的开始地址
- 开始地址存储了prevlen的开头,判断它的第一个字节是否为0xFE
- 如果是,说明prevlen占5个字节。往后推5个字节,找到encoding的起始地址
- 如果不是,说明prevlen占1个字节。往后推1个字节,找到encoding的起始地址
- 找到了encoding,判断encoding的前两位
- 如果是11,说明encoding占1个字节。往后推1个字节,找到了数据的起始地址
- 如果是01、10、11,说明encoding占1、2、5个字节。往后推相应个字节,找到了数据的起始地址
- 找到了数据的起始地址。
- 知道了encoding的长度,忽略前两位,后面的位就表示数据的长度
- 知道了数据的起始地址、数据的长度,就能确定节点的数据内容。
- 如果数据不是所需的,就需要向后遍历。
- 此时就很简单了,节点的开始地址+prevlen长度+encoding长度+数据长度,就找到了下一个节点的开始地址。
压缩列表的设计非常优秀,也可以逆序遍历:
- 从列表头的zltail字段,与列表的开始地址相加,就找到了列表的最后一个节点的开始地址
- 这样,根据上面的逻辑,就可以知道末尾节点的数据
- 如果数据不是所需的,需要向前遍历
- 也很简单,当前节点的prevlen存储了前一个节点的长度
- 将当前节点的开始地址,减去当前节点的prevlen,就找到了前一个节点的开始地址。
4、为什么省内存
压缩列表的每个节点占有的内存并不是定死的,而是根据实际占用的内存来灵活记录的。
每次向压缩列表中插入数据时,压缩列表会根据数据的具体类型、数据的大小,来选择不同长度的prevlen和encoding来记录。
这样的做法可以尽可能地降低节点头结构占用的空间 。
3、连锁更新问题
普通更新问题
- 如果新增一个节点时,压缩列表的空间不够,就必须重新分配列表占用的内存空间
- 由于压缩列表是连续存储的,所以可能涉及到在内存中的整体搬移,这个效率比较低。
连锁更新问题
起因:
- 压缩列表在插入时,为每个节点都尽量“量身打造”了占用的空间。
- 每个节点占用的空间从两个地方体现:自身的encoding的后几位、后一个节点的prevlen。
- 所以,如果一个节点发生了修改而导致占用空间变大,需要修改的地方有:
- 后面的数据全部向后复制(因为连续存储,而且存储空间没有冗余)
- 修改当前节点的encoding
- 修改后一个节点的prevlen
产生的问题:
- 如果当前节点长度突破了prevlen的范围,导致后一个节点的prevlen必须扩大,就可能导致后一个节点也突破了prevlen的范围
- 最终,一连串的节点都发生更新,空间都需要重新分配,导致列表的性能下降
不过压缩列表也考虑到了这个问题,它的解决方案是:
- 如果前一个节点的长度小于254字节,那么prevlen使用1字节来保存
- 如果前一个节点的长度大于254字节,那么prevlen直接使用5字节来保存
可以看到,它只设计了两种长度,这样设计显然是为了避免频繁地修改prevlen长度。
不过如果正好列表中有很多253大小的节点,正好有一个节点变大了,还是会产生连锁更新问题,就像多米诺骨牌。
影响:
- 如果元素较少,那么连锁更新的问题也不大,很快就结束了
- 如果元素比较多,但是元素都不大,那么发生连锁更新的概率较低。就算发生了,影响的范围也不会太大
所以,如果只是在元素较少或元素较小时使用,压缩列表还是很优秀的。
不过Redis在后续版本新增了两种数据结构:quicklist(Redis 3.2 引入) 和 listpack(Redis 5.0 引入)
它们的使命就是,保持压缩列表空间紧凑的设计思想,同时尽可能避免连锁更新问题。
注:文中的插图引用自小林Coding
|