Mobile wallpaper 1Mobile wallpaper 2Mobile wallpaper 3Mobile wallpaper 4
1611 字
8 分钟
Redis学习系列 | 实时排名到底怎么做的

Set 加个分数,威力翻倍#

上一篇说的 Set 有个遗憾:元素是乱的,没法按顺序取。

Sorted Set(也叫 ZSet)在 Set 的基础上加了一个东西——每个元素配一个 score(分值),Redis 自动按 score 排序。元素还是唯一的,但 score 可以重复。score 相同的时候,按 member 的字典序排。

这一个小小的改动,打开了排行榜、延迟队列、优先级调度、滑动窗口限流等一系列场景。Set 能做的去重和存在性判断,ZSet 都能做;ZSet 能做的排序和范围查询,Set 做不到。

基本操作:

ZADD leaderboard 9500 "Alice" 8700 "Bob" 7600 "Carol"
# 添加三个玩家和积分
ZRANGE leaderboard 0 -1 WITHSCORES
# → ["Carol", "7600", "Bob", "8700", "Alice", "9500"] 升序
ZREVRANGE leaderboard 0 -1 WITHSCORES
# → ["Alice", "9500", "Bob", "8700", "Carol", "7600"] 降序
ZRANK leaderboard "Alice" # → 2(升序排名,从 0 开始)
ZREVRANK leaderboard "Alice" # → 0(降序排名,从 0 开始)
ZSCORE leaderboard "Alice" # → "9500"(查分,O(1))
ZINCRBY leaderboard 150 "Bob"
# Bob 加了 150 分 → 8850

ZINCRBY 是排行榜场景的灵魂——不用读出来再加再写回去,一条命令原子搞定。

排行榜:ZSet 的老本行#

游戏积分排行、文章热度排行、销售排行——这些需求的共同点是:经常要更新分数,经常要查 Top N,还要能查某个用户排第几

关系型数据库做这件事很痛苦:每次查 Top 100 要全表排序,ORDER BY score DESC LIMIT 100 在百万行数据下慢得难受。建索引能好一点,但频繁更新分数的场景(比如游戏积分),索引维护本身就是瓶颈。

Redis ZSet 一条命令出结果:

# Top 10
ZREVRANGE leaderboard 0 9 WITHSCORES
# 查 Alice 排名
ZREVRANK leaderboard "Alice"

百万玩家、每秒几千次分数更新和排名查询,Redis 轻松处理。跳表的 O(log N) 在内存里不是问题。

一个生产上的经验:按时间拆 key。日榜用 rank:daily:20250615,周榜用 rank:weekly:2025W25,月榜用 rank:monthly:202506。好处是:

  • 历史排行榜直接读旧 key,不用额外过滤
  • 旧 key 设过期自动清理,不用手动删
  • 单个 key 的数据量可控,操作更快

延迟队列:时间戳就是 score#

延迟任务的经典场景:下单 30 分钟未支付自动取消、会议开始前 5 分钟发提醒、定时推送。

score 不一定是”分数”——它可以是时间戳。把一个任务的到期时间设成 score,消费者定时查当前时间之前的任务:

import time
# 生产者:添加延迟任务
def add_delayed_task(task_id, delay_seconds):
execute_at = time.time() + delay_seconds
redis.zadd("delay_queue", {task_id: execute_at})
# 消费者:轮询到期任务
def poll_tasks():
now = time.time()
# 拿出所有 score ≤ 当前时间的任务
tasks = redis.zrangebyscore("delay_queue", 0, now)
for task_id in tasks:
if process(task_id):
redis.zrem("delay_queue", task_id) # 处理成功,删掉

多消费者时有个竞态问题:两个消费者同时拉到同一个任务。解决方法是用 Lua 脚本把”拉取 + 删除”原子化:

local tasks = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1])
if #tasks > 0 then
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[1])
end
return tasks

或者直接用 Redis 6.2+ 的 ZPOPMIN——从最小 score 开始弹出,天然原子。

轮询间隔取 100~500ms 对于大多数场景够了。延迟敏感的(比如支付超时关单)可以调到 50ms,但如果延迟要求是秒级精确——考虑用 Redis Stream 或 RabbitMQ 的死信队列。

score 精度的一个小坑#

用时间戳做 score 时,如果只精确到秒,同一秒内投递的多个任务排序不可控。解法是用毫秒时间戳。但也不要用纳秒——float64 存不下那么大的整数,会丢失精度。

滑动窗口限流#

前面 String 那篇聊了固定窗口限流的临界问题。Sorted Set 可以做到更平滑的滑动窗口

思路:每个请求以当前时间戳为 score 存入 ZSet。查当前窗口内的请求数,超过阈值就拒绝。旧于窗口的记录定时清理。

def is_allowed(user_id, limit=10, window_sec=60):
key = f"rate:{user_id}"
now = time.time()
window_start = now - window_sec
# 清理过期记录
redis.zremrangebyscore(key, 0, window_start)
# 当前窗口内的请求数
count = redis.zcard(key)
if count < limit:
# member 用时间戳+随机后缀防冲突
redis.zadd(key, {f"{now}:{random_id()}": now})
redis.expire(key, window_sec)
return True
return False

跟 String 固定窗口的区别:滑动窗口在每个时间点都有一道独立的窗口边界,不会出现 59 秒和 61 秒各放 10 个请求的问题。

跳表:ZSet 为什么这么快#

ZSet 底层是 跳表(skiplist)+ 哈希表(dict) 双结构。

哈希表负责 member → score 的 O(1) 查找。跳表负责排序和范围查询。

跳表是什么#

一个有序链表,查找是 O(n) 的。跳表在它上面加了索引层:

Level 2: A ──────────────────→ E
Level 1: A ──────→ C ──────→ E ──→ G
Level 0: A → B → C → D → E → F → G (原始链表,全部元素)

查一个元素,从最高层开始,逐层往下走。每层跳着走,不用一个一个遍历。复杂度 O(log N)。

为什么不用红黑树#

跳表和红黑树都是 O(log N)。但跳表有三个优势:

  1. 范围查询更自然:跳表找到起点后顺着链表走就行,红黑树要中序遍历
  2. 实现简单:跳表就是链表加索引,红黑树要处理旋转、变色
  3. 并发友好:跳表修改只影响局部指针,红黑树旋转可能影响整棵子树

Redis 选跳表还有一个原因——span 字段天然计算排名。跳表的每层索引记录了跨度(span),从 header 走到目标节点,把沿途的 span 累加,就是排名。红黑树要额外维护子树大小才能做到。

总结#

Sorted Set = Set + score + 自动排序。三个经典场景:

场景score 是什么核心命令
排行榜积分、热度值ZADD + ZREVRANGE + ZREVRANK
延迟队列到期时间戳ZADD + ZRANGEBYSCORE + ZREM
滑动窗口限流请求时间戳ZREMRANGEBYSCORE + ZCARD + ZADD

学到这里,Redis 的五种核心数据结构全部讲完了。下一篇是系列的最后一篇——把五种结构串起来,聊聊怎么在实际项目里选型、持久化怎么配、缓存穿透/击穿/雪崩到底怎么防。


这是 Redis 学习系列的第六篇。下一篇:Redis学习系列 | 学完了,然后呢?

Redis学习系列 | 实时排名到底怎么做的
https://qiandaos.top/posts/redis-learning-series/6-sorted-set/
作者
千岛寒流
发布于
2025-06-15
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

封面
Sample Song
Sample Artist
封面
Sample Song
Sample Artist
0:00 / 0:00