资讯详情

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

📅 2026/10/8 2:08:16 | 华诺云谱 👁 阅读
高并发下的排行榜系统:如何用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 和突发流量必须分层设计。四层防线各司其职写入异步削峰积分变更先进 Kafka聚合后批量写 Redis避免瞬间打爆主库读写分离主节点只写从节点扛读可水平扩展从节点数量本地缓存扛热点Top100 榜单用 Caffeine 缓存 1 秒挡住 90% 读请求主动刷新热榜后台线程每秒拉取ZREVRANGE 0 99更新本地缓存用户拉取时零延迟关于这个问题的底层原理和更多实战细节我整理了一份《大厂面试手册》包含大厂高频面试题、源码解析和性能调优案例。关注公众号【Rain的Java大神之路】回复“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 排行榜带分数 SetZSetOperations.TypedTupleString top100 redisTemplate.opsForZSet().reverseRangeWithScores(leaderboard:total, 0, 99);瓶颈在哪单 key 承载千万用户会形成大 keyRedis 单线程下写入 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(ListScoreUpdateMsg msgList) { // 1. 同用户多笔变更聚合合并无效写入 MapLong, Double userDeltaMap new HashMap(); for (ScoreUpdateMsg msg : msgList) { userDeltaMap.merge(msg.getUserId(), msg.getScoreDelta(), Double::sum); } // 2. 按分桶分组同桶数据批量执行 MapInteger, MapString, 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((RedisCallbackObject) 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); ListFutureLong 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 (FutureLong future : futures) { try { totalHigher future.get(); } catch (Exception e) { log.error(分桶排名查询异常, e); } } executor.shutdown(); return totalHigher rankOffset 1; } /** * 技术亮点小顶堆多路归并求 TopN时间复杂度 O(M·logN)内存占用极低 */ public ListLeaderboardVO getGlobalTopN(int n) { ListSetZSetOperations.TypedTupleString bucketTopList new ArrayList(); for (int i 0; i BUCKET_COUNT; i) { SetZSetOperations.TypedTupleString topN redisTemplate.opsForZSet().reverseRangeWithScores(getBucketKey(i), 0, n - 1); if (topN ! null !topN.isEmpty()) bucketTopList.add(topN); } // 小顶堆实现多路归并排序 PriorityQueueZSetOperations.TypedTupleString minHeap new PriorityQueue(n, Comparator.comparingDouble(ZSetOperations.TypedTuple::getScore)); for (SetZSetOperations.TypedTupleString bucket : bucketTopList) { for (ZSetOperations.TypedTupleString tuple : bucket) { if (minHeap.size() n) { minHeap.offer(tuple); } else if (tuple.getScore() minHeap.peek().getScore()) { minHeap.poll(); minHeap.offer(tuple); } } } ListLeaderboardVO result new ArrayList(); while (!minHeap.isEmpty()) { ZSetOperations.TypedTupleString 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(); ListObject 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 CacheString, ListRankItem topNCache Caffeine.newBuilder() .maximumSize(10) .build(); Scheduled(fixedDelay 1000) // 每秒刷新 public void refreshTopN() { RScoredSortedSetString zset redissonClient .getScoredSortedSet(rank:daily: today); CollectionScoredEntryString entries zset.entryRangeReversed(0, 99); ListRankItem topList entries.stream() .map(e - new RankItem(e.getValue(), e.getScore())) .collect(Collectors.toList()); topNCache.put(daily_top100, topList); } // 查询接口直接返回本地缓存近乎零延迟 public ListRankItem getTop100() { return topNCache.get(daily_top100, k - Collections.emptyList()); }效果用户拉取 Top100 延迟 1ms90% 的读请求根本不会到达 Redis。六、性能参考数据方案支持用户规模写入 QPS排名查询耗时内存占用单 ZSet 基础版百万级3~5w1~3ms百 MB 级128 桶分片版千万级30~50w5~10ms1~2GB冷热分离版亿级50wTopN 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 持久化 断点重放业务容忍少量回退极端场景数据安全兜底八、生产环境避坑指南绝对禁止ZRANGE key 0 -1全量遍历大 key会长时间阻塞 Redis 单线程引发雪崩分桶数量提前按 3 倍容量预估避免后续扩容的数据迁移成本活动类排行榜必须设置过期时间日榜 2 天、周榜 15 天、月榜 62 天下线后及时删除避免内存泄漏高频 TopN 查询加 1s 本地缓存抗读峰值大幅降低 Redis 压力ZREVRANK返回名次从 0 开始返回前端时必须 1 转为业务排名这个细节坑过无数人统一使用ZINCRBY增量更新避免ZADD覆盖导致并发扣分/加分乱序Kafka 按 uid 分区保证同一用户的消息顺序消费九、总结用 Redis ZSet 存分、Kafka 异步削峰写入、读写分离 Caffeine 本地缓存扛读、按时间维度拆分榜单避免热 key、哈希分桶解决大 key——这套组合拳就是千万级实时排行榜的标准解法。核心设计哲学就八个字分层治理渐进优化。百万级单 ZSet 足矣别过度设计千万级分桶分片 MQ 削峰 Pipeline 批量写入亿级冷热分离 Redis Cluster ClickHouse 混合方案如果业务发展需要多维度排行榜日榜、周榜、总榜按时间维度拆分独立 ZSet定时滚动归档如果突破亿级超大规模可基于 Redis Cluster 做跨节点分片或引入ClickHouse 物化视图 Redis 缓存的混合架构进一步扩展容量和并发能力。面试加分 Tip聊完基础方案后主动抛出同分排序怎么做分桶后全局排名怎么算异步写入一致性怎么保证这些进阶问题展示你对系统的深度思考比单纯堆方案更有说服力。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