☰
Redis Set详解:从基础命令到集合运算与实战避坑
2026/10/14 9:40:13 网站建设 项目流程

Redis 的命令与数据类型系列,这是第九篇,轮到了 Set。按理说前面已经写过了 String、List、Hash,这篇应该是最没悬念的,但我接触过不少项目,发现大家提到 Set 的第一反应几乎都是同一个——"这不就是个自动去重的 List 吗"。这个理解不能算错,但会让人白白错过 Set 最值钱的特性:它在服务端原生支持交集、并集、差集三种集合运算。同样两个列表,用 List 你只能把数据拉到客户端,循环、对比、去重;用 Set,一条 SINTER 就出结果。这篇文章会从基础命令讲到集合运算,再深入到内部编码和几个我实际踩过的坑,适合刚学完基础命令、正准备把 Redis 用进真实项目的同学。如果你已经习惯把 Set 当 List 用,读完可能也会有一种"原来还能这样"的感觉。

1. Set 的定位:无序、唯一、为集合运算而生

1.1 用一张表看 List、Set、Hash 的区别

在开始敲命令之前,先把 Set 和其他两个"长得像"的数据类型放在一起看清楚。接触新数据类型,遇到的第一件事就是:它和之前学过的有什么区别,什么时候该用它而不是别的。

特性ListSetHash
有序性按插入顺序保存无序字段无序
唯一性允许重复成员唯一字段唯一
底层结构quicklistintset 或 hashtableziplist 或 hashtable
典型场景消息队列、时间线去重、标签、集合运算对象属性存储
代表命令LPUSH、LRANGESADD、SINTERHSET、HGETALL

List 的核心价值是"有序队列",你要排队、要按时间倒序拉数据,用它;Hash 的核心价值是"按字段取值",一个对象有几十个属性,想单独更新其中一个字段,用它;而 Set 的定位是"唯一成员的集合",并且你经常需要在这个集合与另一个集合之间做数学意义上的集合运算。

有个很形象的类比:List 是你手机里的播放列表,顺序错了体验很差,同一首歌也可以出现多次;Set 是某次活动的签到名单,一个人重复签到也只算一次,名单本身没有先后顺序,而这恰恰是它的优势——你不需要为顺序付出任何额外成本。

1.2 Set 的三个特征在实际项目中意味着什么

把"无序""唯一""可运算"这三个特征拆开看,每一个都能对应到具体的业务需求。

无序,意味着取成员的时候不要指望有什么稳定的顺序。今天 SMEMBERS 出来的顺序是 A、B、C,明天可能变成 C、A、B。如果业务上需要按时间排序,应该考虑 List 或 ZSet,而不是硬在 Set 外面套排序。

唯一,这是 Set 最容易被使用的一个特性。点赞记录、报名名单、已读消息、设备号白名单,凡是"同一用户只保留一条记录"的场景,SADD 天然替你做去重,不需要先判断存不存在,直接加就行,重复的会被忽略。

可运算,指的是 SINTER(交集)、SUNION(并集)、SDIFF(差集)。这是 List 做不到的,也是我建议你在设计数据模型时优先考虑 Set 的最重要理由。三个命令都发生在 Redis 服务端,数据不用传回客户端,既省带宽又省开发量。

2. 基础操作命令:SADD、SISMEMBER、SMEMBERS、SREM 全解

2.1 先整一张命令速查表

这一节把日常使用频率最高的 Set 命令放在一起,后面逐个展开讲细节。建议先把表格存下来,真正写代码的时候对着查。

命令语法时间复杂度作用
SADDSADD key member [member ...]O(N)向集合添加一个或多个成员
SREMSREM key member [member ...]O(N)从集合移除一个或多个成员
SCARDSCARD keyO(1)返回集合的成员数量
SISMEMBERSISMEMBER key memberO(1)判断某个成员是否存在
SMISMEMBERSMISMEMBER key member [member ...]O(N)批量判断多个成员(Redis 6.2+)
SMEMBERSSMEMBERS keyO(N)返回集合全部成员
SSCANSSCAN key cursor [MATCH pattern] [COUNT count]增量迭代用游标遍历大集合

