设计一个排行榜系统:Redis Sorted Set、实时更新、历史快照
设计一个排行榜系统:Redis Sorted Set、实时更新、历史快照
适读人群:Java中高级工程师、需要做排行榜的技术人员 | 阅读时长:约16分钟 | 难度:★★★☆☆
开篇故事
排行榜是游戏、电商、社交平台的常见需求。看起来简单,但有几个坑让我印象深刻。
有个游戏公司,用MySQL存储玩家积分,ORDER BY score DESC LIMIT 100查Top100。没有索引,用户量上去后全表扫描,一次排行榜查询要3秒。上了索引后快了,但高并发的分数更新又把索引写入变成瓶颈。
Redis ZSet是排行榜的标准解法,但ZSet也有问题:玩家数据量超过100万时,内存占用几个GB;而且ZSet只能做实时排行,"上周排行""上月排行"需要快照,快照怎么做,过期数据怎么清理,都是需要想清楚的问题。
这篇文章把排行榜系统从基础到进阶完整讲一遍。
一、需求分析与规模估算
功能需求
- 实时排行榜: 用户分数变化后,排名实时更新
- 分页查询: Top N,或查询某个用户的当前排名
- 历史排行榜: 日榜、周榜、月榜、年度总榜
- 用户附近排名: 查询"我"前后各3名的排行(局部排行)
- 多维排行: 按地区、按游戏类型等不同维度分别排行
规模估算
以一个手机游戏为例:
用户规模: 1000万注册用户,100万日活
分数更新频率: 每个活跃用户每天更新分数10次 = 1000万次/天 = 116 QPS(峰值约1000 QPS)
排行榜查询频率: 每天查询5000万次 = 578 QPS(峰值约3000 QPS)
存储估算:
- Redis ZSet:1000万用户 × 每个成员约40字节(userId 8字节 + score 8字节 + 索引开销)= 约400MB
- 历史快照(每天存一份):400MB × 365天 = 146GB/年 → 不能全存Redis,要迁移到MySQL
二、系统架构设计
三、关键代码实现
3.1 分数更新服务
@Service
@Slf4j
public class RankingService {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String REALTIME_RANK_KEY = "rank:realtime:";
private static final String USER_SCORE_KEY = "rank:score:";
/**
* 更新用户分数(增量更新)
* @param boardId 榜单ID(用于多个不同榜单)
* @param userId 用户ID
* @param delta 分数增量(可以为负,表示扣分)
* @return 更新后的总分
*/
public double updateScore(String boardId, Long userId, double delta) {
String rankKey = REALTIME_RANK_KEY + boardId;
String member = String.valueOf(userId);
// ZINCRBY:原子操作,分数增量
Double newScore = redisTemplate.opsForZSet().incrementScore(
rankKey, member, delta);
log.debug("用户分数更新, boardId={}, userId={}, delta={}, newScore={}",
boardId, userId, delta, newScore);
return newScore != null ? newScore : 0.0;
}
/**
* 设置用户分数(绝对值,非增量)
*/
public void setScore(String boardId, Long userId, double score) {
String rankKey = REALTIME_RANK_KEY + boardId;
redisTemplate.opsForZSet().add(rankKey, String.valueOf(userId), score);
}
/**
* 查询Top N排行榜
* @param boardId 榜单ID
* @param n 取前N名
*/
public List<RankItem> getTopN(String boardId, int n) {
String rankKey = REALTIME_RANK_KEY + boardId;
// ZREVRANGE获取按分数倒序的前N名(包含分数)
Set<ZSetOperations.TypedTuple<String>> topItems =
redisTemplate.opsForZSet().reverseRangeWithScores(rankKey, 0, n - 1);
if (topItems == null || topItems.isEmpty()) {
return Collections.emptyList();
}
List<RankItem> result = new ArrayList<>();
int rank = 1;
for (ZSetOperations.TypedTuple<String> item : topItems) {
result.add(RankItem.builder()
.rank(rank++)
.userId(Long.parseLong(item.getValue()))
.score(item.getScore())
.build());
}
return result;
}
/**
* 查询用户当前排名(从1开始)
* @return 排名,-1表示不在排行榜中
*/
public long getUserRank(String boardId, Long userId) {
String rankKey = REALTIME_RANK_KEY + boardId;
// ZREVRANK:倒序排名(0-based),需要+1变为1-based
Long rank = redisTemplate.opsForZSet().reverseRank(
rankKey, String.valueOf(userId));
return rank != null ? rank + 1 : -1;
}
/**
* 查询用户分数
*/
public double getUserScore(String boardId, Long userId) {
String rankKey = REALTIME_RANK_KEY + boardId;
Double score = redisTemplate.opsForZSet().score(rankKey, String.valueOf(userId));
return score != null ? score : 0.0;
}
/**
* 查询用户"附近的排名"(前后各N名)
* 比如查询我排名18,返回第15~21名的用户
*/
public List<RankItem> getNearbyRanking(String boardId, Long userId, int spread) {
long userRank = getUserRank(boardId, userId);
if (userRank == -1) return Collections.emptyList();
long start = Math.max(0, userRank - spread - 1); // 0-based index
long end = userRank + spread - 1;
String rankKey = REALTIME_RANK_KEY + boardId;
Set<ZSetOperations.TypedTuple<String>> items =
redisTemplate.opsForZSet().reverseRangeWithScores(rankKey, start, end);
if (items == null) return Collections.emptyList();
List<RankItem> result = new ArrayList<>();
long rank = start + 1;
for (ZSetOperations.TypedTuple<String> item : items) {
RankItem rankItem = RankItem.builder()
.rank(rank++)
.userId(Long.parseLong(item.getValue()))
.score(item.getScore())
.isCurrentUser(Long.parseLong(item.getValue()) == userId)
.build();
result.add(rankItem);
}
return result;
}
/**
* 分页查询排行榜
*/
public PageResult<RankItem> getRankingPage(String boardId, int page, int size) {
String rankKey = REALTIME_RANK_KEY + boardId;
long total = redisTemplate.opsForZSet().zCard(rankKey);
long start = (long) page * size;
long end = start + size - 1;
Set<ZSetOperations.TypedTuple<String>> items =
redisTemplate.opsForZSet().reverseRangeWithScores(rankKey, start, end);
List<RankItem> rankItems = new ArrayList<>();
if (items != null) {
long rank = start + 1;
for (ZSetOperations.TypedTuple<String> item : items) {
rankItems.add(RankItem.builder()
.rank(rank++)
.userId(Long.parseLong(item.getValue()))
.score(item.getScore())
.build());
}
}
return new PageResult<>(total, rankItems);
}
}3.2 历史快照服务
@Service
@Slf4j
public class RankingSnapshotService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private RankSnapshotMapper snapshotMapper;
private static final String REALTIME_RANK_KEY = "rank:realtime:";
private static final int SNAPSHOT_TOP_SIZE = 1000; // 只保存Top1000的快照
/**
* 每天0点执行日榜快照
*/
@Scheduled(cron = "0 0 0 * * ?")
public void dailySnapshot() {
String yesterdayStr = LocalDate.now().minusDays(1)
.format(DateTimeFormatter.BASIC_ISO_DATE);
// 快照所有活跃榜单
List<String> activeBoardIds = getActiveBoardIds();
activeBoardIds.forEach(boardId ->
takeSnapshot(boardId, "DAILY", yesterdayStr));
}
/**
* 每周一0点执行周榜快照
*/
@Scheduled(cron = "0 0 0 ? * MON")
public void weeklySnapshot() {
// 取上一周的周次
String weekStr = getLastWeekStr();
List<String> activeBoardIds = getActiveBoardIds();
activeBoardIds.forEach(boardId ->
takeSnapshot(boardId, "WEEKLY", weekStr));
}
/**
* 执行快照
*/
private void takeSnapshot(String boardId, String type, String period) {
String rankKey = REALTIME_RANK_KEY + boardId;
// 读取Top 1000(只快照头部,节省存储)
Set<ZSetOperations.TypedTuple<String>> topItems =
redisTemplate.opsForZSet().reverseRangeWithScores(
rankKey, 0, SNAPSHOT_TOP_SIZE - 1);
if (topItems == null || topItems.isEmpty()) return;
// 批量写入MySQL(分批,每批500条)
List<RankSnapshot> snapshots = new ArrayList<>();
int rank = 1;
for (ZSetOperations.TypedTuple<String> item : topItems) {
snapshots.add(RankSnapshot.builder()
.boardId(boardId)
.type(type)
.period(period)
.rank(rank++)
.userId(Long.parseLong(item.getValue()))
.score(item.getScore())
.createTime(LocalDateTime.now())
.build());
}
Lists.partition(snapshots, 500)
.forEach(snapshotMapper::batchInsert);
log.info("排行榜快照完成, boardId={}, type={}, period={}, count={}",
boardId, type, period, snapshots.size());
}
/**
* 查询历史快照排行榜
*/
public List<RankItem> getHistoryRanking(
String boardId, String type, String period, int topN) {
return snapshotMapper.queryTopN(boardId, type, period, topN)
.stream()
.map(s -> RankItem.builder()
.rank(s.getRank())
.userId(s.getUserId())
.score(s.getScore())
.build())
.collect(Collectors.toList());
}
}3.3 分榜单设计(多维度排行)
/**
* 榜单工厂:根据不同维度生成榜单ID
*/
public class BoardIdBuilder {
// 全服总榜
public static String globalBoard() {
return "global";
}
// 区服榜(按服务器ID)
public static String serverBoard(String serverId) {
return "server:" + serverId;
}
// 地区榜
public static String regionBoard(String region) {
return "region:" + region;
}
// 每日榜(当天实时,每天重置)
public static String dailyBoard() {
return "daily:" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
}
// 每周榜
public static String weeklyBoard() {
WeekFields weekFields = WeekFields.of(Locale.CHINA);
int weekOfYear = LocalDate.now().get(weekFields.weekOfYear());
return "weekly:" + LocalDate.now().getYear() + "W" + weekOfYear;
}
}3.4 大榜单的分片策略
当用户数量超过1000万时,单个ZSet的内存超过1GB,需要分片:
@Service
public class ShardedRankingService {
private static final int SHARD_COUNT = 10;
/**
* 分片更新分数
* 用户按userId取模路由到不同分片
*/
public void updateScore(Long userId, double delta) {
int shard = (int)(userId % SHARD_COUNT);
String shardKey = "rank:shard:" + shard;
redisTemplate.opsForZSet().incrementScore(shardKey,
String.valueOf(userId), delta);
}
/**
* 合并分片查询Top N
* 思路:从每个分片取Top N,合并后再取全局Top N
*/
public List<RankItem> getGlobalTopN(int n) {
// 每个分片取Top N
List<RankItem> allItems = new ArrayList<>();
for (int shard = 0; shard < SHARD_COUNT; shard++) {
String shardKey = "rank:shard:" + shard;
Set<ZSetOperations.TypedTuple<String>> items =
redisTemplate.opsForZSet().reverseRangeWithScores(shardKey, 0, n - 1);
if (items != null) {
items.forEach(item -> allItems.add(
new RankItem(Long.parseLong(item.getValue()), item.getScore())
));
}
}
// 全局排序,取Top N
return allItems.stream()
.sorted(Comparator.comparingDouble(RankItem::getScore).reversed())
.limit(n)
.collect(Collectors.toList());
}
}四、扩展性设计
每日榜单的自动重置
每日榜在每天0点重置为0。不能直接DELETE,这会造成服务中断。解决方案:Key轮换,每天用日期作为Key的一部分(如rank:daily:20260421),新的一天自动使用新Key,旧Key等过期自动删除。
// 每次更新时,使用当天的榜单Key
String dailyKey = "rank:daily:" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
// 设置TTL为2天(当天+昨天都保留)
redisTemplate.opsForZSet().incrementScore(dailyKey, userId, delta);
redisTemplate.expire(dailyKey, 2, TimeUnit.DAYS);五、踩坑实录
坑1:ZSet成员数量无限增长
用全局积分榜,用户注销账号后,数据从MySQL删了,但Redis ZSet里还有这个用户的记录。几年下来,ZSet里有几百万个"僵尸"成员,查询Top100时还好(只取前几条),但ZCARD统计总人数时会包含这些账号。
解决方案:用户注销时,ZREM从ZSet里移除。同时每月跑一次对比任务,清理数据库中不存在的成员。
坑2:ZSet精度问题导致排名错误
Redis ZSet的score是double类型,double的精度是15位有效数字。当积分超过1万亿时,1000000000001和1000000000002在double中可能是同一个值,排名出现错误。
解决方案:使用整数积分(不用浮点数),同时在分数中拼接时间戳(低位)来处理同分排名:score = 用户积分 × 10^13 + (Long.MAX_VALUE - 时间戳),积分相同时,先达到该分数的用户排名更靠前。
坑3:快照任务耗时过长阻塞其他业务
有1000万用户的榜单,ZREVRANGE一次性读取全量数据耗时15秒,在这15秒内Redis连接被占用,其他业务的Redis操作都受影响。
解决方案:分批读取(使用ZSCAN游标遍历),每批1000条,避免单次操作时间过长。同时快照任务只保存Top1000,不需要全量读取。
六、总结
排行榜系统的核心是Redis ZSet,关键操作都是O(log N)复杂度:
| 操作 | 命令 | 复杂度 |
|---|---|---|
| 更新分数 | ZINCRBY | O(log N) |
| 查Top N | ZREVRANGE | O(log N + N) |
| 查用户排名 | ZREVRANK | O(log N) |
| 删除成员 | ZREM | O(log N) |
设计要点:
- 用Key轮换实现周期榜单(日榜/周榜),不需要手动重置数据
- 历史榜单快照到MySQL,只保留Top N(大多数用户只看前几名)
- 超大榜单(>500万成员)考虑ZSet分片,牺牲一点全局精确度换取可扩展性
- 分数相同时用时间戳作为决胜局(先达到的排前面)
