Redis底层数据结构——压缩列表

609 阅读7分钟

压缩列表是什么

Redis 中的压缩列表是由一系列特殊编码的连续内存块组成的顺序型数据结构。列表中每个节点可以存储一个字节数组或者一个整数值。

它的存在主要是为了节约内存。

压缩列表应用场景

压缩列表在 Redis 中主要作为了 List 列表和 Hash 哈希两种数据结构的底层实现之一。

在 List 列表中,要是列表中的存储元素数量少且每个元素是小的整数值或者长度较短的字符串,那么列表将使用压缩列表结构作为底层实现。

在 Hash 哈希中,要是哈希中的存储键值对数量少且每个键值对的键和值是小的整数值或者长度较短的字符串,那么哈希将使用压缩列表结构作为底层实现。

压缩列表结构内容

一个大概压缩列表的结构示意图:

属性 zlbytes

属性 zlbytes 占用4个字节的长度,保存了整个压缩列表占用的总字节数。

在对压缩列表重新进行内存分配或者计算 zlend 属性值的时候会用到。

属性 zltail

属性 zltail 占用了4个字节的长度,保存了压缩列表表尾节点距离压缩列表的开始地址有多少个具体字节。

通过这个属性值,程序不用遍历整个压缩列表就可以在时间复杂度为 O(1) 的情况下,直接确定表尾节点的位置。

属性 zllen

属性 zltail 占用了2个字节的长度,保存了压缩列表节点的数量。

需要注意的是,当 zllen 的值为小于UINT16_MAX (65535) 的时候,这个值就是真实的压缩列表节点数量,大于的时候,节点的真实数量是需要遍历整个压缩列表才能计算得出。

属性 zlend

属性 zlend 占用了1个字节的长度,主要用于表示它是在压缩列表的最后位置。

属性 entry

属性 entry 是作为压缩列表的节点,下面我们具体介绍压缩列表节点 entry。

压缩列表节点结构内容

压缩列表节点结构抽象代码:

typedef struct zlentry {    

    unsigned int prevrawlensize, prevrawlen;   
    
    unsigned int lensize, len;    
    
    unsigned int headersize;    
      
    unsigned char encoding;   
       
    unsigned char *p;
    
} zlentry;

这是一个抽象的压缩列表节点结构,实际存储并不是这样子的结构,只是这样设计到时候存取相关操作方便。

属性 prevrawlen、prevrawlensize

属性 prevrawlen 为前置节点的长度,而 prevrawlensize 是代表存储 prevrawlen 这个属性所需的字节大小

属性 len、lensize

属性 len 为当前节点值的长度,而 lensize 是代表存储 len 属性所需的字节大小。(注意:当节点保存的是字符串的时候,len 为字符串长度,如果保存的是整数值,len 为整数值的字节长度)

属性 headersize

属性 headersize 为当前节点 header 的长度,等于 prevrawlensize + lensize 

属性 encoding

属性 encoding 为当前节点值所使用的编码类型

属性 p

属性 p 为一个指针,指向了当前节点的内存地址

压缩列表节点具体存储结构

一个压缩列表节点具体的结构应该是这样子的:

previous_entry_length

属性 previous_entry_length 保存了前置节点的长度。

  • 当前置节点的长度小于 254 字节的时候,那么 previous_entry_length 的长度将为1个字节,前置节点的长度就保存在这个字节里面。
  • 当前置节点的长度大于等于 254 字节的时候,那么 previous_entry_length 的长度将为5个字节,这5个字节的第一个字节将被设置为254,然后剩下的四个字节保存前置节点的长度。

程序主要可以通过这个前置节点的长度,以及根据当前节点的起始地址来计算出前置节点的起始地址。压缩列表的从表头到表尾的遍历操作就是通过这样的原理来实现的。

encoding

属性 encoding 记录了对应节点的 content 属性所保存数据的类型以及长度。

encoding 的长度可能是一个字节、两个字节或者五个字节。它具体多少个字节,取决于 encoding 值的最高两位。当最高两位为00、01、10开头的时候,代表 content 存储的是字符串且 encoding 的长度分别为一个字节、两个字节或者五个字节。而为11开头的时候,即 content 存储的是整数且 encoding 的长度为一个字节。

00开头的时候:

encoding 占用了一个字节的空间,表示 content 能存储长度小于等于 2^6-1 字节的字符串

01开头的时候:

encoding 占用了两个字节的空间,表示 content 能存储长度小于等于 2^14-1 字节的字符串

10开头的时候:

encoding 占用了五个字节的空间,第一字节的后六位由0填充,剩下的表示 content 能存储长度小于等于 2^32-1 字节的字符串

11开头的时候:

encoding 占用了一个字节的空间,且由于编码的不同,content 存储的整数值范围都是不同的

  • encoding 值: 11000000 ,content 能存储 int16_t 类型的整数
  • encoding 值: 11010000 ,content 能存储 int32_t 类型的整数
  • encoding 值: 11100000 ,content 能存储 int64_t 类型的整数
  • encoding 值: 11110000 ,content 能存储 24 位有符号整数
  • encoding 值: 11111110 ,content 能存储 8 位有符号整数
  • encoding 值: 1111xxxx ,content 能存储 0-12 之间的值,没有明确的类型
content

属性 content 保存了压缩列表节点的值,节点值可以是一个字符串或者是整数值,值的类型和长度由 encoding 属性值决定

压缩列表的连锁更新

假如在一个压缩列表中,有多个连续节点 e1 至 eN 且他们各自的长度都在250字节到253字节之间,加上上面提到的 previous_entry_length 的相关内容,这个属性记录每个前置节点的长度的时候,值将都是一个字节长度的。

此时我们把一个长度大于等于254字节的新节点 new 设置到压缩列表的表头的时候,那么 new 节点将成为 e1 节点的前置节点,而 e1 节点的 previous_entry_length 值只有1字节的长度,没有存储表示前置节点长度的5个字节长度,所以程序分配空间给 e1 多四个字节存储,接着 e2 也需要增加空间存储 e1 的长度,然后以此类推,引发了后面所有的节点需要更新空间,这就是连锁更新。

连锁更新会导致在最坏的情况下要对压缩列表 N 次空间重新分配,而每次空间重新分配的复杂度为 O(N),所以连锁更新的最坏时间复杂度为 O(N^2)

不过这种是需要恰好多个连续节点都是在 250-253字节之间,连锁更新才可能发生,这种情况概率很低。又假如是几个连续这样的 250-253 字节之间,对性能是不会造成影响的。

总结

先对 Redis 压缩列表的含义、应用场景讲述,了解了压缩列表主要是为了节约内存的数据结构,再接着对其数据结构的详细了解以及其主要的连锁更新操作,深入了解了压缩列表的底层。

参考:《 Redis设计与实现 》

更多Java后端开发相关技术,可以关注公众号「 红橙呀 」。