6)深度解密 Redis 的集合(Set)

简介: 6)深度解密 Redis 的集合(Set)


楔子



下面来解密 Redis 的集合,整篇文章分为三个部分。

  • Redis 集合的相关命令;
  • Redis 集合的应用场景;
  • Redis 集合的实现原理;



Redis 集合的相关命令



先来看看集合的相关命令,我们首先要学会如何使用它。


sadd key value1 value2 ···:向集合插入多个元素,如果重复会自动去重

Redis的集合和列表是类似的,都是用来存储多个标量,但是它和列表又有不同:

  • 列表中的元素是可以重复的,而集合中的元素不会重复;
  • 列表在插入元素的时候可以保持顺序,而集合不保证顺序;
# 返回成功插入的元素的个数,这里是 3 个
# 因为元素有重复,两个 1,只会插入一个
127.0.0.1:6379> sadd set1 1 1 2 3
(integer) 3  
127.0.0.1:6379>

smembers key:查看集合的所有元素

127.0.0.1:6379> smembers set1
1) "1"
2) "2"
3) "3"
127.0.0.1:6379>

sismember key value:查看 value 是否在集合中

127.0.0.1:6379> sismember set1 1
(integer) 1  # 在的话返回 1
127.0.0.1:6379> sismember set1 5
(integer) 0  # 不在返回 0
127.0.0.1:6379>

scard key:查看集合的元素个数

127.0.0.1:6379> scard set1
(integer) 3
127.0.0.1:6379>

srem key value1 value2 ···:删除集合中的元素

127.0.0.1:6379> srem set1 1 2
(integer) 2  # 返回删除成功的元素个数
127.0.0.1:6379> srem set1 1 2
(integer) 0
127.0.0.1:6379>

spop key count:随机弹出集合中 count 个元素

注意:count 是可以省略的,如果省略则弹出 1 个。另外一旦弹出,原来的集合里面也就没有了。

127.0.0.1:6379> smembers set1
1) "3"  # 还有一个元素
127.0.0.1:6379> sadd set1 1 2
(integer) 2  # 添加两个进去
127.0.0.1:6379> 
127.0.0.1:6379> smembers set1
1) "1"
2) "2"
3) "3"
127.0.0.1:6379> spop set1 1
1) "2"  # 弹出 1 个元素,返回弹出的元素
127.0.0.1:6379> smembers set1
1) "1"
2) "3"
127.0.0.1:6379>

srandmember key count:随机获取集合中 count 个元素

注意:count 是可以省略的,如果省略则获取 1 个。可以看到类似 spop,但是 srandmember 不会删除集合中的元素。

127.0.0.1:6379> smembers set1
1) "1"
2) "3"
127.0.0.1:6379> srandmember set1 1
1) "1"
127.0.0.1:6379> smembers set1
1) "1"
2) "3"

smove key1 key2 value:将 key1 当中的 value 移动到 key2 当中

因此 key1 当中的元素会少一个,key2 会多一个(前提是 value 在 key2 中不重复,否则 key2 还和原来保持一致)。

127.0.0.1:6379> smembers set1
1) "1"
2) "3"
127.0.0.1:6379> smembers set2
1) "1"
127.0.0.1:6379> smove set1 set2 3
(integer) 1
127.0.0.1:6379> smembers set1
1) "1"
127.0.0.1:6379> smembers set2
1) "1"
2) "3"
127.0.0.1:6379>

sinter/sunion/sdiff key1 key2:对 key1 和 key2 分别求交集、并集、差集

127.0.0.1:6379> sinter set1 set2
1) "2"
2) "3"
127.0.0.1:6379> sunion set1 set2
1) "1"
2) "2"
3) "3"
4) "4"
127.0.0.1:6379> sdiff set1 set2
1) "1"
127.0.0.1:6379> sdiff set2 set1
1) "4"
127.0.0.1:6379>

以上就是 Redis 的集合,用法还是很简单的,和 Python 的集合有着相似之处。




Redis 集合的使用场景



集合类型的经典使用场景如下:

1)保存社交软件上关注我的人和我关注的人,因为他们是不重复的;


2)中奖人信息也适合用集合类型存储,这样可以保证一个人不会重复中奖;

总之对于那些需要保证唯一性的场景,都适合使用集合。



