专栏名称: 数据分析与开发
伯乐在线旗下账号,分享数据库相关技术文章、教程和工具,另外还包括数据库相关的工作。偶尔也谈谈程序员人生 :)
目录
相关文章推荐
51好读  ›  专栏  ›  数据分析与开发

Redis 内部数据结构详解(7):intset

数据分析与开发  · 公众号  · 数据库  · 2017-05-12 11:55

正文

请到「今天看啥」查看全文


(点击 上方公众号 ,可快速关注)


作者:张铁蕾

zhangtielei.com/posts/blog-redis-intset.html

如有好文章投稿,请点击 → 这里了解详情



本系列基于 Redis 3.2 分支


本文是《Redis内部数据结构详解》系列的第七篇。在本文中,我们围绕一个Redis的内部数据结构——intset展开讨论。


Redis里面使用intset是为了实现集合(set)这种对外的数据结构。set结构类似于数学上的集合的概念,它包含的元素无序,且不能重复。Redis里的set结构还实现了基础的集合并、交、差的操作。与Redis对外暴露的其它数据结构类似,set的底层实现,随着元素类型是否是整型以及添加的元素的数目多少,而有所变化。概括来讲,当set中添加的元素都是整型且元素数目较少时,set使用intset作为底层数据结构,否则,set使用dict作为底层数据结构。


在本文中我们将大体分成三个部分进行介绍:


  1. 集中介绍intset数据结构。


  2. 讨论set是如何在intset和dict基础上构建起来的。


  3. 集中讨论set的并、交、差的算法实现以及时间复杂度。注意,其中差集的计算在Redis中实现了两种算法。


我们在讨论中还会涉及到一个Redis配置(在redis.conf中的ADVANCED CONFIG部分):


set-max-intset-entries 512


intset数据结构简介


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))


各个字段含义如下:


  • 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可能会随着数据的添加而改变它的数据编码:


最开始,新创建的intset使用占内存最小的INTSET_ENC_INT16(值为2)作为数据编码。


每添加一个新元素,则根据元素大小决定是否对数据编码进行升级。


下图给出了一个添加数据的具体例子(点击看大图)。




在上图中:



intset与ziplist相比:


  • ziplist可以存储任意二进制串,而intset只能存储整数。


  • ziplist是无序的,而intset是从小到大有序的。因此,在ziplist上查找只能遍历,而在intset上可以进行二分查找,性能更高。


  • ziplist可以对每个数据项进行不同的变长编码(每个数据项前面都有数据长度字段len),而intset只能整体使用一个统一的编码(encoding)。


intset的查找和添加操作


要理解intset的一些实现细节,只需要关注intset的两个关键操作基本就可以了:查找(intsetFind)和添加(intsetAdd)元素。


intsetFind的关键代码如下所示(出自intset.c):



关于以上代码,我们需要注意的地方包括:



而intsetAdd的关键代码如下所示(出自intset.c):



关于以上代码,我们需要注意的地方包括:



Redis的set


为了更好地理解Redis对外暴露的set数据结构,我们先看一下set的一些关键的命令。下面是一些命令举例:




上面这些命令的含义:



我们前面提到过,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之外,还有两种情况可能造成这种转换:


对于小集合使用intset来存储,主要的原因是节省内存。特别是当存储的元素个数较少的时候,dict所带来的内存开销要大得多(包含两个哈希表、链表指针以及大量的其它元数据)。所以,当存储大量的小集合而且集合元素都是数字的时候,用intset能节省下一笔可观的内存空间。


实际上,从时间复杂度上比较,intset的平均情况是没有dict性能高的。以查找为例,intset是O(log n)的,而dict可以认为是O(1)的。但是,由于使用intset的时候集合元素个数比较少,所以这个影响不大。


Redis set的并、交、差算法


Redis set的并、交、差算法的实现代码,在t_set.c中。其中计算交集调用的是sinterGenericCommand,计算并集和差集调用的是sunionDiffGenericCommand。它们都能同时对多个(可以多于2个)集合进行运算。当对多个集合进行差集运算时,它表达的含义是:用第一个集合与第二个集合做差集,所得结果再与第三个集合做差集,依次向后类推。


我们在这里简要介绍一下三个算法的实现思路。


交集


计算交集的过程大概可以分为三部分:


  1. 检查各个集合,对于不存在的集合当做空集来处理。一旦出现空集,则不用继续计算了,最终的交集就是空集。


  2. 对各个集合按照元素个数由少到多进行排序。这个排序有利于后面计算的时候从最小的集合开始,需要处理的元素个数较少。


  3. 对排序后第一个集合(也就是最小集合)进行遍历,对于它的每一个元素,依次在后面的所有集合中进行查找。只有在所有集合中都能找到的元素,才加入到最后的结果集合中。


需要注意的是,上述第3步在集合中进行查找,对于intset和dict的存储来说时间复杂度分别是O(log n)和O(1)。但由于只有小集合才使用intset,所以可以粗略地认为intset的查找也是常数时间复杂度的。因此,如Redis官方文档上所说(http://redis.io/commands/sinter),sinter命令的时间复杂度为:


O(N*M) worst case where N is the cardinality of the smallest set and M is the number of sets.


并集


计算并集最简单,只需要遍历所有集合,将每一个元素都添加到最后的结果集合中。向集合中添加元素会自动去重。


由于要遍历所有集合的每个元素,所以Redis官方文档给出的sunion命令的时间复杂度为(http://redis.io/commands/sunion):


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官方文档(http://redis.io/commands/sdiff)只给出了第二种算法的结果,是不准确的。



看完本文有收获?请转发分享给更多人

关注「数据库开发」,提升 DB 技能







请到「今天看啥」查看全文