Redis Set 数据类型

185 阅读18分钟

一、引言

在当今大数据时代,数据处理与存储的高效性成为众多开发者关注的焦点。Redis,作为一款备受青睐的高性能键值对存储数据库,以其丰富多样的数据类型脱颖而出。其中,Set 集合类型在处理不包含重复元素的无序数据集时,展现出独特的优势,为各类业务场景提供了便捷、高效的解决方案。无论是电商平台的用户标签管理,还是社交网络的好友推荐系统,Redis Set 都发挥着不可或缺的作用。接下来,就让我们一同深入探究 Redis Set 的底层实现结构、剖析其优缺点,并领略它在实际业务场景中的精彩应用。

二、Redis Set 底层实现结构解密

2.1 整数集合(intset)

当一个集合只包含整数值元素,并且这个集合的元素数量不多时,Redis 会优先选用整数集合(intset)作为 Set 的底层实现。intset 是一个用于保存整数值的集合抽象数据类型,它能够确保集合中不会出现重复元素,并且所有元素按照从小到大的顺序有序排列,存储在一块连续的内存空间之中,就像是一支排列整齐的队伍。

intset 的结构定义如下:

typedef struct intset {
    uint32_t encoding;
    uint32_t length;
    int8_t contents[];
} intset;

其中,encoding 字段犹如一位 “指挥官”,指明了集合中整数所采用的编码方式,它可以是 INTSET_ENC_INT16(表示每个元素用 2 个字节存储,能表示范围为 -32768 到 32767 的整数)、INTSET_ENC_INT32(每个元素用 4 个字节存储,范围扩大到 -2147483648 到 2147483647)或 INTSET_ENC_INT64(每个元素用 8 个字节存储,覆盖更广泛的整数范围)。length 字段则像是队伍的 “点名册”,记录着集合中当前元素的数量。而 contents 字段就是那片供队伍整齐站立的 “操场”,是一个柔性数组,真正存放着整数集合中的元素,根据 encoding 的指示,每个元素占用相应字节数的空间,紧密排列。

2.2 字典(dict)

然而,当集合中的元素不再局限于整数,或者元素数量逐渐增多,超出了 intset 的承载范围时,Redis 就会果断切换到更为强大、通用的字典(dict)作为 Set 的底层支撑。字典是一种用于保存键值对(key-value)的抽象数据结构,在 Redis 的 Set 场景下,集合中的每个元素都充当键,而值统一被设置为 NULL,就好比每个物品都贴上了独一无二的标签,标签是关键,对应的值暂时不需要额外信息来描述。

字典的结构相对复杂,犹如一座精心设计的多层建筑:

typedef struct dict {
    dictType *type;
    void *privdata;
    dictht ht[2];
    int rehashidx;
} dict;

这里的 type 是指向 dictType 结构的指针,它像是建筑的 “蓝图”,保存了一簇用于操作特定类型键值对的函数,Redis 会依据不同的用途,为字典定制不同的操作指南。privdata 则像是建筑中的 “储物间”,保存了需要传给那些类型特定函数的可选参数。核心部分 ht 是一个包含两个项的数组,每个项都是一个 dictht 哈希表,正常情况下,字典主要使用 ht [0] 哈希表,ht [1] 哈希表则像是备用的 “扩建场地”,只有在对 ht [0] 进行 rehash(重新散列,后续会详细介绍)时才会启用。rehashidx 就像是施工的 “进度标记”,记录着 rehash 目前的进展状态,若未进行 rehash,其值为 -1。

再深入到哈希表内部:

typedef struct dictht {
    dictEntry **table;
    unsigned long size;
    unsigned long sizemask;
    unsigned long used;
} dictht;

table 是一个数组,犹如建筑中的一个个 “房间”,每个房间(数组元素)都保存着一个指向 dictEntry 结构的指针,而 dictEntry 结构就像是房间里的 “储物柜”:

typedef struct dictEntry {
    void *key;
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
    } v;
    struct dictEntry *next;
} dictEntry;

其中,key 就是我们 Set 中的元素,v 用来存放值(在这里固定为 NULL),next 指针则像是房间之间的 “秘密通道”,当出现哈希冲突(不同的键经过哈希计算后得到相同的索引位置)时,通过它将多个哈希值相同的键值对连接在一起,形成一个链表,解决冲突问题,确保每个元素都能在字典中有自己的 “专属空间”。

2.3 编码转换机制