Redis 集合的实现原理



集合的底层实现有两种,分别是整数集合(intset)和哈希表,当集合类型用哈希表存储时,哈希表的 key 为要插入的元素值,而哈希表的 value 则为 Null,如下图所示:

当集合中所有的值都为整数时,Redis 会使用 intset 结构(Redis 为整数专门设计的一种集合结构)来存储,如下代码所示:

127.0.0.1:6379> sadd s 1 2 3
(integer) 3
127.0.0.1:6379> object encoding s
"intset"
127.0.0.1:6379>

从上面代码可以看出,当所有元素都为整数时,集合会以 intset 结构进行数据存储。但当发生以下两种情况时,会导致集合类型使用 hashtable 而非 intset:

  • 当元素个数超过 512 个时,该值可通过参数 set-max-intset-entries 来配置;
  • 当元素为非整数时;
import redis
client = redis.Redis(host="...", 
                     decode_responses="utf-8")
client.sadd("s1", *range(513))
# 超过 512 个,使用哈希表存储
print(
    client.object("encoding", "s1")
)  # hashtable
client.sadd("s2", *range(512))
# 没超过 512 个,使用 intset
print(
    client.object("encoding", "s2")
)  # intset
client.sadd("s3", "satori")
# 不是整数,使用哈希表存储
print(
    client.object("encoding", "s3")
)  # hashtable

由于哈希表我们在介绍 Hash 的时候已经说过了,下面就来看看整数集合(intset)是怎么实现的。相关源码位于 intset.h 和 intset.c 中,前者是定义,后者是实现。

typedef struct intset {
    // 编码方式
    uint32_t encoding;
    // 集合包含的元素数量
    uint32_t length;
    // 保存元素的数组
    int8_t contents[];
} intset;

整数集合是一块连续的内存空间,从定义上也能看出。而且保存元素的容器是一个 contents 数组,从内存使用的角度来看,由于整数集合是一块连续的内存空间,所以这样就避免了内存碎片,并提升了内存使用效率。

另外虽然 contents 被声明为 int8_t 类型的数组,但实际上 contents 数组并不保存 int8_t 类型的元素,contents 数组的真正类型取决于 intset 结构体里的 encoding 字段的值。比如:

  • 如果 encoding 字段为 INTSET_ENC_INT16,那么 contents 就是一个 int16_t 类型的数组,数组中每一个元素的类型都是 int16_t;
  • 如果 encoding 字段为 INTSET_ENC_INT32,那么 contents 就是一个 int32_t 类型的数组,数组中每一个元素的类型都是 int32_t;
  • 如果 encoding 字段为 INTSET_ENC_INT64,那么 contents 就是一个 int64_t 类型的数组,数组中每一个元素的类型都是 int64_t;


不同类型的 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 给我们提供了两个思路:

  • 1)使用连续的内存空间,避免内存碎片;
  • 2)针对不同长度的实体数据,采用不同大小的元信息,以避免使用统一大小的元数据,造成内存浪费。因为如果统一大小,必然要选择最大的;


此外 Redis 在数据访问上也进行了优化,有一些数据是经常需要被访问的,比如常见的整数,Redis 协议中常见的回复信息,以及常见的报错信息等等。


而为了避免在内存中反复创建这些经常被访问的数据,Redis 就采用了共享对象的设计思想。这个设计思想很简单,就是把这些常用数据创建为共享对象,当上层应用需要访问它们时,直接读取就行。

// server.c
void createSharedObjects(void) {
    // …
    //常见回复信息
    shared.ok = createObject(OBJ_STRING,
                             sdsnew("+OK\r\n"));
    shared.err = createObject(OBJ_STRING,
                              sdsnew("-ERR\r\n"));
    //…
    //常见报错信息
    shared.nokeyerr = createObject(
        OBJ_STRING,sdsnew("-ERR no such key\r\n"));
    shared.syntaxerr = createObject(
        OBJ_STRING,sdsnew("-ERR syntax error\r\n"));
    //0 到 9999 的整数
    for (j = 0; j < OBJ_SHARED_INTEGERS; j++) {
        shared.integers[j] =
        makeObjectShared(
            createObject(OBJ_STRING,(void*)(long)j));
        //…
    }
   //…
}

里面创建了非常多的共享对象,可以进入源码中查看。



