引言

前面我们学习了有序的List,今天要学习无序但唯一的Set。

想象一下这些场景:

  • 🏷️ 文章标签:“Java”、“Redis”、“数据库”,每个标签只能添加一次
  • 👥 共同好友:你和张三有哪些共同好友?
  • 🎲 抽奖去重:从1000个用户中随机抽10个中奖者,不能重复
  • 📊 UV统计:今天有多少独立访客?

这些场景的共同特点是:需要去重、需要集合运算。Set正是为此而生。

一、Set的本质

1.1 什么是Set?

Set是一个无序的、不重复的字符串集合

Set: {element1, element2, element3, ...}

特点:
- 无序:元素没有固定顺序
- 唯一:元素不会重复
- 支持集合运算:交集、并集、差集

示例

# 添加元素
127.0.0.1:6379> SADD myset "apple" "banana" "orange"
(integer) 3

# 重复添加无效
127.0.0.1:6379> SADD myset "apple"
(integer) 0  # 0表示未添加(已存在)

# 查看所有元素(无序)
127.0.0.1:6379> SMEMBERS myset
1) "banana"
2) "orange"
3) "apple"
# 顺序可能每次都不同

1.2 Set vs List

特性SetList
有序性无序有序
唯一性元素唯一可重复
查询元素是否存在O(1)O(n)
按索引访问不支持支持
集合运算支持不支持
适用场景去重、标签、关系队列、时间线

1.3 底层实现

Set有两种底层编码:

1. intset(整数集合) - 省内存

条件:

  • 所有元素都是整数
  • 元素数量 < set-max-intset-entries(默认512)

特点:

  • 内存紧凑
  • 有序存储(为了二分查找)
  • 适合小集合

2. hashtable(哈希表) - 高性能

条件:不满足intset条件时

特点:

  • 查询快(O(1))
  • 无序存储
  • 内存占用较大

查看编码

127.0.0.1:6379> SADD nums 1 2 3 4 5
(integer) 5

127.0.0.1:6379> OBJECT ENCODING nums
"intset"  # 全是整数,用intset

127.0.0.1:6379> SADD names "alice" "bob"
(integer) 2

127.0.0.1:6379> OBJECT ENCODING names
"hashtable"  # 字符串,用hashtable

二、Set命令全解析

2.1 基础操作

SADD - 添加元素

# 添加单个元素
127.0.0.1:6379> SADD myset "apple"
(integer) 1

# 添加多个元素
127.0.0.1:6379> SADD myset "banana" "orange" "grape"
(integer) 3

# 重复添加返回0
127.0.0.1:6379> SADD myset "apple"
(integer) 0

SMEMBERS - 获取所有元素

127.0.0.1:6379> SMEMBERS myset
1) "grape"
2) "banana"
3) "orange"
4) "apple"

# ⚠️ 注意:顺序是随机的

⚠️ 警告:SMEMBERS会返回所有元素,大集合慎用(可能阻塞)!

SISMEMBER - 检查元素是否存在

127.0.0.1:6379> SISMEMBER myset "apple"
(integer) 1  # 存在

127.0.0.1:6379> SISMEMBER myset "watermelon"
(integer) 0  # 不存在

时间复杂度:O(1),非常快!

SCARD - 获取元素个数

127.0.0.1:6379> SCARD myset
(integer) 4

SREM - 删除元素

# 删除单个元素
127.0.0.1:6379> SREM myset "apple"
(integer) 1  # 返回删除的个数

# 删除多个元素
127.0.0.1:6379> SREM myset "banana" "grape"
(integer) 2

# 删除不存在的元素返回0
127.0.0.1:6379> SREM myset "watermelon"
(integer) 0

2.2 随机操作

SRANDMEMBER - 随机获取元素(不删除)

127.0.0.1:6379> SADD lottery "user1" "user2" "user3" "user4" "user5"
(integer) 5

# 随机获取1个元素
127.0.0.1:6379> SRANDMEMBER lottery
"user3"

# 随机获取3个元素(不重复)
127.0.0.1:6379> SRANDMEMBER lottery 3
1) "user1"
2) "user4"
3) "user2"

# 负数表示可以重复
127.0.0.1:6379> SRANDMEMBER lottery -3
1) "user3"
2) "user3"  # 可能重复
3) "user1"

SPOP - 随机弹出元素(会删除)

# 随机弹出1个元素
127.0.0.1:6379> SPOP lottery
"user5"

127.0.0.1:6379> SCARD lottery
(integer) 4  # 剩余4个

# 随机弹出2个元素
127.0.0.1:6379> SPOP lottery 2
1) "user2"
2) "user4"

127.0.0.1:6379> SCARD lottery
(integer) 2  # 剩余2个

2.3 集合运算(重要!)

SUNION - 并集(A ∪ B)

