第5章:设计近实时排行榜(Design a Near Real-Time Leaderboard)
一、导读
1.1 本章学习目标
第5章以设计游戏排行榜为案例,讲解如何在高并发场景下实现高效的排名计算。核心挑战是百万级用户的分数更新如何快速反映到排行榜上。
本章的核心学习目标包括:
- 理解排行榜的核心需求:近实时更新、高并发读取、支持分页。
- 掌握多种实现方案的对比:数据库排序 vs 内存排序 vs Redis ZSET。
- 学会 Redis 有序集合的使用:ZADD、ZREVRANGE、ZRANK 操作。
- 理解分片策略:按分数范围分片 vs 按用户 ID 分片。
- 掌握缓存策略:排行榜快照 + 增量更新。
- 学会数据规模估算:内存占用、QPS、延迟要求。
1.2 为什么本章如此重要
排行榜是游戏、社交、电商等平台中最常见的功能之一。看似简单的"按分数排序",在百万级用户、每秒数万次更新的场景下,变成了一个极具挑战的系统设计问题。
1.3 核心问题引导
为什么数据库 ORDER BY 不适合排行榜场景?
Redis ZSET 的底层数据结构是什么?为什么它适合排行榜?
当用户量达到千万级时,单个 Redis 实例不够用怎么办?
如何实现"我附近的排名"功能?
每日/每周排行榜如何自动清理过期数据?
如何保证排行榜的近实时性(延迟 < 1秒)?
二、核心概念详解
2.1 需求分析与规模估算
// 功能需求:
// 1. 更新玩家分数
// 2. 获取前 N 名排行榜
// 3. 获取某玩家的排名
// 4. 获取某玩家附近的排名(前后各5名)
// 5. 支持每日/每周/总排行榜
// 非功能需求:
// 1. 近实时:分数更新后 < 1秒反映到排行榜
// 2. 高并发读:100,000 QPS
// 3. 高并发写:10,000 QPS
// 4. 低延迟:排行榜查询 < 50ms
// 规模估算:
// DAU:1000万
// 每秒分数更新:10,000 次
// 排行榜查询:100,000 QPS
// 每个玩家数据:user_id(8B) + score(8B) = 16B
// 总内存:1000万 × 16B = 160MB
// 结论:内存需求不大,关键是读写性能2.2 方案对比
2.2.1 方案1:数据库排序
sql
-- 数据库方案
CREATE TABLE leaderboard (
user_id BIGINT PRIMARY KEY,
score BIGINT NOT NULL,
updated_at TIMESTAMP,
INDEX idx_score (score DESC)
);
-- 获取前10名
SELECT user_id, score FROM leaderboard ORDER BY score DESC LIMIT 10;
-- 获取用户排名
SELECT COUNT(*) + 1 FROM leaderboard WHERE score > (
SELECT score FROM leaderboard WHERE user_id = ?
);
-- 问题分析:
// ORDER BY 时间复杂度:O(n log n)
// 1000万数据排序:约 100ms-1s
// 100,000 QPS 的查询 → 数据库崩溃
//
// 结论:数据库方案只适合小规模(< 10万用户)2.2.2 方案2:内存排序
// 内存方案:
// 将所有玩家数据加载到内存
// 使用排序数组或跳表维护有序性
//
// 更新分数:
// 1. 从数组中移除旧分数 O(n)
// 2. 插入新分数 O(n)
// 或使用平衡二叉搜索树 O(log n)
//
// 查询排行榜:
// 直接遍历有序结构 O(k)
//
// 问题:
// 1. 单机内存有限(160MB 可以接受)
// 2. 更新操作 O(n) 太慢(数组方案)
// 3. 需要持久化(宕机恢复)
// 4. 单机无法水平扩展
//
// 结论:内存方案比数据库快,但更新性能不够2.2.3 方案3:Redis 有序集合(推荐)
// Redis ZSET(Sorted Set):
// 底层实现:跳表(Skip List)+ 哈希表
// 更新分数:O(log n)
// 查询排名:O(log n)
// 查询范围:O(log n + k)
//
// 1000万数据:
// 更新:O(log 10M) ≈ 23次操作 ≈ 微秒级
// 查询前10名:O(log 10M + 10) ≈ 微秒级
// 单实例 QPS:10万+
//
// 内存占用:
// 每个元素:user_id(8B) + score(8B) + 指针(16B) + 跳表节点(32B) ≈ 64B
// 1000万 × 64B = 640MB
// 可接受!
// Redis 命令:
// 更新分数
ZADD leaderboard 1500 "player:1001"
ZADD leaderboard 2300 "player:1002"
ZINCRBY leaderboard 100 "player:1001" // 增加100分
// 获取前10名(从高到低)
ZREVRANGE leaderboard 0 9 WITHSCORES
// 获取玩家排名(从高到低,0-indexed)
ZREVRANK leaderboard "player:1001"
// 获取玩家分数
ZSCORE leaderboard "player:1001"
// 获取分数区间内的玩家
ZRANGEBYSCORE leaderboard 1000 2000 WITHSCORES
// 获取玩家附近的排名
// 先获取玩家排名
ZREVRANK leaderboard "player:1001" → 返回 50
// 获取排名 45-55 的玩家
ZREVRANGE leaderboard 45 55 WITHSCORES2.3 分片策略
2.3.1 当用户量达到亿级
// 问题:
// 1亿用户 × 64B = 6.4GB
// 单个 Redis 实例内存上限通常为 10-20GB
// 单实例 QPS 约 10万
// 如果写 QPS = 10万,单实例不够
// 解决方案:分片
// 方案1:按分数范围分片
// Shard 1: 0-999分
// Shard 2: 1000-1999分
// Shard 3: 2000-2999分
// Shard 4: 3000+分
//
// 获取全局前10名:
// 1. 从每个 Shard 获取前10名
// 2. 合并40个结果,取前10名
//
// 优点:排行榜查询高效
// 缺点:
// - 分数分布不均匀(大部分人在低分段)
// - 分数更新可能跨分片(需要迁移数据)
// 方案2:按用户 ID 分片
// Shard 1: user_id 0-2499万
// Shard 2: user_id 2500万-4999万
// Shard 3: user_id 5000万-7499万
// Shard 4: user_id 7500万-9999万
//
// 获取全局前10名:
// 1. 从每个 Shard 获取前10名
// 2. 合并后取前10名
//
// 优点:数据分布均匀,无跨分片迁移
// 缺点:排行榜查询需要合并多个 Shard2.3.2 排行榜缓存
// 优化:定期快照 + 增量更新
//
// 问题:
// 每次查询前10名都要 ZREVRANGE → 100,000 QPS 压力大
//
// 方案:
// 1. 后台任务每 1 秒生成排行榜快照
// 2. 快照存储到 Redis 的另一个 Key
// SET leaderboard:top100:snapshot "[...]"
// 3. 查询时直接读取快照
// 4. 快照最多延迟 1 秒(近实时)
//
// 效果:
// 写操作:ZADD 到原始 ZSET(10,000 QPS)
// 读操作:GET 快照(100,000 QPS)
// 快照生成:每秒一次,ZREVRANGE 0 99
//
// 延迟权衡:
// 1秒延迟 → 每秒生成快照
// 5秒延迟 → 每5秒生成快照,减少计算量2.4 时间窗口排行榜
// 每日排行榜:
// 每天零点重置
// Key: leaderboard:daily:2024-01-15
// 设置过期时间:48小时(保留昨天的排行榜)
// Redis 命令:
// 今天的排行榜
ZADD leaderboard:daily:2024-01-15 1500 "player:1001"
EXPIRE leaderboard:daily:2024-01-15 172800 // 48小时后过期
// 每周排行榜:
// Key: leaderboard:weekly:2024-W03
// 过期时间:2周
// 总排行榜:
// Key: leaderboard:alltime
// 永不过期
// 定时任务(每天零点):
// 1. 创建今天的 Key
// 2. 设置过期时间
// 3. 昨天的 Key 自然过期三、实战案例
3.1 案例一:完整的排行榜服务
java
// 排行榜服务实现
@Service
public class LeaderboardService {
@Autowired
private StringRedisTemplate redis;
private static final String LEADERBOARD_KEY = "leaderboard:alltime";
// 更新分数
public void updateScore(String userId, long score) {
redis.opsForZSet().add(LEADERBOARD_KEY, userId, score);
}
// 增加分数
public double incrementScore(String userId, double delta) {
return redis.opsForZSet().incrementScore(LEADERBOARD_KEY, userId, delta);
}
// 获取前N名
public List<LeaderboardEntry> getTopN(int n) {
Set<ZSetOperations.TypedTuple<String>> tuples =
redis.opsForZSet().reverseRangeWithScores(LEADERBOARD_KEY, 0, n - 1);
List<LeaderboardEntry> entries = new ArrayList<>();
int rank = 1;
for (ZSetOperations.TypedTuple<String> tuple : tuples) {
entries.add(new LeaderboardEntry(rank++, tuple.getValue(), tuple.getScore()));
}
return entries;
}
// 获取用户排名(从1开始)
public Long getUserRank(String userId) {
Long rank = redis.opsForZSet().reverseRank(LEADERBOARD_KEY, userId);
return rank != null ? rank + 1 : null;
}
// 获取用户附近的排名
public List<LeaderboardEntry> getNearbyEntries(String userId, int range) {
Long rank = redis.opsForZSet().reverseRank(LEADERBOARD_KEY, userId);
if (rank == null) return Collections.emptyList();
long start = Math.max(0, rank - range);
long end = rank + range;
Set<ZSetOperations.TypedTuple<String>> tuples =
redis.opsForZSet().reverseRangeWithScores(LEADERBOARD_KEY, start, end);
List<LeaderboardEntry> entries = new ArrayList<>();
long currentRank = start + 1;
for (ZSetOperations.TypedTuple<String> tuple : tuples) {
entries.add(new LeaderboardEntry(currentRank++, tuple.getValue(), tuple.getScore()));
}
return entries;
}
// 获取用户总数
public Long getTotalPlayers() {
return redis.opsForZSet().zCard(LEADERBOARD_KEY);
}
}3.2 案例二:排行榜快照优化
java
// 定时生成排行榜快照
@Scheduled(fixedRate = 1000) // 每秒执行
public void generateSnapshot() {
Set<ZSetOperations.TypedTuple<String>> top100 =
redis.opsForZSet().reverseRangeWithScores(LEADERBOARD_KEY, 0, 99);
String snapshot = serialize(top100);
redis.opsForValue().set("leaderboard:snapshot:top100", snapshot);
}
// 查询时读取快照
public List<LeaderboardEntry> getTop100FromSnapshot() {
String snapshot = redis.opsForValue().get("leaderboard:snapshot:top100");
if (snapshot == null) {
return getTopN(100); // 降级:直接查询
}
return deserialize(snapshot);
}四、本章小结
核心知识点:
- 数据库 ORDER BY 不适合排行榜(O(n log n) 太慢)。
- Redis ZSET 基于跳表实现,更新和查询都是 O(log n)。
- 分片策略:按分数范围分片(查询高效)vs 按用户 ID 分片(分布均匀)。
- 排行榜快照 + 增量更新实现近实时查询。
- 时间窗口排行榜通过 Key 命名 + 过期时间实现。
五、思考与练习
思考题
Redis ZSET 的跳表结构为什么比平衡树更适合排行榜?
如果分数相同,如何确定排名顺序?
如何处理作弊玩家的分数清理?
练习题
使用 Redis 实现一个完整的排行榜服务。
实现分片排行榜,支持亿级用户。
设计每日/每周排行榜的自动清理机制。