概述
压缩列表类似于数组,它是由连续的一块内存组成的顺序数据结构,主要是redis用来节省内存空间而设计的(毕竟是内存缓存,内存空间是很重要的一块)。通过固定的元素长度字段来实现变长存储,使内存利用率更紧凑。
设计思想是通过时间换空间,而时间的损耗又相对来说比小(小到几乎可以忽略)
应用
redis中hash、list、zset等数据结构的底层实现,存储数据量较少或者元素值不大的情况下使用了压缩列表。
- 例如最大元素个数512,元素最大长度64;个别数据结构有微小区别
- 3.2后list底层由quicklist实现;最新版本通过listpack替代;但都能看到压缩列表的影子
由于list的常用命令(lpush,rpush,lpop,rpop)要求需要双向遍历的能力,即ziplist也需要支持;解析下面数据结构为啥有prevlen字段的设计
压缩列表结构
压缩列表在表头有三个字段:
-
zlbytes,记录整个压缩列表占用对内存字节数;
-
zltail,记录压缩列表「尾部」节点距离起始地址由多少字节,也就是列表尾的偏移量;可以快速的定位到队尾;
-
zllen,记录压缩列表包含的节点数量。
压缩列表固定表尾字段:
zlend,标记压缩列表的结束点,固定值 0xFF(十进制255)。
压缩列表节点结构包括三部分内容:
-
prevlen,记录了「前一个节点」的长度;该设计是为了支持反向遍历
- 如果前一个节点的长度小于 254 字节,则需要用1字节的空间来存储
- 如果前一个节点的长度大于等于 254 字节,则需要用5字节的空间来存储
-
encoding,记录了当前节点实际数据的类型以及长度;
- 如果当前节点的数据是整数,则 encoding 会使用 1 字节的空间进行编码;
- 如果当前节点的数据是字符串,根据字符串的长度大小,encoding 会使用 1 字节/2字节/5字节的空间进行编码,encoding 编码的前两个 bit 表示数据的类型,后续的其他 bit 标识字符串数据的实际长度,即 data 的长度。
-
data,记录了当前节点的实际数据;
当我们往压缩列表添加或修改元素时,会根据元素内容(数据类型是数字还是字符串,数据长度),使用不用空间大小的prevlen(主要是前一个元素决定)和encoding来保存,这种根据不同类型和长度大小来分配不同存储空间的思想,正是redis用来节省空间的实现。
压缩列表的优缺点
优点
- 内存空间连续,可以利用cpu预读;这也是数组的优势
- 通过数据元素的类型和大小来分配内存空间,可以有效的降低内存开销
不足
- 由于是变长不能像数组一样直接的通过索引快速定位,需要遍历来查询,特别是元素过多的时候查询效率会比较低
- 在插入和修改元素时,由于内存连续需要重新分配占用内存以及元素内存移动,甚至会引起连锁更新问题;这也正是数组相对链表的劣势
连锁更新问题
ziplist除了查询复杂度高的问题,还有个比较大的问题:
当在压缩列表中间插入一个比较大的元素或者将之前一个比较小的元素修改为大的元素时,由于prevlen或encoding的长度变大,可能会导致后续节点的prevlen占用空间都需要调整,从而产生【连锁更新】问题,致使后续所有节点空间都需要重新分配,严重影响性能。
现在假设一个压缩列表中有多个连续的、长度在 250~253 之间的节点,如下图:
因为这些节点长度值小于 254 字节,所以 prevlen 属性需要用 1 字节的空间来保存这个长度值。
这时,如果将一个长度大于等于 254 字节的新节点加入到压缩列表的表头节点,即新节点将成为 e1 的前置节点,如下图:
因为 e1 节点的 prevlen 属性只有 1 个字节大小,无法保存新节点的长度,此时就需要对压缩列表的空间重分配操作,并将 e1 节点的 prevlen 属性从原来的 1 字节大小扩展为 5 字节大小。
多米诺牌的效应就此开始。
e1 原本的长度在 250~253 之间,因为刚才的扩展空间,此时 e1 的长度就大于等于 254 了,因此原本 e2 保存 e1 的 prevlen 属性也必须从 1 字节扩展至 5 字节大小。
正如扩展 e1 引发了对 e2 扩展一样,扩展 e2 也会引发对 e3 的扩展,而扩展 e3 又会引发对 e4 的扩展.... 一直持续到结尾。
缺陷考虑
正由上面查询复杂度和连锁更新问题,压缩列表只会应用于元素个数不多的场景;这样相对来说产生的性能影响则在可接受范围内。
虽然连续空间可以减少内存碎片的问题,但由此带来的问题是需要申请一个相对比较大的连续内存空间,由于内存大了又会带来性能下降的问题,针对这些设计不足,后面也引入了quicklist和listpack,这两种数据结构的设计目标,就是尽可能地保持压缩列表节省内存的优势,同时解决压缩列表的「连锁更新」的问题。
例如quicklist,它时链表(不要求内存空间连续)+压缩列表的结合,主要是数组和链表的优势结合,因为压缩列表需要一次性申请比较大的连续的内存空间,这里可以分段,其实quicklist就是一个分段的ziplist;quicklist存储数据基本单位是quicklistNode,每个quicklistNode的内容区就是以ziplist数据结构存储的。
设计思想考虑
- 没有银弹,在这里有很多都是多种结合,针对不同场景选择不同方式,充分结合优势点。比如list既用了ziplist又用了双向链表;包括后面quicklist的设计;再比如java Arrays.sort()的排序算法
- 空间换时间,很多时候我们都是通过时间来换空间的,而redis是内存数据库,其性能是非常高效的,即时间上基本不是问题,对空间的有效利用率却是重点,在相对可接受的范围内通过牺牲时间的前提条件下,大大的提升内存空间的利用率。
- 利用二八原则,由于该定律,就要考虑怎么充分利用「这绝大部分场景」来更多的提升,这块也是呼应「没有银弹」这个说法。例如java中synchronized锁升级的设计(由于大部分情况锁的竞争都不大或者没竞争,没必要一下子就上重量级锁),又比如java9中String的底层由char改为byte(通过大量的数据表明,现实中绝大部分的字符串都是单子节的);关于这点我个人猜想prevlen分1/5字节,encodeing分1/2/5字节的设计,也是基于这方面考虑,在牺牲一些复杂度的情况下,带来很多的空间利用率提升。
说明:供个人学习和探讨,部分来源小林coding