This repository has been archived on 2026-05-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
all-in-kingsoft/hhs/Redis/08-SortedSet精解.md
T
2026-05-17 00:06:11 +08:00

352 lines
11 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 中**功能最丰富、场景最广泛**的数据结构。它的底层由 skiplist + hashtable 组成,天然支持按分数排序和 O(1) 的成员查找。本节聚焦实战中常用的高级模式。
## 排行榜系统
### 基础实现
```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
```
### 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>
```
> [!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 $now_ms 60000 # 清除 60s 前的数据
ZADD page:home:uv $now_ms visitor:a visitor:b visitor:c
SCARD page:home:uv # 当前窗口内独立访客数
```
```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
# === 固定窗口限流 ===
# key = limit:user:1001, score = unix timestamp(每秒一个请求)
ZREMRANGEBYSCORE limit:user:1001 $now_ts $(($now_ts - 1)) # 清除 1s 前数据
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
# 取全局 Top 10
ZRANGE global:top 0 9 WITHSCORES REVERSE
# → vip_user:A(1800), user:C(900), user:B(500)...
```
```go
keys := []string{"rank:cn", "rank:us", "rank:jp"}
// 合并三个区域排行榜,取分数最高的 20 名
rdb.ZUnionStore(ctx, "global:top20", keys)
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/fi 单位)
# 附近的人(半径 50km 内)
GEORADIUS cities 116.4074 39.9042 50 km WITHDIST WITHCOORD
# 新版本 API(推荐)
GEORADIUSBYMEMBER cities shanghai 100 km DESC COUNT 5
# DESC: 从远到近(默认从近到远)
```
### 常见场景
| 场景 | 命令 | 注意 |
|------|------|------|
| 附近门店 | GEORADIUSBYMEMBER | 限流 + 分页 |
| 打车接单范围 | GEOADD + GEORADIUS | 结合 spatial index |
| 围栏检测 | GEOPOS + 数学计算 | 超出范围触发告警 |
> [!TIP] Geo 底层就是 Sorted Set
> `GEOPOS` 本质是 `ZSCORE`(存的是 encode 后的 lat+lng),所以也可以用 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
# 分数越高 = 共同兴趣越多越强
ZRANGEBYSCORE temp:rec 1.5 2.0 REVERSE WITHSCORES
# → go(1.95), k8s(1.6)
```
> [!WARNING] 大集合运算警告
> `SINTER` / `SUNION` / `ZUNIONSTORE` 的时间复杂度是 O(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 深翻页
编码降级
<64元素 &lt;64B→ziplist
≥128元素→skiplist+hashtable
```
> [!TIP] 编码转换的触发条件
> `maxziplist` entries(默认 128)和 `maxziplistvalue`(默认 64 bytes)控制着 ziplist ↔ skiplist 的转变。在数据量较大时建议调大 `zset-max-ziplist-entries` 以利用 ziplist 的内存紧凑性,但要权衡查询性能。
## 关联笔记
- [[hhs/Redis/02-核心数据类型]] — ZSet 底层编码(ziplist → skiplist)
- [[hhs/Redis/03-基本命令速查]] — ZSet 命令表
- [[hhs/Redis/README]] — 知识索引总览