Learn
Redis/07-zset

ZSet:排行榜背后的跳表

ZSet(Sorted Set)给每个成员绑定一个 double 类型的 score,集合始终按 score 排序。它同时具备三种能力:按分数排序、按排名取段、按成员查分——这让它成为排行榜、延时队列、滑动窗口限流的首选结构。

1. 数据模型

key: rank:game
score      member
------     --------
 98.0  <-> "ada"       按 score 升序排列
 95.5  <-> "bob"       score 相同时按 member 字典序
 95.5  <-> "carol"
 80.0  <-> "dave"

成员唯一(再次 ZADD 同名成员是更新分数),score 可重复。

2. 核心命令实操

2.1 写入与更新

ZADD 的条件参数
ZADD rank:game 80 "dave" 98 "ada" 95.5 "bob"
# 成员已存在:更新分数,返回新增数 0
ZADD rank:game 99 "ada"
# NX:只新增不更新,ada 分数保持 99
ZADD rank:game NX 50 "ada"
ZSCORE rank:game "ada"
# GT:新分数更大才更新;97 小于 99,不更新
ZADD rank:game GT 97 "ada"
ZSCORE rank:game "ada"
# 原子加分,返回新分数
ZINCRBY rank:game 10 "dave"
ZCARD rank:game

ZADD 的条件参数:NX 只增不改、XX 只改不增、GT/LT 只在新分数更大/更小时更新(做"历史最高分"极方便)。

2.2 按排名取段:ZRANGE

按排名与按分数取段
ZADD rank:game 90 "dave" 99 "ada" 95.5 "bob"
# 默认升序
ZRANGE rank:game 0 -1 WITHSCORES
# REV 降序 = Top 3(6.2+ 语法,替代 ZREVRANGE)
ZRANGE rank:game 0 2 REV WITHSCORES
# 闭区间 [90, 96]
ZRANGEBYSCORE rank:game 90 96
# 左开括号排除 90;+inf / -inf 表示无穷
ZRANGEBYSCORE rank:game (90 +inf
# 分数区间内的成员数
ZCOUNT rank:game 90 100

按分数取段用 ZRANGEBYSCORE:区间默认闭合,( 前缀表示开区间,+inf/-inf 表示无穷;只要数量时用 ZCOUNT。

2.3 查排名与删除

排名查询与区间删除
ZADD rank:game 90 "dave" 99 "ada" 95.5 "bob"
# 升序排名,从 0 开始
ZRANK rank:game "ada"
# 降序排名:ada 是第 1 名
ZREVRANK rank:game "ada"
ZREM rank:game "dave"
ZRANGE rank:game 0 -1 WITHSCORES
# 只保留前 1 名(删掉降序第 1 名之外的)
ZREMRANGEBYRANK rank:game 0 -2
ZRANGE rank:game 0 -1 WITHSCORES
# 按分数区间删除(延时队列常用)
ZADD delay:queue 1722310000 "order:1"
ZREMRANGEBYSCORE delay:queue -inf 1722310000

3. 底层实现:skiplist + hashtable 双结构

小 ZSet 用 listpack(≤128 个成员且都 ≤64 字节);超过阈值后用 skiplist + hashtable 的组合:

127.0.0.1:6379> OBJECT ENCODING rank:game
"listpack"
zset-max-listpack-entries 128
zset-max-listpack-value 64

为什么要两个结构一起用?因为 ZSet 要同时满足两类查询:

  • ZSCORE member —— 按成员查分数,需要 hashtable:O(1);
  • ZRANGE/ZRANK —— 按分数/排名的有序操作,需要 skiplist:O(log n)。

两个结构共享成员与分数(通过指针),内存不双倍。

3.1 跳表长什么样

跳表是"带多层快速通道的有序链表"。每个节点随机决定层数(每升一层概率 1/4),高层是低层的"高速公路":

L3:  head ----------------------------> 50 ------------------> NULL
L2:  head ----------> 20 ------------> 50 ------------------> NULL
L1:  head --> 10 --> 20 -----> 35 --> 50 --> 70 ------------> NULL
L0:  head --> 10 --> 20 --> 30 --> 35 --> 50 --> 60 --> 70 --> NULL
 
查找 60: L3 到 50 (下一个是 NULL, 降层)
         L2 从 50 (下一个 NULL, 降层)
         L1 从 50 -> 70 超了, 降层
         L0 从 50 -> 60 命中。 跳过了大部分节点。

平均查找/插入/删除都是 O(log n)。每层节点还记录了到下一节点的跨度(span),把沿途跨度累加就得到排名——这是 ZRANK 也能 O(log n) 的原因。

为什么不用红黑树?作者的理由:跳表实现简单得多;范围查询(ZRANGE)沿底层链表顺序走即可,比树的中序遍历更直接;调整概率参数即可权衡内存与速度。

4. 典型场景

4.1 排行榜

# 玩家得分更新(累加制)
127.0.0.1:6379> ZINCRBY lb:weekly 150 "player:1001"
"150"
# 取 Top 10
127.0.0.1:6379> ZRANGE lb:weekly 0 9 REV WITHSCORES
1) "player:1001"
2) "150"
# 查自己的名次与分数(两条命令可用 pipeline 合并)
127.0.0.1:6379> ZREVRANK lb:weekly "player:1001"
(integer) 0
127.0.0.1:6379> ZSCORE lb:weekly "player:1001"
"150"

