Redis的数据类型之整数集合与压缩列表

124 阅读9分钟

我正在参加「掘金·启航计划」

整数集合

整数集合(intset)是集合键的底层实现之一。当一个集合只包含整数值元素,并且这个集合的元素数量不多时,Redis就会使用整数集合作为集合键的底层实现。

1. 实现

intset是Redis用于保存整数值的集合抽象数据结构,它可以保存类型为 int16_t、int32_t、int64_t的整数值,并且保证集合中不会出现重复元素。

我们用一个 intset.h/intset结构来表示一个整数集合:

typedef struct intset {

    //编码方式
    uint32_t encoding;

    //集合包含的元素数量
    uint32_t length;
    
    //保存元素的数组
    int8_t contents[];

} intset;
  • contents数组是整数集合的底层实现,数组中的各个项按值的大小从小到大排列,并且不包含任何重复项。

  • length记录了整数集合包含的元素数量,也就是contents的长度。

  • 虽然结构中contents属性声明为int8_t类型,但是实际上contents数组并不保存任何int8_t类型的值,它真正的类型取决于encoding的值:

    • INTSET_ENC_INT16:表示contents是一个int16_t类型的数组,取值范围是 -32,768 ~ 32,767。
    • INTSET_ENC_INT32:int32_t类型的数组, -2,147,483,648 ~ 2,127,483,647。
    • INTSET_ENC_INT64:int64_t类型的数组, -9,223,372,036,854,775,808 ~ 9,223,372,036,854,775,808。

12,32,64表示的是一个值占的位数,比如int16_t类型的1,就占有16位。

(是不是有点眼熟,这不就是Java里的short, int, long嘛~)

2. 升级

每当我们要将一个新元素添加到整数集合里去,并且新元素的类型比现有类型要长时,整数集合需要先升级(upgrade),然后才能将新元素添加到集合里。

升级总共分为三个步骤

  1. 根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素也分配空间。
  2. 将底层数组现有的元素都转换成新元素相同的类型,并放置到扩展后的数组对应位置上,并保持底层数组的有序性。
  3. 将新元素添加到底层数组里面。

举个例子:

现在有这么一个整数集合,集合中包含三个int16_t类型的元素:

因为每个元素占有16为,所以整个数组的大小为48位,如图所示:

此时我们要向这个集合里添加一个类型为int32_t的整数65535。因为65535的类型比目前数组中的类型要长,所以这个整数集合要先升级。那么第一个步骤就是扩展底层数组空间了。由于要升级到int32_t的类型,所以升级后的数组大小应该是 32 * 4 = 128位。

然后根据四个元素:1,2,3,65535的大小顺序(新元素最后插入),第一个重新分配3,3在新数组中的对应位置应该是索引为2的位置:

然后是对2进行移动:

然后是对1进行移动:

然后将新元素插入到对应位置上:

由于新元素的长度比原类型的长度长,所以新元素必定在扩展后数组的第一个或者最后一个位置。

最后,整数集合的encoding属性值从INSET_ENC_INT16改为INSET_ENC_INT32,并将length从3改为4。

3.升级的好处

  1. 提升灵活性: C语言中,为了避免类型错误,我们通常不会将两种不同类型的值放到一个数据结构里,但是因为整数集合可以通过自动升级,来适应新元素,所以我们在添加新元素的时候就可以随意将int16_t、int32_t或int64_t三种类型的数插入到集合中。
  2. 节约内存: 如果想让一个数组可以同时存三种类型的值,最简单的方法就是直接定义为int64_t,但是这样做的后果就是所有的值都会占有64位的空间,这样会非常的浪费内存。而整数集合现在的做法是即可以保证适应三种类型,又能确保升级操作只会在必要时进行,尽量的节约内存。

4.降级

整数集合不支持降级操作。

压缩列表

压缩列表(ziplist)是列表键和哈希键的底层实现之一。当一个列表键只包含少量列表项,并且每个列表项要么就是小整数值,要么就是长度较短的字符串,那么Redis就会使用压缩列表来做列表键的底层实现。

1. 结构

Redis使用压缩列表的目的是为了节约内存。压缩列表是由一系列特殊编码的连续内存块组成的顺序型数据结构。一个压缩列表可以包含任意多个节点,每个节点可以保存一个字节数组或者一个整数值。

![digraph {

label = "\n 图 7-1    压缩列表的各个组成部分";

node [shape = record];

ziplist [label = " zlbytes | zltail | zllen | entry1 | entry2 | ... | entryN | zlend "];

}](p3-juejin.byteimg.com/tos-cn-i-k3…)

属性类型长度用途
zlbytesuint32_t4 字节记录整个压缩列表占用的内存字节数:在对压缩列表进行内存重分配, 或者计算 zlend 的位置时使用。
zltailuint32_t4 字节记录压缩列表表尾节点距离压缩列表的起始地址有多少字节: 通过这个偏移量,程序无须遍历整个压缩列表就可以确定表尾节点的地址。
zllenuint16_t2 字节记录了压缩列表包含的节点数量: 当这个属性的值小于 UINT16_MAX (65535)时, 这个属性的值就是压缩列表包含节点的数量; 当这个值等于 UINT16_MAX 时, 节点的真实数量需要遍历整个压缩列表才能计算得出。
entryX列表节点不定压缩列表包含的各个节点,节点的长度由节点保存的内容决定。
zlenduint8_t1 字节特殊值 0xFF (十进制 255 ),用于标记压缩列表的末端

2. 节点

每个压缩列表的节点,保存的是一个字节数组或者一个整数值。

每个节点由三部分组成:previous_entry_length 、encoding 、content 。

2.1. previous_entry_length

记录了前一个节点的长度,以字节为单位。并且它本身的长度可以为1个字节或5个字节

  • 如果前一个节点的长度不超过254字节,那么privious_entry_length的长度就为1,前一节点的长度就存在这1个字节中。
  • 如果前一个节点的长度超过254字节,那么privious_entry_length的长度就为5,这5个字节的第一个字节被设置为0xFE(十进制的254),然后后面四个字节则保存前一节点的长度。

previous_entry_length属性记录了前一个节点的长度,因此程序可以通过指针运算,根据当前节点的起始地址来计算出前一个节点的起始位置。压缩列表的从表尾向表头遍历操作也是使用这个原理。

2.2. encoding

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

  • 1字节、2字节、5字节长,值的最高位为00、01或者10的字节数组编码:表示content保存的是个数组,数组的长度是编码除去最高两位之后的其他位记录。
  • 1字节长,并且值最高位以11开头的是整数编码:表示content保存着整数值,整数值的类型和长度由编码出去最高两位后其他位所记录。

举个栗子:

编码编码长度content 属性保存的值
00bbbbbb1 字节长度小于等于 63 字节的字节数组。
01bbbbbb xxxxxxxx2 字节长度小于等于 16383 字节的字节数组。
10______ aaaaaaaa bbbbbbbb cccccccc dddddddd5 字节长度小于等于 4294967295 的字节数组。

字节数组编码

编码编码长度content 属性保存的值
110000001 字节int16_t 类型的整数。
110100001 字节int32_t 类型的整数。
111000001 字节int64_t 类型的整数。
111100001 字节24 位有符号整数。
111111101 字节8 位有符号整数。
1111xxxx1 字节使用这一编码的节点没有相应的 content 属性, 因为编码本身的 xxxx 四个位已经保存了一个介于 0 和 12 之间的值, 所以它无须 content 属性。

整数编码

2.3. content

content属性负责保存节点的值,节点值可以是一个字节数组或者整数,值的类型和长度在encoding记录。

3. 连锁更新

之前有说过,如果前一个节点的长度小于254字节,那么previous_entry_length只需要1字节来记录长度,但是如果前一个节点的长度大于等于254字节了,previous_entry_length就需要5个字节来记录长度了。

此时有这么一个情况,有一个压缩列表,它包含了多个连续的,并且长度介于250-253之间的节点。

此时因为所有节点都小于254字节长度,所以都只用了1字节来保存前一节点的长度。

这个时候,我们将一个长度大于254的新节点,插入到它们前面,那么原先的压缩列表的第一个节点的previous_entry_length就要从1变成5了,这样一扩展导致整个的原第一节点的长度从250-253之间直接变成大于等于254了,所以原先的第二节点也需要扩展,同理,后续的所有节点都需要扩展了。

程序需要不断地对压缩列表执行空间重分配操作,Redis将这种特殊情况下产生的连续多次空间扩展操作称为“连锁更新”。除了添加新节点之外,删除节点也可能会引发连锁更新。