5系统设计面试:内幕指南(第2版)

设计近实时排行榜

案例4

排行榜实时计算缓存

🎯 AI 时代的学习建议:不要只读理论。理解这一章的设计动机(为什么需要这个概念?)和边界条件(什么情况下会失效?)。当 AI 生成的代码涉及这些概念时,你能判断它是否用对了场景。AI 能帮你总结内容,但无法替代你亲手阅读、思考、建立直觉的过程。

第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 WITHSCORES

2.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名
//
// 优点:数据分布均匀,无跨分片迁移
// 缺点:排行榜查询需要合并多个 Shard

2.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 实现一个完整的排行榜服务。

实现分片排行榜,支持亿级用户。

设计每日/每周排行榜的自动清理机制。

5/6设计近实
0%