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

0 阅读11分钟

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

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


一、为什么是 Redis ZSet?

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

数据结构插入/更新排名查询TopN 查询适配排行榜
ListO(N)O(N)O(1)❌ 完全不适用
HashO(1)O(N)O(N)❌ 无法排序
SetO(1)O(N)O(N)❌ 无排序能力
ZSetO(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 和突发流量,必须分层设计

image.png

四层防线,各司其职:

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

整理了面试真题、每日技术知识点、系统学习路线,都汇总在个人网站 [www.javadashen.com] 有需要的同学自取。

公众号「Rain的Java大神之路」每天拆一个知识点,陪你悄悄变强,有空来坐坐。


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

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

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

// 更新用户积分
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%+。

/**
 * 技术亮点:同用户变更聚合 + 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⁹ 的位宽保证时间戳不会影响高位业务积分的精度
/**
 * 技术亮点:位宽隔离设计,零额外存储实现双维度排序
 */
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 内

/**
 * 技术亮点:多线程并行分桶查询 + 计数聚合,全局排名查询耗时压缩 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 内原子执行多个操作:

-- 原子更新多个榜单
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 端调用:

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 做本地缓存,不设过期随机失效,而是主动定时刷新,杜绝缓存击穿。

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~5w1~3ms百 MB 级
128 桶分片版千万级30~50w5~10ms1~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 ,一个喜欢把复杂技术讲透、让代码落地的实践者。

如果你厌倦了四处收集碎片化的八股文,想看看一线项目里真实的架构决策和踩坑记录,欢迎来我的公众号「Rain 的 Java 大神之路」坐坐。 在这里,我会把每一次技术复盘、每一个项目的设计源码,毫无保留地分享给你。

🔥 技术这条路很酷,我们结伴同行。
🚀Keep Coding, Keep Loving —— 与所有 Java 同路人共勉。

image.png