广告位 · 页头(后台-模块管理-新建模块后替换此区域)

Redis SUNION 如何实现多个集合的并集运算?

对于 Redis 的 Set 类型来说,多个 key 之间的并集运算由 SUNION 命令完成。它会把一个或多个 Set 中的成员合并在一起,去掉重复元素后返回,但不会改动原始数据集。理解 SUNION 的用法并不复杂,关键是掌握它对内存、时间以及 Redis 单线程模型的影响。

Redis SUNION 如何实现多个集合的并集运算?

SUNION 命令的基本用法与返回规则

SUNION 的命令格式为 SUNION key [key ...],其中 key 可以是一个或多个 Set 类型的键。Redis 会从这些键中读取所有成员,将重复成员去重后,以列表形式返回。这里有个容易忽略的点:命令本身不会修改任何原始集合,也不会创建新的键,它只是做一次只读的计算。命令中的 key 不存在时,Redis 将其视为空集合处理;如果所有 key 都不存在,SUNION 会返回空列表,而不会抛错。这个特性让它在动态业务中很友好,你可以直接传入不同维度的标签集合,不必先判断 key 是否存在。

下面通过一个简单示例观察它的行为。先准备两个 Set,分别记录点击过文章的用户和点赞过文章的用户,然后用 SUNION 合并。

127.0.0.1:6379> SADD article:click 张明 李娜 王强
(integer) 3
127.0.0.1:6379> SADD article:like 李娜 王强 赵倩
(integer) 3
127.0.0.1:6379> SUNION article:click article:like
1) 张明
2) 李娜
3) 王强
4) 赵倩

从这个执行结果可以看出,两个集合中的重复成员李娜和王强只出现了一次。Set 本身是无序的,所以 SUNION 返回的成员顺序不保证固定,不要依赖返回顺序。另一个需要提前意识到的问题是,SUNION 会把完整结果一次性返回给客户端,如果两个集合都很大,结果集会直接进入响应缓冲区,既占用服务端发送缓冲区,也会增加客户端的内存消耗和网络传输成本。

SUNION 的执行流程与时间复杂度

SUNION 在 Redis 内部并不是直接对多个 key 做简单拼接。它会先创建一个临时集合或哈希表,然后依次遍历每个给定 key 的 Set,把所有元素插入这个临时结构中。因为 Redis 的 Set 底层使用整数集合 intset 或哈希表 dict 来表示元素,插入过程中天然完成去重。当所有 key 遍历完毕后,Redis 再把临时结构中的成员序列化成响应返回给客户端。执行结束后,临时结构会被释放,所以 SUNION 本身不会持久化任何中间数据。

从复杂度看,SUNION 的时间复杂度是 O(N),其中 N 表示所有参与运算的 Set 中元素总数。例如两个分别包含 10 万条数据的集合做 SUNION,Redis 要遍历约 20 万条数据,而不是只处理较小的那一个。空间复杂度同样为 O(N),因为需要额外保存并集结果。这个成本容易被忽略:即使你不保存结果,Redis 在内部仍然会为本次运算分配内存,当集合规模较大时,瞬时内存占用会明显上升。由于 Redis 命令执行是单线程串行的,长时间的 SUNION 会直接阻塞其他客户端命令。这也就是为什么在性能敏感系统中,不能把超大集合的 SUNION 当成普通 O(1) 或 O(logN) 命令来频繁调用。

如果只读取单个 Set 的全部成员,SMEMBERS 的时间复杂度是 O(M),M 为该 Set 内部的元素数量。但 SUNION 面对的是多个 Set 的聚合读取,复杂度取决于所有参与集合的总量。两者有本质区别,尤其在集合数量很多时,SUNION 的成本会线性叠加。因此不要因为单个 Set 读取很快,就默认多个 Set 做并集也很快。

使用 SUNIONSTORE 保存并集结果

如果业务上需要多次使用同一个并集结果,并且这些原始集合变化不频繁,可以考虑 SUNIONSTORE。它的语法是 SUNIONSTORE destination key [key ...]。与 SUNION 相同,它会计算所有给定 Set 的并集;不同点在于结果不会直接返回给客户端,而是写入 destination 指定的键中。如果 destination 已经存在,Redis 会先删除旧数据再写入新集合,属于覆盖式写入。该命令的返回值是并集结果的元素数量,而不是成员列表。这个返回值可以用来快速判断合并后的规模。

