阶段一 · 基础命令与数据结构

五大数据结构(下)

Set / Sorted Set —— 集合运算与排行榜的利器

Redis 学习笔记 · 第 03 篇 · 大纲 · 前置:第 02 篇

1. Set(集合)

Set 是一个无序、不重复的字符串集合。底层在元素数量少且都是整数时使用 intset 编码,否则使用 hashtable。

Set 就是数学里的"集合"概念——放进去的元素自动去重,没有顺序之分,支持交集、并集、差集运算。类比现实:一个签到簿,同一个人签多少次名字只出现一次。

1.1 基础操作

SADD

SADD key member [member ...]

向集合添加一个或多个元素。已存在的元素自动忽略。返回实际新增的数量。

SREM

SREM key member [member ...]

从集合中移除一个或多个元素。返回实际移除的数量。

SISMEMBER / SMISMEMBER

SISMEMBER key member
SMISMEMBER key member [member ...]

SISMEMBER 判断元素是否在集合中(返回 0 或 1)。SMISMEMBER(Redis 6.2+)批量判断多个元素。

# 用户标签系统
SADD user:1001:tags "golang" "redis" "docker" "k8s"
→ (integer) 4

# 重复添加无效
SADD user:1001:tags "redis" "mysql"
→ (integer) 1    ← 只有 mysql 是新增的

SISMEMBER user:1001:tags "redis"    → (integer) 1
SISMEMBER user:1001:tags "java"     → (integer) 0

SREM user:1001:tags "docker"        → (integer) 1

SMEMBERS / SCARD

SMEMBERS key
SCARD key

SMEMBERS 返回集合的所有元素(无序)。SCARD 返回集合的元素数量(O(1))。

SMEMBERS user:1001:tags
1) "golang"
2) "redis"
3) "k8s"
4) "mysql"

SCARD user:1001:tags    → (integer) 4

SMEMBERS 的性能注意

与 HGETALL 类似,当集合很大时 SMEMBERS 会一次返回全部元素。大集合应使用 SSCAN 渐进式遍历。

1.2 集合运算

Set 最强大的能力是集合间运算——交集、并集、差集,且都是原子操作。

SINTER / SUNION / SDIFF

SINTER key [key ...]
SUNION key [key ...]
SDIFF key [key ...]

SINTER 交集(共同拥有的);SUNION 并集(合在一起的);SDIFF 差集(第一个有但其他没有的)。

# 两个用户的技术栈标签
SADD user:1001:tags "golang" "redis" "docker" "k8s"
SADD user:1002:tags "java" "redis" "mysql" "k8s"

# 交集:共同技能
SINTER user:1001:tags user:1002:tags
1) "redis"
2) "k8s"

# 并集:所有技能汇总
SUNION user:1001:tags user:1002:tags
1) "golang"
2) "redis"
3) "docker"
4) "k8s"
5) "java"
6) "mysql"

# 差集:1001 有但 1002 没有的
SDIFF user:1001:tags user:1002:tags
1) "golang"
2) "docker"
用韦恩图(Venn Diagram)理解:两个圆重叠的部分是 SINTER,两个圆合起来是 SUNION,左圆减去重叠部分是 SDIFF。

SINTERSTORE / SUNIONSTORE / SDIFFSTORE

SINTERSTORE destination key [key ...]
SUNIONSTORE destination key [key ...]
SDIFFSTORE destination key [key ...]

同上,但把结果存储到 destination 而非直接返回。适合结果集较大或需要后续复用的场景。

1.3 随机操作

SRANDMEMBER

SRANDMEMBER key [count]

随机返回集合中的元素但不移除。count > 0:返回不重复的 count 个;count < 0:返回可能重复的 |count| 个。

SPOP

SPOP key [count]

随机弹出(返回并移除)一个或多个元素。抽奖场景的核心命令。

# 抽奖池
SADD lottery "user_A" "user_B" "user_C" "user_D" "user_E"

# 预览候选人(不移除)
SRANDMEMBER lottery 3
1) "user_C"
2) "user_A"
3) "user_E"

# 抽出中奖者(移除,不可重复中奖)
SPOP lottery 2
1) "user_B"
2) "user_D"

# 剩余参与者
SMEMBERS lottery
1) "user_A"
2) "user_C"
3) "user_E"

1.4 典型应用场景