127.0.0.1:6379> SADD set1 "a" "b" "c"
(integer) 3

127.0.0.1:6379> SADD set2 "b" "c" "d"
(integer) 3

# 并集:两个集合的所有元素(去重)
127.0.0.1:6379> SUNION set1 set2
1) "a"
2) "b"
3) "c"
4) "d"

# 可以同时求多个集合的并集
127.0.0.1:6379> SUNION set1 set2 set3

SUNIONSTORE - 并集结果存储到新集合

127.0.0.1:6379> SUNIONSTORE result set1 set2
(integer) 4  # 返回结果集合的元素个数

127.0.0.1:6379> SMEMBERS result
1) "a"
2) "b"
3) "c"
4) "d"

SINTER - 交集(A ∩ B)

# 交集:两个集合的共同元素
127.0.0.1:6379> SINTER set1 set2
1) "b"
2) "c"

SINTERSTORE - 交集结果存储

127.0.0.1:6379> SINTERSTORE common set1 set2
(integer) 2

127.0.0.1:6379> SMEMBERS common
1) "b"
2) "c"

SDIFF - 差集(A - B)

# 差集:在set1中但不在set2中的元素
127.0.0.1:6379> SDIFF set1 set2
1) "a"

# 注意:顺序很重要
127.0.0.1:6379> SDIFF set2 set1
1) "d"

SDIFFSTORE - 差集结果存储

127.0.0.1:6379> SDIFFSTORE diff set1 set2
(integer) 1

127.0.0.1:6379> SMEMBERS diff
1) "a"

2.4 迭代操作

SSCAN - 迭代集合(生产环境推荐)

# 类似KEYS,但不会阻塞
127.0.0.1:6379> SSCAN myset 0
1) "0"  # 下次迭代的游标(0表示结束)
2) 1) "orange"
   2) "apple"
   3) "banana"

# 可以指定匹配模式和数量
127.0.0.1:6379> SSCAN myset 0 MATCH "a*" COUNT 10

三、实战场景

场景1:标签系统

文章标签管理

@Service
public class TagService {

    // 给文章添加标签
    public void addTags(Long articleId, String... tags) {
        String key = "article:tags:" + articleId;
        redis.opsForSet().add(key, tags);
    }

    // 获取文章的所有标签
    public Set<String> getTags(Long articleId) {
        String key = "article:tags:" + articleId;
        return redis.opsForSet().members(key);
    }

    // 删除标签
    public void removeTag(Long articleId, String tag) {
        String key = "article:tags:" + articleId;
        redis.opsForSet().remove(key, tag);
    }

    // 检查文章是否有某个标签
    public boolean hasTag(Long articleId, String tag) {
        String key = "article:tags:" + articleId;
        return redis.opsForSet().isMember(key, tag);
    }

    // 获取带有指定标签的所有文章
    public Set<Long> getArticlesByTag(String tag) {
        String key = "tag:articles:" + tag;
        return redis.opsForSet().members(key).stream()
                .map(Long::valueOf)
                .collect(Collectors.toSet());
    }

    // 双向关联:给文章打标签时,同时维护标签→文章的关系
    public void addTagWithIndex(Long articleId, String tag) {
        String articleKey = "article:tags:" + articleId;
        String tagKey = "tag:articles:" + tag;

        // 文章→标签
        redis.opsForSet().add(articleKey, tag);

        // 标签→文章
        redis.opsForSet().add(tagKey, String.valueOf(articleId));
    }

    // 查找相似文章(标签交集)
    public Set<Long> findSimilarArticles(Long articleId) {
        String key = "article:tags:" + articleId;
        Set<String> tags = redis.opsForSet().members(key);

        if (tags.isEmpty()) {
            return Collections.emptySet();
        }

        // 获取每个标签对应的文章
        List<String> tagKeys = tags.stream()
                .map(tag -> "tag:articles:" + tag)
                .collect(Collectors.toList());

        // 求交集(有共同标签的文章)
        Set<String> similar = redis.opsForSet().intersect(tagKeys.get(0),
                                                           tagKeys.subList(1, tagKeys.size()));

        // 排除自己
        if (similar != null) {
            similar.remove(String.valueOf(articleId));
        }

        return similar.stream()
                .map(Long::valueOf)
                .collect(Collectors.toSet());
    }
}

场景2:共同好友

社交关系管理

@Service
public class FriendService {

    // 添加好友
    public void addFriend(Long userId, Long friendId) {
        String key = "user:friends:" + userId;
        redis.opsForSet().add(key, String.valueOf(friendId));
    }

    // 删除好友
    public void removeFriend(Long userId, Long friendId) {
        String key = "user:friends:" + userId;
        redis.opsForSet().remove(key, String.valueOf(friendId));
    }

