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

五大数据结构(下):Set / Sorted Set

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 dest key [key ...] / SUNIONSTORE dest key [key ...] / SDIFFSTORE dest 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 min max [BYSCORE | BYLEX] [REV] [LIMIT offset count] [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 weight ...] [AGGREGATE SUM|MIN|MAX]

对多个 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 对比

维度 Set Sorted Set
有序性 无序 按 score 有序
元素唯一性 ✅(member 唯一, score 可重复)
查询复杂度 SISMEMBER O(1) ZSCORE O(1), ZRANK O(logN)
范围查询 ❌ 不支持 ✅ 按排名/分值/字典序
集合运算 SINTER/SUNION/SDIFF ZINTERSTORE/ZUNIONSTORE(支持加权)
随机操作 SRANDMEMBER/SPOP ZRANDMEMBER(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", 命令链路是怎样的?