1、压缩列表的优缺点

压缩列表被设计成一种内存紧凑型的数据结构,这样有两个好处:

  • 内存空间连续,可以利用CPU缓存
  • 可以针对不同的数据长度来分配头结构的长度,节省数据结构带来的内存开销

压缩列表的缺点:

  • 如果存储的元素过多,查询效率就会很低,因为查找的方式是挨个遍历
  • 新增或修改某个元素时,压缩列表占用的内存空间需要重新分配,甚至可能引发连锁更新的问题

因此,Redis 对象(List 对象、Hash 对象、Zset 对象)包含的元素数量较少或者元素值不大时,才会使用压缩列表作为底层数据结构。

2、压缩列表的结构设计

1、压缩列表结构

redis压缩列表 结构 redis压缩列表 优点_后端

头部有三个字段:

  • zlbytes:整个压缩列表占用的内存字节数
  • zltail:列表的起始地址到最后一个节点的字节数,可以快速找到列表的最后一个节点,用于逆序遍历
  • zllen:记录列表内元素的个数

在尾部有一个字段:

  • zlend:标记压缩列表的结束点。固定值是 0xFF(十进制255)

查找效率

这样的设计,使得查找列表中第一个元素、最后一个元素,效率很高,是O(1),因为可以根据表头的三个字段直接定位。

但是查找列表中间的元素就只能靠遍历了,所以不适合存储过多的元素,因为查找效率低。

2、压缩列表节点结构

redis压缩列表 结构 redis压缩列表 优点_后端_02

  • 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