127.0.0.1:6379> SUNIONSTORE article:all article:click article:like
(integer) 4
127.0.0.1:6379> SMEMBERS article:all
1) 张明
2) 李娜
3) 王强
4) 赵倩

SUNIONSTORE 的优势在于可以把昂贵的并集计算从实时请求中剥离出来。例如定时任务每分钟重新计算一次标签并集,写入一个结果 Set,线上接口只需用 SMEMBERS 或 SISMEMBER 读取结果,避免每次请求都触发 SUNION。不过需要注意,SUNIONSTORE 会占用额外的内存,目标集合的元素数量等于并集大小,当结果集非常大时,内存增长也很明显。如果 destination 被误写成某个生产 key,覆盖会导致原数据丢失,所以在使用前要确认目标 key 只用于缓存结果。

大集合并集的性能风险与优化方案

大集合的 SUNION 计算有两个主要成本:CPU 遍历成本和临时内存成本。例如两个 100 万元素的 Set 执行 SUNION,Redis 至少需要遍历 200 万条数据,还要在新的临时结构中维护去重映射,这个过程可能持续数百毫秒甚至更长。对于单线程 Redis 来说,这就意味着其他命令必须等待。如果此时还有其他大 key 操作,延迟会进一步放大。现实业务里,用户标签、兴趣集合、设备列表等都很容易达到百万级,所以不能只从功能角度评估 SUNION。

优化方案通常从三个方面入手。第一是预计算,把变化频率低的集合并集结果提前用 SUNIONSTORE 存下来,线上读结果集合。第二是缩小参与运算的集合,比如先通过业务条件过滤出一部分用户,再针对小集合做并集,而不是对全量数据直接合并。第三是把只读聚合放到从节点执行,主节点保持正常写入和低延迟读取。Redis 从节点可以执行 SUNION 等只读命令,虽然同样会阻塞该从节点的其他请求,但至少不会影响主链路。若使用 Redis Cluster,还要注意 SUNION 不支持跨 slot 的多个 key,如果 key 分布在不同节点,命令会报 CROSSSLOT 错误,此时需要通过 hash tag 把相关集合落在同一 slot,或者由客户端分别读取后自行合并。

此外,如果业务只关心并集的基数,不关心具体成员,可以选择 HyperLogLog 的概率基数统计。Redis 提供了 PFADD 和 PFCOUNT,占用内存固定且极小,适合大流量独立访客、独立设备数等场景。虽然它不能完全替代 SUNION,但在很多只需要数量不需要明细的场合,从 Set 迁移到 HyperLogLog 可以明显降低内存和计算成本。

典型应用场景与注意事项

SUNION 常用于标签聚合、用户分群、权限合并等场景。例如用户 A 有权限集合 perm:user:a,用户组 G 有权限集合 perm:group:g,实际可用权限就是 SUNION perm:user:a perm:group:g 的结果。再比如商品同时命中了多个标签,需要一次取出所有候选标签,SUNION 也能直接完成。这类场景下,把不同来源的集合做并集,可以避免客户端拉取多个 Set 再进行本地去重。

使用时要注意几个边界条件。首先,SUNION 的结果不保证顺序,如果业务需要按时间或权重排序,应在客户端拿到结果后再排序。其次,参与运算的 key 如果不是 Set 类型,Redis 会返回 WRONGTYPE 错误。第三,当传入大量 key 时,虽然 Redis 支持可变参数,但单次命令的 key 数量过多也会让请求头变大,同时增加定位和遍历成本。必要时可以分批计算,最后再配合 SUNIONSTORE 合并。还要注意不要在循环中反复对同一批大集合执行 SUNION,这会不断重复同样的计算,最好缓存结果或优化数据模型。

总体来说,SUNION 是 Set 数据结构的核心集合运算之一。它能简洁地解决多集合合并与去重问题,但使用前需要养成绩效意识:清楚它会在服务端临时构建结果、遍历所有成员,并且具有 O(N) 的时间和空间成本。合理的做法是根据集合大小和调用频率选择直接 SUNION、预计算 SUNIONSTORE 或替代数据结构。这样才能在保持代码简单的同时,避免给 Redis 主线程带来不必要的压力。

广告位 · 文章底部(后台-模块管理替换此区域)