小结



Set 存储的如果都是数字,并且数量不超过 512 个,底层使用 intset;如果超过 512 个,或者出现了不是整数的元素,那么会将 intset 换成哈希表。


变长编码,数字范围不同,intset 会选择 int16_t / int32_t /int64_t 编码,具体可以查看 intset.c 的 _intsetValueEncoding 函数;


intset 在存储时是有序的,这意味着查找一个元素,可使用「二分查找」,具体可以查看 intset.c 的 intsetSearch 函数。


编码升级,当数据范围扩大时,会引发编码长度升级。




本文参考自:

  • 极客时间蒋德钧:《Redis 源码剖析与实战
  • 小林 coding:《图解 Redis》
相关文章
|
2月前
|
Java
【Java集合类面试二十三】、List和Set有什么区别?
List和Set的主要区别在于List是一个有序且允许元素重复的集合,而Set是一个无序且元素不重复的集合。
|
5天前
|
存储 JSON NoSQL
redis基本数据结构(String,Hash,Set,List,SortedSet)【学习笔记】
这篇文章是关于Redis基本数据结构的学习笔记,包括了String、Hash、Set、List和SortedSet的介绍和常用命令。文章解释了每种数据结构的特点和使用场景,并通过命令示例演示了如何在Redis中操作这些数据结构。此外,还提供了一些练习示例,帮助读者更好地理解和应用这些数据结构。
redis基本数据结构(String,Hash,Set,List,SortedSet)【学习笔记】
|
5天前
|
存储 前端开发 API
ES6的Set和Map你都知道吗?一文了解集合和字典在前端中的应用
该文章详细介绍了ES6中Set和Map数据结构的特性和使用方法,并探讨了它们在前端开发中的具体应用,包括如何利用这些数据结构来解决常见的编程问题。
ES6的Set和Map你都知道吗?一文了解集合和字典在前端中的应用
|
4天前
|
存储 数据处理 Python
Python中的Set集合:高效数据处理的利器
Python中的Set集合:高效数据处理的利器
11 0
|
2月前
|
索引 Python 容器
为什么Python中会有集合set类型?
为什么Python中会有集合set类型?
|
2月前
|
存储 NoSQL 算法
Redis6入门到实战------ 三、常用五大数据类型(列表(List)、集合(Set)、哈希(Hash)、Zset(sorted set))
这是关于Redis 6入门到实战的文章,具体内容涉及Redis的五大数据类型:列表(List)、集合(Set)、哈希(Hash)、有序集合(Zset(sorted set))。文章详细介绍了这些数据类型的特点、常用命令以及它们背后的数据结构。如果您有任何关于Redis的具体问题或需要进一步的帮助,请随时告诉我。
|
2月前
|
Java
【Java集合类面试二十二】、Map和Set有什么区别?
该CSDN博客文章讨论了Map和Set的区别,但提供的内容摘要并未直接解释这两种集合类型的差异。通常,Map是一种键值对集合,提供通过键快速检索值的能力,而Set是一个不允许重复元素的集合。
|
2月前
|
缓存 NoSQL Redis
【Azure Redis 缓存】Azure Cache for Redis 是否记录具体读/写(Get/Set)或删除(Del)了哪些key呢?
【Azure Redis 缓存】Azure Cache for Redis 是否记录具体读/写(Get/Set)或删除(Del)了哪些key呢?
|
2月前
|
SQL 缓存 NoSQL
【Azure Redis 缓存】使用Azure Redis服务时候,如突然遇见异常,遇见命令Timeout performing SET xxxxxx等情况,如何第一时间查看是否有Failover存在呢?
【Azure Redis 缓存】使用Azure Redis服务时候,如突然遇见异常,遇见命令Timeout performing SET xxxxxx等情况,如何第一时间查看是否有Failover存在呢?
|
存储 NoSQL Redis
Redis---set数据类型操作
一、概述: 在Redis中,我们可以将Set类型看作为没有排序的字符集合,和List类型一样,我们也可以在该类型的数据值上执行添加、删除或判断某一元素是否存在等操作。需要说明的是,这些操作的时间复杂度为O(1),即常量时间内完成次操作。Set可包含的最大元素数量是4294967295。 和List类型不同的是,Set集合中不允许出现重复的元素,这一点和C++标
1410 0