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分批迭代。 -
集合运算尽量用
*STORE:SINTER、SUNION等返回结果集时,如果结果很大,会消耗大量带宽和客户端内存。优先用SINTERSTORE在服务端完成计算。 -
ZSet 的 score 精度:score 是 double 类型,比较时可能出现浮点精度问题。若 score 用于精确业务,可用整数(如毫秒时间戳)避免。
-
延时队列的并发消费:生产环境建议用 Lua 脚本原子化拉取多个任务,或利用 Redis Stream 等更可靠的队列机制。
-
避免大 ZSet 的删除操作 :
ZREMRANGEBYRANK或ZREMRANGEBYSCORE在数据量大时可能阻塞。建议在低峰期操作,或分批次渐进删除。
8. 动手试试
在你的 Redis 环境中完成以下挑战:
-
共同好友 :给三个用户分别关注一些其他用户,用
SINTER找出任意两人之间的共同关注,用SDIFF实现"可能认识的人"推荐。 -
实时排行榜 :模拟 50 个玩家的分数随机变化,循环使用
ZINCRBY更新,每隔一段时间用ZREVRANGE打印 Top 10。 -
延时队列:用 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 思维 !