2.2 SADD 添加成员:返回值里藏着大学问

SADD 的语法是SADD key member [member ...],支持一次添加多个成员。很多人以为它和 LPUSH 一样,返回的是当前集合的总长度,其实不是——它返回的是"实际新增了多少个成员"。

看一个实测例子:

> SADD activity:2024:signup user:1001 user:1002 user:1001 (integer) 2

我故意写了三批 user:1001,实际新增的只有 user:1001 和 user:1002 两个,所以返回(integer) 2。之后再执行一次:

> SADD activity:2024:signup user:1001 (integer) 0

返回 0,说明集合里已经有这个成员了,这次操作相当于"无效写入"。这个返回值的含义在做"写入去重"时很有用:你想判断某个用户是不是第一次加入,不需要先 SISMEMBER 再 SADD,直接看 SADD 的返回是不是 1 就知道。这里损耗一次命令往返,还更简洁。

2.3 SISMEMBER 与 SMISMEMBER:判断成员是否存在

SISMEMBER key member的时间复杂度是 O(1),返回 1 表示存在,0 表示不存在。它是我在做"是否点赞""是否已报名"这类判断时的首选命令,比把所有成员拉出来再 contains 高效太多。

场景举例:文章详情页要显示"当前用户是否已点赞",并发量很大的话,每次查询都 SMEMBERS 把整个集合拉出来判断,是非常糟糕的做法。集合几十万成员时,每次请求的传输和序列化开销都是灾难。正确做法是:

> SISMEMBER article:1024:liked user:1001 (integer) 1

Redis 6.2 之后还提供了批量版本 SMISMEMBER,一次判断多个成员,返回一个数组,每个元素是 0 或 1:

> SMISMEMBER article:1024:liked user:1001 user:9999 user:7777 1) (integer) 1 2) (integer) 0 3) (integer) 0

批量判断的场景也很常见,比如登录之后要批量判断一批文章里用户点赞了哪几篇,一条命令就能拿到结果。

2.4 SMEMBERS 与 SSCAN:全量取出和增量迭代

SMEMBERS key返回集合中所有成员,时间复杂度 O(N)。在集合很小的时候(几十、几百个成员),这是最简单直接的手段。但如果集合已经到了十万、百万级别,SMEMBERS 一次返回所有数据就有风险:Redis 是单线程处理命令的,生成这么大的响应会阻塞其他命令的执行,客户端要反序列化这么大的响应也会卡住。

正确的选择是 SSCAN,它是基于游标的增量迭代,不会一次性把整个集合返回:

> SSCAN article:1024:liked 0 COUNT 100 1) "12345" 2) 1) "user:1001" 2) "user:1002"

返回结果的第一个元素是下一次迭代的游标,如果它是 0,说明遍历完了;如果非 0,就把它作为下一次 SSCAN 的游标继续调用。上面的例子只取了前 100 个成员,不会出现阻塞风险。

2.5 SREM 删除与 SCARD 计数

SREM 用于移除一个或多个成员,返回实际移除的数量:

> SREM activity:2024:signup user:1001 user:1002 (integer) 2

SCARD 用于获取集合中成员的数量,时间复杂度 O(1),底层直接读计数,非常快。这个命令在很多"统计类"业务里是核心,比如文章的点赞总数、活动的报名人数,一个 SCARD 就出来了,不需要写复杂的聚合查询。

3. 集合运算三兄弟:SINTER、SUNION、SDIFF 才是 Set 的灵魂

3.1 SINTER 交集:共同关注、公共标签

SINTER 返回两个或多个集合的交集,即同时出现在这些集合里的成员。语法:

> SADD tag:redis article:1001 article:1002 article:1003 (integer) 3 > SADD tag:nosql article:1002 article:1003 article:1004 (integer) 3 > SINTER tag:redis tag:nosql 1) "article:1003" 2) "article:1002"

