Sorted Set 排行榜 — CodeRoadMapSorted Set 排行榜
ZADD/ZRANGE、分数排序与 Top-N。
Sorted Set 排行榜:课程周榜、总分榜与延时任务
这一课你将能做到
- 用 ZSET 完成加分、取榜单、查排名、查分数的完整操作
- 为本站搭建「课程周榜 + 历史总分榜」双榜结构
- 用 ZSET 实现按时间触发的延时任务和滑动窗口限流
为什么要学
如果说前几类结构各有一句口诀,Sorted Set(有序集合,简称 ZSET)的口诀是「带分数的 Set,且时刻按分数排好序」。成员唯一,但每个成员绑着一个浮点分数,Redis 用跳表(skiplist)+ 哈希表维持有序,取前 10 名、查某人排名、按分数段筛选,全部高效。
本站最显眼的 ZSET 场景就是排行榜:
- 课程周榜:本周通过题目得到的积分,每周一清零重开;
- 全站总分榜:历史累计积分,长期有效;
- 还要能随时回答「我现在第几名、离上一名差几分」。
用数据库做排行榜意味着高频 ORDER BY score DESC LIMIT 10,既慢又难扛并发;ZSET 让这些查询变成常数或对数时间。
先建立图像
key: rank:course:7:weekly
成员(用户ID) 分数(score)
user:1003 → 980
user:1001 → 860
user:1007 → 860
user:1002 → 720
↑ 顺序:分数从低到高存着,取榜从高往低读
同分怎么办?按成员名字字典序再排,所以排名稳定、不并列出错。
底层结构:哈希表负责「成员 → 分数」的 O(1) 查询,跳表负责「按分数有序」地范围读取,两者配合让所有排行榜操作都快。
跟着做
步骤 1:加入成员与加分
127.0.0.1:6379> ZADD rank:course:7:weekly 860 user:1001 720 user:1002 980 user:1003
(integer) 3
用户每通过一题加 10 分,用 ZINCRBY(成员不存在会自动创建,按 0 起加):
127.0.0.1:6379> ZINCRBY rank:course:7:weekly 10 user:1001
"870"
ZADD 常用选项:NX(只加新成员)、XX(只更新已有成员)、GT/LT(新分数大于/小于当前才更新)、INCR(当 ZINCRBY 用)。
步骤 2:取榜单
取分数最高的前 10 名(ZREVRANGE,Redis 7 推荐统一用 ZRANGE + REV):
127.0.0.1:6379> ZRANGE rank:course:7:weekly 0 9 REV WITHSCORES
1) "user:1003"
2) "980"
3) "user:1001"
4) "870"
5) "user:1002"
6) "720"
加上名次给前端用(排行从 1 开始数):
127.0.0.1:6379> ZRANGE rank:course:7:weekly 0 2 REV WITHSCORES
步骤 3:查我的排名和分数
127.0.0.1:6379> ZREVRANK rank:course:7:weekly user:1001
(integer) 1
127.0.0.1:6379> ZSCORE rank:course:7:weekly user:1001
"870"
ZREVRANK 返回从高到低的名次,下标从 0 开始,所以展示时要 +1。ZRANK 是从低到高。查我和上一名差多少:
127.0.0.1:6379> ZRANGE rank:course:7:weekly 0 0 REV
1) "user:1003"
127.0.0.1:6379> ZSCORE rank:course:7:weekly user:1003
"980"
步骤 4:按分数段筛选与总数
127.0.0.1:6379> ZRANGE rank:course:7:weekly 800 +inf BYSCORE REV WITHSCORES
1) "user:1003"
2) "980"
3) "user:1001"
4) "870"
-inf、+inf 表示负、正无穷;分数前加 ( 表示开区间,如 (800 是严格大于 800。榜单总人数:
127.0.0.1:6379> ZCARD rank:course:7:weekly
(integer) 3
步骤 5:周榜与总分榜合并
用户通过一题,周榜加 10 分、总分榜加 10 分:
127.0.0.1:6379> ZINCRBY rank:course:7:weekly 10 user:1001
"880"
127.0.0.1:6379> ZINCRBY rank:alltime 10 user:1001
"1530"
每周一凌晨用定时任务清周榜(直接删键,新一周首次加分自动重建):
127.0.0.1:6379> DEL rank:course:7:weekly
(integer) 1
也可以把多个课程周榜加权汇总成「学习之星总周榜」:
127.0.0.1:6379> ZUNIONSTORE rank:weekly:all 2 rank:course:7:weekly rank:course:8:weekly WEIGHTS 1 1.5 AGGREGATE MAX
(integer) 0
WEIGHTS 给每个榜乘权重(难课分更值钱),AGGREGATE 可取 SUM/MIN/MAX。取交集用 ZINTERSTORE,比如「既在 A 榜又在 B 榜」的活跃学员。
步骤 6:延时任务
把「要做的事」当成员,把「该执行的时间戳」当分数:
127.0.0.1:6379> ZADD delay:tasks 1760000060 job:unlock:order:88
(integer) 1
定时程序每秒捞所有「分数 ≤ 当前时间戳」的任务:
127.0.0.1:6379> ZRANGE delay:tasks -inf 1760000060 BYSCORE LIMIT 0 100
1) "job:unlock:order:88"
127.0.0.1:6379> ZREM delay:tasks job:unlock:order:88
(integer) 1
本站的应用:判题超时后自动把任务标记为失败、解锁超时未支付占用的名额。
步骤 7:滑动窗口限流(更平滑)
把每次请求的时间戳(毫秒)当分数,成员用「时间戳+随机数」保证唯一:
127.0.0.1:6379> ZADD ratelimit:slide:user:1001 1760000000000 req:1
(integer) 1
每次请求先删掉 10 秒窗口外的旧记录,再统计当前数量:
127.0.0.1:6379> ZREMRANGEBYSCORE ratelimit:slide:user:1001 -inf 1759999990000
(integer) 0
127.0.0.1:6379> ZCARD ratelimit:slide:user:1001
(integer) 1
数量 < 5 才放行,然后给键设一个略大于窗口的 TTL 兜底。相比第 2 课的固定窗口,它在任何时刻都严格保证「最近 10 秒不超过 5 次」。
讲清楚要点
- 成员唯一,分数可重复:同成员重复 ZADD 默认是更新分数;同分按成员字典序排序,排名确定。
- 三组坐标别混淆:
0 9 是按排名(index)取,BYSCORE 800 +inf 是按分数取;还有 BYLEX 用于同分下按字典序取。REV 表示反向。
- ZREVRANK 返回 0 基名次:业务展示要 +1,这是最常见的小坑。
- 范围删除很常用:
ZREMRANGEBYSCORE、ZREMRANGEBYRANK 用来清窗口、裁剪榜单。
- ZUNIONSTORE/ZINTERSTORE 是多榜融合的利器:权重和聚合方式让一张总榜可以表达复杂规则。
- 分数是双精度浮点:能存整数也能存小数,但极大整数有精度上限;纯整数场景放心用。
你可能会懵的地方
-
同分的两个人谁排前面?
分数相同时按成员名的字典序排列(ZRANGE 升序下小的在前)。想让先达到该分数的人排前面,成员里带时间戳信息,或额外维护「达到时间」参与排序。
-
ZADD 更新分数后排名为什么立刻就对?
因为跳表始终维持有序,更新即调整位置,不需要重排,查询天然就是最终顺序。
-
周榜清零用什么方式最稳?
低峰期直接 DEL 最简单;怕瞬间冷启动可以提前创建新榜再改名。注意 DEL 一个百万元素大键会短暂阻塞,可用 UNLINK 异步删除。
-
成员该存什么?
存用户 ID(字符串),榜单读出后再查用户信息;不要把昵称塞进成员,昵称改了榜就对不上人。
-
ZSET 做延时任务有什么不足?
任务取出到删除之间消费者宕机可能丢任务(和 List 类似),且没有分片消费机制。海量延时任务建议换专业延时队列或 Stream,ZSET 适合中小规模。
错法对照
| 错法 | 问题 | 正解 |
|---|
每次查榜 ORDER BY 数据库 | 高频排序拖慢主库 | ZSET 维护实时榜 |
| ZREVRANK 结果直接当名次显示 | 0 基下标,第一名显示成第 0 | 展示时 +1 |
| 成员存昵称 | 昵称变更导致榜单失联 | 成员存不变的用户 ID |
| 大榜用 DEL 清 | 短暂阻塞主线程 | 用 UNLINK 异步删 |
| 滑动窗口只 ZADD 不删旧记录 | 键无限增长 | 先 ZREMRANGEBYSCORE 清窗口外 |
分数当字符串传 "10分" | 报浮点解析错误 | 分数只传数字 |
卡点急救
- ZSCORE 返回 nil:成员不在榜里(新用户还没得分),按 0 分处理即可,别当成系统错误。
- 榜单人数异常多/少:检查周榜是否按时清理、成员命名是否统一(
user:1001 和 1001 会被当成两个人)。
- ZUNIONSTORE 结果不符合预期:核对参与键的数量与 WEIGHTS 个数是否一致,以及 AGGREGATE 是否选反(SUM/MAX)。
面试常问
-
ZSET 为什么既能快速查分又能有序取范围?
哈希表支持成员到分数的 O(1) 查询,跳表支持按分数的 O(log N) 定位与范围遍历,双结构各取所长。
-
如何设计排行榜?周榜如何重置?
ZSET 成员为用户 ID、分数为积分;ZINCRBY 更新,ZRANGE REV 取榜,ZREVRANK 查名次。周榜单独一个键,定时 DEL/UNLINK 后按需重建;总分榜长期保留,多榜融合用 ZUNIONSTORE 加权。
-
ZSET 怎么做滑动窗口限流?
成员为每次请求的唯一 ID、分数为时间戳;请求时先移除窗口外旧成员,ZCARD 判断是否超限,再放行并补 TTL 兜底。
自测三问
- 写出「取课程周榜前三名及其分数」和「查 user:1001 的名次」的命令。
- 周榜清零为什么推荐 UNLINK 而不是 DEL?
- 用 ZSET 实现「最近 10 秒最多 5 次」限流,至少需要哪三条命令?
小结一句话
ZSET 用「唯一成员 + 实时有序的分数」把排行榜、排名查询、延时任务和滑动窗口限流统一成一套范围操作,是 Redis 业务表现力最强的结构。
延伸