Redis 从入门到精通:数据结构Set 与 Sorted

IT策士 10余年一线大厂经验,专注 IT 思维、架构、职场进阶。我会在各个平台持续发布最新文章,助你少走弯路。

在 Redis 的基础数据结构家族中,我们已经认识了万能钥匙 String、对象容器 Hash 和顺序双雄 List。但当我们面临"去重"、"共同好友"、"排行榜"、"延时任务"这类场景时,前面的结构要么力不从心,要么实现起来非常绕弯。

今天,我们收官基础数据结构篇,请出最后两位大将:Set(集合)Sorted Set(有序集合)。一个专治唯一性与集合运算,一个擅长排序和范围查询。掌握了它们,你就集齐了 Redis 中最核心的五张王牌。

第一部分:Set------唯一性与集合运算

1. Set 是什么?

Set 是 String 类型的无序、不重复集合。它的底层实现是哈希表(或 intset),所以添加、删除、查找元素的时间复杂度都是 O(1)。

当你需要存储"唯一的标签"、"点赞用户集合"、"参与活动的用户 ID",并且还想求交集、并集、差集时,Set 是最佳选择。

2. Set 核心命令速览

进入 redis-cli 练手:

bash 复制代码
# 添加元素
127.0.0.1:6379> SADD tags:post1 redis python distributed
(integer) 3
127.0.0.1:6379> SADD tags:post1 python   # 重复添加无效
(integer) 0

# 查看所有元素(无序)
127.0.0.1:6379> SMEMBERS tags:post1
1) "python"
2) "distributed"
3) "redis"

# 判断元素是否在集合中
127.0.0.1:6379> SISMEMBER tags:post1 python
(integer) 1
127.0.0.1:6379> SISMEMBER tags:post1 java
(integer) 0

# 获取集合大小
127.0.0.1:6379> SCARD tags:post1
(integer) 3

# 随机获取元素(不删除)
127.0.0.1:6379> SRANDMEMBER tags:post1 2
1) "redis"
2) "distributed"

# 随机弹出元素(会删除)
127.0.0.1:6379> SPOP tags:post1
"python"

# 删除指定元素
127.0.0.1:6379> SREM tags:post1 distributed
(integer) 1

3. 集合运算:交集、并集、差集

这才是 Set 真正的杀手锏。

我们先准备两组标签:

bash 复制代码
127.0.0.1:6379> SADD article:1 tag:A tag:B tag:C
(integer) 3
127.0.0.1:6379> SADD article:2 tag:B tag:C tag:D
(integer) 3

共同标签(交集)

bash 复制代码
127.0.0.1:6379> SINTER article:1 article:2
1) "tag:B"
2) "tag:C"

所有标签(并集)

bash 复制代码
127.0.0.1:6379> SUNION article:1 article:2
1) "tag:A"
2) "tag:B"
3) "tag:C"
4) "tag:D"

特有标签(差集)

bash 复制代码
127.0.0.1:6379> SDIFF article:1 article:2    # article:1 有而 article:2 没有的
1) "tag:A"
127.0.0.1:6379> SDIFF article:2 article:1    # 反过来
1) "tag:D"

可以搭配 *STORE 命令将结果存入新集合,避免返回大结果集造成客户端压力:

bash 复制代码
127.0.0.1:6379> SINTERSTORE common_tags article:1 article:2
(integer) 2
127.0.0.1:6379> SMEMBERS common_tags
1) "tag:B"
2) "tag:C"

4. Python 实战 Set------好友推荐系统

模拟一个社交平台:用户关注其他用户,用 Set 存储粉丝或关注列表,再通过差集、交集实现"共同关注"和"推荐关注"。

bash 复制代码
import redis

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

# 关注操作
def follow(r, user, target):
    r.sadd(f'user:{user}:followings', target)
    r.sadd(f'user:{target}:followers', user)

# 取消关注
def unfollow(r, user, target):
    r.srem(f'user:{user}:followings', target)
    r.srem(f'user:{target}:followers', user)

# 获取关注列表
def get_followings(r, user):
    return r.smembers(f'user:{user}:followings')

# 获取粉丝列表
def get_followers(r, user):
    return r.smembers(f'user:{user}:followers')

# 共同关注
def common_followings(r, user1, user2):
    return r.sinter(f'user:{user1}:followings', f'user:{user2}:followings')

