1. 项目背景

1.1 业务场景

电商 App 准备上线"兴趣社区"模块,产品需求文档列出了四个社交功能:

  1. 关注与取关:用户可以关注感兴趣的达人、品牌账号和其他用户。
  2. 共同关注:在陌生人主页展示"你和 Ta 共同关注了谁"——这是社交产品提高互关率的经典设计。
  3. 好友推荐:系统根据用户的关注关系推荐"你可能认识的人"——本质上是"朋友的朋友"。
  4. 黑名单过滤:用户拉黑不想看到的人,被拉黑者的内容(评论、私信、动态)都应被屏蔽。

后端同学的第一版方案是用 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 适用与不适用场景

适用场景:

  1. 社交关系——关注/粉丝、好友、黑名单、屏蔽列表。
  2. 用户标签——兴趣标签、技能标签、身份标签,反向索引找用户。
  3. 权限集合——角色拥有的权限 ID 集合,SISMEMBER 做权限校验。
  4. 活动参与去重——参与抽奖的用户集合,SADD 天然去重。
  5. 共同关系展示——共同关注、共同群聊、共同参与的活动。
  6. 抽奖/随机选取——SRANDMEMBER/SPOP 做随机取样。

不适用场景:

  1. 需要按时间排序的关注列表——应配合 ZSet,score 放关注时间戳。
  2. 上千万成员的大集合频繁做全量运算——应改用离线计算或分治。
  3. 需要复杂成员属性的——Set 只能存 ID,属性需要额外查 Hash 或数据库。
  4. 图路径查询(如"六度人脉")——需要图数据库。

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 思考题

  1. 设计题:一个社交产品需要实现"你可能认识的人"功能。用户 A 和用户 B 通过多条路径产生关联(共同关注、共同群聊、共同参与活动、手机通讯录匹配)。请设计 Redis 数据结构方案,把这些不同维度的关联度量化成一个推荐分数,并说明如何做 TopN 推荐。

  2. 排查题:线上用户反馈"明明取关了某人,但他的动态还是出现在我的时间线里"。排查发现 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的网络实战圣经

posted on 2026-10-31 11:42  一天不进步,就是退步  阅读(4)  评论(0)    收藏  举报