如何设计一个排行榜系统?
🎯 面试题:如何设计一个实时排行榜?
排行榜是社交和游戏场景中的核心功能:游戏战力榜、积分榜、带货达人榜……需要支撑实时更新、高并发查询,同时保证数据准确性。
一、排行榜的核心挑战
❌ 数据量巨大:千万级玩家,战力排序
❌ 写入频繁:每次战斗/消费都更新分数
❌ 查询并发高:首页展示 TOP 100 万人同时访问
❌ 需要实时:不能有明显的延迟
❌ 周边需求:查自己的排名、查前一名后一名
MySQL ORDER BY score DESC → 百万数据全表扫描 ❌
二、整体架构
┌─────────────────────────────────────────────────────────┐
│ 排行榜系统架构 │
│ │
│ 用户行为 ──▶ 行为事件 Kafka ──▶ 分数计算服务 ──▶ Redis │
│ │
│ 查询请求 ──▶ 排行榜服务 ──▶ Redis ZSet ──▶ 返回 │
│ │ │
│ 异步同步 ──▶ MySQL 归档 │
└─────────────────────────────────────────────────────────┘
核心数据结构:Redis Sorted Set(ZSet)
ZSet = 唯一成员 + 分值(score)
ZRANGE key 0 99 WITHSCORES → 获取 TOP 100
ZRANK key member → 获取成员排名(0-based)
ZSCORE key member → 获取成员分数
ZREVRANK key member → 获取倒序排名(0-based)
三、Redis ZSet 实现排行榜
1. 基本操作
@Service
@Slf4j
public class RankingService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
private static final String RANKING_KEY = "ranking:game:power";
/**
* 更新用户分数(自动更新排名)
* ZADD:member 已存在则更新 score,不存在则新增
*/
public void updateScore(Long userId, double score) {
redisTemplate.opsForZSet().add(RANKING_KEY, String.valueOf(userId), score);
log.debug("[Ranking] Updated score: userId={}, score={}", userId, score);
}
/**
* 批量更新分数(活动结算、批量导入等场景)
*/
public void batchUpdateScore(Map<Long, Double> userScores) {
Set<ZSetOperations.TypedTuple<String>> tuples = userScores.entrySet().stream()
.map(e -> ZSetOperations.TypedTuple.of(
String.valueOf(e.getKey()),
e.getValue()
))
.collect(Collectors.toSet());
redisTemplate.opsForZSet().add(RANKING_KEY, tuples);
}
/**
* 获取 TOP N 排行榜
*/
public List<RankingEntry> getTopN(int n) {
Set<ZSetOperations.TypedTuple<String>> topSet = redisTemplate.opsForZSet()
.reverseRangeWithScores(RANKING_KEY, 0, n - 1);
if (topSet == null) return Collections.emptyList();
List<RankingEntry> result = new ArrayList<>();
int rank = 1;
for (ZSetOperations.TypedTuple<String> tuple : topSet) {
result.add(new RankingEntry(
rank++,
Long.parseLong(tuple.getValue()),
tuple.getScore()
));
}
return result;
}
/**
* 获取用户排名(从 1 开始)
*/
public Long getUserRank(Long userId) {
Long rank = redisTemplate.opsForZSet().reverseRank(RANKING_KEY, String.valueOf(userId));
return rank != null ? rank + 1 : null;
}
/**
* 获取用户周围的排名(前三名后三名)
*/
public List<RankingEntry> getNeighborRanks(Long userId) {
Long rank = redisTemplate.opsForZSet().reverseRank(RANKING_KEY, String.valueOf(userId));
if (rank == null) return Collections.emptyList();
long start = Math.max(0, rank - 3);
long stop = rank + 3;
Set<ZSetOperations.TypedTuple<String>> neighborSet = redisTemplate.opsForZSet()
.reverseRangeWithScores(RANKING_KEY, start, stop);
List<RankingEntry> result = new ArrayList<>();
int displayRank = (int) start + 1;
for (ZSetOperations.TypedTuple<String> tuple : neighborSet) {
result.add(new RankingEntry(
displayRank++,
Long.parseLong(tuple.getValue()),
tuple.getScore()
));
}
return result;
}
/**
* 获取指定区间排名(如第 1001-1100 名)
*/
public List<RankingEntry> getRange(int start, int end) {
Set<ZSetOperations.TypedTuple<String>> rangeSet = redisTemplate.opsForZSet()
.reverseRangeWithScores(RANKING_KEY, start, end);
List<RankingEntry> result = new ArrayList<>();
int rank = start + 1;
for (ZSetOperations.TypedTuple<String> tuple : rangeSet) {
result.add(new RankingEntry(
rank++,
Long.parseLong(tuple.getValue()),
tuple.getScore()
));
}
return result;
}
/**
* 移除用户(封禁、清榜等场景)
*/
public void removeUser(Long userId) {
redisTemplate.opsForZSet().remove(RANKING_KEY, String.valueOf(userId));
}
/**
* 获取排行榜总人数
*/
public Long getTotalCount() {
return redisTemplate.opsForZSet().zCard(RANKING_KEY);
}
@Data
@AllArgsConstructor
public static class RankingEntry {
private long rank;
private long userId;
private double score;
}
}
2. 增量更新(防频繁写入)
问题:每次战斗都更新 Redis,高并发时 Redis 压力大
解决:本地缓存聚合 + 定时批量同步
用户战斗 → 本地缓存(内存 Map)→ 定时批量写入 Redis
@Component
@Slf4j
public class ScoreAggregator {
// 本地聚合缓存(用户 ID → 累计增量)
private final Map<Long, Double> localCache = new ConcurrentHashMap<>();
// 每 5 秒批量同步到 Redis
private static final long SYNC_INTERVAL = 5_000L;
@PostConstruct
public void init() {
Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(
this::flushToRedis, SYNC_INTERVAL, SYNC_INTERVAL, TimeUnit.MILLISECONDS
);
}
/**
* 增量更新分数(写本地缓存,不直接写 Redis)
*/
public void addScore(Long userId, double delta) {
localCache.merge(userId, delta, Double::sum);
}
/**
* 定时将本地缓存批量同步到 Redis
*/
private void flushToRedis() {
if (localCache.isEmpty()) return;
Map<Long, Double> toFlush = new HashMap<>(localCache);
localCache.clear();
for (Map.Entry<Long, Double> entry : toFlush.entrySet()) {
Long userId = entry.getKey();
Double delta = entry.getValue();
String member = String.valueOf(userId);
// 先获取当前分数
Double currentScore = redisTemplate.opsForZSet()
.score("ranking:game:power", member);
double newScore = (currentScore != null ? currentScore : 0) + delta;
redisTemplate.opsForZSet().add("ranking:game:power", member, newScore);
}
log.info("[ScoreAggregator] Flushed {} users to Redis", toFlush.size());
}
}
四、分层排行榜设计
问题:TOP 1000 热门,其他名次查询少但数据量大
解决:分层存储 + 分级查询
Redis 热层:只存 TOP 10000(实时同步,高频访问)
MySQL 冷层:全量数据(低频访问,按需归档)
写入流程:
用户分数变化
↓
先更新 Redis ZSet(热层)
↓
异步写入 MySQL(冷层)→ 按需归档
查询流程:
├── 查 TOP 100 → Redis 热层(毫秒级)
├── 查自己排名 → Redis 热层 + 自己分数区间判断
└── 查万名后 → MySQL 索引查询
@Service
public class TieredRankingService {
private static final int HOT_THRESHOLD = 10_000; // 热层只保留 TOP 10000
@Autowired private RedisTemplate<String, String> redisTemplate;
@Autowired private RankingMapper rankingMapper;
/**
* 分层查询:热层优先,冷层兜底
*/
public RankingEntry queryRank(Long userId) {
// 1. 先查 Redis 热层
Long rank = redisTemplate.opsForZSet().reverseRank("ranking:hot", String.valueOf(userId));
Double score = redisTemplate.opsForZSet().score("ranking:hot", String.valueOf(userId));
if (rank != null) {
return new RankingEntry(rank + 1, userId, score);
}
// 2. 热层没有,查 MySQL 冷层
RankingDO ranking = rankingMapper.selectByUserId(userId);
if (ranking != null) {
return new RankingEntry(ranking.getRank(), userId, ranking.getScore());
}
return null;
}
/**
* 热层数据更新时,同步到 MySQL
*/
@Scheduled(fixedRate = 60_000) // 每分钟同步一次
public void syncHotRankingToDB() {
// 取出热层 TOP 10000 全量同步到 MySQL
// 实际生产中增量同步更优,这里简化示例
Set<ZSetOperations.TypedTuple<String>> hotSet = redisTemplate.opsForZSet()
.reverseRangeWithScores("ranking:hot", 0, HOT_THRESHOLD - 1);
if (hotSet == null) return;
rankingMapper.batchUpsert(
hotSet.stream().map(tuple -> {
RankingDO r = new RankingDO();
r.setUserId(Long.parseLong(tuple.getValue()));
r.setScore(tuple.getScore());
return r;
}).toList()
);
}
}
五、游戏战力排行榜实战
场景:MOBA 类游戏战力排行榜
战力分数组成:
- 段位分:青铜/白银/黄金/钻石/王者
- 胜率分:最近 100 场胜率加成
- 战力分:KDA 累计分
更新时机:
- 每场对局结束后(通过 MQ 异步触发)
- 每天凌晨定时刷新(胜率分每日重算)
@Service
@Slf4j
public class GamePowerRankingService {
private static final String POWER_RANKING = "ranking:game:power";
private static final String TIER_RANKING = "ranking:game:tier";
private static final String WEEK_RANKING = "ranking:game:weekly";
/**
* 对局结束后计算战力分并更新排行榜
*/
public void onGameFinished(GameResultEvent event) {
long userId = event.getUserId();
GameStats stats = event.getStats();
// 计算综合战力分
double powerScore = calculatePowerScore(stats);
// 1. 更新总战力榜
redisTemplate.opsForZSet().add(POWER_RANKING, String.valueOf(userId), powerScore);
// 2. 更新段位榜(同段位内排名)
String tierKey = TIER_RANKING + ":" + stats.getTier();
redisTemplate.opsForZSet().add(tierKey, String.valueOf(userId), powerScore);
// 3. 更新周榜(每周重置)
redisTemplate.opsForZSet().add(WEEK_RANKING, String.valueOf(userId), powerScore);
// 4. 异步记录战绩(持久化)
mqTemplate.convertAndSend("game-result-topic", event);
log.info("[Ranking] Updated power: userId={}, power={}, tier={}",
userId, powerScore, stats.getTier());
}
/**
* 综合战力分计算公式
*/
private double calculatePowerScore(GameStats stats) {
// 段位基础分(段位越高基础分越高)
double tierScore = stats.getTier() * 5000.0;
// 胜率分:胜率 * 2000(满胜率 = +2000 分)
double winRateScore = stats.getWinRate() * 2000;
// KDA 分:最近 20 场平均 KDA * 100
double kdaScore = stats.getRecentKdaAverage() * 100;
// 场次加成:打的越多,加成越高(封顶 500 分)
double gamesScore = Math.min(stats.getTotalGames() * 5, 500);
return tierScore + winRateScore + kdaScore + gamesScore;
}
/**
* 获取游戏战力榜详情(含段位信息)
*/
public List<PowerRankingVO> getTopPowerRanking(int n) {
Set<ZSetOperations.TypedTuple<String>> topSet = redisTemplate.opsForZSet()
.reverseRangeWithScores(POWER_RANKING, 0, n - 1);
List<Long> userIds = topSet.stream()
.map(t -> Long.parseLong(t.getValue()))
.collect(Collectors.toList());
// 批量查询用户段位信息(批量 IN 查询)
Map<Long, UserInfo> userInfoMap = userMapper.batchSelectUserInfo(userIds);
List<PowerRankingVO> result = new ArrayList<>();
int rank = 1;
for (ZSetOperations.TypedTuple<String> tuple : topSet) {
long uid = Long.parseLong(tuple.getValue());
UserInfo info = userInfoMap.getOrDefault(uid, UserInfo.EMPTY);
result.add(new PowerRankingVO(
rank++,
uid,
info.getNickname(),
info.getAvatar(),
info.getTier(),
tuple.getScore()
));
}
return result;
}
/**
* 获取自己在战力榜中的位置
*/
public MyRankVO getMyRank(Long userId) {
Long rank = redisTemplate.opsForZSet().reverseRank(POWER_RANKING, String.valueOf(userId));
Double score = redisTemplate.opsForZSet().score(POWER_RANKING, String.valueOf(userId));
Long total = redisTemplate.opsForZSet().zCard(POWER_RANKING);
if (rank == null) {
return new MyRankVO(null, null, total, "未上榜");
}
return new MyRankVO(
rank + 1,
score,
total,
getTierName((int) (score / 5000)) // 根据分值反推段位
);
}
private String getTierName(int tier) {
return switch (tier) {
case 0 -> "青铜";
case 1 -> "白银";
case 2 -> "黄金";
case 3 -> "铂金";
case 4 -> "钻石";
case 5 -> "星耀";
case 6 -> "王者";
default -> tier >= 7 ? "荣耀王者" : "青铜";
};
}
}
六、周榜/月榜实现(滑动窗口)
需求:每周重置一次排行榜,但保留历史记录
实现:按时间桶分 key
本周 key: ranking:game:weekly:2026_13 (第 13 周)
上周 key: ranking:game:weekly:2026_12
每周一凌晨 00:00:
1. 将上周排行榜归档到 MySQL
2. 旧 key 删除或归档
3. 新 key 开始计数
@Component
public class WeeklyRankingKeyManager {
/**
* 获取当前周期的排行榜 Key
* 格式:ranking:{type}:weekly:{year}_{week}
*/
public String getCurrentWeeklyKey(String type) {
LocalDate now = LocalDate.now();
int year = now.getYear();
int week = now.get(WeekFields.ISO.weekOfWeekBasedYear());
return String.format("ranking:%s:weekly:%d_%02d", type, year, week);
}
/**
* 获取指定周期的排行榜 Key
*/
public String getWeeklyKey(String type, int year, int week) {
return String.format("ranking:%s:weekly:%d_%02d", type, year, week);
}
/**
* 每周一归档上周排行榜
*/
@Scheduled(cron = "0 0 0 ? * MON") // 每周一凌晨
public void archiveLastWeekRanking() {
LocalDate lastWeek = LocalDate.now().minusWeeks(1);
int year = lastWeek.getYear();
int week = lastWeek.get(WeekFields.ISO.weekOfWeekBasedYear());
String lastWeekKey = getWeeklyKey("game", year, week);
// 1. 取出上周排行榜 TOP 1000
Set<ZSetOperations.TypedTuple<String>> top1000 = redisTemplate.opsForZSet()
.reverseRangeWithScores(lastWeekKey, 0, 999);
if (top1000 != null && !top1000.isEmpty()) {
// 2. 归档到 MySQL
archiveService.saveWeeklyRanking("game", year, week, top1000);
}
// 3. 删除旧 key 或转移
// redisTemplate.delete(lastWeekKey);
redisTemplate.rename(lastWeekKey, "archive:" + lastWeekKey);
log.info("[WeeklyRanking] Archived: year={}, week={}, count={}",
year, week, top1000 != null ? top1000.size() : 0);
}
}
七、排行榜周边需求
1. 缓存用户昵称和头像
排行榜 ZSet 只存 memberId + score,不存昵称
每次展示都要 JOIN 用户表查昵称 → 性能差
解决:排行榜缓存用户信息
@Component
public class RankingCacheService {
private static final String USER_INFO_KEY = "ranking:user:info:";
/**
* 批量缓存用户信息
*/
public void cacheUserInfo(List<UserInfo> users) {
for (UserInfo user : users) {
String key = USER_INFO_KEY + user.getUserId();
Map<String, String> info = Map.of(
"nickname", user.getNickname(),
"avatar", user.getAvatar() != null ? user.getAvatar() : "",
"tier", String.valueOf(user.getTier())
);
redisTemplate.opsForHash().putAll(key, info);
redisTemplate.expire(key, 1, TimeUnit.HOURS);
}
}
/**
* 获取用户信息(缓存优先,miss 查 DB)
*/
public UserInfo getUserInfo(Long userId) {
String key = USER_INFO_KEY + userId;
Map<Object, Object> cached = redisTemplate.opsForHash().entries(key);
if (!cached.isEmpty()) {
return new UserInfo(userId,
(String) cached.get("nickname"),
(String) cached.get("avatar"),
Integer.parseInt((String) cached.get("tier"))
);
}
// 缓存 miss,查 DB 并回填
UserInfo user = userMapper.selectById(userId);
if (user != null) {
cacheUserInfo(List.of(user));
}
return user;
}
}
2. 排行榜变更事件推送
场景:游戏玩家战力变化后,需要通知前端更新展示
方案:WebSocket 推送 + 订阅 Redis Keyspace 通知(不推荐生产)
推荐方案:玩家主动拉取 + 前端轮询
八、高频面试题
Q1: Redis ZSet 的时间复杂度是多少?
ZADD、ZREM、ZINCRBY 都是 O(log N),N 为集合大小。ZRANGE、ZRANK 是 O(log N + M)(M 为返回元素数量)。因为底层是跳表(SkipList)实现,查询效率接近红黑树但写入并发更好。
Q2: 如何保证排行榜的精确性?
增量更新要原子操作:用 Lua 脚本保证 ZINCRBY 的原子性。并发写入时分数用
+delta而非先读再写,避免 ABA 问题。如果需要绝对精确,最终以 MySQL 数据为准。
Q3: 热榜(TOP 100)和长尾榜(万名后)怎么分层处理?
热层用 Redis ZSet 存 TOP 10000,保证毫秒级查询。万名后走 MySQL 索引(score + user_id 复合索引),配合分页查询。查询排名时优先热层,热层 miss 再查 MySQL。
Q4: 排行榜数据丢失怎么办?
Redis 数据定期持久化到 MySQL(比如每分钟增量同步)。Redis 本身开启 AOF 持久化(RDB 每 5 分钟)。异常恢复时从 MySQL 重建 Redis 数据。冷热数据分离让故障恢复更快。
Q5: 如何防止刷分(作弊)?
① 分数变更必须经过服务端验签;② 单用户更新频率限流(如每秒最多更新 1 次);③ 异常分数变化监控(如瞬间暴涨 10 倍触发告警);④ 关键排行榜定期人工复核。
参考链接: