整数集合(intset)是一个有序的、存储整型数据的结构。 当Redis集合类型的元素都是整数并且都处在64位有符号整数范围之内时,使用该结构体存储。
在两种情况下,底层编码会发生转换。
一种情况为当元素个数超过一定数量之后(默认值为512),即使元素类型仍然是整型,也会
将编码转换为hashtable,该值由如下配置项决定:set-max-intset-entries 512
另一种情况为当增加非整型变量时,例如在集合中增加元素'a'后,testSet的底层编码从intset转换为hashtable:
6.1 数据存储
整数集合在Redis中可以保存int16_t、int32_t、int64_t类型的整型数据,并且可以保证集合中不会出现重复数据。每个整数集合使用一个intset类型的数据结构表示。
typedef struct intset {
uint32_t encoding; //编码类型
uint32_t length; //元素个数
int8_t contents[]; //柔性数组,根据encoding字段决定几个字节表示一个元素
} intset;
图6-1 Intset的结构
encoding:编码类型,决定每个元素占用几个字节。有如下3种类型。
1)INTSET_ENC_INT16:当元素值都位于INT16_MIN和INT16_MAX之间时使用。该编码方式为每个元素占用2个字节。
2)INTSET_ENC_INT32:当元素值位于INT16_MAX到INT32_MAX或者INT32_MIN到INT16_MIN之间时使用。该编码方式为每个元素占用4个字节。
3)INTSET_ENC_INT64:当元素值位于INT32_MAX到INT64_MAX或者INT64_MIN到INT32_MIN之间时使用。该编码方式为每个元素占用8个字节。
intset结构体会根据待插入的值决定是否需要进行扩容操作。扩容会修改encoding字段,而encoding字段决定了一个元素在contents柔性数组中占用几个字节。所以当修改encoding字段之后,intset中原来的元素也需要在contents中进行相应的扩展。注意,根据表6-1能得到一个简单的结论,只要待插入的值导致了扩容,则该值在待插入的intset中不是最大值就是最小值。
intset结构体本身占用8字节,4个元素按INTSET_ENC_INT16编码,每个占用2字节,8+4×2=16,正好是16个字节。
6.2 基本操作
6.2.1 查询元素
intset是按从小到大有序排列的,所以通过防御性判断之后使用二分法进行元素的查找。
/* Determine whether a value belongs to this set */
uint8_t intsetFind(intset *is, int64_t value) {
uint8_t valenc = _intsetValueEncoding(value); //判断编码方式
//编码方式如果大于当前intset的编码方式,直接返回0。否则调用intsetSearch函数进行查找
return valenc <= intrev32ifbe(is->encoding) && intsetSearch(is,value,NULL);
}
intsetSearch函数进行查找
static uint8_t intsetSearch(intset *is, int64_t value, uint32_t *pos) {
int min = 0, max = intrev32ifbe(is->length)-1, mid = -1;
int64_t cur = -1;
/*如果intset中没有元素,直接返回0 */
if (intrev32ifbe(is->length) == 0) {
if (pos) *pos = 0;
return 0;
} else {
/* 如果元素大于最大值或者小于最小值,直接返回0 */
if (value > _intsetGet(is,max)) {
if (pos) *pos = intrev32ifbe(is->length);
return 0;
} else if (value < _intsetGet(is,0)) {
if (pos) *pos = 0;
return 0;
}
}
while(max >= min) { //二分查找该元素
mid = ((unsigned int)min + (unsigned int)max) >> 1;
cur = _intsetGet(is,mid);
if (value > cur) {
min = mid+1;
} else if (value < cur) {
max = mid-1;
} else {
break;
}
}
if (value == cur) { //查找到返回1,未查找到返回0
if (pos) *pos = mid;
return 1;
} else {
if (pos) *pos = min;
return 0;
}
}
intset查询的具体流程如图6-3所示。
6.2.2 添加元素
intsetAdd函数根据插入值的编码类型和当前intset的编码类型决定是直接插入还是先进行intset升级再执行插入(升级插入的函数为intsetUpgradeAndAdd,见图6-6)。
intset *intsetAdd(intset *is, int64_t value, uint8_t *success) {
uint8_t valenc = _intsetValueEncoding(value); //获取添加元素的编码值
uint32_t pos;
if (success) *success = 1;
/* 如果大于当前intset的编码,说明需要进行升级 */
if (valenc > intrev32ifbe(is->encoding)) {
/*调用intsetUpgradeAndAdd进行升级后添加 */
return intsetUpgradeAndAdd(is,value);
} else {
/* 否则先进行查重,如果已经存在该元素,直接返回. */
if (intsetSearch(is,value,&pos)) {
if (success) *success = 0;
return is;
}
//如果元素不存在,则添加元素 , 首先将intset占用内存扩容
is = intsetResize(is,intrev32ifbe(is->length)+1);
//如果插入元素在intset中间位置,调用intsetMoveTail给元素挪出空间
if (pos < intrev32ifbe(is->length)) intsetMoveTail(is,pos,pos+1);
}
_intsetSet(is,pos,value); //保存元素
is->length = intrev32ifbe(intrev32ifbe(is->length)+1); //修改intset的长度,将其加1
return is;
}
intsetMoveTail函数中使用的是memmove函数,而非memcpy函数。memcpy函数的目的地址和源地址不能有重叠,否则会发生数据覆盖。而memmove地址之间可以有重叠,其实现原理为先将源地址数据拷贝到一个临时的缓冲区中,然后再从缓冲区中逐字节拷贝到目的地址。
图6-5 intsetMoveTail实现原理
升级当前的编码类型
/* Upgrades the intset to a larger encoding and inserts the given integer. */
static intset *intsetUpgradeAndAdd(intset *is, int64_t value) {
uint8_t curenc = intrev32ifbe(is->encoding);
uint8_t newenc = _intsetValueEncoding(value);
int length = intrev32ifbe(is->length);
int prepend = value < 0 ? 1 : 0;
//如果待插入元素小于0,说明需要插入到intset的头部位置。如果大于0,需要插到intset的末尾位置。此处原理前文有说明,既然执行了扩容,则说明待插入元素不是最大值就是最小值 /* First set new encoding and resize */
is->encoding = intrev32ifbe(newenc);
is = intsetResize(is,intrev32ifbe(is->length)+1); //将intset内存空间进行扩容
/*从最后一个元素逐个往前扩容。注意必须从最后一个元素开始,否则有可能会导致元素覆盖*/
while(length--)
_intsetSet(is,length+prepend,_intsetGetEncoded(is,length,curenc));
/* 如果待插入元素小于0,插入intset头部位置 */
if (prepend)
_intsetSet(is,0,value);
else //否则插入末尾位置
_intsetSet(is,intrev32ifbe(is->length),value);
is->length = intrev32ifbe(intrev32ifbe(is->length)+1); //修改intset的长度加1
return is;
}
图6-6 intset升级并添加元素
6.2.3 删除元素
入口函数是intsetRemove,该函数查找需要删除的元素然后通过内存地址的移动直接将该元素覆盖掉。删除元素的代码流程如下:
/* Delete integer from intset */
intset *intsetRemove(intset *is, int64_t value, int *success) {
uint8_t valenc = _intsetValueEncoding(value); //获取待删除元素编码
uint32_t pos;
if (success) *success = 0;
//待删除元素编码必须小于等于intset编码并且查找到该元素,才会执行删除操作
if (valenc <= intrev32ifbe(is->encoding) && intsetSearch(is,value,&pos)) {
uint32_t len = intrev32ifbe(is->length);
if (success) *success = 1;
/* 如果待删除元素位于中间位置,则调用intsetMoveTail直接覆盖掉该元素,
如果待删除元素位于intset末尾,则intset收缩内存后直接将其丢弃 */
if (pos < (len-1)) intsetMoveTail(is,pos+1,pos);
is = intsetResize(is,len-1);
is->length = intrev32ifbe(len-1); //修改intset的长度,将其减1
}
return is;
}
图6-9 intset删除元素
删除元素的具体流程:
1)函数标签为intset*intsetRemove(intset*is,int64_tvalue,int*success),首先判断编码是否小于等于当前编码,若不是,直接返回。
2)调用intsetSearch查找该值是否存在,不存在直接返回;存在则获取位置position。
3)如果要删除的数据不是该intset的最后一个值,则通过将position+1和之后位置的数据移动到position来覆盖掉position位置的值。图6-5是将position位置的数据往后挪动到position+1,给新插入的数据留出空间,反之如果将position+1位置的数据整体往前挪动到position位置,则会将position位置的数据覆盖。如果要删除的数据是该intset的最后一个值,假设该intset长度为length,则调用intsetResize分配length-1长度的空间之后会自动丢弃掉position位置的值。最后更新intset的length为length-1。
至此,intset的删除操作就完成了。
6.2.4 常用API
intset常用API操作复杂度
6.3 本章小结
intset用于Redis中集合类型的数据。
当集合元素都是整型并且元素不多时使用intset保存。
并且元素按从小到大顺序保存。
本章首先介绍了intset的存储结构并通过GDB验证一个集合类型存储为intset时实际的存储方式,然后介绍intset增加、删除和查找元素的方法。
最后介绍了一些intset常见的API和操作复杂度。