高并发下的排行榜系统:如何用Redis快速实现千万用户实时排名

高并发下的排行榜系统:如何用Redis快速实现千万用户实时排名

高并发、千万用户、实时排名------听起来吓人,拆开来看全是套路。本文从数据结构选型到工业级落地,手把手带你打出一套 Redis 排行榜的"组合拳",附带完整代码,可直接抄到生产环境。


一、为什么是 Redis ZSet?

排行榜的核心需求就三件事:分数实时更新、排名快速查询、TopN 批量拉取 。Redis 的 Sorted Set(有序集合)是原生适配该场景的最优解,底层采用压缩列表 + 跳表双结构实现,时间复杂度表现优异。

数据结构 插入/更新 排名查询 TopN 查询 适配排行榜
List O(N) O(N) O(1) ❌ 完全不适用
Hash O(1) O(N) O(N) ❌ 无法排序
Set O(1) O(N) O(N) ❌ 无排序能力
ZSet O(log N) O(log N) O(log N + M) ✅ 原生适配

千万数据量下,ZREVRANK 只需约 24 次比较(log₂1000万 ≈ 23.3),单次耗时约 0.02ms。小数据量时 ZSet 用压缩列表省内存,元素超过阈值自动切换为跳表,兼顾内存与查询性能。

内存估算:千万成员,每个 member + score 约 50 字节,总共约 500MB,一台 Redis 轻松吃下。


二、整体架构:不能把流量直接打到 Redis

单点 Redis 扛不住热 key 和突发流量,必须分层设计。

四层防线,各司其职:

  • 写入异步削峰:积分变更先进 Kafka,聚合后批量写 Redis,避免瞬间打爆主库
  • 读写分离:主节点只写,从节点扛读,可水平扩展从节点数量
  • 本地缓存扛热点:Top100 榜单用 Caffeine 缓存 1 秒,挡住 90% 读请求
  • 主动刷新热榜 :后台线程每秒拉取 ZREVRANGE 0 99 更新本地缓存,用户拉取时零延迟

关于这个问题的底层原理和更多实战细节,我整理了一份《大厂面试手册》,包含大厂高频面试题、源码解析和性能调优案例。

关注公众号【Rain的Java大神之路】,回复"Java"即可免费领取,持续更新中。


三、从百万到亿级:渐进式优化路线

3.1 基础版:单 ZSet 搞定百万级

百万用户规模内,一个 ZSet 直接搞定:

java 复制代码
// 更新用户积分
redisTemplate.opsForZSet().add("leaderboard:total", "user:" + userId, score);

// 查询用户排名(降序,返回 0 开始的名次,+1 转为业务排名)
Long rank = redisTemplate.opsForZSet().reverseRank("leaderboard:total", "user:" + userId);
long realRank = rank == null ? -1 : rank + 1;

// 查询 Top100 排行榜(带分数)
Set<ZSetOperations.TypedTuple<String>> top100 =
    redisTemplate.opsForZSet().reverseRangeWithScores("leaderboard:total", 0, 99);

瓶颈在哪? 单 key 承载千万用户会形成大 key,Redis 单线程下写入 QPS 上限仅 3~5w,无法承接高并发场景。

3.2 进阶版:水平分桶分片,解决大 key 与写入瓶颈

核心思路 :按用户 ID 哈希取模,拆分为 N 个独立 ZSet(例如分 128 桶:leaderboard:0 ~ leaderboard:127)。

  • 写入 :hash(userId) % 128 定位桶,分散写入压力,整体吞吐量线性提升
  • 个人排名:先查当前桶内排名,累加其余所有桶中分数高于当前用户的人数
  • TopN 查询:每个桶取前 N 名,应用层小顶堆归并排序得到全局 TopN

除非并发极高(10w+ QPS 写),一般千万级单 ZSet 完全够用,先不做过度设计。

3.3 终极版:冷热分离,兼顾性能与内存成本

99% 的请求集中在 Top100 / Top1000,尾部千万用户的排名查询占比极低,全量放 Redis 浪费内存。

  • 热数据层:Redis 仅存 Top 10000 用户,支撑高频 TopN 和头部用户排名查询
  • 全量层:全量用户分数落 MySQL / ClickHouse,尾部用户排名走数据库 + 5 分钟缓存
  • 异步校准:每分钟定时将全量数据的 TopN 刷新到 Redis,保证最终一致性

内存成本降低 90% 以上,同时保证头部热点数据毫秒级响应。


四、四大核心技术难点,逐个击破

4.1 峰值写入削峰:MQ + 同用户聚合 + Pipeline

活动峰值(签到、任务奖励秒刷)会产生海量积分更新请求,直接打 Redis 会触发限流甚至阻塞。