场景实现方式关键命令
标签系统 每个实体一个 Set,元素为标签 SADD / SREM / SMEMBERS
共同好友/兴趣 两个用户的好友 Set 求交集 SINTER
去重 UV 统计、已推荐内容等 SADD + SISMEMBER
抽奖 参与者加入 Set,SPOP 抽取 SPOP / SRANDMEMBER
黑/白名单 IP 或用户 ID 存入 Set,SISMEMBER 校验 SISMEMBER(O(1) 判断)
可能认识的人 A 的好友 SDIFF B 的好友 → 推荐给 B SDIFF

2. Sorted Set(有序集合)

Sorted Set(简称 ZSet)是 Set 的升级版——每个元素关联一个分值(Score),集合内的元素按 score 从小到大自动排序,且元素仍然唯一(但不同元素可以有相同 score)。

如果 Set 是一袋弹珠(每颗唯一但没顺序),那 Sorted Set 就是一个成绩排行榜——每位选手(member)有一个分数(score),按分数自动排位。你可以瞬间回答"前 10 名是谁"、"某人排第几"、"60~90 分之间有多少人"。

底层实现

Sorted Set 在元素少(<128 个且值短)时使用 ziplist/listpack 编码;元素多时使用 skiplist + hashtable 双重结构——skiplist 支持范围查询 O(logN),hashtable 支持 O(1) 按 member 查 score。两者协作让 ZSet 既能排序又能精确查找。

2.1 基础操作

ZADD

ZADD key [NX|XX] [GT|LT] [CH] score member [score member ...]

添加元素及其分值。member 已存在则更新 score。可选标志:NX 仅新增;XX 仅更新;GT 仅当新 score > 旧 score 时更新;LT 反之;CH 返回变化(新增+更新)数量而非仅新增数量。

ZSCORE / ZRANK / ZREVRANK

ZSCORE key member
ZRANK key member
ZREVRANK key member

ZSCORE 获取分值。ZRANK 获取正序排名(0-based,分值最小 = 排名 0)。ZREVRANK 获取逆序排名(分值最大 = 排名 0)。

# 游戏排行榜
ZADD leaderboard 2500 "player_A" 3200 "player_B" 1800 "player_C" 4100 "player_D"
→ (integer) 4

ZSCORE leaderboard "player_B"      → "3200"
ZRANK leaderboard "player_B"       → (integer) 2     ← 正序第3(0-based)
ZREVRANK leaderboard "player_B"    → (integer) 1     ← 逆序第2(D第1、B第2)

ZINCRBY

ZINCRBY key increment member

对指定 member 的 score 做原子增减。member 不存在时从 0 开始。排行榜加分的核心命令。

# player_A 得了 500 分
ZINCRBY leaderboard 500 "player_A"    → "3000"

# 查看当前排名变化
ZREVRANK leaderboard "player_A"       → (integer) 2    ← 从第4升到第3

ZREM / ZCARD

ZREM key member [member ...]
ZCARD key

ZREM 移除成员。ZCARD 返回集合总数(O(1))。

2.2 范围查询

Sorted Set 的杀手锏——按排名或按分值范围高效查询。

按排名范围

ZRANGE / ZREVRANGE

ZRANGE key start stop [BYSCORE | BYLEX] [REV] [LIMIT offset count] [WITHSCORES]
ZREVRANGE key start stop [WITHSCORES]

按排名(下标)返回元素。Redis 6.2+ 的 ZRANGE 统一了所有范围查询能力(通过 BYSCORE/BYLEX/REV 标志)。加 WITHSCORES 同时返回分值。

# Top 3(分值最高的前3名)
ZREVRANGE leaderboard 0 2 WITHSCORES
1) "player_D"
2) "4100"
3) "player_B"
4) "3200"
5) "player_A"
6) "3000"

# 所有人(正序:分值从低到高)
ZRANGE leaderboard 0 -1 WITHSCORES
1) "player_C"
2) "1800"
3) "player_A"
4) "3000"
5) "player_B"
6) "3200"
7) "player_D"
8) "4100"

按分值范围

ZRANGEBYSCORE / ZREVRANGEBYSCORE

ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT offset count]