周榜按周分 key(lb:2026w31),过期自动清理。同分排名问题:score 相同按成员字典序,若业务要求"先到先得排前面",可以把时间戳编进小数位:score = 分数 * 10^10 + (10^10 - 时间戳)。

4.2 延时队列

score 存"应执行的时间戳",消费者轮询取"到期任务":

ZSet 延时队列
# 生产:把"应执行时间戳"当作 score
ZADD delay:close-order 1722312000 "order:8801"
ZADD delay:close-order 1722315600 "order:8802"
# 消费:取一批到期任务(当前时间之前的)
ZRANGEBYSCORE delay:close-order -inf 1722312000 LIMIT 0 10
# 取到后用 ZREM 抢占,返回 1 才算抢到,防多个消费者重复处理
ZREM delay:close-order "order:8801"
# 未到期的任务仍留在队列里
ZRANGE delay:close-order 0 -1 WITHSCORES

"ZRANGEBYSCORE 再 ZREM"两步之间存在竞态,严格场景用 Lua 脚本把"取+删"做成原子操作(第 12 章),或用 Redis 7 的 ZMPOP。

4.3 滑动窗口限流

member 存唯一请求 ID,score 存时间戳;统计窗口内的请求数:

# 判断 user:1 最近 60 秒是否超过 100 次请求
127.0.0.1:6379> ZREMRANGEBYSCORE rate:u1 -inf 1722311940   # 清掉窗口外
(integer) 3
127.0.0.1:6379> ZCARD rate:u1
(integer) 97            # 未超限, 放行并记录本次
127.0.0.1:6379> ZADD rate:u1 1722312000 "req-uuid-x"
(integer) 1

完整原子版本在第 20 章用 Lua 实现。

⚠️ZSet 的成本意识

ZSet 是所有结构里单元素内存开销最大的(skiplist 节点 + hashtable 项 + 分数)。百万级成员的 ZSet 内存可观且 ZRANGEBYSCORE 大范围扫描是 O(log n + m)。排行榜只保留 Top N(定期 ZREMRANGEBYRANK 裁剪),限流 key 记得设 TTL。

小结

  • ZSet = 排序 + 去重 + 按成员查分,三种查询一个结构全包
  • ZADD 的 GT/LT/NX/XX 参数优雅解决"历史最高分""只增不改"类需求
  • 底层 skiplist(有序、范围、排名)+ hashtable(O(1) 查分)双结构配合
  • 三大场景:排行榜(ZINCRBY + ZRANGE REV)、延时队列(score=执行时间)、滑动窗口限流
  • 下一章是三个特殊结构:Bitmap、HyperLogLog、GEO →
🎯练习
  1. 构建一个 10 人游戏排行榜,实现:加分、取 Top 3、查询某玩家名次、只保留前 5 名。
  2. 用 ZSet 实现延时队列:加入 3 个不同到期时间的任务,模拟消费者取出"当前已到期"的任务并删除。
  3. 向 ZSet 写入 130 个成员,用 OBJECT ENCODING 验证 listpack 到 skiplist 的转换。