![]() 楔子 ![]() 下面来解密 Redis 的集合,整篇文章分为三个部分。
![]() Redis 集合的相关命令 ![]() 先来看看集合的相关命令,我们首先要学会如何使用它。 sadd key value1 value2 ···:向集合插入多个元素,如果重复会自动去重 Redis的集合和列表是类似的,都是用来存储多个标量,但是它和列表又有不同:
smembers key:查看集合的所有元素
sismember key value:查看 value 是否在集合中
scard key:查看集合的元素个数
srem key value1 value2 ···:删除集合中的元素
spop key count:随机弹出集合中 count 个元素 注意:count 是可以省略的,如果省略则弹出 1 个。另外一旦弹出,原来的集合里面也就没有了。
srandmember key count:随机获取集合中 count 个元素 注意:count 是可以省略的,如果省略则获取 1 个。可以看到类似 spop,但是 srandmember 不会删除集合中的元素。
smove key1 key2 value:将 key1 当中的 value 移动到 key2 当中 因此 key1 当中的元素会少一个,key2 会多一个(前提是 value 在 key2 中不重复,否则 key2 还和原来保持一致)。
sinter/sunion/sdiff key1 key2:对 key1 和 key2 分别求交集、并集、差集
以上就是 Redis 的集合,用法还是很简单的,和 Python 的集合有着相似之处。 ![]() Redis 集合的使用场景 ![]() 集合类型的经典使用场景如下: 1)保存社交软件上关注我的人和我关注的人,因为他们是不重复的; 2)中奖人信息也适合用集合类型存储,这样可以保证一个人不会重复中奖; 总之对于那些需要保证唯一性的场景,都适合使用集合。 ![]() Redis 集合的实现原理 ![]() 集合的底层实现有两种,分别是整数集合(intset)和哈希表,当集合类型用哈希表存储时,哈希表的 key 为要插入的元素值,而哈希表的 value 则为 Null,如下图所示: 当集合中所有的值都为整数时,Redis 会使用 intset 结构(Redis 为整数专门设计的一种集合结构)来存储,如下代码所示:
从上面代码可以看出,当所有元素都为整数时,集合会以 intset 结构进行数据存储。但当发生以下两种情况时,会导致集合类型使用 hashtable 而非 intset:
由于哈希表我们在介绍 Hash 的时候已经说过了,下面就来看看整数集合(intset)是怎么实现的。相关源码位于 intset.h 和 intset.c 中,前者是定义,后者是实现。
整数集合是一块连续的内存空间,从定义上也能看出。而且保存元素的容器是一个 contents 数组,从内存使用的角度来看,由于整数集合是一块连续的内存空间,所以这样就避免了内存碎片,并提升了内存使用效率。 另外虽然 contents 被声明为 int8_t 类型的数组,但实际上 contents 数组并不保存 int8_t 类型的元素,contents 数组的真正类型取决于 intset 结构体里的 encoding 字段的值。比如:
不同类型的 contents 数组,意味着数组的大小也会不同。 于是这就产生了一个问题,假设 contents 数组的类型是 int16_t,但现在插入一个 int32_t 类型的元素,这个时候要怎么办? 由于 C 数组只能保存相同类型的元素,所以为了能够存储 int32_t,整个集合中已存在的所有元素都要先变成 int32_t 类型。也就是按新元素的类型(int32_t)扩展 contents 数组的空间大小,然后才能将新元素加入到整数集合里。 上述这个过程叫做升级,当然升级的过程中,也要维持整数集合的有序性。 注意:整数集合升级的过程不会重新分配一个新类型的数组,而是在原本的数组上扩展空间,然后再将每个元素转成指定类型。举个例子,假设有一个整数集合,里面存了 3 个元素,并且类型为 int16_t。 现在需要往这个整数集合中加入一个新元素 65535,这个新元素需要用 int32_t 类型来保存,所以整数集合要进行升级操作。 首先会为 contents 数组扩容,在原本空间的大小之上再扩容多 80 个位(10 字节)。因为新来的元素占 4 字节,之前的 3 个元素本来是 int16_t(2 字节),变成 int32_t 之后每个元素大小要增加 2 字节,所以总共要扩容 10( 4 + 3 * 2 )字节,这样才能保存 4 个 int32_t 类型的数据。 扩容完 contents 数组空间大小后,需要将之前的三个元素转换为 int32_t 类型,并将转换后的元素放置到正确的位上面,并且需要维持底层数组的有序性不变,整个转换过程如下: 逻辑不难理解,重点是整数集合升级的具体实现,也是非常考验编程功底的地方。正如使用数组实现大整数一样,思路都很简单,但具体实现非常考验技巧,有兴趣可以自己尝试一下。那么问题来了,整数集合升级有什么好处呢?不用想绝对是省内存。 如果要让一个数组同时保存 int16_t, int32_t, int64_t 类型的元素,最简单的做法就是直接使用 int64_t 类型的数组。不过这样的话,当元素都是 int16_t 类型时,就会造成内存浪费的情况,每个元素都要浪费 6 字节。 而整数集合升级就能避免这种情况,如果一直向整数集合添加 int16_t 类型的元素,那么整数集合的底层就会一直使用 int16_t 类型的数组。只有在我们要将 int32_t 或 int64_t 类型的元素添加到集合时,才会对数组进行升级操作。 因此整数集合升级的好处就是节省内存资源。 另外,整数集合不支持降级操作,一旦对数组进行了升级,就会一直保持升级后的状态。比如前面升级操作的例子,如果删除了 65535 元素,整数集合的数组还是 int32_t 类型的,并不会因此降级为 int16_t 类型。 所以 Redis 在数据结构的设计上可谓是下足了功夫,作为一款内存数据库,对内存的使用做足了优化。特别是在数据结构上面,Redis 给我们提供了两个思路:
此外 Redis 在数据访问上也进行了优化,有一些数据是经常需要被访问的,比如常见的整数,Redis 协议中常见的回复信息,以及常见的报错信息等等。 而为了避免在内存中反复创建这些经常被访问的数据,Redis 就采用了共享对象的设计思想。这个设计思想很简单,就是把这些常用数据创建为共享对象,当上层应用需要访问它们时,直接读取就行。
里面创建了非常多的共享对象,可以进入源码中查看。 ![]() 小结 ![]() Set 存储的如果都是数字,并且数量不超过 512 个,底层使用 intset;如果超过 512 个,或者出现了不是整数的元素,那么会将 intset 换成哈希表。 变长编码,数字范围不同,intset 会选择 int16_t / int32_t /int64_t 编码,具体可以查看 intset.c 的 _intsetValueEncoding 函数; intset 在存储时是有序的,这意味着查找一个元素,可使用「二分查找」,具体可以查看 intset.c 的 intsetSearch 函数。 编码升级,当数据范围扩大时,会引发编码长度升级。 本文参考自:
|
|