解法 :用 Kafka 做削峰填谷,消费侧做同用户变更聚合 ,再用 Redis Pipeline 批量写入,IO 次数减少 90%+。

java 复制代码
/**
 * 技术亮点:同用户变更聚合 + Redis Pipeline 批量写入,IO 次数减少 90%+
 */
@KafkaListener(topics = "leaderboard_score_update", groupId = "leaderboard-group")
public void batchConsumeUpdate(List<ScoreUpdateMsg> msgList) {
    // 1. 同用户多笔变更聚合,合并无效写入
    Map<Long, Double> userDeltaMap = new HashMap<>();
    for (ScoreUpdateMsg msg : msgList) {
        userDeltaMap.merge(msg.getUserId(), msg.getScoreDelta(), Double::sum);
    }

    // 2. 按分桶分组,同桶数据批量执行
    Map<Integer, Map<String, Double>> bucketBatch = new HashMap<>();
    userDeltaMap.forEach((userId, delta) -> {
        int bucketIdx = getBucketIndex(userId);
        bucketBatch.computeIfAbsent(bucketIdx, k -> new HashMap<>())
                .put(String.valueOf(userId), delta);
    });

    // 3. 每个桶用 Pipeline 批量执行,减少网络 RTT 开销
    bucketBatch.forEach((bucketIdx, scoreMap) -> {
        String key = getBucketKey(bucketIdx);
        redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
            scoreMap.forEach((userId, delta) ->
                    connection.zIncrBy(key.getBytes(), delta, userId.getBytes())
            );
            return null;
        });
    });
}

收益 :牺牲百毫秒级实时性,换取 10 倍以上的写入吞吐量,符合绝大多数业务"近实时"要求。

4.2 同分排序:位宽隔离加权,零额外存储

ZSet 默认同分按成员字典序排序,不符合"同分先达到者靠前"的业务需求。

解法:通过分数加权设计,将业务积分和时间戳编码到一个 double 值中:

复制代码
最终分数 = 业务积分 × 10⁹ + (最大时间戳 - 当前时间戳)
  • 积分高的用户永远靠前(高位)
  • 积分相同时,达成时间越早,时间差值越大,最终分数越高
  • 10⁹ 的位宽保证时间戳不会影响高位业务积分的精度
java 复制代码
/**
 * 技术亮点:位宽隔离设计,零额外存储实现双维度排序
 */
public class ScoreUtils {
    private static final double TIME_OFFSET = 1_000_000_000.0;
    private static final long MAX_TIMESTAMP = 4102444800L; // 2100-01-01

    public static double buildFinalScore(double bizScore) {
        long timeDiff = MAX_TIMESTAMP - System.currentTimeMillis() / 1000;
        return bizScore * TIME_OFFSET + timeDiff;
    }

    public static double extractBizScore(double finalScore) {
        return Math.floor(finalScore / TIME_OFFSET);
    }
}

4.3 分桶后全局排名:并行查询 + 小顶堆归并

分桶后最大的挑战是全局排名查询------串行遍历所有桶耗时可达百毫秒级。

解法 :多线程并行查询各桶计数 + 小顶堆多路归并计算 TopN,查询耗时压缩至 10ms 内。

java 复制代码
/**
 * 技术亮点:多线程并行分桶查询 + 计数聚合,全局排名查询耗时压缩 80%+
 */
public Long getGlobalRank(Long userId) {
    int userBucket = getBucketIndex(userId);
    String userKey = getBucketKey(userBucket);
    Double userScore = redisTemplate.opsForZSet().score(userKey, String.valueOf(userId));
    if (userScore == null) return -1L;

    Long bucketRank = redisTemplate.opsForZSet().reverseRank(userKey, String.valueOf(userId));
    long rankOffset = bucketRank == null ? 0 : bucketRank;

    // 并行查询其余桶中分数高于当前用户的总人数
    ExecutorService executor = Executors.newFixedThreadPool(8);
    List<Future<Long>> futures = new ArrayList<>();
    for (int i = 0; i < BUCKET_COUNT; i++) {
        if (i == userBucket) continue;
        int idx = i;
        futures.add(executor.submit(() -> {
            Long count = redisTemplate.opsForZSet().count(
                getBucketKey(idx), userScore, Double.MAX_VALUE);
            return count == null ? 0 : count;
        }));
    }

    long totalHigher = 0;
    for (Future<Long> future : futures) {
        try { totalHigher += future.get(); }
        catch (Exception e) { log.error("分桶排名查询异常", e); }
    }
    executor.shutdown();
    return totalHigher + rankOffset + 1;
}

/**
 * 技术亮点:小顶堆多路归并求 TopN,时间复杂度 O(M·logN),内存占用极低
 */
