--- 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 LPUSH processing_queue ``` > [!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
优化策略)) 按业务维度拆分 全国排行→按省份 rank:gd 广东 rank:js 江苏 rank:sz 四川 各端独立 feed:recommend:app1 feed:recommend:app2 预计算 + 缓存 ZUNIONSTORE→定时刷新 低峰期异步合并 游标分页 ZRANGEBYSCORE + min/max 避免 OFFSET 深翻页 编码降级 <64元素 <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]] — 知识索引总览