Redis 的聪明之处不仅在于提供了两种底层实现方式,还在于它能够根据数据的动态变化,灵活地进行编码转换。当一个原本使用 intset 作为底层实现的 Set,遇到以下情况时,就会触发向字典的转换:一是新插入的元素不是整数,打破了 intset 只容纳整数的规则;二是集合中元素的数量增多,当元素个数超过了配置参数 set-max-intset-entries(默认值为 512)时,为了保证后续操作的高效性,Redis 会自动将 intset 升级为 dict。

例如,起初我们创建了一个 Set,向其中插入整数元素 1、2、3,此时 Set 会以 intset 形式存储,占用较小的内存空间。但当我们继续插入一个字符串元素 “abc” 时,Redis 会迅速检测到数据类型的不一致,立即启动编码转换机制。它会先按照字典的结构创建新的存储区域,然后将原 intset 中的整数元素 1、2、3 逐个取出,以元素为键、NULL 为值,插入到字典的 ht [0] 哈希表中,同时释放原 intset 占用的内存,至此,Set 的底层实现就从 intset 平稳过渡到了 dict。

反之,一旦 Set 采用了 dict 作为底层实现,即便后续集合中的元素发生变化,满足了使用 intset 的条件(如所有元素变为整数且数量减少到阈值以下),Redis 也不会再将其转换回 intset,因为频繁的转换操作可能带来额外的性能开销,得不偿失。这种编码转换机制就像是一位智慧的管家,根据家中物品(数据)的增减和种类变化,合理安排储物空间(底层结构),既保障了空间利用的高效性,又兼顾了管理操作的便捷性。

三、Redis Set 底层实现的优势尽显

3.1 内存优化策略

Redis Set 在内存优化方面堪称一绝。以 intset 为例,它能够根据数据的大小动态地选择最为合适的编码方式。当集合中的整数元素范围较小,均在 -32768 到 32767 之间时,intset 会采用 INTSET_ENC_INT16 编码,每个元素仅占用 2 个字节的存储空间;倘若元素范围扩大到 -2147483648 到 2147483647,它又会智能地切换为 INTSET_ENC_INT32 编码,每个元素占用 4 个字节,以此类推。这种灵活的编码选择机制,使得 Redis 在存储整数集合时,能够最大限度地节省内存空间,避免不必要的内存浪费。

与传统的数组或链表存储方式相比,intset 的优势尤为明显。假设我们需要存储 100 个范围在 -32768 到 32767 的整数,如果使用普通的 32 位整数数组来存储,将占用 100 * 4 = 400 字节的内存空间;而 intset 采用 INTSET_ENC_INT16 编码,仅需占用 100 * 2 = 200 字节,内存使用量直接减半。在大规模数据存储场景下,这种内存优化效果将带来显著的性能提升,既降低了内存成本,又提高了数据存储密度,使得 Redis 能够在有限的内存资源中处理更多的数据。

3.2 操作高效性能

Redis Set 无论是对单个元素的增删查操作,还是针对多个集合的交并差等集合运算,都展现出了极高的效率。对于使用 intset 实现的 Set,由于元素是有序存储在连续内存空间中的,查找元素时可以利用二分查找算法,时间复杂度低至 O (logN);而插入和删除元素,虽然在某些情况下可能触发升级操作,导致时间复杂度变为 O (N),但只要数据量不大且升级操作不频繁,整体性能依然可观。

当 Set 底层采用字典(dict)实现时,得益于哈希表的优秀设计,其添加、删除、查找元素的平均时间复杂度均能稳定在 O (1)。在进行集合间的交并差运算时,Redis 巧妙地利用了字典的特性,通过对哈希表的快速遍历和匹配,能够在极短的时间内得出结果。以常见的社交网络点赞场景为例,当用户对某条动态进行点赞时,系统使用 SADD 命令将用户 ID 添加到存储点赞用户的 Set 中,操作瞬间即可完成;当需要统计该动态的点赞总数时,SCARD 命令能够立即返回准确的点赞人数,无论该动态的热度有多高,响应速度都不会受到明显影响。再比如电商平台的抽奖活动,参与者的用户 ID 被存储在 Set 中,抽奖时通过 SRANDMEMBER 或 SPOP 命令随机抽取中奖用户,整个过程高效流畅,即使面对海量的参与者,也能快速给出抽奖结果,极大地提升了用户体验。

四、Redis Set 底层实现的局限洞察

4.1 整数集合升级开销