    // 获取好友列表
    public Set<Long> getFriends(Long userId) {
        String key = "user:friends:" + userId;
        return redis.opsForSet().members(key).stream()
                .map(Long::valueOf)
                .collect(Collectors.toSet());
    }

    // 检查是否是好友
    public boolean isFriend(Long userId, Long friendId) {
        String key = "user:friends:" + userId;
        return redis.opsForSet().isMember(key, String.valueOf(friendId));
    }

    // 查找共同好友(交集)
    public Set<Long> getCommonFriends(Long userId1, Long userId2) {
        String key1 = "user:friends:" + userId1;
        String key2 = "user:friends:" + userId2;

        Set<String> common = redis.opsForSet().intersect(key1, key2);

        if (common == null) {
            return Collections.emptySet();
        }

        return common.stream()
                .map(Long::valueOf)
                .collect(Collectors.toSet());
    }

    // 可能认识的人(好友的好友,但不是自己的好友)
    public Set<Long> getMayKnow(Long userId) {
        String key = "user:friends:" + userId;
        Set<String> friends = redis.opsForSet().members(key);

        Set<Long> mayKnow = new HashSet<>();

        // 遍历每个好友
        for (String friendId : friends) {
            String friendKey = "user:friends:" + friendId;
            Set<String> friendsOfFriend = redis.opsForSet().members(friendKey);

            // 好友的好友
            for (String fof : friendsOfFriend) {
                Long fofId = Long.valueOf(fof);

                // 排除自己和已经是好友的人
                if (!fofId.equals(userId) && !friends.contains(fof)) {
                    mayKnow.add(fofId);
                }
            }
        }

        return mayKnow;
    }

    // 获取好友数量
    public Long getFriendCount(Long userId) {
        String key = "user:friends:" + userId;
        return redis.opsForSet().size(key);
    }
}

场景3:抽奖系统

去重抽奖

@Service
public class LotteryService {

    // 参与抽奖
    public boolean joinLottery(Long userId, String lotteryId) {
        String key = "lottery:participants:" + lotteryId;

        // 检查是否已参与
        if (redis.opsForSet().isMember(key, String.valueOf(userId))) {
            return false;  // 已参与,不能重复
        }

        // 添加到参与者集合
        redis.opsForSet().add(key, String.valueOf(userId));

        // 设置过期时间(活动结束后7天)
        redis.expire(key, 7, TimeUnit.DAYS);

        return true;
    }

    // 获取参与人数
    public Long getParticipantCount(String lotteryId) {
        String key = "lottery:participants:" + lotteryId;
        return redis.opsForSet().size(key);
    }

    // 开奖:随机抽取N个中奖者
    public Set<Long> drawWinners(String lotteryId, int count) {
        String key = "lottery:participants:" + lotteryId;

        // 随机弹出N个元素
        List<String> winners = redis.opsForSet().pop(key, count);

        if (winners == null) {
            return Collections.emptySet();
        }

        return winners.stream()
                .map(Long::valueOf)
                .collect(Collectors.toSet());
    }

    // 开奖(不删除参与者,用于可以多次抽奖)
    public Set<Long> drawWinnersWithoutRemove(String lotteryId, int count) {
        String key = "lottery:participants:" + lotteryId;

        // 随机获取N个元素(不删除)
        List<String> winners = redis.opsForSet().randomMembers(key, count);

        if (winners == null) {
            return Collections.emptySet();
        }

        return winners.stream()
                .map(Long::valueOf)
                .collect(Collectors.toSet());
    }

    // 查询是否中奖
    public boolean isWinner(Long userId, String lotteryId) {
        String key = "lottery:winners:" + lotteryId;
        return redis.opsForSet().isMember(key, String.valueOf(userId));
    }

    // 保存中奖名单
    public void saveWinners(String lotteryId, Set<Long> winners) {
        String key = "lottery:winners:" + lotteryId;

        String[] winnerArray = winners.stream()
                .map(String::valueOf)
                .toArray(String[]::new);

        redis.opsForSet().add(key, winnerArray);

        // 永久保存
        redis.persist(key);
    }
}

场景4:UV统计(独立访客)

网站UV统计

@Service
public class UVStatService {

    // 记录访问
    public void recordVisit(String page, Long userId) {
        String today = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
        String key = "uv:" + page + ":" + today;

        // 添加用户ID(Set自动去重)
        redis.opsForSet().add(key, String.valueOf(userId));

        // 设置过期时间(保留90天)
        redis.expire(key, 90, TimeUnit.DAYS);
    }

    // 获取今日UV
    public Long getTodayUV(String page) {
        String today = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
        String key = "uv:" + page + ":" + today;

        return redis.opsForSet().size(key);
    }

    // 获取指定日期UV
    public Long getUV(String page, LocalDate date) {
        String dateStr = date.format(DateTimeFormatter.BASIC_ISO_DATE);
        String key = "uv:" + page + ":" + dateStr;

        return redis.opsForSet().size(key);
    }

