高并发下的排行榜系统:如何用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更新本地缓存,用户拉取时零延迟
整理了面试真题、每日技术知识点、系统学习路线,都汇总在个人网站 [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~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 持久化 + 断点重放,业务容忍少量回退 | 极端场景数据安全兜底 |
八、生产环境避坑指南
- 绝对禁止
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:聊完基础方案后,主动抛出"同分排序怎么做""分桶后全局排名怎么算""异步写入一致性怎么保证"这些进阶问题,展示你对系统的深度思考,比单纯堆方案更有说服力。
【别走,交个朋友】
我是 Rain ,一个喜欢把复杂技术讲透、让代码落地的实践者。
如果你厌倦了四处收集碎片化的八股文,想看看一线项目里真实的架构决策和踩坑记录,欢迎来我的公众号「Rain 的 Java 大神之路」坐坐。 在这里,我会把每一次技术复盘、每一个项目的设计源码,毫无保留地分享给你。
🔥 技术这条路很酷,我们结伴同行。
🚀Keep Coding, Keep Loving —— 与所有 Java 同路人共勉。