设计一个实时排行榜——Redis ZSet 的边界与替代方案
设计一个实时排行榜——Redis ZSet 的边界与替代方案
适读人群:做过或准备做排行榜/积分类系统的工程师 | 阅读时长:约15分钟 | 核心价值:Redis ZSet 不是排行榜的万能解,了解它的边界才能做出正确选型
先说一个被 ZSet 坑了的项目
两年前我们在做一个直播平台的礼物排行榜,需求是:每场直播结束后展示本场打赏金额 Top 100 的观众。
方案选型时我没想太多,直接用了 Redis ZSet——这是最常见的 Redis 排行榜方案,业界口碑很好。每次用户送礼物,就把对应的金额累加到 ZSet 里。
// 当时的代码
public void addGift(String liveId, String userId, long amount) {
String key = "rank:live:" + liveId;
redisTemplate.opsForZSet().incrementScore(key, userId, amount);
}
public List<RankItem> getTopN(String liveId, int n) {
String key = "rank:live:" + liveId;
Set<ZSetOperations.TypedTuple<String>> results =
redisTemplate.opsForZSet().reverseRangeWithScores(key, 0, n - 1);
return convertToRankItems(results);
}看起来完美。跑了几个月都没问题。
直到有一天,平台搞了一个超大型活动,同时开了 5000 场直播,每场都有人送礼物。我们的 Redis 内存告警,使用率飙到 85%。
一分析,发现了问题:每场直播对应一个 ZSet,5000 场直播同时在线,每个 ZSet 里有几千到几万个用户,光这些排行榜 Key 就占了几十 GB 内存。
而且很多直播结束了,Key 没有设过期时间,还留着,内存越来越大。
ZSet 的能力边界
Redis ZSet 是一个非常优秀的数据结构,但它有清晰的适用边界:
适合的场景:
- 数据量可控(几千到几十万条)
- 排行榜整体读多写多,但同时存在的 Key 数量不多
- 需要频繁的
ZADD、ZINCRBY、ZRANK、ZRANGE操作 - 对内存不敏感
不适合的场景:
- 大量并发的独立排行榜(如每个用户都有自己的榜单)
- 历史数据多但需要持久化的排行榜
- 需要复杂查询(如按时间段分组、多维度排名)
- 单个 ZSet 中成员数量达到百万级(性能开始退化)
排行榜的几种方案对比
方案一:Redis ZSet(基础方案)
适合:全局榜、数据量不大、实时性要求高
// 增加分数
zset.zincrby(key, score, member);
// 获取排名(0-indexed)
zset.zrevrank(key, member);
// 获取 Top N
zset.zrevrangeWithScores(key, 0, n-1);内存估算: 每个成员大约占用 60-80 字节,100 万成员大约 80MB。10 个 Key 就是 800MB。这在内存敏感场景下是很大的开销。
方案二:Redis ZSet + 定期归档
对于历史榜单,活动结束后把数据从 Redis 导出到 MySQL,然后删除 Redis Key 释放内存。
@Scheduled(cron = "0 5 * * * *") // 每小时的第5分钟执行
public void archiveExpiredRanks() {
List<String> expiredLiveIds = liveService.getEndedLiveIds(60); // 结束超过1小时的
for (String liveId : expiredLiveIds) {
String key = "rank:live:" + liveId;
// 读取 ZSet 数据
Set<ZSetOperations.TypedTuple<String>> data =
redisTemplate.opsForZSet().reverseRangeWithScores(key, 0, -1);
// 写入 MySQL
rankArchiveMapper.batchInsert(liveId, data);
// 删除 Redis Key
redisTemplate.delete(key);
}
}这是最简单的解法,解决了内存泄漏问题。
方案三:分层排行榜(大规模场景)
当排行榜的规模很大时(比如全平台所有直播的总榜,成员数百万),单个 ZSet 会有性能问题。
方案:把一个大的排行榜拆成多个小分片,每个分片独立维护,定期合并成总榜。
// 写入时,按 userId hash 到不同的分片
public void addScore(String boardId, String userId, long score) {
int shard = Math.abs(userId.hashCode()) % SHARD_COUNT;
String shardKey = "rank:" + boardId + ":shard:" + shard;
redisTemplate.opsForZSet().incrementScore(shardKey, userId, score);
}
// 合并分片(定期执行,不适合实时查询)
public List<RankItem> mergeAndGetTop(String boardId, int topN) {
// 用优先队列合并多个有序列表(类似多路归并)
PriorityQueue<RankEntry> heap = new PriorityQueue<>(
(a, b) -> Double.compare(b.score, a.score)
);
for (int i = 0; i < SHARD_COUNT; i++) {
String shardKey = "rank:" + boardId + ":shard:" + i;
Set<ZSetOperations.TypedTuple<String>> top =
redisTemplate.opsForZSet().reverseRangeWithScores(shardKey, 0, topN - 1);
top.forEach(t -> heap.offer(new RankEntry(t.getValue(), t.getScore())));
}
List<RankItem> result = new ArrayList<>();
while (!heap.isEmpty() && result.size() < topN) {
result.add(convertToRankItem(heap.poll()));
}
return result;
}代价: 合并操作有延迟,不能做到精确实时;如果需要查询某个用户的精确排名,需要遍历所有分片。
方案四:数据库方案(历史榜单)
对于历史榜单的查询(如"上周榜单"),直接用 MySQL 按分数排序完全够用:
SELECT user_id, total_score,
RANK() OVER (ORDER BY total_score DESC) AS rank_no
FROM weekly_rank
WHERE week_id = '2024-W01'
ORDER BY total_score DESC
LIMIT 100;MySQL 8.0 的窗口函数 RANK() 可以很优雅地处理并列名次的场景,这是 Redis ZSet 做不到的。
踩坑记录
踩坑一:并列名次的处理
Redis ZSet 中分数相同的成员,ZREVRANK 返回的排名是不确定的(按字典序)。
场景:两个用户都是 1000 分,他们在排行榜上的名次应该如何显示?
- 展示方式 A:并列第 3 名,没有第 4 名
- 展示方式 B:按先到先得,先达到这个分数的排名更靠前
Redis ZSet 不支持这两种语义的区分,需要在应用层处理:
// 处理并列名次:检查上一名和下一名的分数
public int getRank(String key, String userId) {
Double myScore = redisTemplate.opsForZSet().score(key, userId);
if (myScore == null) return -1;
// 比我分数高的人数 + 1
Long higherCount = redisTemplate.opsForZSet()
.reverseRangeByScore(key, myScore + 0.01, Double.MAX_VALUE)
.size();
return (int) (higherCount + 1);
}踩坑二:ZSet Key 没有设过期时间,内存泄漏
这就是我一开始说的那个故障。
最佳实践:每个 ZSet Key 创建时必须设置过期时间。如果活动是时间有限的(如一场直播),过期时间可以设置为活动结束时间 + 1 小时。
public void initRankBoard(String liveId, Date endTime) {
String key = "rank:live:" + liveId;
long ttl = (endTime.getTime() - System.currentTimeMillis()) / 1000 + 3600;
redisTemplate.expire(key, ttl, TimeUnit.SECONDS);
}踩坑三:大型全局榜单的实时性幻觉
我们曾经搞过一个"全平台月度消费榜",几百万用户都在上面,每次消费都实时更新 ZSet。
结果是:虽然数据是"实时"的,但取 Top 100 的接口响应时间有时高达 100ms,用户体验很差。
原因:ZSet 的 ZREVRANGE 在成员数量很大时,虽然复杂度是 O(log n + k),但底层数据结构(skiplist)的常数因子在几百万成员时已经相当大了。
解决方案:对于超大规模的全局榜,不用 ZSet 做实时排名,而是定期(比如每 5 分钟)用 Spark/Flink 批量计算一次,结果写到 Redis String 里。展示的是"准实时"排名,但性能和成本都更合理。
选型决策树
最后一点
排行榜系统的选型,核心是三个维度:数据规模、实时性要求、历史数据需求。
Redis ZSet 在数据量可控、实时性要求高的场景里几乎是无敌的,但一旦规模上来,或者对历史数据有需求,就需要引入更复杂的方案。
不要因为"大家都用 ZSet 做排行榜"就不假思索地选它。先弄清楚你的场景,再做选型。