    // 获取最近7天UV(并集)
    public Long getRecentUV(String page, int days) {
        List<String> keys = new ArrayList<>();

        for (int i = 0; i < days; i++) {
            LocalDate date = LocalDate.now().minusDays(i);
            String dateStr = date.format(DateTimeFormatter.BASIC_ISO_DATE);
            keys.add("uv:" + page + ":" + dateStr);
        }

        // 求并集(去重)
        if (keys.isEmpty()) {
            return 0L;
        }

        Set<String> union = redis.opsForSet().union(keys.get(0),
                                                     keys.subList(1, keys.size()));

        return union != null ? (long) union.size() : 0L;
    }

    // 检查用户今天是否访问过
    public boolean hasVisitedToday(String page, Long userId) {
        String today = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
        String key = "uv:" + page + ":" + today;

        return redis.opsForSet().isMember(key, String.valueOf(userId));
    }
}

场景5:黑名单/白名单

IP黑名单

@Service
public class BlacklistService {

    private static final String BLACKLIST_KEY = "ip:blacklist";

    // 添加到黑名单
    public void addToBlacklist(String ip) {
        redis.opsForSet().add(BLACKLIST_KEY, ip);
    }

    // 从黑名单移除
    public void removeFromBlacklist(String ip) {
        redis.opsForSet().remove(BLACKLIST_KEY, ip);
    }

    // 检查是否在黑名单
    public boolean isBlacklisted(String ip) {
        return redis.opsForSet().isMember(BLACKLIST_KEY, ip);
    }

    // 获取黑名单列表
    public Set<String> getBlacklist() {
        return redis.opsForSet().members(BLACKLIST_KEY);
    }

    // 批量添加
    public void batchAdd(List<String> ips) {
        redis.opsForSet().add(BLACKLIST_KEY, ips.toArray(new String[0]));
    }

    // 获取黑名单大小
    public Long getBlacklistSize() {
        return redis.opsForSet().size(BLACKLIST_KEY);
    }
}

四、最佳实践

4.1 避免大集合

// ❌ 不推荐:单个Set存储百万级数据
redis.opsForSet().add("big:set", ...);  // 100万个元素

// ✅ 推荐:按分片存储
String shardKey = "set:shard:" + (id % 100);
redis.opsForSet().add(shardKey, value);

建议

  • 单个Set元素数 < 10000
  • 使用SSCAN而非SMEMBERS遍历大集合
  • 监控集合大小,设置告警

4.2 集合运算优化

// ❌ 不推荐:多次网络请求
Set<String> set1 = redis.opsForSet().members(key1);
Set<String> set2 = redis.opsForSet().members(key2);
set1.retainAll(set2);  // 在应用层求交集

// ✅ 推荐:在Redis服务器求交集
Set<String> result = redis.opsForSet().intersect(key1, key2);

4.3 Set vs Bitmap

UV统计场景选择

方案SetBitmap
内存占用
精确性精确精确
查询是否访问快(O(1))快(O(1))
适用场景UV < 100万UV > 100万
// Set方式(精确,但占内存)
redis.opsForSet().add("uv:page1", userId);

// Bitmap方式(省内存)
redis.opsForValue().setBit("uv:page1", userId, true);

五、总结

核心要点

  1. Set是无序唯一集合:自动去重,查询是否存在O(1)
  2. 支持集合运算:交集、并集、差集,解决复杂关系问题
  3. 随机操作:SRANDMEMBER/SPOP适合抽奖场景
  4. 底层实现:小整数集合用intset,其他用hashtable
  5. 应用场景:标签系统、好友关系、抽奖、UV统计、黑名单

命令速查表

命令作用时间复杂度
SADD添加元素O(1)
SREM删除元素O(1)
SISMEMBER判断存在O(1)
SCARD元素个数O(1)
SMEMBERS所有元素O(n)
SRANDMEMBER随机获取O(1)
SPOP随机弹出O(1)
SUNION并集O(n)
SINTER交集O(n*m)
SDIFF差集O(n)

集合运算图解

Set A: {1, 2, 3, 4}
Set B: {3, 4, 5, 6}

并集 SUNION: {1, 2, 3, 4, 5, 6}
交集 SINTER: {3, 4}
差集 SDIFF A B: {1, 2}
差集 SDIFF B A: {5, 6}

下一步

掌握了Set类型后,下一篇我们将学习ZSet有序集合

  • 分数排序机制
  • 跳表数据结构
  • 排行榜、延迟队列实现

思考题

  1. 为什么Set能O(1)判断元素是否存在,而List需要O(n)?
  2. 如何用Set实现"你可能认识的人"功能?
  3. 集合运算(交并差)的应用场景还有哪些?

下一篇见!