返回 score 在 [min, max] 范围内的元素。支持 -inf / +inf 表示无穷。前缀 ( 表示开区间。Redis 6.2+ 可用 ZRANGE key min max BYSCORE 替代。

# 分值在 2000~3500 之间的玩家
ZRANGEBYSCORE leaderboard 2000 3500 WITHSCORES
1) "player_A"
2) "3000"
3) "player_B"
4) "3200"

# 开区间:大于 3000(不含3000)
ZRANGEBYSCORE leaderboard (3000 +inf WITHSCORES
1) "player_B"
2) "3200"
3) "player_D"
4) "4100"

# 配合 LIMIT 分页:跳过前1个,取2个
ZRANGEBYSCORE leaderboard -inf +inf LIMIT 1 2
1) "player_A"
2) "player_B"

ZCOUNT

ZCOUNT key min max

统计 score 在 [min, max] 范围内的元素数量。O(logN)。

ZREMRANGEBYRANK / ZREMRANGEBYSCORE

ZREMRANGEBYRANK key start stop
ZREMRANGEBYSCORE key min max

按排名/分值范围批量删除。常用于维护固定容量的排行榜。

# 只保留 Top 100,删除排名 100 以后的
ZREMRANGEBYRANK leaderboard 0 -101    ← 删除正序排名 0 到倒数第101

# 删除 7 天前的数据(用时间戳做 score)
ZREMRANGEBYSCORE events 0 1717200000

2.3 字典序查询(同分值场景)

当所有元素的 score 相同时,Sorted Set 退化为一个按字典序排列的有序集合。此时可以用 LEX 系列命令做范围查询。

ZRANGEBYLEX

ZRANGEBYLEX key min max [LIMIT offset count]

按字典序范围查询。min/max 语法:[value 表示闭区间,(value 表示开区间,- 表示最小,+ 表示最大。

# 所有 score 设为 0,实现有序字典
ZADD cities 0 "beijing" 0 "chengdu" 0 "guangzhou" 0 "hangzhou" 0 "shanghai"

# 查找 b~h 之间(含首字母)
ZRANGEBYLEX cities "[b" "(i"
1) "beijing"
2) "chengdu"
3) "guangzhou"
4) "hangzhou"

# 自动补全:前缀匹配 "ch"
ZRANGEBYLEX cities "[ch" "[ch\xff"
1) "chengdu"

应用:自动补全

将所有候选词以 score=0 存入 ZSet,用 ZRANGEBYLEX 做前缀范围查询。比模糊匹配更高效,且天然有序。搜索框自动补全、词典查找都可以这样实现。

2.4 集合运算

ZUNIONSTORE / ZINTERSTORE

ZUNIONSTORE dest numkeys key [key ...] [WEIGHTS w1 w2 ...] [AGGREGATE SUM|MIN|MAX]
ZINTERSTORE dest numkeys key [key ...] [WEIGHTS ...] [AGGREGATE ...]

对多个 ZSet 求并集/交集,结果存入 dest。WEIGHTS 对不同 key 的 score 加权;AGGREGATE 决定相同 member 的 score 如何合并(默认 SUM)。

# 多维度评分合并
ZADD score:quality 80 "article_A" 90 "article_B" 70 "article_C"
ZADD score:popularity 1000 "article_A" 500 "article_B" 2000 "article_C"

# 加权综合评分:质量权重 0.6,人气权重 0.4
ZUNIONSTORE score:final 2 score:quality score:popularity WEIGHTS 0.6 0.4
→ (integer) 3

ZREVRANGE score:final 0 -1 WITHSCORES
1) "article_C"
2) "842"        ← 70*0.6 + 2000*0.4 = 42 + 800
3) "article_A"
4) "448"        ← 80*0.6 + 1000*0.4 = 48 + 400
5) "article_B"
6) "254"        ← 90*0.6 + 500*0.4 = 54 + 200
ZUNIONSTORE + WEIGHTS 就像一个评委打分系统——多个评委(不同 ZSet)给同一组选手打分,你可以指定每个评委的权重,最终合成综合分排名。

2.5 典型应用场景

场景score 代表什么关键命令
排行榜 分数、点赞数、销量 ZINCRBY + ZREVRANGE
延迟队列 执行时间戳 ZADD + ZRANGEBYSCORE 轮询
滑动窗口限流 请求时间戳 ZADD + ZREMRANGEBYSCORE + ZCARD
Timeline/Feed 发布时间戳 ZADD + ZREVRANGEBYSCORE
优先级队列 优先级数值 ZADD + ZPOPMIN
自动补全 全设为 0(利用字典序) ZRANGEBYLEX
多维度排序 加权综合分 ZUNIONSTORE + WEIGHTS

