无题
在 Java 里面,HashSet 底层是用 HashMap 实现的。在 Redis 里面也是类似的,Redis 里面的 Hash 底层结构是 dict,Set 底层的结构也是 dict。但是,在元素都是整数值的时候,Set 可以用一种更省空间的方式来存数据,这种省空间的方式就是这一节要说的 intset。
一个 Set 要用 intset 作为底层存储的话,需要满足两个条件:
一个就是前面说的,元素都是整数类型;
另一个条件是这个 Set 里面的元素个数,要少于 set-max-intset-entries 配置指定的这个值,这个值默认是 512。
一旦这个 Set 集合不满足这两个条件,就会切换成 dict 作为底层存储。Redis 之所以使用 intset 结构来进行优化,主要是为了减少内存碎片,提高查询效率,这也体现了 Redis 在空间占用和耗时等方面的折中和思考。
intset 结构体
从名字就可以看出,intset 结构体是用来存储整数类型的集合,不仅如此,intset 中存储的整数还是有序的,这样我们就可以非常方便地使用二分查找来查找一个元素。下面来看 intset 结构体的定义:
1 | typedef struct intset { |
下面展开说说 intset 中每个字段的含义。
encoding 字段:编码格式,表示 intset 中存储的每个元素需要用几个字节来存储。其可选值有如下三个:
- INTSET_ENC_INT16:值为 2,表示所有元素值都处于 [INT16_MIN, INT16_MAX] 范围内,那么在 contents 数组中每 2 个字节表示一个整数元素。
- INTSET_ENC_INT32:值为 4,表示有元素的值处于 (INT16_MAX, INT32_MAX] 或是 [INT32_MIN, INT16_MIN) 范围内,那么在 contents 数组中每 4 个字节表示一个元素。
- INTSET_ENC_INT64:值为 8,表示有元素的值处于 (INT32_MAX, INT64_MAX] 或是 [INT64_MIN, INT32_MIN) 范围内,那么在 contents 数组中每 8 个字节表示一个元素。
length 字段:表示 intset 中的元素个数。
contents 字段:用来存储 intset 中元素的数组,这是个柔性数组,不使用时不会占用空间。
从 intset 结构体的定义可以看出,它与前文介绍的 ziplist 类似,也是通过一块连续的内存空间实现的,如下图所示,其中 encoding 和 length 两部分构成了 intset 的头,总共占 8 字节。
intset 核心函数
了解了 intset 的核心定义之后,我们来看 intset 的核心函数。
首先是用于创建 intset 实例的 intsetNew() 函数,其中会通过 malloc() 调用来申请内存空间,然后将 encoding 字段初始化为 INTSET_ENC_INT16, length 字段初始化为 0。
1 | intset *intsetNew(void) { |
查找数据
我们可以使用 intsetFind() 函数从 intset 中查找一个元素,返回 0 表示 intset 中没有目标元素,返回 1 表示 intset 中存在目标元素。下面我们来看 intsetFind() 函数的核心逻辑。
- 在查找开始之前会先通过前文介绍的 encoding 取值范围,计算目标值的编码值,如果超出了 intset 的编码值,查找直接失败。
1 | uint8_t intsetFind(intset *is, int64_t value) { |
完成编码值校验之后,会通过 intsetSearch() 函数开始二分查找。intsetSearch() 里面先会进行三个边界检查:intset 是不是空、intset 中的最大值小于目标值、intset 中的最小值大于目标值,满足任意一个条件,就表示查找失败,并返回 0。
接下来开始
二分查找,具体实现如下:
1 | static uint8_t intsetSearch(intset *is, int64_t value, uint32_t *pos) { |
注意,intsetSearch() 函数除了返回值可以表示查找是否成功之外,还会修改 pos 指针(第三个参数)的值:
- 如果查找成功,pos 记录的是目标值所在的位置;
- 如果查找失败,也就是目标值不在 inset 里面,那如果要把目标值插入到 inset 里面,pos 记录的就是这个插入位置。
添加数据
再来看往 intset 里面添加元素的逻辑,入口函数是 intsetAdd() 函数,核心逻辑如下。
通过前面介绍的 encoding 取值范围,计算待插入整型数据的编码值。如果插入元素的 encoding 值大于 intset 的 encoding 编码值时,就会通过 intsetUpgradeAndAdd()函数对 intset 进行升级并同时完成新元素的插入,这个后面我们单独说。
接下来,通过 intsetSearch() 函数查找待插入的值。如果查找成功,说明这个值已经在 intset 里面了,无需后续的插入逻辑;如果未查找成功,intsetSearch() 函数会修改 pos 指针的值,将其指向插入位置。
确定了插入位置之后,会通过 intsetResize() 函数对 intset 进行扩容。
如果 pos 指向的插入位置不在 intset 这块连续空间的两端,就需要调用 intsetMoveTail() 函数将 pos 之后的全部元素后移。
最后调用 _intsetSet() 函数,将 intset 的第 pos 个元素赋值为目标值,同时还会递增 intset 的 length 字段。
整个插入过程如下图所示:
在整个添加元素的过程中,需要展开介绍的是 intsetUpgradeAndAdd() 函数,其具体实现和详细的注释如下:
1 | static intset *intsetUpgradeAndAdd(intset *is, int64_t value) { |
这里解释一下 prepend 变量的作用:既然是要对 intset 的编码格式进行升级,那插入的新值必然超出了当前的编码格式所表示的范围,也就是说,新插入的这个值,要么大于 intset 现有元素,要么小于 intset 现有元素。如果是大于现有元素而触发升级,新元素必然是正数;如果是小于的场景触发升级,新元素必然是负数。再推导一步,如果新元素是负数,就需要插入 intset 头部;如果是正数,就需要插入到 intset 尾部。
这里有个注意的地方,在升级过程中,要从后向前后移 intset 元素,就如下图所示,其中的编号表示了拷贝操作执行的顺序,如果我们先执行 element 1 的拷贝(也就是图中的操作 3),就可能覆盖原 intset 中 element 2 元素的值。
删除数据
在了解了从 intset 查找数据以及向 intset 添加数据的逻辑之后,最后再来看 intset 删除数据的实现,其入口是 intsetRemove() 函数,核心步骤如下。
第一步依旧是计算目标值的编码类型,并和当前 intset 的编码类型进行比较,如果超出了 intset 的编码范围,则表示 intset 中不存在该值,此次删除操作直接结束。
通过编码范围检查之后,调用 intsetSearch() 函数查找目标值所在的位置。如果查找失败,也说明目标值不在 inset 中,此次删除操作结束。
最后一种情况是,查找成功,得到目标所在的位置 pos 之后,直接调用 intsetMoveTail() 函数进行内存拷贝,将 pos + 1 到 intset 末尾的全部元素前移,前移一个元素所占的字节数,覆盖掉目标元素,达到删除的效果。
1 | intset *intsetRemove(intset *is, int64_t value, int *success) { |
总结
本节我们重点介绍了 Redis 中 intset 的相关内容,intset 是除 dict 之外,set 数据类型底层依赖的另一个重要的存储结构。
我们先介绍了 intset 涉及到的核心概念以及核心结构体的定义。然后,详细剖析了 intset 相关的函数,具体讲解了创建 intset 实例、向 intset 插入新元素、删除元素以及查询元素的核心函数。
