用 Redis 做排行榜和延时队列:两个 zset 的经典用法

米阳 2025-7-12 4 7/12

有序集合(zset)是 Redis 里最有用的结构之一:每个元素带一个分数,按分数自动排序。排行榜和延时队列都是它的直接应用。

排行榜

# 加分
ZINCRBY rank:2025-07 1 user:1001

# 前 10 名(降序,带分数)
ZREVRANGE rank:2025-07 0 9 WITHSCORES

# 某个用户的排名(从 0 开始,降序)
ZREVRANK rank:2025-07 user:1001

# 分数区间(比如 100~200 分之间)
ZRANGEBYSCORE rank:2025-07 100 200

ZINCRBY 是原子操作,并发安全,不用先读再写。

排名分页

ZREVRANGE start stop 就是分页,start/stop 是索引:

# 第 3 页,每页 20 条
ZREVRANGE rank:2025-07 40 59 WITHSCORES

注意深分页依然是 O(log N + M),M 是返回条数,比数据库的 LIMIT 100000, 20 快得多。但返回的数据量大了还是慢,前 1000 名够用了。

并列怎么处理

分数相同时,Redis 按成员的字典序排列。如果想「先到先得」,把时间戳编进分数:

score = points * 10**10 + (10**10 - int(time.time()))
redis.zincrby(key, 1, user_id)  # 这个不适合,因为分数会重算

更实际的做法是分数存 points,需要展示并列时在应用层二次排序。硬编码进分数会让加减分变复杂。

延时队列

思路:member 是任务 id,score 是执行时间戳(毫秒)。消费者轮询取出到期的:

import time

def push(task_id, delay_seconds):
    redis.zadd("delay:queue", {task_id: time.time() + delay_seconds})

def pop_due(limit=10):
    now = time.time()
    ids = redis.zrangebyscore("delay:queue", 0, now, start=0, num=limit)
    for tid in ids:
        # 用 ZREM 的返回值做抢占,多消费者安全
        if redis.zrem("delay:queue", tid):
            yield tid

def worker():
    while True:
        for tid in pop_due():
            handle(tid)
        time.sleep(1)

关键点:用 ZREM 的返回值判断是否抢到。ZRANGEBYSCORE 只是读,多个消费者会读到同一条;ZREM 删成功(返回 1)的那个才继续处理,这是原子的。

可靠性

上面的实现有个问题:取出来之后进程崩了,任务就丢了。要可靠的话,先转移到「处理中」队列,处理完再删:

ZADD processing:{ts} task    # 转移
# 处理成功后
ZREM processing:{ts} task
# 定时扫描 processing,超时未完成的重新入队

或者直接用 Redis 5.0 的 Stream + 消费组,支持 ack 和 pending 列表,比 zset 方案完整。

什么时候不该用

  • 任务量大(百万级)、需要持久化和重放 → 用 RabbitMQ / Kafka
  • 需要精确到毫秒的定时 → Redis 轮询精度不够,用专门的时间轮
  • 单机就够 → 数据库表 + 定时任务更简单

一句话

zset 的分数可以是「积分」也可以是「时间戳」,同一个结构解决两个看似无关的问题。理解了这一点,很多场景都能套上来。

- THE END -

米阳

7月12日09:35

最后修改:2025年7月12日
0

非特殊说明,本博所有文章均为博主原创。

共有 0 条评论