public List<LeaderboardVO> getGlobalTopN(int n) {
    List<Set<ZSetOperations.TypedTuple<String>>> bucketTopList = new ArrayList<>();
    for (int i = 0; i < BUCKET_COUNT; i++) {
        Set<ZSetOperations.TypedTuple<String>> topN =
            redisTemplate.opsForZSet().reverseRangeWithScores(getBucketKey(i), 0, n - 1);
        if (topN != null && !topN.isEmpty()) bucketTopList.add(topN);
    }

    // 小顶堆实现多路归并排序
    PriorityQueue<ZSetOperations.TypedTuple<String>> minHeap =
        new PriorityQueue<>(n, Comparator.comparingDouble(ZSetOperations.TypedTuple::getScore));

    for (Set<ZSetOperations.TypedTuple<String>> bucket : bucketTopList) {
        for (ZSetOperations.TypedTuple<String> tuple : bucket) {
            if (minHeap.size() < n) {
                minHeap.offer(tuple);
            } else if (tuple.getScore() > minHeap.peek().getScore()) {
                minHeap.poll();
                minHeap.offer(tuple);
            }
        }
    }

    List<LeaderboardVO> result = new ArrayList<>();
    while (!minHeap.isEmpty()) {
        ZSetOperations.TypedTuple<String> tuple = minHeap.poll();
        LeaderboardVO vo = new LeaderboardVO();
        vo.setUserId(Long.valueOf(tuple.getValue()));
        vo.setScore(ScoreUtils.extractBizScore(tuple.getScore()));
        result.add(0, vo);
    }
    for (int i = 0; i < result.size(); i++) {
        result.get(i).setRank(i + 1);
    }
    return result;
}

4.4 多级榜单原子更新:Lua 脚本一招搞定

日榜、周榜、月榜需要同时更新,如果用多条命令存在部分失败的风险。用 Lua 脚本可以在 Redis 内原子执行多个操作:

lua 复制代码
-- 原子更新多个榜单
local uid = KEYS[1]
local delta = tonumber(ARGV[1])
local today_key = KEYS[2]
local week_key = KEYS[3]
local month_key = KEYS[4]

redis.call('ZINCRBY', today_key, delta, uid)
redis.call('ZINCRBY', week_key, delta, uid)
redis.call('ZINCRBY', month_key, delta, uid)
-- 设置过期时间,避免内存无限膨胀
redis.call('EXPIRE', today_key, 86400 * 2)
redis.call('EXPIRE', week_key, 86400 * 7 * 2)

配合 Java 端调用:

java 复制代码
RScript rScript = redissonClient.getScript();
List<Object> keys = Arrays.asList(uid,
    "rank:daily:" + today,
    "rank:weekly:" + weekId,
    "rank:monthly:" + monthId);
rScript.eval(RScript.Mode.READ_WRITE, script,
    RScript.ReturnType.VALUE, keys, delta);

五、本地缓存扛热点:Caffeine 主动刷新

Top100 榜单变化不频繁,没必要每次请求都打到 Redis。用 Caffeine 做本地缓存,不设过期随机失效 ,而是主动定时刷新,杜绝缓存击穿。

java 复制代码
private final Cache<String, List<RankItem>> topNCache = Caffeine.newBuilder()
        .maximumSize(10)
        .build();

@Scheduled(fixedDelay = 1000) // 每秒刷新
public void refreshTopN() {
    RScoredSortedSet<String> zset = redissonClient
        .getScoredSortedSet("rank:daily:" + today);
    Collection<ScoredEntry<String>> entries = zset.entryRangeReversed(0, 99);
    List<RankItem> topList = entries.stream()
        .map(e -> new RankItem(e.getValue(), e.getScore()))
        .collect(Collectors.toList());
    topNCache.put("daily_top100", topList);
}

// 查询接口直接返回本地缓存,近乎零延迟
public List<RankItem> getTop100() {
    return topNCache.get("daily_top100", k -> Collections.emptyList());
}

效果:用户拉取 Top100 延迟 < 1ms,90% 的读请求根本不会到达 Redis。


六、性能参考数据

方案 支持用户规模 写入 QPS 排名查询耗时 内存占用
单 ZSet 基础版 百万级 3~5w 1~3ms 百 MB 级
128 桶分片版 千万级 30~50w 5~10ms 1~2GB
冷热分离版 亿级 50w+ TopN < 1ms;尾部 10~50ms 百 MB 级
场景 方案 延迟
查自己排名 读从库 / 本地缓存 < 1ms
拉 Top100 榜单 Caffeine 本地缓存 1s 刷新 < 1ms
实时刷新 Top100 主动拉 ZSet ~2ms
积分变更生效 Kafka → Redis 异步写入 50~200ms

积分变动后延迟一两百毫秒刷新排名,完全可接受------换来的是一整个量级的吞吐能力提升。


