申明
- 本文基于Redis源码5.0.8
- 本文内容大量借鉴《Redis设计和实现》和《Redis5设计与源码分析》
概念
整数集合(intset)是一个有序的、存储整型数据的结构,当Redis集合类型的元素都是整数并且都处在64位有符号整数范围之内时,使用该结构体存储。
在两种情况下,底层编码会发生转换。
- 当元素个数超过一定数量之后(默认值为512),即使元素类型仍然是整型,也会将编码转换为hashtable;
- 当增加非整型变量时,底层编码从intset转换为hashtable。
整数集合在Redis中可以保存int16_t、int32_t、int64_t类型的整型数据,并且可以保证集合中不会出现重复数据。每个整数集合使用一个intset类型的数据结构表示。intset结构体表示如下:
encoding | length | element1 | element2 | element3 |
---|
其源码定义如下:
typedef struct intset {
uint32_t encoding;//编码类型
uint32_t length;//元素个数
int8_t contents[];//保存元素的数组
} intset;
encoding:编码类型,决定每个元素占用几个字节。有如下3种类型。
- INTSET_ENC_INT16:当元素值都位于INT16_MIN和INT16_MAX之间时使用。该编码方式为每个元素占用2个字节。
- INTSET_ENC_INT32:当元素值位于INT16_MAX到INT32_MAX或者INT32_MIN到INT16_MIN之间时使用。该编码方式为每个元素占用4个字节。
- INTSET_ENC_INT64:当元素值位于INT32_MAX到INT64_MAX或者INT64_MIN到INT32_MIN之间时使用。该编码方式为每个元素占用8个字节。
在源码中对应的定义如下:
#define INTSET_ENC_INT16 (sizeof(int16_t))
#define INTSET_ENC_INT32 (sizeof(int32_t))
#define INTSET_ENC_INT64 (sizeof(int64_t))
contents:存储具体元素,根据encoding字段决定多少个字节表示一个元素
基本操作
接下来我们看下整数集合的基本操作。
查询元素
先看代码:
uint8_t intsetFind(intset *is, int64_t value) {
uint8_t valenc = _intsetValueEncoding(value);
return valenc <= intrev32ifbe(is->encoding) && intsetSearch(is,value,NULL);
}
整数集合中的所有元素的编码类型都是一样的,所有在查找前我们可以先判断查询value的编码类型是否超过当前编码类型,超过的话说明查询的value不可能在整数集合中,缩短无效查询的耗时。_intsetValueEncoding的逻辑很简单,我们看下源码:
static uint8_t _intsetValueEncoding(int64_t v) {
if (v < INT32_MIN || v > INT32_MAX)
return INTSET_ENC_INT64;
else if (v < INT16_MIN || v > INT16_MAX)
return INTSET_ENC_INT32;
else
return INTSET_ENC_INT16;
}
这么简单的逻辑直接忽略,如果编码没有超过当前编码则开始查询value,看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为空*/
if (intrev32ifbe(is->length) == 0) {
if (pos) *pos = 0;
return 0;
} else {
/* 有些场景下知道无法找到value,但是希望知道value的插入位置 */
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) {
if (pos) *pos = mid;
return 1;
} else {
if (pos) *pos = min;
return 0;
}
}
这段代码的核心其实就是一个二分查找,二分查找前做一些基本判断,intset是否为空,value是否比intset最大值大,是否比intset最小值小,并更新相应的插入位置,如果value的值介于最小值和最大值之间,可使用二分查找的思想来定位value的位置,二分查找我们就不说了吧。然后按照查找结果返回数据。
添加元素
我们知道整数集合中所有元素的编码都是一致的,那么如果集合中的数据都是很小的,比如不超过100,这个时候集合的编码格式就是INTSET_ENC_INT16,但是如果此时存入了一个比较大的值,比如220,或者-220,此时INTSET_ENC_INT16是保存不了,那么集合就会升级编码到INTSET_ENC_INT32,当前所有的元素占用的存储空间都要对齐到4字节,而不是之前的2字节了。
会不会有人问为什么不能只把当前的值用4个字节,其余的依然保留2字节,整数集合的存在不是为了节约内存嘛?如果你这么干了后面查找就没办法按照固定的offset去获取元素值了,可以参考下压缩列表的设计做比对。
这么看添加元素有两种,一种是不需要做元素升级操作,一种是需要做升级元素操作的,我们先看简单的。
redis中是没有降级元素操作的,因为删除元素时判断是否缩减还需要遍历一次。
无需升级操作的添加元素
我们先看下源码片段:
if (intsetSearch(is,value,&pos)) {
if (success) *success = 0;
return is;
}
is = intsetResize(is,intrev32ifbe(is->length)+1);
if (pos < intrev32ifbe(is->length)) intsetMoveTail(is,pos,pos+1);
_intsetSet(is,pos,value);
is->length = intrev32ifbe(intrev32ifbe(is->length)+1);
首先查询value,如果存在,直接返回;否则的话,就先扩容intset,知道为什么intset不用来存在大量元素了吧,每次插入都是要做内存Resize的。
内存申请完了,是不是将插入位置pos后面的数据全部右移一个单位让给value,我们的intsetMoveTail就是干这个事情的,将pos起始的所有数据全部移到以pos+1起始处,然后再讲value插入到pos处,更新intset的长度信息。
需要升级操作的添加元素
既然需要升级存储编码,那么插入value和intset中当前的值之间只会有两种情况,第一,插入的值是正数且比intset中最大值还要大,那么插入到intset最后面;第二,插入的值是负数且比intset最小值还要小,那么插入到intset最前面。只能有这两种情况。带着这个认知我们看下源码:
static intset *intsetUpgradeAndAdd(intset *is, int64_t value) {
uint8_t curenc = intrev32ifbe(is->encoding);
uint8_t newenc = _intsetValueEncoding(value);
int length = intrev32ifbe(is->length);
//通过value正负判断插入位置是最前还是最后,最前的话在copy时通过prepend控制位置
int prepend = value < 0 ? 1 : 0;
/* 修改新的encoding和resize*/
is->encoding = intrev32ifbe(newenc);
is = intsetResize(is,intrev32ifbe(is->length)+1);
/* 倒序逐个赋值 */
while(length--)
_intsetSet(is,length+prepend,_intsetGetEncoded(is,length,curenc));
/* 按照在前在后插入新值 */
if (prepend)
_intsetSet(is,0,value);
else
_intsetSet(is,intrev32ifbe(is->length),value);
//更新length
is->length = intrev32ifbe(intrev32ifbe(is->length)+1);
return is;
}
当value小于0,说明插入位置在前面,当value大于0,说明插入位置在后面。在resize后进行while循环赋值,当value小于0,prepend=1,length+1说明起始位置是resize后的最后一个元素,因为value小于0,插入位置是前面嘛,后面不用留空间,当时value大于0时就需要预留位置了,所以prepend=0,从length位置开始插入(resize后长度变成length+1了)。后面再按照prepend判断插入位置,更新完值后再更新下length就结束了。
while中倒序赋值的原因我想不用细说都知道原因吗?唯独有一点,在赋值过程中会不会出现旧值还没来得及赋值到新位置就被覆盖了?不会的,我算过了,只有当赋值到最后一个元素,覆盖位置才会追上,所以不用担心。
删除元素
先看下源码:
intset *intsetRemove(intset *is, int64_t value, int *success) {
uint8_t valenc = _intsetValueEncoding(value);
uint32_t pos;
if (success) *success = 0;
if (valenc <= intrev32ifbe(is->encoding) && intsetSearch(is,value,&pos)) {
uint32_t len = intrev32ifbe(is->length);
if (success) *success = 1;
/* 重写value */
if (pos < (len-1)) intsetMoveTail(is,pos+1,pos);
is = intsetResize(is,len-1);
is->length = intrev32ifbe(len-1);
}
return is;
}
删除前也做一次编码格式的检测,如果能够查找到value就讲value所在pos后面的有效内存全部向前移动一个存储单位,这就是intsetMoveTail做的事情,然后通过resize缩减内存,更新length,删除操作就结束了。