Redis数据结构之整数集合

1、整数集合

Redis 中有集合(set)的操作,常用的指令有 SADD、SCARD 等,而在底层的实现中,整数集合(intset)就是 Redis 集合的实现方式之一。

Redis 的集合是有序集合,intset 也是有序的。

根据 Redis 对集合的操作,我们可以大致想象出,intset 需要哪些功能:

  1. 添加/移除元素要快;
  2. 查找元素要快;
  3. 方便两个集合对比;
  4. 统计元素个数;
  5. 占用内存要少;

Redis 作为高性能数据库,占用内存和操作快速是最主要的功能。intset 为了保证除了在保证快速的同时,使用升级来保证能够存储数据但是又节省空间的需求。

Redis 的 intset 相对来说比较简单,下面也就大致的描述一下

2、intset的实现

intset 声明位于源码目录下 intset.h 文件中,声明如下:

typedef struct intset {
    uint32_t encoding;
    uint32_t length;
    int8_t contents[];
} intset;

contents: contents 是一个柔性数组,这里不多说,它里面存储的就是 intset 的所有整数项;

需要注意的是,虽然 contents 是 int8_t,不表示 contents 内部只能存 int8_t 类型的数据,后面会细说;

length: length 记录了整个集合的元素数量,即 contents 数组的长度,当需要获取元素个数的时候,直接返回这个值就行了,时间复杂度 O(1);

encoding: encoding 决定该集合存储的整数的类型,它的值有三种,如下:

/* Note that these encodings are ordered, so:
 * INTSET_ENC_INT16 < INTSET_ENC_INT32 < INTSET_ENC_INT64. */
#define INTSET_ENC_INT16 (sizeof(int16_t))
#define INTSET_ENC_INT32 (sizeof(int32_t))
#define INTSET_ENC_INT64 (sizeof(int64_t))

可以看到,encoding 其实就可以表示 int8_t、int16_t、int32_t 和 int64_t(32位编译器下分别表示占用1一个、2个、4个 和 8个字节,换做大部分人能认识的就是 char、short、int 和 long),其实可以看出来,encoding 就是这几种类型的占用内存,所以,如果如要计算集合中的所有元素所占的内存,只需要用 length * encoding 就可以了

Redis 中使用 encoding 无非是为了节约内存,假如所有元素都是 int8_t,开辟了 int16_t 的空间,白白的就是在浪费内存嘛。

2.1、intset数据的存储

那么,contents 数组内是如何兼容不同类型的整数呢?我们都知道,C语言中最小的数据结构 char 占8位,而16、32、64位均是8位的整数倍,所以我们只需要知道当前数组存储的数据类型,就可以根据自己位计算出数据来,而不关注这个数组的长度了。

比如,现在 contents 数组共占16位,从低到高分别是:

0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
1 1 1 0 0 1 0 1 1 0 0 0 1 1 0 1

如果 encoding 表示8位元素,那么,这里可以得出两个 int8_t 数据出来,分别是 229 和 141;

如果 encoding 表示16位元素,那么,这里可以得出一个 int16_t 数据出来,是 58765;

可见,不是说 contents 数组的类型是 int8_t 就只能存8位的数据,但是,contents 只能存储一种类型的数据。

3、升级和降级

当我们需要将一个新的元素放入到集合中,并且这个新的数据比集合中的其他元素的类型的最大值还要大的时候,Redis 就需要统一使用较大的类型来存储了,即需要扩容,这在 Redis 中叫做升级(upgrade)。

Redis 中升级集合并添加新元素总共需要三步:

  1. 根据新元素的大小,确定数组的类型,并为数组分配空间;
  2. 将底层已存在的旧有转换成新的类型,并按照原先的顺序,放置在固定的内存位置上;
  3. 将新元素放在数组里。

因为集合中的元素都是有序的,就算我们不需要进行升级,仍然需要从头遍历元素,也就是还是上面三步,所以插入元素的时间复杂度为 O(N)。

一旦出现需要升级操作,则表示新元素一定比旧有的元素要大,所以新元素放在最后就好。

举个例子,现在有一个集合,其中有三个 int8_t 类型的元素:1、2、3,则 intset 中 encoding 值小于 INTSET_ENC_INT16,length 为3,contents 数组所占内存为 8 * 3 = 24bit,即3个字节,数据占内存位置如下:

0~7位 8~15位 16~23位
元素 1 2 3

现在插入一个 int16_t 类型的元素 32,我们发现,encoding 的值小于 INTSET_ENC_INT16,所以需要扩容,content 重新分配内存,16 *4 = 64bit,即8个字节。扩容后的内存如下:

0~7位 8~15位 16~23位 24~63位
元素 1 2 3 新分配的空间

随后,将原先的1、2、3转换成 int16_t 类型,并按照原先的顺序放置在固定位置,如下:

0~15位 16~31位 32~47位 48~63位
元素 1 2 3 新分配

最后,将新元素放在数字的最后,即

0~15位 16~31位 32~47位 48~63位
元素 1 2 3 32

元素放置完毕,encoding 值变为 INTSET_ENC_INT16, length 变为 4

Redis 的 intset 是不支持降级操作的,一旦数据升级,就会保持升级之后的类型,哪怕唯一个占用内存大的元素被删除了,剩下的元素仍然占用大的类型的元素占用的内存。

4、inset操作API

intset.h 文件中声明了 intset 操作的API,整理如下:

API 描述 时间复杂度
intsetNew 创建一个新的inset O(1)
intsetAdd 添加新元素 O(N)
intsetRemove 移除元素 O(N)
intsetFind 查找元素 有序集合可以使用二分法,O(logN)
intsetRandom 随机返回一个元素 O(1)
intsetGet 根据索引取出元素 O(1)
intsetLen 获取元素个数 O(1)
intsetBlobLen 获取占用内存字节数 O(1)