尽管整数集合(intset)的升级策略在一定程度上为节约内存和提升灵活性立下了汗马功劳,但它也并非十全十美。每当有新元素加入,且新元素的类型长度超过当前集合中已有元素的类型长度时,intset 就不得不启动升级流程。这一过程犹如一场大规模的 “搬家” 行动:首先,要依据新元素的类型,为底层数组开辟更大的 “居住空间”,确保新元素能有安身之所;接着,将原数组中的所有 “住户”(已有元素)逐一进行类型转换,使其适应新的 “居住标准”,并小心翼翼地按照从小到大的顺序重新安置,维持整体的有序性;最后,才能将新元素顺利 “入住”。

以一个具体场景为例,假设我们有一个初始编码为 INTSET_ENC_INT16 的整数集合,存储着 100 个范围在 -32768 到 32767 的整数元素,此时集合占用内存为 100 * 2 = 200 字节。当我们尝试插入一个超出 INTSET_ENC_INT16 范围的整数,如 32768 时,intset 便会触发升级至 INTSET_ENC_INT32 编码。这意味着,它需要先为底层数组重新分配 101 * 4 = 404 字节的内存空间,比原空间增大了一倍有余;随后,对原有的 100 个整数元素逐个进行类型转换,将其从 2 字节的存储格式转换为 4 字节,这一过程涉及大量的内存拷贝与数据处理操作;最后再把新元素插入到正确的位置。在这个过程中,不仅消耗了额外的内存,还使得插入操作的时间复杂度飙升至 O (N),严重影响了性能。倘若在高并发场景下频繁出现此类升级操作,将会给系统带来巨大的压力,就如同交通高峰期的狭窄道路,极易引发 “拥堵”,导致系统响应迟缓。

4.2 字典的空间与碰撞问题

字典(dict)作为 Redis Set 的另一种底层实现,虽然在通用性和应对复杂数据类型方面表现卓越,但也存在着一些不容忽视的问题。一方面,为了减少频繁的哈希表扩容操作带来的性能损耗,Redis 在创建字典时会预先为哈希表分配一定的空间。然而,这种预分配策略在某些情况下可能会造成内存资源的浪费。例如,当我们创建一个 Set,预计存储少量元素,但由于预分配机制,系统依然会为哈希表开辟相对较大的初始空间,而这些未被使用的空间就如同闲置的 “空房间”,白白占用内存,在大规模部署多个 Set 且元素数量参差不齐的场景下,这种浪费累积起来将十分可观。

另一方面,哈希表依赖散列函数将元素均匀地分布到各个桶(bucket)中,但理想很丰满,现实却很骨感,散列冲突始终是难以完全避免的 “幽灵”。尽管 Redis 采用了链表法来巧妙化解冲突,即将散列到同一桶的多个元素通过链表串联起来,但当冲突频繁发生时,链表会逐渐变长,这就如同一条拥堵不堪的 “单行道”,查找元素时不得不沿着链表逐个遍历,使得原本期望的 O (1) 查找时间复杂度大打折扣,在极端情况下,甚至可能退化为 O (n),严重拖慢系统的运行速度,就像一辆在泥泞道路上艰难前行的汽车,效率极其低下。

五、Redis Set 在业务场景中的实战

5.1 社交领域的应用

在社交网络蓬勃发展的当下,Redis Set 为社交平台的功能拓展与用户体验提升注入了强大动力。以好友推荐系统为例,平台常常需要依据用户的社交关系,为其精准推送可能认识的人,从而扩大用户社交圈。假设我们有两个用户集合,分别是用户 A 的好友集合 userA_friends 和用户 B 的好友集合 userB_friends,通过 Redis 的 SINTER 命令求这两个集合的交集,瞬间就能得到他们的共同好友列表。基于共同好友数量的多寡,平台能够筛选出与用户 A 具有较多共同社交联系的其他用户,并将这些潜在好友推荐给 A,极大地提高了好友推荐的精准度与效率。这种基于 Set 集合运算的推荐方式,就像是在茫茫人海中,凭借着社交关系的线索,快速为用户找到志同道合、联系紧密的伙伴,助力用户拓展人脉,增强平台的社交粘性。

在群组管理方面,Set 同样发挥着关键作用。当创建一个社交群组时,成员列表可以自然地存储为一个 Set,利用 SADD 命令轻松添加成员,SREM 命令移除成员,SCARD 命令快速获取群组规模。而且,借助 Set 的特性,能够确保群组内成员的唯一性,不会出现重复添加的情况,有效维护了群组数据的准确性。例如,一场线上的行业交流群组活动,组织者通过 Redis Set 高效管理参与成员,随时了解人员动态,确保交流活动的有序进行,为参与者营造良好的沟通氛围。