# 推荐关注:user1 的二度好友(我关注的人的关注列表,去掉我已关注的)
def recommend(r, user):
    my_followings = f'user:{user}:followings'
    # 获取所有被关注者的关注列表的交集,用 SUNION 合并?更好的方法:遍历每个被关注者的关注列表,用 SUNIONSTORE 合并,再 SDIFF
    # 为简化,这里演示一个轻量版:推荐 user 可能认识的人
    # 实现:我关注的每一个人,他们关注的人,去掉我已经关注的
    following_list = r.smembers(my_followings)
    candidates = set()
    for following in following_list:
        their_followings = r.smembers(f'user:{following}:followings')
        candidates.update(their_followings)
    # 排除自己和已关注的
    candidates.discard(user)
    already_follow = set(r.smembers(my_followings))
    return candidates - already_follow

# ----- 构造测试数据 -----
follow(r, 'Alice', 'Bob')
follow(r, 'Alice', 'Charlie')
follow(r, 'Bob', 'Charlie')
follow(r, 'Bob', 'David')
follow(r, 'Charlie', 'David')

print("Alice 关注:", get_followings(r, 'Alice'))
print("Bob 关注:", get_followings(r, 'Bob'))
print("Alice 与 Bob 共同关注:", common_followings(r, 'Alice', 'Bob'))
print("推荐给 Alice 的用户:", recommend(r, 'Alice'))

输出示例:

bash 复制代码
Alice 关注: {'Charlie', 'Bob'}
Bob 关注: {'Charlie', 'David'}
Alice 与 Bob 共同关注: {'Charlie'}
推荐给 Alice 的用户: {'David'}

集合运算还能实现标签系统、IP 黑白名单、活动抽奖(SRANDMEMBER/SPOP)、微博点赞用户去重等功能。

5. Set 内部编码

Set 有两种内部编码:

bash 复制代码
127.0.0.1:6379> SADD numbers 1 2 3
(integer) 3
127.0.0.1:6379> OBJECT ENCODING numbers
"intset"
127.0.0.1:6379> SADD numbers "hello"
(integer) 1
127.0.0.1:6379> OBJECT ENCODING numbers
"hashtable"

第二部分:Sorted Set------有序的集合

1. Sorted Set 是什么?

Sorted Set(简写 ZSet)和 Set 一样,元素是唯一的 String 成员,但每个成员都关联一个 double 类型的 score(分数),Redis 会根据 score 从小到大自动排序。score 可以重复,成员必须唯一。

当业务需要排序、范围查询、取 Top N 时,ZSet 就是天选之子:排行榜、滑动窗口限流、延时队列、带权重的标签等。

2. Sorted Set 核心命令速览

bash 复制代码
# 添加成员及其分数
127.0.0.1:6379> ZADD leaderboard 100 "Alice" 95 "Bob" 80 "Charlie" 78 "David"
(integer) 4

# 查看成员分数
127.0.0.1:6379> ZSCORE leaderboard "Alice"
"100"

# 按排名范围查询(默认升序)
127.0.0.1:6379> ZRANGE leaderboard 0 -1 WITHSCORES
1) "David"
2) "78"
3) "Charlie"
4) "80"
5) "Bob"
6) "95"
7) "Alice"
8) "100"

# 降序查询(从高到低)
127.0.0.1:6379> ZREVRANGE leaderboard 0 2 WITHSCORES
1) "Alice"
2) "100"
3) "Bob"
4) "95"
5) "Charlie"
6) "80"

# 按分数范围查询
127.0.0.1:6379> ZRANGEBYSCORE leaderboard 80 95 WITHSCORES
1) "Charlie"
2) "80"
3) "Bob"
4) "95"

# 增加成员分数(原子操作)
127.0.0.1:6379> ZINCRBY leaderboard 5 "Charlie"
"85"

# 查询成员排名(从 0 开始)
127.0.0.1:6379> ZRANK leaderboard "Charlie"
(integer) 1
127.0.0.1:6379> ZREVRANK leaderboard "Charlie"  # 降序排名
(integer) 2

# 删除成员
127.0.0.1:6379> ZREM leaderboard "David"
(integer) 1

# 获取集合大小
127.0.0.1:6379> ZCARD leaderboard
(integer) 3

# 按分数范围删除
127.0.0.1:6379> ZREMRANGEBYSCORE leaderboard 0 80
(integer) 1

3. 跳表原理浅析

你可能好奇:Redis 怎么做到既保持排序,又高效插入、删除、范围查询?答案是 跳表(skiplist)

简单理解:跳表是一种多层链表。最底层(Level 0)是一个有序的普通链表;往上每一层都是下层的"快速通道",节点数量逐层减半。

bash 复制代码
Level 2:  HEAD -----> Node50 ----------> Node100
Level 1:  HEAD -----> Node50 -> Node80 -> Node100
Level 0:  HEAD -> Node30 -> Node50 -> Node80 -> Node100 -> NULL
  • 查找时从最上层开始,用类似二分的方式快速逼近目标。

  • 插入和删除只需调整指针,性能稳定。

  • 平均时间复杂度 O(log N),与平衡树相当,但实现更简单。

