大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
Set 集合:题目标签、共同推荐与抽奖去重
这一课你将能做到
- 熟练对集合做增删、判重、取数量和随机抽取
- 用交集、并集、差集实现「大家都在做的题」和个性化推荐
- 用集合完成抽奖去重,并说清楚 Set 底层为什么又快又省
为什么要学
Set 是无序、元素唯一的字符串集合。你在数学里学过的集合运算——交集、并集、差集——Redis 直接给你做成了原子命令。
本站有几个需求几乎是为 Set 量身定做的:
- 每道题有若干标签(数组、哈希表、动态规划),每个用户也有「已做过的题」集合;
- 推荐页要展示「和你水平相近的人都做了、但你还没做」的题——这是一个差集运算;
- 节日活动抽奖:一个用户只能中奖一次,奖品随机抽出,且要当场知道还剩多少人没抽。
判重 + 集合运算 + 随机抽取,这三件事用关系型数据库做要写一堆 JOIN 和 DISTINCT,用 Set 各是一条命令。
先建立图像
标签集合:
tags:problem:501 = { "array", "hash", "easy" }
tags:problem:502 = { "array", "dp", "hard" }
交集 SINTER → { "array" } 两道题的共同标签
用户做题集合(元素是题目 ID):
solved:user:1001 = { "501", "502", "503" }
solved:user:1002 = { "502", "503", "504" }
差集 SDIFF(1002 做了而 1001 没做)→ { "504" } ← 推荐给 1001
底层方面,Redis 7 的 Set 在元素少且短时用整数集合(intset)或列表编码,元素变多自动转为哈希表,判重和增删都是 O(1),这个转换对使用者透明。
跟着做
步骤 1:增删与查看
127.0.0.1:6379> SADD tags:problem:501 array hash easy
(integer) 3
127.0.0.1:6379> SADD tags:problem:501 array
(integer) 0
第二次添加返回 0——元素已存在,集合自动去重,这是 Set 的核心价值。
127.0.0.1:6379> SMEMBERS tags:problem:501
1) "easy"
2) "hash"
3) "array"
127.0.0.1:6379> SCARD tags:problem:501
(integer) 3
127.0.0.1:6379> SISMEMBER tags:problem:501 dp
(integer) 0
127.0.0.1:6379> SREM tags:problem:501 easy
(integer) 1
注意 SMEMBERS 返回顺序不保证,且集合很大时会一次拉全量,大集合请改用 SSCAN 游标遍历:
127.0.0.1:6379> SSCAN solved:user:1001 0 COUNT 100
1) "0"
2) 1) "501"
2) "502"
步骤 2:用两个集合记录做题情况
127.0.0.1:6379> SADD solved:user:1001 501 502 503
(integer) 3
127.0.0.1:6379> SADD solved:user:1002 502 503 504
(integer) 3
127.0.0.1:6379> SISMEMBER solved:user:1001 504
(integer) 0
前端展示做题进度时,SCARD 给出已做总数,SISMEMBER 决定某道题要不要打勾,全是常数时间。
步骤 3:交集——共同标签与「都做过」
127.0.0.1:6379> SADD tags:problem:502 array dp hard
(integer) 3
127.0.0.1:6379> SINTER tags:problem:501 tags:problem:502
1) "array"
判断两个用户有没有共同做过的题,有交集就推荐「切磋」:
127.0.0.1:6379> SINTER solved:user:1001 solved:user:1002
1) "502"
2) "503"
SINTERCARD 数量 key1 key2 [LIMIT n] 是 Redis 7 的新命令,只返回交集元素个数而不返回成员,推荐场景下更省网络。
步骤 4:差集——推荐你没做过的题
用户 1002 做过而 1001 没做的题:
127.0.0.1:6379> SDIFF solved:user:1002 solved:user:1001
1) "504"
注意差集有方向:SDIFF A B 是「属于 A 但不属于 B」,调换顺序结果完全不同。并集则不关心方向:
127.0.0.1:6379> SUNION solved:user:1001 solved:user:1002
1) "501"
2) "502"
3) "503"
4) "504"
步骤 5:把运算结果存起来
每次请求都现算交集也有成本,可以把结果落到一个新键:
127.0.0.1:6379> SDIFFSTORE recommend:user:1001 solved:user:1002 solved:user:1001
(integer) 1
127.0.0.1:6379> SMEMBERS recommend:user:1001
1) "504"
127.0.0.1:6379> EXPIRE recommend:user:1001 600
(integer) 1
同类命令还有 SINTERSTORE、SUNIONSTORE。推荐结果缓存 10 分钟,过期重算,兼顾实时性和性能。
步骤 6:抽奖——随机抽取且自动去重
活动开始,把参与者加入抽奖池(用户重复报名无效):
127.0.0.1:6379> SADD lottery:2026-national 1001 1002 1003 1004 1005
(integer) 5
抽一个中奖者,并从池中移除(保证不重复中奖):
127.0.0.1:6379> SPOP lottery:2026-national
"1003"
一次抽 3 个三等奖:
127.0.0.1:6379> SPOP lottery:2026-national 3
1) "1001"
2) "1005"
3) "1002"
如果只想「看看」谁可能中奖而不移除(比如模拟开奖预览),用 SRANDMEMBER:
127.0.0.1:6379> SRANDMEMBER lottery:2026-national 2
1) "1004"
2) "1002"
SPOP 与 SRANDMEMBER 的区别就是「抽走」和「偷看」。活动结束后给抽奖键设置过期或直接删除。
讲清楚要点
- 三大特性:无序、唯一、O(1) 判重。凡是回答「有没有」「是不是重复了」的问题,先想 Set。
- 集合运算是服务端原子完成的:
SINTER/SUNION/SDIFF在 Redis 内部计算,避免把数据拉回应用再循环比对,网络和正确性都更好。 - 差集有方向:
SDIFF A B≠SDIFF B A;交集、并集无方向。 - STORE 系列命令会覆盖目标键:
SINTERSTORE dst ...若 dst 已存在,其内容会被整体替换,键名别和业务键撞车。 - 大集合用 SSCAN:SMEMBERS 可能返回百万成员,撑爆客户端内存;SSCAN 分批取,代价是遍历期间数据变动可能出现重复或遗漏,新创建的元素不保证一定被扫到。
- 元素建议短而规整:纯整数元素集合能使用 intset 编码,显著省内存。
你可能会懵的地方
-
Set 里元素有顺序吗?能取「第一个」吗? 没有顺序概念,SMEMBERS 每次返回顺序可能不同。需要顺序请用 List 或 ZSET。
-
SADD 返回 0 是失败了吗? 不是失败,是「没有新元素被加入」——元素本来就存在。它正好可以用来判断用户是不是第一次报名。
-
SPOP 是随机的吗?随机性公平吗? Redis 从内部结构中随机选取,业务层面可视为公平随机。但要注意它不是密码学随机,抽奖够用,安全场景不行。
-
集合运算的键不存在怎么办? 视为空集参与运算。所有键都不存在时,交集为空;并集也为空;
SCARD 不存在的键返回 0。 -
SPOP 一次抽多个,数量比池子里还多会怎样? 有多少返回多少,不会报错,返回完池就空了。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
| 用 List 做点赞用户去重 | 重复点赞产生重复元素 | 用 Set,天然唯一 |
| 把全量集合拉到 Java 里做 retainAll | 网络传输大、应用内存高 | 用 SINTER 服务端计算 |
| 抽奖用 SRANDMEMBER 却不删除 | 同一用户可能反复中奖 | 中奖用 SPOP 移除 |
| 大集合 SMEMBERS 全量取 | 客户端 OOM、响应变慢 | SSCAN 分批遍历 |
| 推荐结果不设过期 | 推荐内容长期不更新且占内存 | STORE 后 EXPIRE |
| 混淆 SDIFF 两个参数方向 | 推荐结果张冠李戴 | 牢记「A 有 B 没有」 |
卡点急救
- SISMEMBER 总返回 0:检查元素内容是否完全一致(
501和" 501"、P501是不同元素),以及键名环境是否一致。 - 集合运算结果为空但肉眼看应该有交集:多半是元素格式不统一(大小写、前后缀、字符串/数字混用),先 SMEMBERS 核对原始数据。
- 抽奖池意外被清空:SPOP 是破坏性操作,开奖前确认程序没有预演性调用;需要预演用 SRANDMEMBER。
面试常问
-
Set 的使用场景?SISMEMBER 为什么快? 标签、去重点赞、共同关系、抽奖、权限集合。底层用哈希表(小集合用紧凑编码)时,成员查找是 O(1)。
-
SINTER、SUNION、SDIFF 分别对应什么业务? 交集:共同标签/共同好友;并集:合并多个圈子做推荐池;差集:我没做过的题、未关注的人,方向是「第一个集合减去其余集合」。
-
SPOP 和 SRANDMEMBER 的区别? SPOP 返回并移除成员,用于不能重复中奖的抽奖;SRANDMEMBER 只返回不移除,可用于预览或允许重复抽样。
自测三问
- 如何用一条命令找出用户 1001 和 1002 都做过的题目?只想知道数量用什么命令?
- 推荐「1002 做过但 1001 没做过」的题,写出命令,并说明参数调换会怎样。
- 抽奖要保证每人最多中一次,应该用 SPOP 还是 SRANDMEMBER?为什么?
小结一句话
Set 用「唯一元素 + 交并差运算 + 随机抽取」三件武器,把去重、共同关系和抽奖这一类问题压缩成几条原子命令。
延伸
- Set 命令文档
- SINTERCARD(Redis 7)
- 下一课:Hash 对象——把用户资料和做题进度按字段精细管理。