第7章:Redis有序集合 ZSet 实战:排行榜与延时任务

0 阅读21分钟

1. 项目背景

1.1 业务场景

运营团队规划了两项活动需求:

第一,618 积分冲榜。用户在活动期间下单、评价、分享都能获得积分,App 首页实时展示"积分榜 Top 100"和"我的排名"。运营还要求每天 0 点自动归档昨日榜单,生成"日冠军"名单,同时总榜继续累加。产品经理强调:相同积分必须按并列名次处理,不能因为 Redis 默认排序让同分用户分出先后。

第二,订单 30 分钟未支付自动关闭。用户创建订单后 30 分钟内如果未完成支付,系统需要自动关闭订单、释放冻结的库存,并退还优惠券。这个需求如果用数据库定时任务实现——每分钟执行 UPDATE orders SET status='CLOSED' WHERE status='WAIT_PAY' AND created_at < NOW() - INTERVAL 30 MINUTE——会遇到两个问题:扫描间隔太短会影响数据库性能,间隔太长会导致订单关闭不及时。而且在秒杀场景下,同一秒可能有上千订单到期,数据库行锁竞争会很激烈。

这两个需求看似不相关,但本质都是"按某个数值排序并快速取出 Top N"——排行榜排的是积分,延时任务排的是执行时间戳。Redis ZSet 恰好是解决这类问题的标准答案。

1.2 痛点

需求数据库方案的表现ZSet 方案的优势
积分榜 Top 100SELECT uid, score FROM rank ORDER BY score DESC LIMIT 100 → 高峰期每次积分变化都触发排序,大表全表扫描或索引回表ZREVRANGE key 0 99 WITHSCORES → O(log N+100),不依赖全表扫描
查我的排名SELECT COUNT(*) FROM rank WHERE score > myScore → O(N) 统计,慢查询ZREVRANK key uid → O(log N)
积分更新UPDATE rank SET score = score + ? WHERE uid = ? → 行锁,热点行竞争ZINCRBY key 100 uid → O(log N) 原子操作
订单超时扫描SELECT * FROM orders WHERE status='WAIT_PAY' AND expire_time < NOW() → 全表扫描或索引 + 定时轮询ZRANGEBYSCORE key -inf NOW LIMIT 0 50 → O(log N + 50),按需取
并发抢占多个消费实例同时 UPDATE → 行锁竞争ZREM + 应用层抢占 → 只有删除成功的才处理
历史榜单需要额外表来归档,查询需要 JOIN每个日期一个 ZSet key,各自独立 TTL

1.3 本章目标

掌握 Redis ZSet 的三种实战模式:

  • 积分排行榜:ZADD/ZINCRBY/ZREVRANGE/ZREVRANK + 并列名次设计。
  • 延时任务队列:用 ZSet 的 score 存执行时间戳,ZRANGEBYSCORE + ZREM 实现消费抢占。
  • 时间线/热榜:score 设计从简单积分到复合分值(时间加权)。

同时理解三个关键边界:

  • ZSet 的 score 是双精度浮点数——不适合拼接复杂信息。
  • 深分页(ZREVRANGE key 10000 10020)虽然比数据库好,但仍有 O(log N + M) 的成本。
  • 延时任务的消费者抢占机制——ZREM 返回 1 的才处理,但仍需数据库幂等兜底。

2. 项目设计

2.1 第一幕:score 怎么设计?

小胖:"排行榜不就是按积分排吗?积分高的站上面。ZADD rank:618 uid score,好了。"

小白:"那相同积分的人怎么排?Redis 默认同分按 member 字典序排——如果两个用户积分都是 1680,名字排序靠前的就永远在前面。但这不合理吧?并列金牌应该是两个人并列第一,下一个是第三名。"

大师:"score 设计是 ZSet 的第一课。先说我画的几种常见 score 模式:"

模式 1:单一分值(积分榜)
  member: 用户ID
  score:  积分
  → 简单直接,适合"同分同排名"的竞技榜

模式 2:时间戳(延时任务)
  member: 订单ID / 任务ID
  score:  执行时间戳(Unix 秒)
  → 越早到期的 score 越小,ZRANGEBYSCORE 按时间扫

模式 3:复合分值(热榜)
  score = 基础分 + 时间加权
  → 例如: score = viewCount * 0.6 + likeCount * 0.4 + (now - postTime) * -0.1
  → 老内容逐渐降权,新内容自然上升

模式 4:时间倒序(时间线)
  member: 动态ID
  score:  发布时间戳
  → ZREVRANGE 返回最新发布的动态

小胖:"那并列名次怎么办?用 ZREVRANK + 1 肯定不对——因为这会给同分的人排不同的名次。"

大师:"正确做法不是直接拿 ZREVRANK,而是统计'有多少人的分数严格大于我':"

# 我的分数
ZSCORE rank:618 1001
# → "1680"

# 分数严格大于 1680 的人数
ZCOUNT rank:618 (1680 +inf
# → 2  (表示有 2 个人分数 > 1680)

# 我的名次 = 大于我的人数 + 1 = 3 (并列第 3)

技术映射:ZREVRANK 是 Redis 内部的排名(从 0 开始,同分按字典序),不等于业务的并列排名。业务并列排名 = 分数严格大于我的人数 + 1。

2.2 第二幕:延时任务——ZSet 不是 RabbitMQ

小胖:"订单关闭这事,不就是把订单 ID 放进队列,等 30 分钟再处理吗?List 不能做?"

小白:"List 的问题是——你没法'等到某个时间点才消费'。List 是先进先出,不是'到期再出'。如果你用 List,必须靠消费者轮询 + sleep,粗粒度不可靠。"

大师:"ZSet 做延时任务的核心是把'处理时间'编码为 score。消费者的逻辑非常简单:"

消费者的核心循环:
  while true:
    now = currentTimestamp()
    tasks = ZRANGEBYSCORE delay:order:close -inf now LIMIT 0 50
    for task in tasks:
      if ZREM delay:order:close task == 1:  ← 抢占成功
        processTask(task)
      else:
        continue  ← 被其他消费者抢了
    sleep(1)  ← 没有到期任务时休眠 1 秒

小胖:"那如果消费者 ZREM 成功后宕机了呢?订单不就没人关了?"

大师:"这就要靠数据库兜底了。ZREM 只保证'Redis 中的任务被移除且只有一个消费者拿到',但消费者拿到后能不能成功处理,是另一个问题。所以处理逻辑必须是幂等的:"

-- 幂等关闭订单(只有 WAIT_PAY 状态的才会被关闭)
UPDATE orders
SET status = 'CLOSED', closed_at = NOW()
WHERE id = ? AND status = 'WAIT_PAY';

大师:"而且还需要一个对账任务——每小时扫描一次数据库,找出所有超过 30 分钟仍处于 WAIT_PAY 状态的订单,用 ZADD 重新放回 ZSet。这样即使消费者崩溃了,最多 1 小时后订单也会被重新处理。"

技术映射:ZSet 延时任务 = 调度层(ZSet 负责"到点了发现任务") + 执行层(应用负责"正确地执行任务") + 兜底层(对账任务负责"发现漏网之鱼")。

2.3 第三幕:历史榜单和 key 生命周期

小胖:"日榜要每天归档,周榜每周归档,总榜不归档。这么多榜单,Redis key 不会爆炸吗?"

大师:"按日期分 key 是最清晰的设计:"

日榜: rank:618:daily:20260718  → EXPIRE 30 天后自动删除
周榜: rank:618:weekly:2026W29   → EXPIRE 90 天后自动删除
总榜: rank:618:total            → 不设过期,长期保留

每次积分变动同时更新:
  ZINCRBY rank:618:daily:20260718 10 uid
  ZINCRBY rank:618:weekly:2026W29  10 uid
  ZINCRBY rank:618:total           10 uid

小白:"三个 ZSet 三倍内存,会不会太浪费?"

大师:"单个 ZSet 的内存开销并不大——1 万个 member 的 ZSet 大约占用 200-400KB。而且日榜设了 30 天 TTL,到时会自动清理。相比把多个日期塞进一个 ZSet(用 member 拼接日期),分开 key 的查询性能更好、清理也更简单。"

技术映射:按时间维度拆分 key 是 Redis 的最佳实践。"大而全"的 key 查询复杂且难以清理。

2.4 第四幕:热榜——score 可以随时间变化

小胖:"热榜是什么?和积分榜不一样吗?"

大师:"积分榜是纯累加——分越来越高。热榜是动态的——老内容即使之前很火,时间久了也应该降权。这要求 score 不是一成不变的:"

热榜 score 的组成:
  score = 热度分 - 时间衰减

  热度分 = viewCount * 1 + likeCount * 3 + commentCount * 5
  时间衰减 = (当前时间 - 发布时间) * 衰减系数

  发布时间越早,衰减越大,score 越低
  新发布的内容即使热度分不高,衰减小,score 可能高于老内容

大师:热榜的 score 需要定期刷新(比如每 5 分钟重算一次)。但这会带来一个问题——如果全量重算所有内容的 score,在 100 万条数据时非常重。实际做法是:只重算最近 N 天发布的内容 + 前 1000 名热度内容;其他内容可以降低刷新频率。


3. 项目实战

3.1 环境准备

docker exec -it redis-lab redis-cli -a redis123

3.2 实战一:积分排行榜

步骤 1:初始化榜单

# ZADD:key=榜单名,score=积分,member=用户ID
127.0.0.1:6379> ZADD rank:618:daily:20260718 1200 1001 980 1002 1680 1003 1680 1004 750 1005
(integer) 5

# 查看 Top 5(按积分从高到低)
127.0.0.1:6379> ZREVRANGE rank:618:daily:20260718 0 4 WITHSCORES
1) "1003"
2) "1680"
3) "1004"
4) "1680"
5) "1001"
6) "1200"
7) "1002"
8) "980"
9) "1005"
10) "750"
# 注意:1003 和 1004 同分 1680,Redis 按 member 字典序排(1003 < 1004)