这个例子模拟的是标签体系:一篇技术文章被打上多个标签,每个标签对应一个 Set,里面存的都是文章 ID。想查"同时包含 redis 和 nosql 两个标签的文章",一个 SINTER 直接搞定。如果场景换成社交关系,就是"我关注的人和你也关注的人有哪些交集",也就是共同好友的实现。

3.2 SUNION 并集:去重合并的利器

SUNION 返回两个或多个集合的并集,结果自动去重:

> SUNION tag:redis tag:nosql 1) "article:1001" 2) "article:1002" 3) "article:1003" 4) "article:1004"

注意 article:1002 和 article:1003 在两个标签里都出现,但并集结果里只有一份。这个命令适合做"合并后去重"的场景:比如推送系统里,同一个用户可能命中多个消息触达条件,每个条件是一个 Set,最后想输出一份不重复的推送用户名单,SUNION 一次完成。

3.3 SDIFF 差集:顺序敏感的减法

SDIFF 返回第一个集合中有、但其他集合中没有的成员。它的语义是"属于 A 但不属于 B",所以参数顺序非常关键——SDIFF A B和SDIFF B A的结果完全不是一回事。

> SDIFF tag:redis tag:nosql 1) "article:1001"

结果是只打了 redis 标签、没有打 nosql 标签的文章。反过来执行:

> SDIFF tag:nosql tag:redis 1) "article:1004"

就变成了只打了 nosql 标签、没有打 redis 标签的文章。

这个命令在业务里最常见的用途是"差异比对"。比如关注关系里,想看"我关注了哪些人,但对方没有关注我",假设我用user:100:following存我关注的用户 ID,user:100:fans存我的粉丝 ID,那么:

> SDIFF user:100:following user:100:fans

出来的就是一串"单向关注"的用户 ID,找互粉异常、清理僵尸关注都非常方便。换一种顺序SDIFF user:100:fans user:100:following,得到的就是"关注了我、但我没有回关"的用户,这个在"你可能认识的人"里很好用。

3.4 STORE 变体与运算成本控制

SINTER、SUNION、SDIFF 在拿到结果的同时,也支持把结果直接落地成一个新的 Set,对应的命令是 SINTERSTORE、SUNIONSTORE、SDIFFSTORE。语法就是在原命令基础上加一个目标 key:

> SINTERSTORE tag:redis_and_nosql tag:redis tag:nosql (integer) 2

这条命令做了两件事:先把 tag:redis 和 tag:nosql 的交集算出来,然后写入 tag:redis_and_nosql 这个新 key。之后你要反复用这个交集结果时,直接读新 key 就行,不需要每次都重新做集合运算。

为什么要关心这个?因为 SINTER 在集合比较大时不是免费操作。Redis 的实现逻辑是:遍历最小的集合,逐个判断是否存在于其他集合中,整体复杂度受最小集合大小和其他集合数量的影响。如果两个集合都有几十万成员,而且这个查询还在请求热路径上,Redis 单线程会因此卡顿。

我的建议是:如果是低频的后台任务或管理端查询,直接 SINTER 没问题;如果是用户高频请求,优先考虑把运算结果缓存到 STORE 键里,或者干脆在业务写数据的时候就同步维护一份"关系结果集",查询时直接 GET。

另外,Redis 7.0 还出了一个 SINTERCARD,只返回交集基数的数量而不返回具体成员。如果你只需要"共同点赞数""共同好友数"这类数字指标,用它省掉不必要的元素传输:

> SINTERCARD tag:redis tag:nosql (integer) 2

4. 移动与随机:SMOVE、SRANDMEMBER、SPOP 的语义差异

4.1 SMOVE:集合间原子搬运

SMOVE 的语法是SMOVE source destination member,把某个成员从源集合原子地移动到目标集合。移动成功返回 1,源集合里不存在这个成员则返回 0,并且不会动目标集合。