Redis 的 ZSet 内部使用「字典(dict)+ 跳表(skiplist)」组合:字典保证按成员查找 O(1),跳表保证按分数范围查询 O(log N)。二者共存储一份数据,通过指针共享成员和分数,不会造成空间浪费。

💡 为什么不用红黑树?跳表实现简单,支持范围查找,顺序操作方便,且 Redis 作者认为跳表足够好。

4. Python 实战场景一:实时排行榜

实现一个游戏积分排行榜,支持更新分数、查询 Top N 和某个玩家排名。

bash 复制代码
import redis

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

class Leaderboard:
    def __init__(self, name):
        self.key = f'leaderboard:{name}'

    def update_score(self, player, score):
        """更新或新增玩家分数"""
        r.zadd(self.key, {player: score})

    def add_score(self, player, increment):
        """增加玩家分数,返回新分数"""
        return r.zincrby(self.key, increment, player)

    def get_top(self, n, with_scores=True):
        """获取前 N 名"""
        items = r.zrevrange(self.key, 0, n-1, withscores=with_scores)
        return items

    def get_rank(self, player):
        """获取玩家当前排名(0-based,从高到低)"""
        rank = r.zrevrank(self.key, player)
        return rank + 1 if rank is not None else None

    def get_score(self, player):
        return r.zscore(self.key, player)

# 测试
lb = Leaderboard('game:season1')

# 初始化分数
scores = {'Alice': 100, 'Bob': 95, 'Charlie': 80, 'David': 78}
for name, s in scores.items():
    lb.update_score(name, s)

print("Top 3 玩家:")
for rank, (player, score) in enumerate(lb.get_top(3), start=1):
    print(f"  {rank}. {player} - {int(score)} 分")

# Charlie 加 20 分
new_score = lb.add_score('Charlie', 20)
print(f"Charlie 新分数: {int(new_score)}")
print(f"Charlie 当前排名: {lb.get_rank('Charlie')}")

print("最新 Top 3:")
for rank, (player, score) in enumerate(lb.get_top(3), start=1):
    print(f"  {rank}. {player} - {int(score)} 分")

输出示例:

bash 复制代码
Top 3 玩家:
  1. Alice - 100 分
  2. Bob - 95 分
  3. Charlie - 80 分
Charlie 新分数: 100
Charlie 当前排名: 1
最新 Top 3:
  1. Alice - 100 分
  2. Charlie - 100 分
  3. Bob - 95 分

注意:score 相同时,Redis 按成员字符串的字典序排序。如需精确控制同分排序,可把时间戳等信息编码进 score。

5. Python 实战场景二:延时队列

前面我们学了 List 做队列,但无法延迟执行。ZSet 可以用任务执行时间戳作为 score,实现延时队列

原理:生产者将任务和期望执行时间 ZADD 入队,消费者定时用 ZRANGEBYSCORE 拉取到达执行时间的任务,处理完删除。为保证任务不丢,拉取和处理需要原子化,可以使用 Lua 脚本或者简单的 ZPOPMIN(Redis 5.0+)。

使用 ZPOPMIN(Redis 5.0+):原子地弹出 score 最小的成员。

bash 复制代码
import redis
import time
import json

class DelayQueue:
    def __init__(self, name):
        self.key = f'delay_queue:{name}'

    def add_task(self, task_data, delay_seconds):
        """delay_seconds 秒后执行"""
        score = time.time() + delay_seconds
        r.zadd(self.key, {json.dumps(task_data): score})

    def consume(self, timeout=0):
        """阻塞消费,最多等待 timeout 秒,0 表示一直等"""
        while True:
            # 原子弹出最早到期的一个任务(ZPOPMIN 返回 [(member, score), ...])
            items = r.zpopmin(self.key, 1)
            if not items:
                if timeout == 0:
                    time.sleep(0.5)
                    continue
                return None
            member, task_time = items[0]
            current_time = time.time()
            if task_time > current_time:
                # 任务尚未到执行时间,放回去
                r.zadd(self.key, {member: task_time})
                time.sleep(0.1)
                continue
            return json.loads(member)

# 测试
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
dq = DelayQueue('order_timeout')

# 添加一个 2 秒后执行的任务
print("添加任务,2 秒后执行...")
dq.add_task({'order_id': 'A001', 'action': 'timeout_close'}, delay_seconds=2)

print("开始消费...")
task = dq.consume(timeout=5)
if task:
    print(f"处理任务: {task}")
else:
    print("超时未获取到任务")

输出示例:

bash 复制代码
添加任务,2 秒后执行...
开始消费...
处理任务: {'order_id': 'A001', 'action': 'timeout_close'}

如果 Redis 版本低于 5.0,可以用 Lua 脚本模拟 ZPOPMIN,保证原子性:

bash 复制代码
# 兼容旧版本的原子弹出脚本
def zpopmin_compat(r, key):
    script = """
    local members = redis.call('ZRANGEBYSCORE', KEYS[1], 0, ARGV[1], 'LIMIT', 0, 1)
    if next(members) ~= nil then
        redis.call('ZREM', KEYS[1], members[1])
        return members[1]
    end
    return nil
    """
    return r.eval(script, 1, key, time.time())

延时队列在订单超时取消、定时推送等场景非常实用。

6. Sorted Set 内部编码

ZSet 也有两种内部编码:

bash 复制代码
127.0.0.1:6379> ZADD small 1 "a" 2 "b"
(integer) 2
127.0.0.1:6379> OBJECT ENCODING small
"ziplist"

实际业务中,如果排行榜数据量大,会自然转为 skiplist,无需手动干预。

7. 常见误区与最佳实践

  • Set 不是全能的过滤层 :如果元素数量极大(千万级),慎用 SMEMBERS 全量拉取,易阻塞 Redis。使用 SSCAN 分批迭代。

  • 集合运算尽量用 *STORESINTERSUNION 等返回结果集时,如果结果很大,会消耗大量带宽和客户端内存。优先用 SINTERSTORE 在服务端完成计算。

  • ZSet 的 score 精度:score 是 double 类型,比较时可能出现浮点精度问题。若 score 用于精确业务,可用整数(如毫秒时间戳)避免。

  • 延时队列的并发消费:生产环境建议用 Lua 脚本原子化拉取多个任务,或利用 Redis Stream 等更可靠的队列机制。

  • 避免大 ZSet 的删除操作ZREMRANGEBYRANKZREMRANGEBYSCORE 在数据量大时可能阻塞。建议在低峰期操作,或分批次渐进删除。

8. 动手试试

在你的 Redis 环境中完成以下挑战:

  1. 共同好友 :给三个用户分别关注一些其他用户,用 SINTER 找出任意两人之间的共同关注,用 SDIFF 实现"可能认识的人"推荐。

  2. 实时排行榜 :模拟 50 个玩家的分数随机变化,循环使用 ZINCRBY 更新,每隔一段时间用 ZREVRANGE 打印 Top 10。

  3. 延时队列:用 ZSet 实现一个"5 秒后发送提醒"的延时队列,生产者添加 3 个任务,消费者在 10 秒内依次消费。

预期效果:共同好友正确输出交集;排行榜实时刷新;延时任务在设定时间点被准确消费。

9. 总结

本篇我们终结了 Redis 基础数据结构的探索:

  • Set:无序唯一集合,O(1) 查找,强大集合运算,成就了标签、好友关系、抽奖等场景。

  • Sorted Set:有序集合,基于跳表 + 字典,支持按 score 排序和范围查询,是排行榜和延时队列的利器。

  • 两者的内部编码(intset/hashtable, ziplist/skiplist+dict)体现了 Redis 极致的内存与性能权衡。

至此,你已经手握 String、Hash、List、Set、Sorted Set 五大核心数据结构,足以应对 80% 以上的缓存和业务场景。从下一篇开始,我们将转向 Redis 的"高级数据结构",用位图、HyperLogLog 和 GEO 解决更特殊的统计与位置问题。

想了解更多还可以去各个平台搜索「IT策士」,一起升级 IT 思维 !

相关推荐
2401_873479401 小时前
如何用IP离线库检测DNS隧道和C2通信?企业DNS安全防护指南
网络·数据库·tcp/ip·安全·ip
填满你的记忆1 小时前
10万QPS下,Redis缓存如何避免雪崩?
数据库·redis·缓存
IT界的老黄牛2 小时前
MongoDB 主从切换排查实战:从 docker ps 到 jq,一套 SOP 定位死因
数据库·mongodb·docker
睡不醒男孩0308232 小时前
第四篇:数据库国产化与信创替代的守护者:基于CLup的异构数据库一站式运维平台构建
运维·数据库·金融·clup·中启乘数
Lumistory2 小时前
2026年城市照明工程4大核心痛点及解决方案
大数据·数据库
小欣加油2 小时前
leetcode121买卖股票的最佳时机
数据结构·c++·算法·leetcode·职场和发展
岳麓丹枫0012 小时前
PG数据库无法接受连接问题分析定位
数据库·postgresql
IT策士2 小时前
Redis 从入门到精通:数据结构String 与键管理
数据结构·redis·wpf