步骤 2:积分增减和排名查询

# 用户 1002 完成任务,加 300 分
127.0.0.1:6379> ZINCRBY rank:618:daily:20260718 300 1002
"1280"

# 查询用户 1002 的当前分数
127.0.0.1:6379> ZSCORE rank:618:daily:20260718 1002
"1280"

# 查询排名(Redis 内部排名,从 0 开始,高分在前用 ZREVRANK)
127.0.0.1:6379> ZREVRANK rank:618:daily:20260718 1002
(integer) 2     # 排名第 3(0-based → 展示为 3)

# 查询低分到高分的排名(用 ZRANK)
127.0.0.1:6379> ZRANK rank:618:daily:20260718 1005
(integer) 0     # 1005 分数 750,最低,排第 1(从低到高)

# 查看用户附近排名(上下各 2 名)
127.0.0.1:6379> ZREVRANK rank:618:daily:20260718 1001
(integer) 1     # 我排第 2
127.0.0.1:6379> ZREVRANGE rank:618:daily:20260718 0 3 WITHSCORES
# 返回 0~3 名,包含了我的前一名和后两名

步骤 3:并列名次处理(关键)

# 1003 和 1004 都是 1680 分 → 应该并列第一

# 错误做法:
127.0.0.1:6379> ZREVRANK rank:618:daily:20260718 1003
(integer) 0     # 排名 0 → 直接 +1 = 第 1 名
127.0.0.1:6379> ZREVRANK rank:618:daily:20260718 1004
(integer) 1     # 排名 1 → 直接 +1 = 第 2 名 ← 错误!应该是并列第 1

