1. 项目背景
1.1 业务场景
电商 App 准备上线"兴趣社区"模块,产品需求文档列出了四个社交功能:
- 关注与取关:用户可以关注感兴趣的达人、品牌账号和其他用户。
- 共同关注:在陌生人主页展示"你和 Ta 共同关注了谁"——这是社交产品提高互关率的经典设计。
- 好友推荐:系统根据用户的关注关系推荐"你可能认识的人"——本质上是"朋友的朋友"。
- 黑名单过滤:用户拉黑不想看到的人,被拉黑者的内容(评论、私信、动态)都应被屏蔽。
后端同学的第一版方案是用 MySQL 实现。关注关系用 user_follow 表(from_uid, to_uid, created_at),标签用 user_tag 表(uid, tag)。查询共同关注需要先查出用户 A 的关注列表,再查出用户 B 的关注列表,然后在应用层做交集——如果两人各关注了 1000 个账号,每次请求就要从数据库拉 2000 行数据,应用层再循环比一次。
大促活动期间,这个问题被放大了。某个达人直播间涌入 50 万观众,其中数万人同时点关注、取消关注。数据库的 user_follow 表写入 QPS 涨到 8000,查询共同关注的接口 P99 延迟从 20ms 飙升到 800ms。DBA 紧急加了索引和读写分离,但问题没有根治——因为关系运算的本质是集合运算,数据库的"行"模型表达集合运算是借力的。
1.2 痛点
| 操作 | MySQL 实现 | 问题 |
|---|---|---|
| 共同关注 | SELECT to_uid FROM user_follow WHERE from_uid=A + 应用层循环 |
两人各关注 1000+ 时,拉了 2000 行数据,应用层 O(N*M) 比对 |
| 好友推荐 | 取 A 的关注 → 取这些人的关注 → 去重 → 排除已关注和黑名单 | SQL 变得极度复杂,多表 JOIN + 子查询,执行计划不稳定 |
| 黑名单过滤 | 内容查询时 NOT IN (SELECT to_uid FROM blacklist WHERE from_uid=?) |
热点用户的评论流查询频繁触发子查询 |
| 标签匹配 | SELECT * FROM user_tag WHERE tag IN (?) |
多标签求交集同样需要多次查询+应用层聚合 |
Redis Set 恰好就是为解决"集合关系"而生的数据结构。它不关心顺序,只关心"谁在集合里",提供了一组天然的命令来做交集(共同关注)、并集(好友推荐候选)、差集(排除已关注)、存在判断(是否已拉黑)。而且所有运算都在 Redis 服务端完成,不会把大量数据拉到应用层。
1.3 本章目标
掌握 Redis Set 的五种实战模式:
- 关注/粉丝模型:
SADD+SREM双写正反索引。 - 共同关注:
SINTER/SINTERCARD做实时交集。 - 好友推荐:
SUNION+SDIFF做候选集生成。 - 黑名单过滤:
SISMEMBER做 O(1) 存在检查。 - 标签系统:Set 表达用户标签 +
SINTER匹配共同兴趣。
同时理解三个关键边界:
- 正反索引(followings + followers)的维护成本和一致性风险。
- 大 Set 的集合运算代价——什么时候该用
SINTERCARD而非SINTER。 SMEMBERS的危险——为什么大 Set 必须用SSCAN。
2. 项目设计
2.1 第一幕:关注关系不只是加一个集合
小胖:"关注不就一个操作嘛?我点关注大师,就往我的关注列表里加一个'大师'。SADD followings:小胖 大师,完事!"
小白:"那大师的粉丝列表呢?如果大师想看谁关注了他,你难道要遍历所有用户的关注列表来找?100 万用户每人一个关注列表,你打算扫 100 万个 Set?"
大师点头:"这叫'有正向索引,没有反向索引'。数据库里一个表可以建两个索引,Redis 里你要自己维护双向关系。我画一下:"
正向索引(我关注了谁):
followings:1001 → {2001, 2002, 2003}
→ 用户 1001 打开自己的关注列表 → O(1) 查 followings:1001
反向索引(谁关注了我):
followers:2001 → {1001, 1002, 1005}
→ 用户 2001 打开自己的粉丝列表 → O(1) 查 followers:2001
关注动作需要双写:
关注: SADD followings:1001 2001
SADD followers:2001 1001
取关: SREM followings:1001 2001
SREM followers:2001 1001
小胖:"那双写如果一条成功、一条失败怎么办?比如 SADD followings:1001 2001 成功了,但 Redis 这时候挂了,SADD followers:2001 1001 没执行——那岂不是正反索引不一致了?"
小白:"对,这就是 Redis 单条命令原子、多条命令不原子的经典场景。两条 SADD 之间没有事务保证。"
大师:"当前阶段,我们先接受'允许少量不一致+定时对账修复'的代价。因为关注关系不是交易数据——一个人多一个粉丝少一个粉丝,用户几乎感知不到。等学到 Lua 脚本(第 13 章)和事务(第 14 章),我们可以用脚本把两条 SADD 绑在一起。但即使那样,极端情况下(脚本执行到一半 Redis 崩溃 + 没有 AOF),仍然可能出现不一致。"
技术映射:分布式系统中,写入多个 key 的原子性需要额外机制保证。在可接受的业务损失范围内,"双写+定时对账"是常见模式。
2.2 第二幕:共同关注的实现选择
小胖:"共同关注就是两个人的关注列表取交集对吧?SINTER followings:A followings:B,一下就出来了。"
小白:"那如果我只想知道'他俩有没有共同关注'——不需要具体是谁——也要用 SINTER 把所有共同关注者都取出来吗?两个人各关注了 5000 人,SINTER 返回 300 个结果。但如果我只想知道'共同关注数 > 3',不应该为了这个判断就传输 300 个 ID。"
大师:"问得好。这就是 SINTERCARD 存在的意义——它只返回交集的基数(数量),不返回具体成员。"
# 需要具体名单 → SINTER
SINTER followings:1001 followings:1002
# 返回: [2002, 2003, 2005, ...]
# 只需要数量 → SINTERCARD(Redis 7.0+)
SINTERCARD 2 followings:1001 followings:1002
# 返回: (integer) 23 ← 只有数量,不传输成员列表
# 可以加 LIMIT 限制最大计算量
SINTERCARD 2 followings:1001 followings:1002 LIMIT 100
# 最多计算到 100 就停止
大师:"注意一个细节——SINTERCARD 的数字参数 2 表示后面跟的 key 数量。它的性能比 SINTER 好得多,因为它不需要构建完整的结果集、不需要把数据传回客户端。如果你的 UI 逻辑是'共同关注数 > 0 才展示模块',用 SINTERCARD ... LIMIT 1 就够了——找到第一个交集成员就停。"
技术映射:SINTER 返回集合(O(N*M) 最坏情况),SINTERCARD 返回基数(性能更优,可提前终止)。需求是"有哪些"用前者,需求是"有几个"用后者。
2.3 第三幕:好友推荐不是一道简单的题
小胖:"推荐好友不就是'我关注的人也关注的人'吗?听起来就是先取我关注的人,再取他们关注的人,去掉我已经关注的。"
大师:"思路对,但实现上要小心。我画一下完整流程:"
推荐好友候选流程:
┌─────────────────────────────────────────────────────┐
│ Step 1: 取我关注的用户 │
│ 我(1001)关注的: {2001, 2002, 2003} │
│ │
│ Step 2: 取他们关注的用户(并集) │
│ 2001关注: {3001, 3002, 2002} │
│ 2002关注: {3002, 3003, 3004} │
│ 2003关注: {3001, 3004, 3005} │
│ SUNION → {3001, 3002, 3003, 3004, 3005, 2002} │
│ │
│ Step 3: 排除已经关注的 + 黑名单 + 自己 │
│ SDIFF candidate followings:1001 blacklist:1001 │
│ → 最终推荐候选: {3001, 3002, 3003, 3004, 3005} │
│ │
│ Step 4(可选): 排序按"共同关注数" │
│ 对每个候选,计算 SINTERCARD 和用户关注列表的交集 │
│ → 交集越大,推荐优先级越高 │
└─────────────────────────────────────────────────────┘
小白:"这个流程看起来没问题,但如果我关注的人里有大 V——他关注了 5000 个人,那 SUNION 就需要合并多个大集合,会不会拖慢 Redis?"
大师:"两个优化点。第一,你可以限制参与计算的'二度好友'数量——只取二度好友中出现次数最多的前 N 个。第二,推荐不需要实时计算——可以写成离线任务,每 30 分钟算一次,结果存到 recommend:{uid} 的一个临时 Set 里,前端直接读这个 Set。"
技术映射:Set 的 SUNION/SDIFF 适合中小规模实时运算,大规模推荐需要离线预计算。Redis 是工具,不是推荐引擎。
2.4 第四幕:大 Set 的暗面——SMEMBERS 和集合运算
小胖:"我用 SMEMBERS 看集合里都有谁,方便得很。为什么文档说生产慎用?"
大师:"因为 SMEMBERS 的复杂度是 O(N),而且会把整个集合的元素全部返回给客户端。如果一个达人有 100 万粉丝,SMEMBERS followers:达人ID 会——第一,阻塞 Redis 主线程扫描整个集合;第二,通过网络传输 100 万个 ID;第三,客户端接收 100 万个 ID 并分配内存。这三件事都可能导致严重问题。"
SMEMBERS 的风险:
集合大小 返回数据量 网络耗时 主线程阻塞
100 个 ≈ 3 KB < 1ms < 0.1ms
10,000 个 ≈ 300 KB ≈ 5ms ≈ 1ms
100,000 个 ≈ 3 MB ≈ 50ms ≈ 10ms
1,000,000 个 ≈ 30 MB ≈ 500ms ≈ 100ms ← 危险!
SSCAN 的安全替代:
SSCAN followers:1001 0 COUNT 100 → 每次只返回约 100 个
→ 分多次迭代,不阻塞主线程,客户端分批接收
技术映射:SMEMBERS 是"全量倾倒",SSCAN 是"分批浇水"。生产环境对任何可能超过 1000 成员的 Set 都应使用 SSCAN。
3. 项目实战
3.1 环境准备
docker exec -it redis-lab redis-cli -a redis123
3.2 实战一:关注与取关(双向索引)
步骤 1:初始化关注关系
# 用户 1001 关注了 2001、2002、2003
127.0.0.1:6379> SADD followings:1001 2001 2002 2003
(integer) 3
# 同时维护反向索引(粉丝列表)
127.0.0.1:6379> SADD followers:2001 1001
(integer) 1
127.0.0.1:6379> SADD followers:2002 1001
(integer) 1
127.0.0.1:6379> SADD followers:2003 1001
(integer) 1
# 用户 1002 关注了 2002、2003、2004
127.0.0.1:6379> SADD followings:1002 2002 2003 2004
(integer) 3
127.0.0.1:6379> SADD followers:2002 1002
(integer) 1
127.0.0.1:6379> SADD followers:2003 1002
(integer) 1
127.0.0.1:6379> SADD followers:2004 1002
(integer) 1
步骤 2:查询关系
# 查看 1001 关注了谁
127.0.0.1:6379> SMEMBERS followings:1001
1) "2001"
2) "2002"
3) "2003"
# 查看 2002 的粉丝
127.0.0.1:6379> SMEMBERS followers:2002
1) "1001"
2) "1002"
# 判断 1001 是否关注了 2002
127.0.0.1:6379> SISMEMBER followings:1001 2002
(integer) 1
# 统计关注数和粉丝数
127.0.0.1:6379> SCARD followings:1001
(integer) 3
127.0.0.1:6379> SCARD followers:2002
(integer) 2
步骤 3:取关(双删)
# 1001 取关 2003
127.0.0.1:6379> SREM followings:1001 2003
(integer) 1
127.0.0.1:6379> SREM followers:2003 1001
(integer) 1
# 验证
127.0.0.1:6379> SISMEMBER followings:1001 2003
(integer) 0
127.0.0.1:6379> SISMEMBER followers:2003 1001
(integer) 0
步骤 4:Java 关注服务
public class FollowService {
public void follow(String fromUid, String toUid) {
if (fromUid.equals(toUid)) {
throw new BizException("不能关注自己");
}
// 检查是否在黑名单中
Boolean blocked = redis.sIsMember("blacklist:" + fromUid, toUid);
if (Boolean.TRUE.equals(blocked)) {
throw new BizException("已将该用户拉黑,无法关注");
}
// 双写正反索引
redis.sAdd("followings:" + fromUid, toUid);
redis.sAdd("followers:" + toUid, fromUid);
}
public void unfollow(String fromUid, String toUid) {
redis.sRem("followings:" + fromUid, toUid);
redis.sRem("followers:" + toUid, fromUid);
}
public Set<String> getFollowings(String uid) {
// 注意:生产环境如果集合可能很大,用 SSCAN 替代
return redis.sMembers("followings:" + uid);
}
public Set<String> getFollowers(String uid) {
return redis.sMembers("followers:" + uid);
}
public boolean isFollowing(String fromUid, String toUid) {
return Boolean.TRUE.equals(redis.sIsMember("followings:" + fromUid, toUid));
}
public long getFollowingsCount(String uid) {
Long count = redis.sCard("followings:" + uid);
return count == null ? 0 : count;
}
public long getFollowersCount(String uid) {
Long count = redis.sCard("followers:" + uid);
return count == null ? 0 : count;
}
}
3.3 实战二:共同关注
# 1001 关注了 2001, 2002, 2003
# 1002 关注了 2002, 2003, 2004
# 共同关注 = 2002, 2003
# 方式一:SINTER 返回具体成员
127.0.0.1:6379> SINTER followings:1001 followings:1002
1) "2002"
2) "2003"
# 方式二:SINTERCARD 只返回数量(Redis 7.0+)
127.0.0.1:6379> SINTERCARD 2 followings:1001 followings:1002
(integer) 2
# 方式三:SINTERCARD LIMIT —— 只判断"有没有"
127.0.0.1:6379> SINTERCARD 2 followings:1001 followings:1002 LIMIT 1
(integer) 1 # 至少有 1 个共同关注
# 方式四:缓存共同关注结果(热门用户避免重复计算)
127.0.0.1:6379> SINTERSTORE common:1001:1002 followings:1001 followings:1002
(integer) 2
127.0.0.1:6379> EXPIRE common:1001:1002 60
(integer) 1
127.0.0.1:6379> SMEMBERS common:1001:1002
1) "2002"
2) "2003"
Java 实现:
public class CommonFollowService {
// 场景一:需要展示共同关注列表(如 20 个以内)
public Set<String> getCommonFollowings(String uid1, String uid2) {
String cacheKey = buildCommonKey(uid1, uid2); // 小uid:大uid 保证唯一
// 先查缓存
Set<String> cached = redis.sMembers(cacheKey);
if (cached != null && !cached.isEmpty()) {
return cached;
}
// 实时计算 + 缓存 60 秒
redis.sInterStore(cacheKey, "followings:" + uid1, "followings:" + uid2);
redis.expire(cacheKey, Duration.ofSeconds(60));
return redis.sMembers(cacheKey);
}
// 场景二:只需要共同关注数量
public long getCommonCount(String uid1, String uid2) {
Long count = redis.sInterCard("followings:" + uid1, "followings:" + uid2);
return count == null ? 0 : count;
}
// 场景三:只需判断"有没有共同关注"(性能最优)
public boolean hasCommonFollow(String uid1, String uid2) {
Long count = redis.sInterCard(1, "followings:" + uid1, "followings:" + uid2); // LIMIT 1
return count != null && count > 0;
}
private String buildCommonKey(String uid1, String uid2) {
return "common:" + (Long.parseLong(uid1) < Long.parseLong(uid2) ? uid1 + ":" + uid2 : uid2 + ":" + uid1);
}
}
注意:缓存共同关注的 key 设计要保证方向一致——common:1001:1002 和 common:1002:1001 是同一个业务问题,把较小的 uid 放在前面避免重复缓存。
3.4 实战三:好友推荐候选
步骤 1:搭建推荐数据
# 1001 的关注: 2001, 2002
# 2001 的关注: 3001, 3002, 3003
# 2002 的关注: 3002, 3003, 3004
127.0.0.1:6379> SADD followings:2001 3001 3002 3003
(integer) 3
127.0.0.1:6379> SADD followings:2002 3002 3003 3004
(integer) 3
# 候选集 = 2001关注 ∪ 2002关注
127.0.0.1:6379> SUNION followings:2001 followings:2002
1) "3001"
2) "3002"
3) "3003"
4) "3004"
# 排除已关注(1001的关注)
127.0.0.1:6379> SUNIONSTORE recommend:candidate:1001 followings:2001 followings:2002
(integer) 4
127.0.0.1:6379> SDIFF recommend:candidate:1001 followings:1001
1) "3001"
2) "3002"
3) "3003"
4) "3004"
# → 这四个都是 1001 未关注的二度好友
# 加上黑名单过滤
127.0.0.1:6379> SADD blacklist:1001 3004
(integer) 1
127.0.0.1:6379> SDIFF recommend:candidate:1001 followings:1001 blacklist:1001
1) "3001"
2) "3002"
3) "3003"
# → 3004 被黑名单过滤掉了
# 清理临时 key
127.0.0.1:6379> EXPIRE recommend:candidate:1001 3600
(integer) 1
步骤 2:按共同关注数排序(进阶)
public class FriendRecommendService {
public List<RecommendCandidate> recommend(String uid, int limit) {
// 1. 取我关注的用户
Set<String> myFollowings = redis.sMembers("followings:" + uid);
// 2. 取这些用户的关注列表,做并集
String[] keys = myFollowings.stream()
.map(id -> "followings:" + id)
.toArray(String[]::new);
String tempKey = "recommend:temp:" + uid;
redis.sUnionStore(tempKey, keys);
redis.expire(tempKey, Duration.ofMinutes(10));
// 3. 排除已关注、黑名单和自己
String resultKey = "recommend:result:" + uid;
redis.sDiffStore(resultKey, tempKey,
"followings:" + uid,
"blacklist:" + uid);
redis.expire(resultKey, Duration.ofMinutes(10));
// 4. 计算每个候选的共同关注数并排序
Set<String> candidates = redis.sMembers(resultKey);
List<RecommendCandidate> ranked = new ArrayList<>();
for (String candidateId : candidates) {
long commonCount = redis.sInterCard(
"followings:" + uid,
"followings:" + candidateId);
if (commonCount > 0) {
ranked.add(new RecommendCandidate(candidateId, commonCount));
}
}
// 5. 按共同关注数降序排列,返回 Top N
ranked.sort((a, b) -> Long.compare(b.commonCount, a.commonCount));
return ranked.subList(0, Math.min(limit, ranked.size()));
}
}
3.5 实战四:用户标签系统
# 用户打标签
127.0.0.1:6379> SADD tags:1001 "redis" "java" "backend" "game"
(integer) 4
127.0.0.1:6379> SADD tags:1002 "redis" "python" "ops" "movie"
(integer) 4
127.0.0.1:6379> SADD tags:1003 "redis" "java" "backend" "movie"
(integer) 4
# 查看用户标签
127.0.0.1:6379> SMEMBERS tags:1001
1) "redis"
2) "java"
3) "backend"
4) "game"
# 共同标签(1001 和 1002 都有的标签)
127.0.0.1:6379> SINTER tags:1001 tags:1002
1) "redis"
# 基于标签找用户(反向索引:标签 → 用户集合)
127.0.0.1:6379> SADD tag:redis:users 1001 1002 1003
(integer) 3
127.0.0.1:6379> SADD tag:java:users 1001 1003
(integer) 2
127.0.0.1:6379> SADD tag:backend:users 1001 1003
(integer) 2
# 同时有 java 和 backend 标签的用户
127.0.0.1:6379> SINTER tag:java:users tag:backend:users
1) "1001"
2) "1003"
标签系统的正反索引设计:
public class TagService {
// 为用户添加标签(双写)
public void addTag(String uid, String tag) {
redis.sAdd("tags:" + uid, tag); // 正向:用户有哪些标签
redis.sAdd("tag:" + tag + ":users", uid); // 反向:标签有哪些用户
}
// 查找拥有所有指定标签的用户(交集)
public Set<String> findUsersByAllTags(String... tags) {
String[] keys = Arrays.stream(tags)
.map(tag -> "tag:" + tag + ":users")
.toArray(String[]::new);
return redis.sInter(keys);
}
// 查找拥有任一标签的用户(并集)
public Set<String> findUsersByAnyTag(String... tags) {
String[] keys = Arrays.stream(tags)
.map(tag -> "tag:" + tag + ":users")
.toArray(String[]::new);
return redis.sUnion(keys);
}
}
3.6 实战五:黑名单过滤
# 1001 拉黑 2004 和 2005
127.0.0.1:6379> SADD blacklist:1001 2004 2005
(integer) 2
# 检查 2004 是否被 1001 拉黑
127.0.0.1:6379> SISMEMBER blacklist:1001 2004
(integer) 1
# 检查 2006 是否被 1001 拉黑
127.0.0.1:6379> SISMEMBER blacklist:1001 2006
(integer) 0
# 查看黑名单
127.0.0.1:6379> SMEMBERS blacklist:1001
1) "2004"
2) "2005"
# 移除黑名单
127.0.0.1:6379> SREM blacklist:1001 2004
(integer) 1
评论流中的黑名单过滤:
public class ContentFilterService {
// 批量过滤评论者:排除黑名单用户
public List<Comment> filterBlacklisted(String viewerUid, List<Comment> comments) {
if (comments.isEmpty()) return comments;
// 一次性检查所有评论者是否在黑名单中
String[] commenterIds = comments.stream()
.map(c -> c.getUserId())
.distinct()
.toArray(String[]::new);
// 使用 Pipeline 批量检查 SISMEMBER(减少网络往返)
List<Object> results = redis.executePipelined((RedisCallback<Object>) conn -> {
for (String commenterId : commenterIds) {
conn.setCommands().sIsMember(
("blacklist:" + viewerUid).getBytes(),
commenterId.getBytes());
}
return null;
});
// 构建黑名单集合
Set<String> blockedUsers = new HashSet<>();
for (int i = 0; i < commenterIds.length; i++) {
if (Boolean.TRUE.equals(results.get(i))) {
blockedUsers.add(commenterIds[i]);
}
}
// 过滤
return comments.stream()
.filter(c -> !blockedUsers.contains(c.getUserId()))
.collect(Collectors.toList());
}
// 更高效的方式:在应用层缓存黑名单 Set,不用每次都访问 Redis
// 但要注意缓存失效——黑名单变更时通过 Pub/Sub 通知刷新
}
3.7 实战六:SSCAN 安全遍历大 Set
# 准备测试数据:写入 1000 个成员
127.0.0.1:6379> DEL bigset:test
(integer) 1
# (略去批量写入脚本,假设已写入 1000 个成员)
# 安全遍历:SSCAN 分批
127.0.0.1:6379> SSCAN bigset:test 0 COUNT 100
1) "512" # 下次迭代的游标
2) 1) "member_1"
2) "member_10"
...
# 继续迭代
127.0.0.1:6379> SSCAN bigset:test 512 COUNT 100
1) "0" # 游标为 0 表示遍历结束
2) ...
Java 安全遍历:
public Set<String> safeGetAll(String key, int maxCount) {
Set<String> result = new LinkedHashSet<>();
String cursor = "0";
do {
ScanResult<String> scanResult = redis.sScan(key, cursor,
ScanOptions.scanOptions().count(100).build());
result.addAll(scanResult.getValues());
// 安全上限:防止无限循环
if (result.size() >= maxCount) {
log.warn("Set {} 成员数超过安全上限 {},已截断", key, maxCount);
break;
}
cursor = scanResult.getCursor();
} while (!"0".equals(cursor));
return result;
}
3.8 实战七:Set 集合运算性能对比
# 准备两个各有 100 个成员的 Set(略去写入脚本)
# SINTER:返回具体成员 → 需要构建完整结果集
127.0.0.1:6379> SINTER set:a set:b
# → O(N*M) 最坏情况,结果集大小决定网络开销
# SINTERCARD:只返回基数 → 无需构建结果集,性能更优
127.0.0.1:6379> SINTERCARD 2 set:a set:b
# → 只需要计数,不需要复制数据
# SINTERSTORE:存到新 key → 适合缓存
127.0.0.1:6379> SINTERSTORE result:ab set:a set:b
127.0.0.1:6379> EXPIRE result:ab 60
# 无对应 STORE 版本的运算,如有需要可用 Lua
# 例如 SINTERCARD 的 STORE 版本不存在,但可以用 SINTERSTORE + SCARD 模拟
127.0.0.1:6379> SINTERSTORE temp:ab set:a set:b
(integer) 15
127.0.0.1:6379> SCARD temp:ab
(integer) 15
4. 项目总结
4.1 核心命令速查
| 命令 | 用途 | 复杂度 | 注意事项 |
|---|---|---|---|
SADD key member |
添加成员 | O(1) | 可一次添加多个;返回新增数 |
SREM key member |
删除成员 | O(1) | 可一次删除多个 |
SISMEMBER key member |
判断是否存在 | O(1) | 返回 1 或 0 |
SCARD key |
获取成员数量 | O(1) | 内部维护计数器 |
SMEMBERS key |
获取所有成员 | O(N) | 大 Set 危险,生产用 SSCAN |
SSCAN key cursor |
分批迭代 | O(1)/次 | 不保证一致性,需设上限 |
SINTER key1 key2... |
交集 | O(N*M) | 返回具体成员 |
SINTERCARD N k1 k2... |
交集基数 | 优于 SINTER | 只返回数量,Redis 7.0+ |
SINTERSTORE dst k1 k2... |
交集存为新集合 | O(N*M) | 适合缓存结果 |
SUNION key1 key2... |
并集 | O(N) | N 为所有集合总成员数 |
SDIFF key1 key2... |
差集 | O(N) | 返回 key1 中有但其他 key 中没有的 |
SPOP key [count] |
随机弹出成员 | O(1) | 适合抽奖、随机取样 |
SRANDMEMBER key [count] |
随机获取(不删除) | O(1) | 适合随机展示 |
4.2 优点与缺点
| 维度 | 详细说明 |
|---|---|
| 优点1:集合运算天然 | SINTER/SUNION/SDIFF 是集合论的一等公民,不需应用层拼 SQL |
| 优点2:存在判断 O(1) | SISMEMBER 比数据库的 SELECT COUNT(*) WHERE... 快 100 倍以上 |
| 优点3:正反索引灵活 | 关注链和粉丝链分离,各自独立查询,互不影响 |
| 优点4:去重自动 | SADD 幂等,重复添加自动忽略,不需要 INSERT IGNORE |
| 优点5:标签反向查询 | tag:redis:users 这种设计让"找有某标签的用户"变成 O(1) |
| 缺点1:双写不一致 | 正反索引的全量一致性需要额外保障(Lua/事务/对账) |
| 缺点2:大集合运算慢 | SUNION 多个大集合可能成为慢命令 |
| 缺点3:不支持排序 | 不能"按关注时间排序",需要配合 ZSet |
| 缺点4:不能部分更新 | 没有"修改集合中某个成员"的操作,只能删了再加 |
4.3 适用与不适用场景
适用场景:
- 社交关系——关注/粉丝、好友、黑名单、屏蔽列表。
- 用户标签——兴趣标签、技能标签、身份标签,反向索引找用户。
- 权限集合——角色拥有的权限 ID 集合,
SISMEMBER做权限校验。 - 活动参与去重——参与抽奖的用户集合,
SADD天然去重。 - 共同关系展示——共同关注、共同群聊、共同参与的活动。
- 抽奖/随机选取——
SRANDMEMBER/SPOP做随机取样。
不适用场景:
- 需要按时间排序的关注列表——应配合 ZSet,score 放关注时间戳。
- 上千万成员的大集合频繁做全量运算——应改用离线计算或分治。
- 需要复杂成员属性的——Set 只能存 ID,属性需要额外查 Hash 或数据库。
- 图路径查询(如"六度人脉")——需要图数据库。
4.4 常见踩坑经验
| 案例 | 表象 | 根因 | 解法 |
|---|---|---|---|
| SMEMBERS 拖慢 Redis | 管理后台加载粉丝列表时页面卡死,Redis CPU 100% | 某个达人 50 万粉丝,后台页面直接调 SMEMBERS |
前端分页 + 后端用 SSCAN 分批返回,每页 20 条 |
| 正反索引不一致 | 用户关注数显示 100,但粉丝列表里找不到对应用户 | 关注时先写了 followings,随后 Redis 重启,followers 未写入 | 双写操作用 Lua 脚本包裹;定时对账任务扫描不一致的记录 |
| SINTER 返回大量数据 | 商品详情页共同关注接口超时 | 两个大 V 各关注 2 万人,SINTER 返回 5000 个共同关注 | 服务端只返回前 20 个共同关注;用 SINTERCARD 先判断数量 |
| 好友推荐全量计算 | 推荐任务在白天高峰期执行导致 Redis 延迟抖动 | SUNION 涉及 10 个各 5000 人的集合,在主线程执行 | 拆成多个小批次;避开高峰期;用 SUNIONSTORE 存临时结果 |
| 黑名单过滤低效 | 评论列表接口 P99 延迟偏高 | 每条评论逐个调 SISMEMBER,100 条评论 = 100 次 RTT |
用 Pipeline 批量检查;或在应用层缓存黑名单 Set(黑名单变更少) |
4.5 思考题
-
设计题:一个社交产品需要实现"你可能认识的人"功能。用户 A 和用户 B 通过多条路径产生关联(共同关注、共同群聊、共同参与活动、手机通讯录匹配)。请设计 Redis 数据结构方案,把这些不同维度的关联度量化成一个推荐分数,并说明如何做 TopN 推荐。
-
排查题:线上用户反馈"明明取关了某人,但他的动态还是出现在我的时间线里"。排查发现
followings:uid中没有该用户,但动态仍然被推送。请列出至少 3 种可能的原因以及对应的排查步骤。
(答案提示将在下一章末尾给出。)
上一章思考题答案提示(第5章):
第1题(订单超时取消):不应该用 List。List 不支持按时间排序取出元素。正确方案是用 ZSet——member 存订单 ID,score 存"应关闭时间戳"。消费者每次用
ZRANGEBYSCORE delay:order:close -inf NOW LIMIT 0 50扫描到期订单,ZREM抢占处理权。详见第 7 章。第2题(队列 LLEN 暴涨但消费正常):3 种根因:① 生产者写入速率远大于消费者消费速率(虽然消费者每秒 50 条,但生产者可能每秒 500 条)——检查生产者 QPS;② 多个生产者写入同一个 List,但消费者只有一个——消费者数量不足,需扩容;③ 消费者处理时间越来越长(如短信接口越来越慢),导致有效消费速率下降——检查消费者单条处理耗时趋势。最可能的诊断步骤:先看 LLEN 趋势图(线性增长→生产>消费),再看消费者的处理耗时(是否逐渐变慢),最后看生产者 QPS(是否突增)。
延伸阅读与资源
Redis 8 实战精讲:从 CRUD 到源码,构建高可用缓存系统
Java 工程师进阶:从 JVM 生产排障到OpenJDK原理
Nacos 3.x 注册配置中心实战修炼:从入门到源码扩展
Elasticsearch从入门到进阶的实战之旅
MySQL Server 9从入门到进阶的实战之旅
RabbitMQ从入门到进阶的实战之旅:从单机到大促高可用架构
Celery 入门到进阶之路:从异步任务到自研调度平台
LangGraph 生产级实战进阶:从零到生产级Agent工作流开发
Dify 从入门到源码:LLM 应用平台实战修炼
从零到生产级:FastAPI 异步高并发、源码与 SRE 实战
实战SQLAlchemy 2.0: 从 CRUD 到生产级架构
从零打造企业级 AI 助手:LangChain RAG、Agent 与生产实战
后端工程师 AI 转型课:Ollama 私有化大模型从入门到生产
MongoDB 实战进阶与内核修炼
NumPy 从入门到生产落地:全链路实战指南(科学计算/向量化/性能调优)
Milvus向量数据库实战修炼:从 0 到 1 精通向量检索与生产落地
Python 3实战精进:从脚本到高并发订单引擎
python入门:Rquests从菜鸟脚本到企业级SDK的网络实战圣经

微信公众号: 架构师日常笔记 欢迎关注!
浙公网安备 33010602011771号