5.2 电商场景的运用

电商领域,数据的快速处理与精准营销至关重要,Redis Set 成为众多电商平台实现高效运营的得力助手。在商品标签筛选场景中,每件商品通常都会被打上诸如品牌、品类、价格区间、适用人群等多个标签,这些标签信息被存储在以标签名为键、商品 ID 集合为值的多个 Set 中。当用户在前端进行筛选操作,比如选择 “品牌:华为”“品类:手机”“价格区间:3000 - 5000 元” 时,后端利用 Redis 的 SINTER 命令对相应的 Set 进行交集运算,能够在极短时间内从海量商品库中精准定位出符合用户需求的商品列表,为用户呈现个性化的商品推荐页面。这一过程犹如一位经验丰富的导购员,在庞大的商品仓库中,迅速根据顾客的各种需求,挑选出最合适的商品,极大提升了购物效率,优化用户购物体验。

此外,电商平台常常推出限时抢购、热门推荐等营销活动,Redis Set 也能完美适配。例如,将参与限时抢购的商品 ID 存入一个 Set,利用 SPOP 或 Srandmember 命令可以快速随机抽取中奖用户或选取幸运商品进行展示;对于热门推荐商品,通过对用户购买历史、浏览记录等数据的分析,将具有相似兴趣标签的用户组成 Set,再结合商品的热度评分等因素,筛选出热门且符合用户兴趣的商品推荐给目标用户群体,有效提高商品的曝光率与销量,助力电商平台在激烈的市场竞争中脱颖而出。

5.3 内容推荐系统实战

在信息爆炸的时代,如何从海量的内容资源中为用户精准推送感兴趣的信息,成为内容平台的核心挑战,而 Redis Set 在此扮演着不可或缺的角色。以新闻资讯类 APP 为例,每个用户在注册或日常使用过程中会设置或积累一系列兴趣标签,如科技、财经、体育、娱乐等,这些标签对应着一个个存储相关新闻 ID 的 Set。当用户打开 APP 时,系统通过求用户兴趣标签 Set 与各个分类下最新新闻 Set 的交集,迅速筛选出符合用户兴趣的新闻资讯,并按照热度、时效性等因素进行排序展示。这就如同为每位用户量身定制了一份专属报纸,头条新闻皆是用户所关注领域的最新动态,极大地提高了用户获取信息的效率,增强用户对平台的依赖度。

音乐、视频等流媒体平台亦是如此,用户的听歌历史、收藏列表、点赞视频等行为都会被转化为一个个兴趣标签,存储在相应的 Set 中。平台依据这些 Set 数据,精准分析用户的音乐口味、视频偏好,利用 SINTER 等集合运算,从海量的曲库、视频库中挑选出符合用户喜好的内容进行个性化推荐。例如,网易云音乐通过 Redis Set 记录用户对不同音乐风格、歌手的偏好,为用户打造每日专属的个性化歌单,让用户沉浸在自己喜爱的音乐世界中,感受音乐与心灵的共鸣,使得平台在众多竞品中独树一帜,吸引并留住大量忠实用户。

六、总结

通过对 Redis Set 底层实现结构的深入探究,我们清晰地认识到它在数据存储与处理领域的精妙设计。整数集合(intset)与字典(dict)两种底层实现方式相辅相成,各自在适合的场景下发挥优势,为 Redis Set 带来了出色的内存优化效果与高效的操作性能。尽管在使用过程中,如整数集合的升级、字典的空间与碰撞问题等,存在一些局限性,但只要我们在实际应用中充分了解这些特性,合理规划数据结构与操作流程,就能有效避开 “雷区”,最大化地发挥 Redis Set 的价值。

在社交、电商、内容推荐等诸多实际业务场景中,Redis Set 已经展现出无可替代的作用,助力各类平台提升用户体验、优化业务流程、实现精准运营。希望本文能够成为您深入理解和运用 Redis Set 的得力指南,在未来的技术探索与项目实践中,您可以更加自信地驾驭 Redis Set 这一强大工具,挖掘更多创新的应用可能,为系统性能的提升注入源源不断的动力,让您的技术方案在众多竞品中脱颖而出。倘若您在后续的学习与实践中有任何新的感悟或疑问,欢迎随时深入钻研,与同行们分享交流,共同在技术的海洋中破浪前行。