延迟队列示例

# 生产者:添加延迟任务(score = 执行时间戳)
ZADD delay_queue 1717200060 "send_email:user_42"
ZADD delay_queue 1717200120 "notify:order_1001"

# 消费者:每秒轮询当前时刻到期的任务
ZRANGEBYSCORE delay_queue 0 1717200065    ← 当前时间戳
1) "send_email:user_42"                   ← 这条已到期

# 取出后删除(原子操作用 ZPOPMIN 或 Lua 脚本)
ZPOPMIN delay_queue
1) "send_email:user_42"
2) "1717200060"

滑动窗口限流示例

# 每个请求记录时间戳(member 用唯一ID避免重复)
ZADD rate:user_123 1717200001 "req_uuid_1"
ZADD rate:user_123 1717200002 "req_uuid_2"
...

# 清除窗口外的旧记录(保留最近 60 秒)
ZREMRANGEBYSCORE rate:user_123 0 1717199940

# 统计窗口内的请求数
ZCARD rate:user_123    → 当前窗口内请求数

# 若超过阈值则拒绝
# if ZCARD > 100 → reject

滑动窗口 vs 固定窗口

第 02 篇的 String INCR + EXPIRE 实现的是固定窗口限流(整分钟/整秒重置)。Sorted Set 实现的是滑动窗口——任意连续 60 秒内不超过 N 次,更精确但内存消耗更大(每个请求都要记录)。两者各有适用场景。

3. Set vs Sorted Set 对比

维度SetSorted Set
有序性无序按 score 有序
元素唯一性✅(member 唯一,score 可重复)
查询复杂度SISMEMBER O(1)ZSCORE O(1),ZRANK O(logN)
范围查询❌ 不支持✅ 按排名/分值/字典序
集合运算SINTER/SUNION/SDIFFZINTERSTORE/ZUNIONSTORE(支持加权)
随机操作SRANDMEMBER/SPOPZRANDMEMBER(Redis 6.2+)
内存消耗较小较大(额外存 score + skiplist 索引)
典型场景去重、标签、黑名单排行榜、延迟队列、限流

选择原则

问自己一个问题:"需要排序或范围查询吗?" 需要 → Sorted Set;不需要 → Set 更轻量。别为了"以防万一"选 ZSet——额外的 score 和 skiplist 结构意味着更多内存开销。

快速回顾

类型核心命令记忆要点
Set SADD / SREM 增删元素,自动去重
SISMEMBER O(1) 判断是否存在
SINTER / SUNION / SDIFF 交并差集运算
SPOP / SRANDMEMBER 随机弹出/查看(抽奖)
Sorted Set ZADD / ZINCRBY 添加/原子加分
ZSCORE / ZRANK 查分值/查排名
ZRANGE / ZREVRANGE 按排名范围取元素
ZRANGEBYSCORE 按分值范围取元素
ZUNIONSTORE 多集合加权合并

动手练习

  1. Set 标签:为 user:Auser:B 各建一个标签 Set(至少 4 个标签,有部分重叠),然后用 SINTER 找出共同标签,用 SDIFF 找出 A 有但 B 没有的。
  2. Set 抽奖:向 lottery 集合加入 10 个用户,用 SPOP 抽出 3 个中奖者,验证他们不在剩余集合中。
  3. ZSet 排行榜:创建一个游戏排行榜(至少 5 个玩家),然后:
    • 用 ZREVRANGE 取 Top 3
    • 用 ZINCRBY 给某个玩家加分,观察排名变化
    • 用 ZRANGEBYSCORE 查询 2000~4000 分的玩家
    • 用 ZRANK 查某个玩家的排名
  4. ZSet 延迟队列:用当前时间戳 + N 秒作为 score 添加 3 个任务,然后用 ZRANGEBYSCORE 模拟"取出到期任务"。
  5. ZSet 加权排序:创建两个评分维度(如 quality 和 popularity),用 ZUNIONSTORE + WEIGHTS 合成综合分,对比不同权重下排名的变化。
  6. (思考题)微博的"共同关注"功能用什么数据结构实现最合适?如果要显示"你关注的人中有 N 人也关注了 TA",命令链路是怎样的?