> SADD task:todo task:001 (integer) 1 > SMOVE task:todo task:done task:001 (integer) 1 > SISMEMBER task:todo task:001 (integer) 0 > SISMEMBER task:done task:001 (integer) 1

它的价值在于"原子性"。如果不用 SMOVE,你得先 SREM 再 SADD,两步操作之间一旦出现异常,成员可能丢在中间状态。而且注意,SMOVE 的 O(1) 复杂度意味着它直接操作底层结构,非常高效。

我常在"名单流转"场景里用它:比如候选人从"待处理"集合移动到"已通过"集合;活动参与者从"已报名"移动到"已签到"。用两个 Set 加一条 SMOVE,状态流转既清晰又不会产生重复记录。

4.2 SRANDMEMBER:只看不拿的随机采样

SRANDMEMBER key [count]随机返回成员,但不会从集合里移除。不传 count,返回 1 个随机成员。

传 count 时行为有点复杂,这里需要特别留意两个方向:

  • count 为正数:返回去重后的随机成员,最多 count 个,不会出现同一个成员重复;如果 count 大于等于集合大小,返回整个集合。
  • count 为负数:返回恰好 |count| 个成员,可能出现重复,相当于有放回的随机抽取。
> SRANDMEMBER activity:2024:signup 3 1) "user:1001" 2) "user:1002" 3) "user:1003" > SRANDMEMBER activity:2024:signup -5 1) "user:1001" 2) "user:1001" 3) "user:1004" 4) "user:1002" 5) "user:1002"

负 count 出现重复是正常的,官方文档也明确描述了这一点。做"随机展示几条数据",用正数;做"需要恰好 N 条候选,允许重复"的采样,用负数。

4.3 SPOP:拿到就把成员移除

SPOP key [count]随机移除并返回一个或多个成员。这是它和 SRANDMEMBER 最本质的区别:一个不删,一个删。

> SPOP activity:2024:signup 2 1) "user:1005" 2) "user:1008" > SCARD activity:2024:signup (integer) 98

上面操作后集合从 100 变成 98,两个成员已经被移除了。注意一点:如果 count 大于集合的当前大小,SPOP 会返回整个集合的所有成员,集合变为空,而不是返回报错。

4.4 一张表看清怎么选

四种"取成员"的方式,很容易搞混,我常用这张表帮自己做决策:

命令是否删除返回值典型场景
SRANDMEMBER key不删除一个随机成员抽奖页面展示"幸运用户预览"
SRANDMEMBER key count(正数)不删除去重的随机成员列表随机推荐列表
SRANDMEMBER key count(负数)不删除允许重复的随机列表模拟采样
SPOP key删除一个随机成员抽奖消耗名额、抢红包扣减
SPOP key count删除随机成员列表一次抽出多个中奖者

核心判断标准就一句话:这次操作之后,成员还应该在集合里吗?在,用 SRANDMEMBER;不在,用 SPOP。

5. 内部编码:intset 与 hashtable 的一升一降

5.1 intset 整数集合:内存紧凑的功臣

Redis 里的每个 Set 底层不是只有一种结构,而是根据数据特征选两种编码方式之一:intset 和 hashtable。

intset 是一个有序的整数数组,所有成员都是整数时才有资格使用它。它的特点是内存极度紧凑,元素本身按从小到大的顺序排列在连续内存里,查找用二分查找,时间复杂度 O(log N)。

这里的"整数"指 64 位有符号整数,也就是 long long 范围内的数字。一旦集合里出现非整数字符串,比如"user:1001",intset 就装不下了。

5.2 hashtable 哈希表:通用方案的兜底

hashtable 编码就是 Redis 普通的哈希表实现,每个成员作为键存储。它的查找是 O(1),支持任意字符串成员,没有集合大小的限制,也没有成员类型的限制。代价是内存占用比 intset 高不少——每个成员都要维护一个完整的哈希表节点结构。

5.3 切换边界与 set-max-intset-entries

intset 什么时候升级成 hashtable?答案是触发任一条件:

  • 插入了一个非整数的成员;
  • 成员数量超过了set-max-intset-entries配置项,默认值是 512。

