Files
cs-note/hhs/Redis/08-SortedSet精解.md
T
2026-05-25 23:07:45 +08:00

371 lines
13 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 高级玩法
## 概述
Sorted Set(有序集合)是 Redis 中**功能最丰富、场景最广泛**的数据结构。底层采用双编码策略:小集合(默认 ≤128 个元素,Redis 7.0+ 使用 listpack 替代 ziplist)用连续内存紧凑存储;超过阈值后自动切换为 **skiplist + hashtable** 组合,天然支持按分数排序和 O(log 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 位(适合排行榜)
### 多维度排行榜技巧
同一份数据需要按不同维度排名时,不要用多个 ZSet——用 **score = base + fraction**:
```
总分 = 胜场 × 10000 + 击杀数
例: 5胜3杀 → score = 50003
→ 先比胜率,再比击杀数,完美映射为单浮点数
```
## 延迟队列
### 原理
利用 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 适合**规模适中(万级以内)**的延迟任务场景。
## 滑动窗口去重计数
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。
## 滑动窗口限流器
用 `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 — 地理位置
### 基本操作
```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 方案)
当每个标签有**权重分数**时,把标签转为 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]] — 知识索引总览