# 正确做法:统计分数严格大于我的人数
127.0.0.1:6379> ZSCORE rank:618:daily:20260718 1003
"1680"
127.0.0.1:6379> ZCOUNT rank:618:daily:20260718 (1680 +inf
(integer) 0     # 0 个人分数 > 1680 → 排名第 1

127.0.0.1:6379> ZSCORE rank:618:daily:20260718 1001
"1200"
127.0.0.1:6379> ZCOUNT rank:618:daily:20260718 (1200 +inf
(integer) 2     # 2 个人分数 > 1200(1003 和 1004) → 排名第 3

Java 并列排名实现:

public class RankService {
    private static final String RANK_KEY_PREFIX = "rank:618:daily:";

    // 获取用户积分
    public double getScore(String date, String uid) {
        Double score = redis.zScore(RANK_KEY_PREFIX + date, uid);
        return score == null ? 0 : score;
    }

    // 增加积分
    public double addScore(String date, String uid, double delta) {
        return redis.zIncrBy(RANK_KEY_PREFIX + date, delta, uid);
    }

    // 获取 Top N
    public List<RankItem> getTopN(String date, int n) {
        Set<ZSetOperations.TypedTuple<String>> topN =
            redis.zRevRangeWithScores(RANK_KEY_PREFIX + date, 0, n - 1);
        return topN.stream()
            .map(t -> new RankItem(t.getValue(), t.getScore()))
            .collect(Collectors.toList());
    }

    // 获取用户的业务排名(并列处理)
    public long getUserRank(String date, String uid) {
        String key = RANK_KEY_PREFIX + date;
        Double score = redis.zScore(key, uid);
        if (score == null) {
            return -1;  // 未上榜
        }
        // 统计分数严格大于该用户的 count
        Long higherCount = redis.zCount(key, score + 1, Double.POSITIVE_INFINITY);
        // 名次 = 高于我的 count + 1
        return (higherCount == null ? 0 : higherCount) + 1;
    }

    // 获取用户附近排名(上下各 m 名)
    public List<RankItem> getUserNearby(String date, String uid, int m) {
        String key = RANK_KEY_PREFIX + date;
        Long myRank = redis.zRevRank(key, uid);  // Redis 内部排名
        if (myRank == null) return Collections.emptyList();

        long start = Math.max(0, myRank - m);
        long end = myRank + m;
        Set<ZSetOperations.TypedTuple<String>> nearby =
            redis.zRevRangeWithScores(key, start, end);
        return nearby.stream()
            .map(t -> new RankItem(t.getValue(), t.getScore()))
            .collect(Collectors.toList());
    }
}

3.3 实战二:延时任务(订单超时关闭)

步骤 1:写入延时任务

# 订单 9001 期望在时间戳 1714470000 关闭
# 订单 9002 期望在时间戳 1714470060 关闭

127.0.0.1:6379> ZADD delay:order:close 1714470000 9001
(integer) 1
127.0.0.1:6379> ZADD delay:order:close 1714470060 9002
(integer) 1

# 查看所有延时任务(按到期时间排序)
127.0.0.1:6379> ZRANGE delay:order:close 0 -1 WITHSCORES
1) "9001"
2) "1714470000"
3) "9002"
4) "1714470060"

# 查看还有多少秒到期
127.0.0.1:6379> ZSCORE delay:order:close 9001
"1714470000"

步骤 2:消费者扫描并抢占任务

# 假设当前时间戳为 1714470000

# 第一步:扫描已到期的任务
127.0.0.1:6379> ZRANGEBYSCORE delay:order:close -inf 1714470000 LIMIT 0 50
1) "9001"     # 只返回已到期的

# 第二步:用 ZREM 抢占(返回 1 表示抢占成功)
127.0.0.1:6379> ZREM delay:order:close 9001
(integer) 1     # 抢占成功!

# 如果另一个消费者也尝试 ZREM 同一个订单
127.0.0.1:6379> ZREM delay:order:close 9001
(integer) 0     # 抢占失败——已被别人处理或不存在

步骤 3:Java 延时任务消费者

public class DelayedTaskConsumer implements Runnable {
    private static final String DELAY_KEY = "delay:order:close";
    private static final int BATCH_SIZE = 50;
    private static final int POLL_INTERVAL_MS = 1000;

    private final AtomicBoolean running = new AtomicBoolean(true);

    @Override
    public void run() {
        while (running.get()) {
            try {
                long now = System.currentTimeMillis() / 1000;

                // 1. 扫描已到期的任务
                Set<String> expiredIds = redis.zRangeByScore(
                    DELAY_KEY, 0, now, 0, BATCH_SIZE);

                if (expiredIds == null || expiredIds.isEmpty()) {
                    Thread.sleep(POLL_INTERVAL_MS);
                    continue;
                }

                // 2. 逐个抢占
                for (String orderId : expiredIds) {
                    Long removed = redis.zRem(DELAY_KEY, orderId);
                    if (removed != null && removed == 1) {
                        // 抢占成功,处理任务
                        processDelayedTask(orderId);
                    }
                    // removed == 0 → 被其他消费者抢了,跳过
                }

            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                break;
            } catch (Exception e) {
                log.error("延时任务处理异常", e);
                safeSleep(2000);
            }
        }
    }