七、技术难点全景对照表

技术难点 问题影响 落地方案 实际收益
单 Key 大 key 风险 千万级单 ZSet 体积达 GB 级,阻塞 Redis 单线程 用户 ID 哈希分桶分片,拆分 N 个独立 ZSet 彻底消除大 key 风险,写入吞吐量线性提升
峰值写入压垮集群 活动峰值写入 QPS 10w+,同步直连易打满带宽 MQ 削峰 + 同用户聚合 + Pipeline 批量写入 写入承载能力提升 10 倍以上
分片后全局排名慢 串行遍历所有桶,耗时百毫秒级 多线程并行查询 + 小顶堆多路归并 排名查询耗时压缩至 10ms 内
同分排序不符合业务 ZSet 默认字典序,无法满足"先达成者靠前" 位宽隔离加权:积分占高位、时间戳占低位 零额外存储,100% 匹配业务规则
海量数据内存成本 亿级全量存 Redis 成本极高 冷热分离:Redis 仅存热数据,全量落库 内存成本降低 90%+
多级榜单一致性 日/周/月榜并发更新可能部分失败 Lua 脚本原子更新多榜单 严格原子性,杜绝部分更新
缓存击穿 TopN 缓存过期瞬间大量请求穿透 Caffeine 主动定时刷新,不设随机过期 延迟 < 1ms,杜绝击穿
主从切换丢数据 主节点宕机,从节点提升时可能丢失少量写入 MQ 持久化 + 断点重放,业务容忍少量回退 极端场景数据安全兜底

八、生产环境避坑指南

  1. 绝对禁止 ZRANGE key 0 -1 全量遍历大 key,会长时间阻塞 Redis 单线程,引发雪崩
  2. 分桶数量提前按 3 倍容量预估,避免后续扩容的数据迁移成本
  3. 活动类排行榜必须设置过期时间(日榜 2 天、周榜 15 天、月榜 62 天),下线后及时删除,避免内存泄漏
  4. 高频 TopN 查询加 1s 本地缓存,抗读峰值,大幅降低 Redis 压力
  5. ZREVRANK 返回名次从 0 开始,返回前端时必须 +1 转为业务排名,这个细节坑过无数人
  6. 统一使用 ZINCRBY 增量更新 ,避免 ZADD 覆盖导致并发扣分/加分乱序
  7. Kafka 按 uid 分区,保证同一用户的消息顺序消费

九、总结

用 Redis ZSet 存分、Kafka 异步削峰写入、读写分离 + Caffeine 本地缓存扛读、按时间维度拆分榜单避免热 key、哈希分桶解决大 key ------这套组合拳就是千万级实时排行榜的标准解法。

核心设计哲学就八个字:分层治理,渐进优化。

  • 百万级:单 ZSet 足矣,别过度设计
  • 千万级:分桶分片 + MQ 削峰 + Pipeline 批量写入
  • 亿级:冷热分离 + Redis Cluster + ClickHouse 混合方案

如果业务发展需要多维度排行榜(日榜、周榜、总榜),按时间维度拆分独立 ZSet,定时滚动归档;如果突破亿级超大规模,可基于 Redis Cluster 做跨节点分片,或引入 ClickHouse 物化视图 + Redis 缓存的混合架构,进一步扩展容量和并发能力。

面试加分 Tip:聊完基础方案后,主动抛出"同分排序怎么做""分桶后全局排名怎么算""异步写入一致性怎么保证"这些进阶问题,展示你对系统的深度思考,比单纯堆方案更有说服力。


如果本文对你有帮助,欢迎关注我的公众号【Rain的Java大神之路】。

专注 Java 面试、源码、高并发实战,回复"Java"领取《大厂面试手册》,持续更新。

相关推荐
Rain的Java大神实战圈4 小时前
Elasticsearch从0-1部署成功实战
场景设计题
Rain的Java大神实战圈1 天前
100亿订单号如何去重
场景设计题
Rain的Java大神实战圈1 天前
Git 冲突全攻略:从原理到实战,一文打通所有场景
场景设计题
Rain的Java大神实战圈1 天前
保证线程安全的方法有哪些
场景设计题
Rain的Java大神实战圈7 天前
DTO、VO、PO 到底要不要拆?一篇讲透 Java 实体分层的底层逻辑
场景设计题
Rain的Java大神实战圈8 天前
百万数据Excel如何快速导入导出
场景设计题
Rain的Java大神实战圈9 天前
如何快速上传10G文件
场景设计题
Rain的Java大神实战圈12 天前
数据脱敏是怎么做的
场景设计题
Rain的Java大神实战圈13 天前
对加密的手机号如何进行模糊查询
场景设计题
Rain的Java大神实战圈14 天前
如何自定义一个MyBatis插件
场景设计题