Files
cs-note/hhs/Redis/08-SortedSet精解.md
T

371 lines
13 KiB
Markdown
Raw Normal View History

2026-05-24 11:42:38 +08:00
---
tags: [Redis, 缓存, 数据结构, SortedSet]
create time: 2026-05-15 18:14
---
# Sorted Set 高级玩法
## 概述
2026-05-25 23:07:45 +08:00
Sorted Set(有序集合)是 Redis 中**功能最丰富、场景最广泛**的数据结构。底层采用双编码策略:小集合(默认 ≤128 个元素,Redis 7.0+ 使用 listpack 替代 ziplist)用连续内存紧凑存储;超过阈值后自动切换为 **skiplist + hashtable** 组合,天然支持按分数排序和 O(log N) 的范围查询。本节聚焦实战中常用的高级模式。
2026-05-24 11:42:38 +08:00
## 排行榜系统
### 基础实现
```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
```
2026-05-25 23:07:45 +08:00
> [!WARNING] ZRANGEBYSCORE 已废弃(Redis 6.2+)
> `ZRANGEBYSCORE` 在 Redis 6.2 起被统一到 `ZRANGE ... BYSCORE` 语法。本文档保留旧命令便于理解,但新代码建议写成:
> `ZRANGE leaderboard 3000 4000 BYSCORE WITHSCORES`
2026-05-24 11:42:38 +08:00
### 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>
```
2026-05-25 23:07:45 +08:00
> [!CAUTION] 多步操作需保证原子性
> 上面的"查询 → 删除 → 转存"三步并非原子操作,多个消费者同时拉取会导致**重复消费**。生产环境应将这段逻辑封装到 Lua 脚本中,通过 `EVAL` 一次执行,或使用 `BZPOPMIN` 的阻塞弹出模型天然规避此问题。
2026-05-24 11:42:38 +08:00
> [!WARNING] 延迟队列不是消息队列
> 对于大量并发任务,ZSet 的 O(log N) 插入和维护成本会累积。考虑专用 MQ(Kafka/RabbitMQ)更合适。Redis ZSet 适合**规模适中(万级以内)**的延迟任务场景。
## 滑动窗口去重计数
ZSet 天然适合做**固定时间窗口内的去重计数**(如每分钟独立访客):
```bash
# key = page:home:uv,score = unix timestamp ms,member = visitor_id
2026-05-25 23:07:45 +08:00
ZREMRANGEBYSCORE page:home:uv 0 $((now_ms - 60000)) # 清除 60s 窗口外的旧数据
2026-05-24 11:42:38 +08:00
ZADD page:home:uv $now_ms visitor:a visitor:b visitor:c
2026-05-25 23:07:45 +08:00
ZCARD page:home:uv # 当前窗口内独立访客数(ZSet 用 ZCARD,不是 SCARD)
2026-05-24 11:42:38 +08:00
```
```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
# === 固定窗口限流 ===
2026-05-25 23:07:45 +08:00
# 按整秒截断:窗口起点 = 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 # 记录本次请求
2026-05-24 11:42:38 +08:00
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
2026-05-25 23:07:45 +08:00
# AGGREGATE 三种模式:
# SUM — 分数求和(默认,适合多维度累加)
# MAX — 取各集合中的最大分数(适合"最高分"场景)
# MIN — 取各集合中的最小分数(适合"保底分"场景)
2026-05-24 11:42:38 +08:00
# 取全局 Top 10
2026-05-25 23:07:45 +08:00
ZREVRANGE global:top 0 9 WITHSCORES
2026-05-24 11:42:38 +08:00
# → vip_user:A(1800), user:C(900), user:B(500)...
```
```go
keys := []string{"rank:cn", "rank:us", "rank:jp"}
2026-05-25 23:07:45 +08:00
// 第一个参数是目标 key,结果写入 "global:top"
rdb.ZUnionStore(ctx, "global:top", &redis.ZStore{
Keys: keys,
Aggregate: "SUM", // 可选: "SUM" / "MAX" / "MIN"
})
2026-05-24 11:42:38 +08:00
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 # 获取经纬度坐标
2026-05-25 23:07:45 +08:00
GEODIST cities beijing nanning km # 距离(km/m/ft/mi 单位)
2026-05-24 11:42:38 +08:00
2026-05-25 23:07:45 +08:00
# 附近的人(半径 50km 内)—— Redis 6.2+ 推荐
GEOSEARCH cities FROMLONLAT 116.4074 39.9042 BYRADIUS 50 km WITHDIST WITHCOORD ASC COUNT 5
# ASC: 从近到远(默认)
2026-05-24 11:42:38 +08:00
2026-05-25 23:07:45 +08:00
# 以某个成员为中心搜索
GEOSEARCH cities FROMMEMBER shanghai BYRADIUS 100 km DESC COUNT 5
# DESC: 从远到近
2026-05-24 11:42:38 +08:00
```
2026-05-25 23:07:45 +08:00
> [!WARNING] GEORADIUS / GEORADIUSBYMEMBER 已废弃
> 这两个命令在 Redis 6.2 后被 `GEOSEARCH`(查询)和 `GEOSEARCHSTORE`(查询并存储)取代。旧命令仍可使用,但新代码应直接用 `GEOSEARCH`。
2026-05-24 11:42:38 +08:00
### 常见场景
| 场景 | 命令 | 注意 |
|------|------|------|
2026-05-25 23:07:45 +08:00
| 附近门店 | `GEOSEARCH FROMMEMBER` | 限流 + `COUNT` 分页 |
| 打车接单范围 | `GEOSEARCH FROMLONLAT` | 结合空间索引,`ASC` 按距离排序 |
| 围栏检测 | `GEOPOS` + Haversine 公式 | 超出范围触发告警 |
2026-05-24 11:42:38 +08:00
> [!TIP] Geo 底层就是 Sorted Set
2026-05-25 23:07:45 +08:00
> `GEOPOS` 本质是 `ZSCORE` + GeoHash 解码(score 存的是 52 位 GeoHash 编码值,不是原始经纬度),所以也可以用 `ZSCORE` 读出编码值,或用 `ZREVRANGE` 做通用范围查询。Geo 只是一层语法糖。
2026-05-24 11:42:38 +08:00
## 社交关系 —— 共同好友 / 标签匹配
### 基础交集
用 **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
# 分数越高 = 共同兴趣越多越强
2026-05-25 23:07:45 +08:00
ZREVRANGEBYSCORE temp:rec 2.0 1.5 WITHSCORES
2026-05-24 11:42:38 +08:00
# → go(1.95), k8s(1.6)
```
> [!WARNING] 大集合运算警告
2026-05-25 23:07:45 +08:00
> `SINTER` / `SUNION` / `ZUNIONSTORE` 的时间复杂度是 O(N × M)(N 为最小集合的元素数,M 为集合个数),在大型集合上可能阻塞主线程。建议预计算 + 定时刷新,或在低峰期异步完成。
2026-05-24 11:42:38 +08:00
## 优先级队列
### 用 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 深翻页
编码降级
2026-05-25 23:07:45 +08:00
小集合→listpack 紧凑编码
大集合→skiplist+hashtable
2026-05-24 11:42:38 +08:00
```
> [!TIP] 编码转换的触发条件
2026-05-25 23:07:45 +08:00
> 配置参数 `zset-max-ziplist-entries`(默认 128)和 `zset-max-ziplist-value`(默认 64 bytes)控制着 listpack(Redis 7.0 前为 ziplist)↔ skiplist 的转变。小集合优先使用 listpack 紧凑编码以节省内存,超过阈值自动升级为 skiplist + hashtable。在数据量较大时可适当调大 entries 阈值以延迟升级,但需权衡 listpack 的 O(N) 插入性能。
2026-05-24 11:42:38 +08:00
## 关联笔记
- [[hhs/Redis/02-核心数据类型]] — ZSet 底层编码(ziplist → skiplist)
- [[hhs/Redis/03-基本命令速查]] — ZSet 命令表
- [[hhs/Redis/README]] — 知识索引总览