    private void processDelayedTask(String orderId) {
        // 幂等关闭订单——只有 WAIT_PAY 状态才会被关闭
        int affected = jdbcTemplate.update(
            "UPDATE orders SET status = 'CLOSED', closed_at = NOW() " +
            "WHERE id = ? AND status = 'WAIT_PAY'",
            orderId
        );

        if (affected > 0) {
            log.info("订单 {} 超时自动关闭成功", orderId);
            // 释放库存、退还优惠券等后续操作...
        } else {
            log.info("订单 {} 已非待支付状态,跳过关闭", orderId);
        }
    }
}

步骤 4:对账任务(兜底)

@Scheduled(fixedDelay = 3600_000)  // 每小时执行一次
public void reconcile() {
    // 从数据库找出超时未关闭的订单
    List<String> staleOrders = jdbcTemplate.queryForList(
        "SELECT id FROM orders " +
        "WHERE status = 'WAIT_PAY' " +
        "AND created_at < NOW() - INTERVAL 30 MINUTE",
        String.class
    );

    for (String orderId : staleOrders) {
        // 问:这个订单还在 ZSet 里吗?
        Double score = redis.zScore(DELAY_KEY, orderId);
        if (score == null) {
            // 不在 ZSet 里 → 可能被消费者取走了但处理失败
            // 重新放入 ZSet,score 设为现在(立即触发)
            redis.zAdd(DELAY_KEY, System.currentTimeMillis() / 1000.0, orderId);
            log.warn("对账发现遗漏订单 {}, 重新加入延时队列", orderId);
        }
        // 如果在 ZSet 里 → score 如果已经小于 now,说明消费者还没来得及扫到
        // score 如果还在未来,说明还没到期,正常
    }
}

3.4 实战三:复合分值热榜

# 热榜 score 可以多维合成
# rank:hot -> score = viewCount * 1 + likeCount * 3 - timeDecay

# 初始化热榜数据
127.0.0.1:6379> ZADD rank:hot:20260718 850 post:1001 920 post:1002 780 post:1003

# 每次浏览增加 1 分
127.0.0.1:6379> ZINCRBY rank:hot:20260718 1 post:1001
"851"

# 每次点赞增加 3 分
127.0.0.1:6379> ZINCRBY rank:hot:20260718 3 post:1001
"854"

# 定期做时间衰减(比如每 30 分钟执行一次)
# 从 ZSet 取出所有 member 和 score,减去时间衰减后写回
# (实际项目中,往往是懒计算——读取时根据发布时间和当前时间算衰减,
#  而不是频繁全量更新 score)

时间衰减的懒计算模式(推荐):

public class HotRankService {

    // 不实时更新 score 做衰减,而是读取时动态计算
    public List<HotPost> getHotPosts(String date, int limit) {
        // 1. 从 ZSet 拿原始热度分 Top N*2(留余量)
        Set<ZSetOperations.TypedTuple<String>> candidates =
            redis.zRevRangeWithScores("rank:hot:raw:" + date, 0, limit * 2 - 1);

        List<HotPost> result = new ArrayList<>();
        long now = System.currentTimeMillis() / 1000;

        for (ZSetOperations.TypedTuple<String> t : candidates) {
            String postId = t.getValue();
            double rawScore = t.getScore();

            // 2. 查帖子发布时间
            Long postTime = getPostPublishTime(postId);

            // 3. 计算衰减后的真实分数
            long ageSeconds = now - postTime;
            double decay = ageSeconds / 3600.0 * 0.5;  // 每小时降 0.5 分
            double finalScore = rawScore - decay;

            result.add(new HotPost(postId, finalScore));
        }

        // 4. 按最终分数重新排序
        result.sort((a, b) -> Double.compare(b.score, a.score));
        return result.subList(0, Math.min(limit, result.size()));
    }
}

3.5 实战四:多榜单联动(日榜+周榜+总榜)

