本文内容均来自《Redis设计与实现》一书
压缩列表是列表键和哈希键的底层实现之一。当一个列表项只包含少量列表项,并且每个列表项要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来作为列表键的底层实现。另外,当一个哈希键只包含少量键值对时,并且每个键值对的键和值要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来作为哈希键的底层实现。
1.压缩列表
压缩列表是Redis为了节约内存而开发的,是由一系列特殊编码的连续内存块组成的顺序型数据结构。一个压缩列表可以包含任意多个节点,每个节点可以保存一个字节数组或者一个整数值。
结构
zlbytes(4字节):记录整个压缩列表占用的内存字节数,在对压缩列表进行内存重分配或者计算zlend位置时使用。
zltail(4字节):记录压缩列表表尾节点距离压缩列表的起始地址有多少字节,通过这个偏移量,程序无需遍历整个压缩列表就可以确定表尾节点的位置。
zllen(2字节):记录压缩列表包含的节点数量,当这个属性的值小于UINT16_MAX(65535)时,这个属性的值就是压缩列表包含的节点数量,当这个值等于UINT64_MAX时,节点的真实数量需要遍历整个压缩列表才能计算出。
entryX(不定):压缩列表包含的各个节点,节点的长度由节点保存的内容决定。
zlend(1字节):特殊值0xFF,用于标记压缩列表的末端。
2.压缩列表节点
每个压缩列表节点可以保存一个字节数组或者一个整数值。
字节数组的长度(最大字节数):2^6-1字节、2^14-1字节、2^32-1字节。
整数值的长度:4位无符号整数、1字节有符号整数、3字节有符号整数、int16_t整数、int32_t整数、int64_t整数。
结构
previous_entry_length:记录压缩列表中前一个节点的长度。长度可以是1字节或5字节。
- 如果前一节点的长度小于254字节,那么previous_entry_length属性的长度为1,前一节点的长度就保存在这一个字节里面。
- 如果前一节点的长度大于等于254字节,那么previous_entry_length属性的长度为5,其中属性的第一字节会被设置为0xFE(十进制值254),而之后的4个字节用于保存前一节点的长度。
因为节点的previous_entry_length属性记录了前一个节点的长度,所以程序可以通过指针运算,根据当前节点的起始地址来计算出前一个节点的起始地址。
- 前一个节点的起始地址 = 当前节点的起始地址 - 当前节点的previous_entry_length的值
压缩列表的从表尾向表头遍历操作就是使用这一原理实现的,只要我们拥有一个指向某个节点起始指针的地址,那么通过这个指针以及这个节点previous_entry_length属性,可以一直向前一个节点回溯,最终到达压缩列表的表头节点。
encoding:记录节点的content属性所保存数据的类型以及长度。
- 1字节、2字节或者5字节,值的最高位为00、01或者10的是字节数组编码:这种编码表示节点的content属性保存着字节数组,数组的长度由编码除去最高两位之后的其他位记录。
- 1字节长,值得最高为以11开头的是整数编码:这种编码表示节点的content属性保存着整数值,整数值的类型和长度由编码除去最高两位之后的其他位记录。
content:记录节点的值,可以是一个字节数组或整数,值的类型和长度由节点的encoding属性决定。
3.连锁更新
每个节点的previous_entry_length属性记录着前一个节点的长度。如果前一节点的长度小于254字节,那么previous_entry_length属性需要用1字节的空间来保存这个值;如果前一节点的长度大于等于254字节,那么previous_entry_length属性需要用5字节的空间来保存这个值。
示例
假设在一个压缩列表中,有多个连续的、长度介于250字节到253字节之间的节点e1至eN:
因为e1至eN的所有节点的长度都小于254字节,所以e1至eN的所有节点的previous_entry_length属性都是1字节长,如果我们将一个长度大于254字节的新节点new设置为压缩列表的表头节点,那么new将成为e1的前置节点:
因为e1的previous_entry_length属性为1字节,它没办法保存新的节点new的长度,所以程序将对压缩列表执行空间重分配操作,并将e1的previous_entry_length属性从原来的1字节扩展为5字节。在e1的previous_entry_length属性扩展为5字节后,e1的长度就大于254字节了,此时e2的previous_entry_length的1字节就无法保存新的e1的长度,同理需要对e2进行扩展。为了让每个节点的previous_entry_length属性都符合压缩列表对节点的要求,程序需要不断地压缩列表执行空间重分配操作,直到eN为止。
Redis将这种特殊情况下产生地连续多次空间扩展操作就称为“连锁更新”。
除了添加新节点可能会引发连锁更新之外,删除节点也可能会引发连锁更新。
因为连锁更新在最坏情况下需要对压缩列表执行N次空间重分配操作,而每次空间重分配的最坏复杂度为O(N),所以连锁更新的最坏复杂度为O(N²)。
要注意的是,尽管连锁更新的复杂度很高,但是它真正造成性能瓶颈问题的几率很低:
- 压缩列表里恰好有多个连续的、长度介于250字节和253字节之间的节点,连锁更新才有可能被引发,在实际中,这种情况并不多见。
- 即使出现连锁更新,但只要被更新的节点数量不多,就不会对性能造成任何影响。