本文是《Redis内部数据结构详解》系列的第七篇。在本文中,咱们围绕一个Redis的内部数据结构——intset展开讨论。javascript
Redis里面使用intset是为了实现集合(set)这种对外的数据结构。set结构相似于数学上的集合的概念,它包含的元素无序,且不能重复。Redis里的set结构还实现了基础的集合并、交、差的操做。与Redis对外暴露的其它数据结构相似,set的底层实现,随着元素类型是不是整型以及添加的元素的数目多少,而有所变化。归纳来说,当set中添加的元素都是整型且元素数目较少时,set使用intset做为底层数据结构,不然,set使用dict做为底层数据结构。java
在本文中咱们将大致分红三个部分进行介绍:redis
咱们在讨论中还会涉及到一个Redis配置(在redis.conf中的ADVANCED CONFIG部分):算法
set-max-intset-entries 512复制代码
注:本文讨论的代码实现基于Redis源码的3.2分支。数组
intset顾名思义,是由整数组成的集合。实际上,intset是一个由整数组成的有序集合,从而便于在上面进行二分查找,用于快速地判断一个元素是否属于这个集合。它在内存分配上与ziplist有些相似,是连续的一整块内存空间,并且对于大整数和小整数(按绝对值)采起了不一样的编码,尽可能对内存的使用进行了优化。网络
intset的数据结构定义以下(出自intset.h和intset.c):数据结构
typedef struct intset {
uint32_t encoding;
uint32_t length;
int8_t contents[];
} intset;
#define INTSET_ENC_INT16 (sizeof(int16_t))
#define INTSET_ENC_INT32 (sizeof(int32_t))
#define INTSET_ENC_INT64 (sizeof(int64_t))复制代码
各个字段含义以下:app
encoding
: 数据编码,表示intset中的每一个数据元素用几个字节来存储。它有三种可能的取值:INTSET_ENC_INT16表示每一个元素用2个字节存储,INTSET_ENC_INT32表示每一个元素用4个字节存储,INTSET_ENC_INT64表示每一个元素用8个字节存储。所以,intset中存储的整数最多只能占用64bit。length
: 表示intset中的元素个数。encoding
和length
两个字段构成了intset的头部(header)。contents
: 是一个柔性数组(flexible array member),表示intset的header后面紧跟着数据元素。这个数组的总长度(即总字节数)等于encoding * length
。柔性数组在Redis的不少数据结构的定义中都出现过(例如sds, quicklist, skiplist),用于表达一个偏移量。contents
须要单独为其分配空间,这部份内存不包含在intset结构当中。其中须要注意的是,intset可能会随着数据的添加而改变它的数据编码:ide
下图给出了一个添加数据的具体例子(点击看大图)。函数
在上图中:
encoding
= 2, length
= 0。encoding
不变,值仍是2。encoding
必须升级到INTSET_ENC_INT32(值为4),即用4个字节表示一个元素。encoding
字段的4个字节应该解释成0x00000004,而第5个数据应该解释成0x000186A0 = 100000。intset与ziplist相比:
len
),而intset只能总体使用一个统一的编码(encoding
)。要理解intset的一些实现细节,只须要关注intset的两个关键操做基本就能够了:查找(intsetFind
)和添加(intsetAdd
)元素。
intsetFind
的关键代码以下所示(出自intset.c):
uint8_t intsetFind(intset *is, int64_t value) {
uint8_t valenc = _intsetValueEncoding(value);
return valenc <= intrev32ifbe(is->encoding) && intsetSearch(is,value,NULL);
}
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;
/* The value can never be found when the set is empty */
if (intrev32ifbe(is->length) == 0) {
if (pos) *pos = 0;
return 0;
} else {
/* Check for the case where we know we cannot find the value, * but do know the insert position. */
if (value > _intsetGet(is,intrev32ifbe(is->length)-1)) {
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;
}
}复制代码
关于以上代码,咱们须要注意的地方包括:
intsetFind
在指定的intset中查找指定的元素value
,找到返回1,没找到返回0。_intsetValueEncoding
函数会根据要查找的value
落在哪一个范围而计算出相应的数据编码(即它应该用几个字节来存储)。value
所需的数据编码比当前intset的编码要大,则它确定在当前intset所能存储的数据范围以外(特别大或特别小),因此这时会直接返回0;不然调用intsetSearch
执行一个二分查找算法。intsetSearch
在指定的intset中查找指定的元素value
,若是找到,则返回1而且将参数pos
指向找到的元素位置;若是没找到,则返回0而且将参数pos
指向能插入该元素的位置。intsetSearch
是对于二分查找算法的一个实现,它大体分为三个部分:
value
比最后一个元素还要大或者比第一个元素还要小的时候。实际上,这两部分的特殊处理,在二分查找中并非必须的,但它们在这里提供了特殊状况下快速失败的可能。min
指定的位置。intrev32ifbe
是为了在须要的时候作大小端转换的。前面咱们提到过,intset里的数据是按小端(little endian)模式存储的,所以在大端(big endian)机器上运行时,这里的intrev32ifbe
会作相应的转换。而intsetAdd
的关键代码以下所示(出自intset.c):
intset *intsetAdd(intset *is, int64_t value, uint8_t *success) {
uint8_t valenc = _intsetValueEncoding(value);
uint32_t pos;
if (success) *success = 1;
/* Upgrade encoding if necessary. If we need to upgrade, we know that * this value should be either appended (if > 0) or prepended (if < 0), * because it lies outside the range of existing values. */
if (valenc > intrev32ifbe(is->encoding)) {
/* This always succeeds, so we don't need to curry *success. */
return intsetUpgradeAndAdd(is,value);
} else {
/* Abort if the value is already present in the set. * This call will populate "pos" with the right position to insert * the value when it cannot be found. */
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);
return is;
}复制代码
关于以上代码,咱们须要注意的地方包括:
intsetAdd
在intset中添加新元素value
。若是value
在添加前已经存在,则不会重复添加,这时参数success
被置为0;若是value
在原来intset中不存在,则将value
插入到适当位置,这时参数success
被置为0。value
所需的数据编码比当前intset的编码要大,那么则调用intsetUpgradeAndAdd
将intset的编码进行升级后再插入value
。intsetSearch
,若是能查到,则不会重复添加。intsetResize
对intset进行内存扩充,使得它可以容纳新添加的元素。由于intset是一块连续空间,所以这个操做会引起内存的realloc
(参见man.cx/realloc)。这有可能带来一次数据拷贝。同时调用intsetMoveTail
将待插入位置后面的元素统一贯后移动1个位置,这也涉及到一次数据拷贝。值得注意的是,在intsetMoveTail
中是调用memmove
完成此次数据拷贝的。memmove
保证了在拷贝过程当中不会形成数据重叠或覆盖,具体参见man.cx/memmove。intsetUpgradeAndAdd
的实现中也会调用intsetResize
来完成内存扩充。在进行编码升级时,intsetUpgradeAndAdd
的实现会把原来intset中的每一个元素取出来,再用新的编码从新写入新的位置。intsetAdd
的返回值,它返回一个新的intset指针。它可能与传入的intset指针is
相同,也可能不一样。调用方必须用这里返回的新的intset,替换以前传进来的旧的intset变量。相似这种接口使用模式,在Redis的实现代码中是很常见的,好比咱们以前在介绍sds和ziplist的时候都碰到过相似的状况。intsetAdd
算法总的时间复杂度为O(n)。为了更好地理解Redis对外暴露的set数据结构,咱们先看一下set的一些关键的命令。下面是一些命令举例:
上面这些命令的含义:
sadd
用于分别向集合s1
和s2
中添加元素。添加的元素既有数字,也有非数字("a"和"b")。sismember
用于判断指定的元素是否在集合内存在。sinter
, sunion
和sdiff
分别用于计算集合的交集、并集和差集。咱们前面提到过,set的底层实现,随着元素类型是不是整型以及添加的元素的数目多少,而有所变化。例如,具体到上述命令的执行过程当中,集合s1
的底层数据结构会发生以下变化:
sadd s1 13 5
以后,因为添加的都是比较小的整数,因此s1
底层是一个intset,其数据编码encoding
= 2。sadd s1 32768 10 100000
以后,s1
底层仍然是一个intset,但其数据编码encoding
从2升级到了4。sadd s1 a b
以后,因为添加的元素再也不是数字,s1
底层的实现会转成一个dict。咱们知道,dict是一个用于维护key和value映射关系的数据结构,那么当set底层用dict表示的时候,它的key和value分别是什么呢?实际上,key就是要添加的集合元素,而value是NULL。
除了前面提到的因为添加非数字元素形成集合底层由intset转成dict以外,还有两种状况可能形成这种转换:
set-max-intset-entries
配置的值的时候,也会致使intset转成dict(具体的触发条件参见t_set.c中的setTypeAdd
相关代码)。对于小集合使用intset来存储,主要的缘由是节省内存。特别是当存储的元素个数较少的时候,dict所带来的内存开销要大得多(包含两个哈希表、链表指针以及大量的其它元数据)。因此,当存储大量的小集合并且集合元素都是数字的时候,用intset能节省下一笔可观的内存空间。
实际上,从时间复杂度上比较,intset的平均状况是没有dict性能高的。以查找为例,intset是O(log n)的,而dict能够认为是O(1)的。可是,因为使用intset的时候集合元素个数比较少,因此这个影响不大。
Redis set的并、交、差算法的实现代码,在t_set.c中。其中计算交集调用的是sinterGenericCommand
,计算并集和差集调用的是sunionDiffGenericCommand
。它们都能同时对多个(能够多于2个)集合进行运算。当对多个集合进行差集运算时,它表达的含义是:用第一个集合与第二个集合作差集,所得结果再与第三个集合作差集,依次向后类推。
咱们在这里简要介绍一下三个算法的实现思路。
计算交集的过程大概能够分为三部分:
须要注意的是,上述第3步在集合中进行查找,对于intset和dict的存储来讲时间复杂度分别是O(log n)和O(1)。但因为只有小集合才使用intset,因此能够粗略地认为intset的查找也是常数时间复杂度的。所以,如Redis官方文档上所说(redis.io/commands/si…),sinter
命令的时间复杂度为:
O(N*M) worst case where N is the cardinality of the smallest set and M is the number of sets.
计算并集最简单,只须要遍历全部集合,将每个元素都添加到最后的结果集合中。向集合中添加元素会自动去重。
因为要遍历全部集合的每一个元素,因此Redis官方文档给出的sunion
命令的时间复杂度为(redis.io/commands/su…):
O(N) where N is the total number of elements in all given sets.
注意,这里同前面讨论交集计算同样,将元素插入到结果集合的过程,忽略intset的状况,认为时间复杂度为O(1)。
计算差集有两种可能的算法,它们的时间复杂度有所区别。
第一种算法:
这种算法的时间复杂度为O(N*M),其中N是第一个集合的元素个数,M是集合数目。
第二种算法:
这种算法的时间复杂度为O(N),其中N是全部集合的元素个数总和。
在计算差集的开始部分,会先分别估算一下两种算法预期的时间复杂度,而后选择复杂度低的算法来进行运算。还有两点须要注意:
对于sdiff
的时间复杂度,Redis官方文档(redis.io/commands/sd…)只给出了第二种算法的结果,是不许确的。
系列下一篇待续,敬请期待。
(完)
其它精选文章: