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

17 KiB
Raw Blame History

tags, create time
tags create time
Redis
缓存
数据结构
SortedSet
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) 的范围查询。

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 聚合"]

本节聚焦实战中常用的高级模式——你会发现,很多看似不同的业务需求,本质上都是「带分数的排行榜」。

排行榜系统

基础实现

# 添加用户分数
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 代码示例

// 更新用户分数(原子加分)
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 名的分数差距
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 ≤ 当前时间,说明到期了,执行它;否则放回去继续等。

flowchart LR
    A["生产者: 提交任务"] -->|"ZADD score=执行时间"| B["ZSet: 延迟队列"]
    B -->|"score最小的到期了?"| C{"消费者检查"}
    C -->|"到期"| D["执行任务"]
    C -->|"未到期"| B
    D --> E["ZREM 移除"]

利用 ZSet 的 score 字段存储执行时间戳,通过 BZPOPMIN(阻塞弹最低分)定时拉取到期的任务:

# 提交延迟任务(5秒后执行)
ZADD delayed_tasks 1715000005 "task:sms:user:42"

# 消费者循环拉取
BZPOPMIN delayed_tasks 0        # 超时为 0,一直等待
# 返回: ["delayed_tasks", "task:sms:user:42", "1715000005"]
// 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 可以实现可靠的多消费者竞争

精度与漂移

# 毫秒级精度(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 天然适合做固定时间窗口内的去重计数(如每分钟独立访客):

# 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 —— 每请求一次做一次清理 + 添加
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 个请求」。如果用固定窗口(按整秒切分),在窗口边界会发生什么?

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 实现经典的固定窗口 / 滑动窗口限流:

# === 固定窗口限流 ===
# 按整秒截断:窗口起点 = 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 则放行,否则拒绝
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 秒拆成多个子窗口来平滑过渡,减少边界突刺。生产环境建议优先用成熟客户端库。

flowchart LR
    subgraph W["滑动窗口限流流程"]
        A["请求到达"] --> B["删除窗口外过期成员"]
        B --> C["写入当前请求时间戳"]
        C --> D{"数量 ≤ 上限?"}
        D -->|是| E["✅ 放行"]
        D -->|否| F["❌ 限流拒绝"]
    end

Top-N 实时聚合

业务需要合并多个排行榜取全局 Top-K 时,用 ZUNIONSTORE / ZINTERSTORE:

# 已有两个分区的排行榜
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)...
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。

基本操作

# 添加位置
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 做加权推荐:

# 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 计算匹配度:

# 用户兴趣 + 权重分数
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 做优先级任务队列

# 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 优化策略

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) 插入性能。

关联笔记