Files
cs-note/hhs/Redis/08-SortedSet精解.md
T
2026-05-27 23:01:37 +08:00

447 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [Redis, 缓存, 数据结构, SortedSet]
create time: 2026-05-15 18:14
---
# Sorted Set 高级玩法
## 概述
> [!TIP] 一句话理解 Sorted Set
> 想象一个**自动按分数排序的排行榜**:你往里扔一个"成员 + 分数"的组合,Redis 帮你从小到大排好。你随时可以问"第 N 名是谁""分数在 80~100 之间的有哪些""某个成员排第几"——全部 O(log N) 搞定。
>
> 这就是 Sorted Set 的本质:**每个成员都有一个分数(score),Redis 自动按分数排序,且成员不重复**。
Sorted Set(有序集合)是 Redis 中**功能最丰富、场景最广泛**的数据结构。底层采用双编码策略:小集合(默认 ≤128 个元素,Redis 7.0+ 使用 listpack 替代 ziplist)用连续内存紧凑存储;超过阈值后自动切换为 **skiplist + hashtable** 组合,天然支持按分数排序和 O(log N) 的范围查询。
```mermaid
graph LR
A["Sorted Set"] --> B["score 排序"]
A --> C["member 唯一"]
A --> D["O(log N) 范围查询"]
B --> E["排行榜"]
B --> F["延迟队列"]
C --> G["去重计数"]
D --> H["Top-N 聚合"]
```
本节聚焦实战中常用的高级模式——你会发现,很多看似不同的业务需求,本质上都是「**带分数的排行榜**」。
## 排行榜系统
### 基础实现
```bash
# 添加用户分数
ZADD leaderboard 3500 "user:101"
ZADD leaderboard 4200 "user:102"
ZADD leaderboard 3800 "user:103"
# 获取 Top 5
ZREVRANGE leaderboard 0 4 WITHSCORES
# 返回:user:102(4200), user:103(3800), user:101(3500)
# 查询排名(从大到小)
ZREVRANK leaderboard "user:101" # 2
# 获取指定分数范围的用户
ZRANGEBYSCORE leaderboard 3000 4000
```
> [!WARNING] ZRANGEBYSCORE 已废弃(Redis 6.2+)
> `ZRANGEBYSCORE` 在 Redis 6.2 起被统一到 `ZRANGE ... BYSCORE` 语法。本文档保留旧命令便于理解,但新代码建议写成:
> `ZRANGE leaderboard 3000 4000 BYSCORE WITHSCORES`
### Go 代码示例
```go
// 更新用户分数(原子加分)
rdb.ZIncr(ctx, "leaderboard", redis.Z{
Score: 50.5,
Member: "player:" + userID,
})
// 获取排名 + 分数区间
rank, _ := rdb.ZRevRank(ctx, "leaderboard", "player:"+userID).Result()
members, _ := rdb.ZRangeWithScores(ctx, "leaderboard", rank-4, rank).Result()
// 展示上下各 2 名的分数差距
```
```mermaid
flowchart TD
S["新得分到达"] --> A{"用户是否已有排名?"}
A -->|否| B["ZADD leaderboard score member"]
A -->|是| C["ZINCRBY leaderboard delta member"]
C --> D["计算前后排名差"]
D --> E["推送排名变化通知"]
```
> [!TIP] ZRANK vs ZREVRANK
> - `ZRANK`:升序排名,最小值排第 0 位(适合延迟队列)
> - `ZREVRANK`:降序排名,最大值排第 0 位(适合排行榜)
### 多维度排行榜技巧
> [!QUESTION] 思考一下
> 游戏排行榜需要先按**胜场**排名,胜场相同时再按**击杀数**排。Sorted Set 只有一个 score 字段,怎么同时表示两个维度?
答案是:把两个维度"打包"成一个浮点数——**score = 主维度 × 基数 + 次维度**。
```
总分 = 胜场 × 10000 + 击杀数
例: 5胜3杀 → score = 50003
↑高位决定主排序 ↑低位决定次排序
```
为什么能行?因为浮点数比较是**先比高位再比低位**的——这和我们日常说"先看总分,总分一样看小分"是一回事。只要次维度的最大值不超过基数(这里是 10000),就不会"进位"干扰主维度。
> [!TIP] 基数怎么选?
> 基数 = 次维度的最大可能值 + 1。比如击杀数最大 9999,基数就取 10000。如果有第三个维度,可以再嵌套:`(主 × 10000 + 次) × 10000 + 第三`。
## 延迟队列
### 原理
> [!QUESTION] 思考一下
> 你有 1000 个定时任务分散在不同时间点需要执行——用户注册 5 分钟后发短信、订单 30 分钟后自动取消……怎么用 Redis 保证它们按时执行?
思路很直观:**把"什么时候执行"存进 score,把"执行什么"存进 member**。消费者不断从 ZSet 里弹出 score 最小(即时间最早)的元素,如果它的 score ≤ 当前时间,说明到期了,执行它;否则放回去继续等。
```mermaid
flowchart LR
A["生产者: 提交任务"] -->|"ZADD score=执行时间"| B["ZSet: 延迟队列"]
B -->|"score最小的到期了?"| C{"消费者检查"}
C -->|"到期"| D["执行任务"]
C -->|"未到期"| B
D --> E["ZREM 移除"]
```
利用 ZSet 的 score 字段存储执行时间戳,通过 `BZPOPMIN`(阻塞弹最低分)定时拉取到期的任务:
```bash
# 提交延迟任务(5秒后执行)
ZADD delayed_tasks 1715000005 "task:sms:user:42"
# 消费者循环拉取
BZPOPMIN delayed_tasks 0 # 超时为 0,一直等待
# 返回: ["delayed_tasks", "task:sms:user:42", "1715000005"]
```
```go
// Go 伪代码——定时轮询模型
for {
results, err := rdb.BZPopMin(ctx, time.Second, "tasks").Result()
if err == redis.Nil {
continue // 无任务,继续等
}
executeTask(results.Value.(string))
}
```
> [!QUESTION] 为什么不用 Timer 而用 ZSet?
> - Timer/chan 只能单机内使用,分布式场景下多节点无法共享
> - ZSet 可以跨节点消费同一个延迟队列
> - 配合 Leader Election 可以实现可靠的多消费者竞争
### 精度与漂移
```bash
# 毫秒级精度(Unix timestamp in ms)
ZADD delayed_tasks $(($(date +%s%3N) + 5000)) "task:id:123"
# 检查是否有到期任务(不阻塞)
ZRANGEBYSCORE delayed_tasks -inf $(date +%s%3N)
# 然后把结果移到处理队列
ZREM delayed_tasks <completed-tasks>
LPUSH processing_queue <completed-tasks>
```
> [!CAUTION] 多步操作需保证原子性
> 上面的"查询 → 删除 → 转存"三步并非原子操作,多个消费者同时拉取会导致**重复消费**。生产环境应将这段逻辑封装到 Lua 脚本中,通过 `EVAL` 一次执行,或使用 `BZPOPMIN` 的阻塞弹出模型天然规避此问题。
> [!WARNING] 延迟队列不是消息队列
> 对于大量并发任务,ZSet 的 O(log N) 插入和维护成本会累积。考虑专用 MQ(Kafka/RabbitMQ)更合适。Redis ZSet 适合**规模适中(万级以内)**的延迟任务场景。
## 滑动窗口去重计数
> [!QUESTION] 思考一下
> 统计"最近 1 分钟内有多少独立用户访问了首页"——你能想到几种方案?为什么 ZSet 是其中最简单直接的一种?
核心思路可以用一句话概括:**把 ZSet 当成一个带时间戳的签到本**。每个用户来访时"签到"(score = 当前时间戳,member = 用户 ID),然后把超过 1 分钟的签到记录撕掉,剩下的就是窗口内的独立访客数。
> [!TIP] 为什么 ZSet 天然去重?
> 因为 ZSet 的 **member 是唯一的**——同一个用户多次访问,score 会被更新但不会产生重复记录。这比 List 或 Stream 省去了额外的去重逻辑。
ZSet 天然适合做**固定时间窗口内的去重计数**(如每分钟独立访客):
```bash
# key = page:home:uv,score = unix timestamp ms,member = visitor_id
ZREMRANGEBYSCORE page:home:uv 0 $((now_ms - 60000)) # 清除 60s 窗口外的旧数据
ZADD page:home:uv $now_ms visitor:a visitor:b visitor:c
ZCARD page:home:uv # 当前窗口内独立访客数(ZSet 用 ZCARD,不是 SCARD)
```
```go
// Go —— 每请求一次做一次清理 + 添加
rdb.ZRemRangeByScore(ctx, "page:home:uv", "0", now.Add(-time.Minute).UnixMilli())
rdb.ZAdd(ctx, "page:home:uv", redis.Z{Score: float64(now.UnixMilli()), Member: visitorID})
count, _ := rdb.ZCard(ctx, "page:home:uv").Result()
```
> [!TIP] ZSet vs Bitmap vs HyperLogLog — 计数器选型
> | 方案 | 精度 | 内存(百万UV) | 支持查询明细 |
> |------|------|---------------|-------------|
> | ZSet | ✅ 精确 | ~60 MB | ✅ member = 用户 ID |
> | Bitmap | ✅ 精确 | ~125 KB | ❌ 不可逆 |
> | HyperLogLog | ⚠️ ±0.1% | 12 KB | ❌ 不可逆 |
>
> **选法**: 需要查"谁来过"或用 score 做时间分析 → ZSet;纯计数、极大规模 → Bitmap / HLL。
## 滑动窗口限流器
> [!QUESTION] 思考一下
> 限流器的核心问题:「在最近 N 秒内,这个用户最多只能发 100 个请求」。如果用**固定窗口**(按整秒切分),在窗口边界会发生什么?
```mermaid
graph TB
subgraph FW["固定窗口 — 按秒硬切"]
F1["0.9s 发 100 个"] --> F2["1.0s 窗口重置"]
F2 --> F3["1.1s 再发 100 个"]
F4["结果: 0.2s 内通过 200 个!"]
end
subgraph SW["滑动窗口 — 时间平滑"]
S1["任何时候都只看最近 1s"] --> S2["窗口内永远 ≤ 100"]
end
FW -->|"问题"| SW
```
这就是固定窗口的**边界突刺**问题:用户在窗口交界处 0.2 秒内发出 200 个请求,实际速率远超限制。滑动窗口通过"只看最近 N 秒"的方式平滑地解决了这个问题。
用 `ZREMRANGEBYSCORE` + `ZCARD` 实现经典的**固定窗口 / 滑动窗口限流**:
```bash
# === 固定窗口限流 ===
# 按整秒截断:窗口起点 = floor(now_s / 1) * 1
WINDOW_START=$((now_ts - now_ts % 1)) # 当前秒的起始时间戳
ZREMRANGEBYSCORE limit:user:1001 0 $((WINDOW_START - 1)) # 清除上一个窗口之前的数据
ZADD limit:user:1001 $now_ts req:$RANDOM # 记录本次请求
ZCARD limit:user:1001 # 当前窗口 QPS
# === 滑动窗口限流(更精准)===
WINDOW_MS=1000
ZREMRANGEBYSCORE ratelimit:user:1001 0 $(($(date +%s%3N) - WINDOW_MS))
ZADD ratelimit:user:1001 $(date +%s%3N) $(date +%s%3N):$$
ZCARD ratelimit:user:1001 # ≤ 100 则放行,否则拒绝
```
```go
const maxReqPerSec = 100
func SlidingWindowLimit(rdb *redis.Client, ctx context.Context, userID string) bool {
now := time.Now().UnixMilli()
windowStart := now - 1000 // 1 秒窗口
pipe := rdb.Pipeline()
pipe.ZRemRangeByScore(ctx, "rl:"+userID, "0", fmt.Sprint(windowStart))
pipe.ZAdd(ctx, "rl:"+userID, redis.Z{Score: float64(now), Member: fmt.Sprint(now)})
pipe.ZCard(ctx, "rl:"+userID)
results, err := pipe.Exec(ctx)
if err != nil {
return false // 系统异常时保守拒绝
}
count := results[2].(*redis.IntCmd).Val()
return count <= maxReqPerSec
}
```
> [!NOTE] Redisson 的滑动窗口限流器
> Redisson 提供了开箱即用的 `RRateLimiter`,内部正是基于 ZSet + Lua 脚本实现的 **multi-window** 模型——把 1 秒拆成多个子窗口来平滑过渡,减少边界突刺。生产环境建议优先用成熟客户端库。
```mermaid
flowchart LR
subgraph W["滑动窗口限流流程"]
A["请求到达"] --> B["删除窗口外过期成员"]
B --> C["写入当前请求时间戳"]
C --> D{"数量 ≤ 上限?"}
D -->|是| E["✅ 放行"]
D -->|否| F["❌ 限流拒绝"]
end
```
## Top-N 实时聚合
业务需要合并多个排行榜取**全局 Top-K** 时,用 `ZUNIONSTORE` / `ZINTERSTORE`:
```bash
# 已有两个分区的排行榜
ZADD rank:cn 1000 "vip_user:A" 500 "user:B"
ZADD rank:us 800 "vip_user:A" 900 "user:C"
# 合并求和(默认分数相加)
ZUNIONSTORE global:top 2 rank:cn rank:us AGGREGATE SUM
# AGGREGATE 三种模式:
# SUM — 分数求和(默认,适合多维度累加)
# MAX — 取各集合中的最大分数(适合"最高分"场景)
# MIN — 取各集合中的最小分数(适合"保底分"场景)
# 取全局 Top 10
ZREVRANGE global:top 0 9 WITHSCORES
# → vip_user:A(1800), user:C(900), user:B(500)...
```
```go
keys := []string{"rank:cn", "rank:us", "rank:jp"}
// 第一个参数是目标 key,结果写入 "global:top"
rdb.ZUnionStore(ctx, "global:top", &redis.ZStore{
Keys: keys,
Aggregate: "SUM", // 可选: "SUM" / "MAX" / "MIN"
})
top20, _ := rdb.ZRevRangeWithScores(ctx, "global:top20", 0, 19).Result()
```
> [!WARNING] ZUNIONSTORE / ZINTERSTORE 阻塞风险
> 当参与合并的集合都很大时,这两个命令是 O(N+M) 且在主线程执行。**最佳实践**:将结果写入另一个 ZSet,定时(如每分钟)异步刷新。
## Geo — 地理位置
> [!QUESTION] 思考一下
> 老板说:「加个附近 5 公里奶茶店的功能」。你手头只有 Redis,怎么做?
其实 Geo 就是 **Sorted Set 的语法糖**——底层把经纬度编码成一个 52 位 GeoHash 数字作为 score,成员是地点名称。既然 score 是个数字,自然就能排序、比较距离、查找附近范围。所以 Geo 不是新数据结构,而是**把「找附近」这个常见需求包装成了更友好的 API**。
### 基本操作
```bash
# 添加位置
GEOADD cities 116.4074 "beijing" 121.4737 "shanghai" 108.9690 "nanning"
# 计算两点距离(米)
GEOPOS cities beijing shanghai # 获取经纬度坐标
GEODIST cities beijing nanning km # 距离(km/m/ft/mi 单位)
# 附近的人(半径 50km 内)—— Redis 6.2+ 推荐
GEOSEARCH cities FROMLONLAT 116.4074 39.9042 BYRADIUS 50 km WITHDIST WITHCOORD ASC COUNT 5
# ASC: 从近到远(默认)
# 以某个成员为中心搜索
GEOSEARCH cities FROMMEMBER shanghai BYRADIUS 100 km DESC COUNT 5
# DESC: 从远到近
```
> [!WARNING] GEORADIUS / GEORADIUSBYMEMBER 已废弃
> 这两个命令在 Redis 6.2 后被 `GEOSEARCH`(查询)和 `GEOSEARCHSTORE`(查询并存储)取代。旧命令仍可使用,但新代码应直接用 `GEOSEARCH`。
### 常见场景
| 场景 | 命令 | 注意 |
|------|------|------|
| 附近门店 | `GEOSEARCH FROMMEMBER` | 限流 + `COUNT` 分页 |
| 打车接单范围 | `GEOSEARCH FROMLONLAT` | 结合空间索引,`ASC` 按距离排序 |
| 围栏检测 | `GEOPOS` + Haversine 公式 | 超出范围触发告警 |
> [!TIP] Geo 底层就是 Sorted Set
> `GEOPOS` 本质是 `ZSCORE` + GeoHash 解码(score 存的是 52 位 GeoHash 编码值,不是原始经纬度),所以也可以用 `ZSCORE` 读出编码值,或用 `ZREVRANGE` 做通用范围查询。Geo 只是一层语法糖。
## 社交关系 —— 共同好友 / 标签匹配
### 基础交集
用 **Set** 存储用户的兴趣标签,用 **ZSet** 做加权推荐:
```bash
# Set 存储原始标签(轻量,适合求交集)
SADD interests:user:1 go redis docker k8s
SADD interests:user:2 go python flask k8s
# 共同兴趣
SINTER interests:user:1 interests:user:2
# → go, k8s
```
### 加权推荐(ZSet 方案)
> [!QUESTION] 思考一下
> 两个人兴趣标签有重叠,但重叠程度不同。如果想给用户推荐「最志同道合」的人,怎么**量化匹配度**?
思路:把每个标签赋予一个权重分数(比如用户对该话题的关注度),然后**把两个用户的兴趣 ZSet 做分数求和**——共同标签的分数会叠加,非共同标签保持原分。叠加后的分数越高,说明匹配度越强。
当每个标签有**权重分数**时,把标签转为 ZSet,利用 `ZUNIONSTORE` 计算匹配度:
```bash
# 用户兴趣 + 权重分数
ZADD rec:user:1 "go" 1.0 "redis" 0.9 "docker" 0.8 "k8s" 0.7
ZADD rec:user:2 "go" 0.95 "python" 1.0 "flask" 0.6 "k8s" 0.9
# 合并两个用户的兴趣,分数相加 = 共同权重
ZUNIONSTORE temp:rec 2 rec:user:1 rec:user:2 AGGREGATE SUM
# 分数越高 = 共同兴趣越多越强
ZREVRANGEBYSCORE temp:rec 2.0 1.5 WITHSCORES
# → go(1.95), k8s(1.6)
```
> [!WARNING] 大集合运算警告
> `SINTER` / `SUNION` / `ZUNIONSTORE` 的时间复杂度是 O(N × M)(N 为最小集合的元素数,M 为集合个数),在大型集合上可能阻塞主线程。建议预计算 + 定时刷新,或在低峰期异步完成。
## 优先级队列
### 用 ZSet 做优先级任务队列
```bash
# score = priority (数值越大优先级越高)
# member = task JSON
ZADD priority-queue 10 '{"id":"t1","action":"email"}'
ZADD priority-queue 8 '{"id":"t2","action":"sms"}'
ZADD priority-queue 15 '{"id":"t3","action":"push"}'
# 取出最高优先级任务
ZPOPMAX priority-queue
# → {"id":"t3","action":"push"}
# 或阻塞式(无任务时等待)
BZPOPMAX priority-queue 5
```
## Sorted Set 性能边界
| 指标 | 数据量 | 说明 |
|------|--------|------|
| 单个 ZSet 推荐上限 | 200 万成员 | 超过后写入延迟上升 |
| skiplist 高度期望值 | O(log₂N) ≈ 20 | 平均跳板层数 |
| 内存占用估算 | ~500 bytes/entry | 含 ziplist/skiplist + hashtable 开销 |
### 百万级 ZSet 优化策略
```mermaid
mindmap
root((百万级 ZSet<br/>优化策略))
按业务维度拆分
全国排行→按省份
rank:gd 广东
rank:js 江苏
rank:sz 四川
各端独立
feed:recommend:app1
feed:recommend:app2
预计算 + 缓存
ZUNIONSTORE→定时刷新
低峰期异步合并
游标分页
ZRANGEBYSCORE + min/max
避免 OFFSET 深翻页
编码降级
小集合→listpack 紧凑编码
大集合→skiplist+hashtable
```
> [!TIP] 编码转换的触发条件
> 配置参数 `zset-max-ziplist-entries`(默认 128)和 `zset-max-ziplist-value`(默认 64 bytes)控制着 listpack(Redis 7.0 前为 ziplist)↔ skiplist 的转变。小集合优先使用 listpack 紧凑编码以节省内存,超过阈值自动升级为 skiplist + hashtable。在数据量较大时可适当调大 entries 阈值以延迟升级,但需权衡 listpack 的 O(N) 插入性能。
## 关联笔记
- [[hhs/Redis/02-核心数据类型]] — ZSet 底层编码(ziplist → skiplist)
- [[hhs/Redis/03-基本命令速查]] — ZSet 命令表
- [[hhs/Redis/README]] — 知识索引总览