# 一笔积分写入同时更新三个榜单
127.0.0.1:6379> ZINCRBY rank:618:daily:20260718 100 1001
"1300"
127.0.0.1:6379> ZINCRBY rank:618:weekly:2026W29 100 1001
"1300"
127.0.0.1:6379> ZINCRBY rank:618:total 100 1001
"1300"

# 日榜设置 30 天过期
127.0.0.1:6379> EXPIRE rank:618:daily:20260718 2592000
(integer) 1

# 周榜设置 90 天过期
127.0.0.1:6379> EXPIRE rank:618:weekly:2026W29 7776000
(integer) 1

# 总榜不设置过期
127.0.0.1:6379> TTL rank:618:total
(integer) -1     # 永不过期

3.6 实战五:ZSet 范围操作

# 查询分数在 1000-2000 之间的用户
127.0.0.1:6379> ZRANGEBYSCORE rank:618:daily:20260718 1000 2000 WITHSCORES
1) "1001"  2) "1200"
2) "1002"  3) "1280"
3) "1003"  4) "1680"
4) "1004"  5) "1680"

# 按排名删除末位(保留前 1000 名,删除 1001 名之后)
127.0.0.1:6379> ZREMRANGEBYRANK rank:618:daily:20260718 0 -1001

# 删除 750 分以下的用户
127.0.0.1:6379> ZREMRANGEBYSCORE rank:618:daily:20260718 -inf 750

# 统计分数在某范围的人数
127.0.0.1:6379> ZCOUNT rank:618:daily:20260718 1000 2000
(integer) 3

# 获取排行榜总人数
127.0.0.1:6379> ZCARD rank:618:daily:20260718
(integer) 5

3.7 实战六:观察 ZSet 编码和内存

# 小 ZSet 用 listpack 编码(Redis 7.0+)
127.0.0.1:6379> OBJECT ENCODING rank:618:daily:20260718
"listpack"

# 查看内存占用
127.0.0.1:6379> MEMORY USAGE rank:618:daily:20260718
(integer) 312

# 当成员数量超过阈值,编码自动切换为 skiplist(跳表+哈希表)
# 跳表负责排序,哈希表负责 O(1) 查找 member 的 score

4. 项目总结

4.1 核心命令速查

命令用途复杂度注意事项
ZADD key score member添加/更新成员O(log N)可一次加多个;member 已存在则更新 score
ZINCRBY key delta member原子自增 scoreO(log N)member 不存在时先加再增
ZSCORE key member获取成员分数O(1)member 不存在返回 nil
ZRANK key member排名(低→高,从 0 开始)O(log N)
ZREVRANK key member排名(高→低,从 0 开始)O(log N)等同分不同 member 排名不同
ZRANGE key start end按排名范围取O(log N+M)M 是返回数量
ZREVRANGE key start end倒序按排名取O(log N+M)带 WITHSCORES 返回分数
ZRANGEBYSCORE key min max按分数范围取O(log N+M)(1000 表示 >1000;LIMIT offset count 分页
ZCOUNT key min max统计分数范围 countO(log N)等同分名次的关键命令
ZREM key member删除成员O(log N)延时任务抢占的核心
ZREMRANGEBYRANK key start end按排名删除O(log N+M)保留前 N 名
ZREMRANGEBYSCORE key min max按分数删除O(log N+M)
ZCARD key获取成员总数O(1)
ZINTERSTORE dst N k1 k2交集存为新 ZSetO(N*K)+O(M log M)可指定聚合方式(SUM/MIN/MAX)
ZUNIONSTORE dst N k1 k2并集存为新 ZSetO(N)+O(M log M)同上

4.2 优点与缺点

维度详细说明
优点1:天然排序插入时就维护好顺序,读取时直接按 index 范围取
优点2:排名查询快ZRANK/ZREVRANK 都是 O(log N),远胜数据库的 COUNT
优点3:原子自增ZINCRBY 不需要读改写,高并发下安全
优点4:延时任务轻量无需额外中间件就能实现"按时间触发"的调度
优点5:范围操作丰富按排名、按 score、按字典序,灵活组合
缺点1:score 精度限制双精度浮点数,拼接复合信息需谨慎
缺点2:深分页有开销ZREVRANGE key 100000 100020 仍需遍历前面 100000 个节点
缺点3:不存复杂属性member 通常只是 ID,需要额外查其他数据源
缺点4:全量更新重热榜的时间衰减如果全量重算,大数据量时成本高

4.3 适用与不适用场景

适用场景:

  1. 各类排行榜——积分榜、战力榜、消费榜、贡献榜,支持日/周/月/总榜。
  2. 延时任务——订单超时关闭、优惠券到期提醒、定时推送。
  3. 时间线——按发布时间排序的动态流、消息列表。
  4. 热榜——浏览+点赞+评论复合分数,支持时间衰减。
  5. 优先级队列——按优先级 score 排序的任务队列,ZPOPMAX 取最高优先级。
  6. 滑动窗口限流——member 是请求 ID/timestamp,score 是时间戳,ZREMRANGEBYSCORE 清理过期窗口。

不适用场景:

  1. 需要消息确认和重投的可靠队列——应使用 Stream(第 20 章)。
  2. 只需"有没有"而不需排序的关系——应使用 Set(第 6 章)。
  3. 大量成员需要按复杂属性过滤——ZSet 只有 score 一个排序维度。
  4. member 非常大且数量极多——ZSet 的内部跳表需要额外的指针开销。

4.4 常见踩坑经验

案例表象根因解法
并列名次计算错误用户投诉"我和朋友都是 1680 分,他显示第 1 名我显示第 2 名"用 ZREVRANK + 1 直接当名次,同分被字典序分开改用 ZSCORE + ZCOUNT 统计严格大于自己的人数 + 1
延时任务被重复处理用户收到两条"订单已关闭"的短信两个消费者同时 ZRANGEBYSCORE 拿到同批任务,都用 ZREM 抢占但 DB 更新没加状态条件ZREM 判断返回值;DB UPDATE 加 WHERE status='WAIT_PAY' 幂等
热榜全量重算打挂 Redis每 5 分钟热榜全量重算,Redis CPU 飙到 95%100 万条 member 全量 ZADD 更新 score只更新近 3 天数据 + 前 1000 名;其他数据用懒衰减
深分页慢查询排行榜"第 1000 页"加载需要 2 秒ZREVRANGE key 10000 10020 的 O(log N + 10000) 成本限制最大翻页深度(如前 100 页);提供"跳转到我的排名附近"
score 浮点精度问题复合 score(时间戳+积分)排序不准把时间戳(10 位)+ 积分拼接进双精度浮点数,超出 53 位有效精度复合 score 尽量保持在一个数量级内;或用二级排序

4.5 思考题

  1. 设计题:一个直播平台的"小时榜"需要统计最近 60 分钟的礼物收入,每分钟刷新一次。如果用一个 ZSet,你需要定期清理 60 分钟前的数据。请设计具体的实现方案——key 设计、score 设计、清理策略,以及如何在清理期间保持榜单正确性。

  2. 优化题:延时任务队列(delay:order:close)中堆积了 100 万个待关闭订单。消费者每次 ZRANGEBYSCORE -inf now LIMIT 0 50,但发现每次扫描都返回空(因为 score 最小的订单还没到期)。如何优化避免"空转扫描"浪费 CPU?

(答案提示将在下一章末尾给出。)

上一章思考题答案提示(第6章):

第1题(多维推荐):不同维度的关联度量化:共同关注数用 SINTERCARD 直接获取数量;共同群聊用 SINTER groups:A groups:B 后用 SCARD;通讯录匹配在应用层做。最终推荐分数 = 各维度加权求和(如共同关注×5 + 共同群聊×3 + 通讯录×10)。TopN 推荐:离线任务对所有候选计算分数后写入 ZSet(recommend:scores:{uid}),member=候选 userId,score=推荐分数,前端读 ZREVRANGE 即可。

第2题(取关后仍看到动态):3 种可能:① 动态内容在取关前已被推送到时间线缓存里(如用户下拉刷新前已经加载了这批数据)——前端刷新后即消失;② 关注/取关的双写不一致——followings 集合已删除但 followers 或关系链缓存未更新——检查反向索引和缓存 key;③ 推送服务使用了独立的关注缓存(本地内存),取关时未通知其失效——检查是否有缓存刷新机制。