也就是说,一个 Set 里全是 1、2、3 这种整数且数量不超过 512 个时,用 intset,内存非常省;一旦有人往里面塞了一个带冒号的字符串,或者数量超过 512,整个集合就会切换成 hashtable。

这里有个容易忽略的细节:切换是单向的。intset 可以升级为 hashtable,但 hashtable 不会因为删掉非整数成员、删到 500 个以内就自动降级回 intset。底层的编码只升不降。如果你发现某个集合因为误加了几个字符串导致内存猛涨,想恢复紧凑,最直接的办法是重建这个 key,而不是指望 Redis 自动变回去。

5.4 用 OBJECT ENCODING 验证

实际操作中想确认某个 Set 用的是哪种编码,直接执行:

> SADD test:intset 1 2 3 (integer) 3 > OBJECT ENCODING test:intset "intset" > SADD test:str user:1001 user:1002 (integer) 2 > OBJECT ENCODING test:str "hashtable"

我排查线上内存问题时,习惯用redis-cli --bigkeys配合 OBJECT ENCODING 看哪些 Set 是 hashtable 编码、成员数量很大。如果确认业务允许(只存数字 ID),可以用 rebuild 的方式尽量保持 intset,能省下不少内存。当然,前提是你真的在意这几百字节的成员开销,小项目不必过度优化。

6. 真实项目落地:四个场景直接抄作业

6.1 标签系统:用 SADD 打标,用 SINTER 筛选

我做一个内容聚合类项目时,给每篇文章打标签是最早一批用 Set 的场景。当时设计了两种键:

  • article:{id}:tags:存一篇文章的所有标签;
  • tag:{name}:articles:存某个标签下的所有文章 ID。

打标签就是两个方向同时 SADD:

> SADD article:1001:tags redis nosql (integer) 2 > SADD tag:redis article:1001 article:1002 (integer) 2

查询时,文章详情页用 SMEMBERS 或 SSCAN 拉文章标签;筛选"同时含多标签的文章"用 SINTER。这套设计数据模型非常清晰,而且查询全部走 Redis,比在数据库里搞标签多对多关联查询快了几个数量级。

6.2 抽奖活动:SPOP 消耗名额,SCARD 看剩余

线上抽奖是 Set 很典型的场景。活动开始时,把参与用户 ID 全部 SADD 进一个奖品池 Set;开奖时用 SPOP 随机取出中奖用户,并且要保证同一个用户不会被抽中第二次——这正是 SPOP 的原子语义带来的好处。

> SADD lottery:2024:pool user:1001 user:1002 ... user:10000 (integer) 10000 > SPOP lottery:2024:pool "user:3789" > SCARD lottery:2024:pool (integer) 9999

想要"展示幸运用户预览但又不消耗名额"就用 SRANDMEMBER。两个命令配合,一套抽奖系统后端逻辑不超过 10 行。比传统"选个随机数然后去数据库更新状态"的方案简单,而且天然防重复、原子操作,并发安全。

6.3 社交关系链:共同好友、单向关注的实现

社交类项目是集合运算发挥最大价值的领域。我做过一个模拟的社区产品,关注关系用下面几个 Set 维护:

  • user:{id}:following:我关注的人;
  • user:{id}:fans:关注我的人。

共同好友,一条 SINTER:

> SINTER user:1001:following user:1002:following

单向关注(我关注了对方,对方没回关):

> SDIFF user:1001:following user:1001:fans

"可能认识的人"推荐,可以先 SUNION 出我所有好友关注的人,再利用 SDIFF 减去我已经关注过的,剩下的就是候选推荐列表。这种运算放在服务端做,逻辑简洁,性能也可控。

6.4 点赞与收藏去重:SISMEMBER 判断、SCARD 计数

点赞和收藏是几乎所有内容类产品都有的功能。用 Set 实现时,一个 key 存所有点赞用户 ID:

> SADD article:1024:liked user:1001 (integer) 1 > SISMEMBER article:1024:liked user:1001 (integer) 1 > SADD article:1024:liked user:1001 (integer) 0 > SCARD article:1024:liked (integer) 1

用户点一次赞,SADD 返回 1;再点一次想做取消,SADD 返回 0,说明之前已经点过。判断"当前用户是否点过赞"用 SISMEMBER;统计点赞总数用 SCARD。全程不用维护任何计数器,Set 本身就是事实来源。

如果要支持"取消点赞",SREM 把成员移除就行,SCARD 会自动减一。

7. 实战里容易翻车的地方和个人建议

7.1 大 Set 上的遍历与运算都可能阻塞 Redis

Redis 单线程执行命令,这个特性决定了任何 O(N) 或更复杂的命令都有可能成为性能瓶颈。SMEMBERS 返回全部成员、SINTER 对几十万成员的集合做交集运算,都会在主线程上持续占用比较长的时间。我见过一次事故:有个管理后台定时任务用 SMEMBERS 拉一个 80 万成员的 Set 做全量导出,Redis 主线程卡了几秒,线上接口集体超时。

避免办法分几层:能不用全量就不用全量,能预计算就预计算,非实时场景迁到从节点执行。

7.2 高并发下集合运算的结果缓存与预计算

如果某个 SINTER 结果会被反复查询,比如首页上的"热门标签组合"接口,每次都实时计算是不划算的。我的建议是写定时任务,每 5 分钟用 SINTERSTORE 算一次结果并写入缓存 key,查询接口直接读这个 key。或者在写入侧就维护结果集,虽然写的时候多一次操作,但读的延迟和开销大幅降低。

记住一个原则:集合运算是滞后且昂贵的,适合低频更新、高频读取的场合;高频更新、高频读取的场合,考虑在数据写入时就顺便维护好结果。

7.3 集合键的设计规范与成员过期问题

Set 的 key 命名建议统一用英文冒号分隔,比如module:object:property。我常用的模式是article:1001:tags、user:1001:following、lottery:2024:pool。这样在redis-cli里用 keys 命令排查时,一眼能看出属于哪个业务。

还有一个容易被忽略的问题:Set 的过期时间只能作用在整个 key 上,不能对单个成员设置 TTL。如果你的业务需要"成员一定时间后自动失效"(比如临时黑名单、限时活动参与名单),用 Set 本身就有点别扭。这时候要么在业务侧做定时清理,定期用 SPOP 或 SSCAN + SREM 把超时成员清掉;要么换一个带 score 的 ZSet,把过期时间戳放在 score 里,用 ZRANGEBYSCORE 做范围删除。选型要提前想清楚,别等到线上数据越来越脏了再来补。

7.4 我需要调整 set-max-intset-entries 吗

默认 512 是官方经过大量内存和性能测试后选出来的值。对大多数业务来说不需要动。如果你确认某个 Set 只存整数成员且数量会稳定在一个区间,比如从不超过 1000,可以把它调大一点,让这个 Set 在 1000 个成员以内都保持 intset 紧凑编码:

> CONFIG SET set-max-intset-entries 1024

调大之后的内存收益是显性的,但代价是 intset 的查找复杂度是 O(log N),在同一个 Set 上反复 SISMEMBER 的频率很高时,性能会略低于哈希表。另外要注意,这个配置只影响调整之后新发生的编码切换,已经升级成 hashtable 的键不会自动退回去。所以调优前最好先用 OBJECT ENCODING 确认现有键的编码状态,再决定要不要在低峰期重建这些键。

做完这一轮 Set 的梳理,我最大的体会是:Set 的学习曲线其实很平缓,四个基础命令加三个集合运算命令就覆盖了九成需求,但真正拉开差距的是"什么场景该用集合运算"这个意识。我建议你在设计数据模型时,先问一句"这里要不要做唯一性约束、要不要做集合间的比对",如果答案是肯定的,那 Set 大概率是你该选的类型。下次再遇到"两个列表找共同项"这种需求,记得先想想 SINTER。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询