游戏排行榜系统的架构设计:从Redis Sorted Set到分布式Top-K方案

📅 2026/7/24 17:12:11
游戏排行榜系统的架构设计:从Redis Sorted Set到分布式Top-K方案
游戏排行榜系统的架构设计从Redis Sorted Set到分布式Top-K方案一、排行榜的业务特征与技术挑战排行榜是游戏中最具社交属性的系统之一。它不只是展示谁是第一更是驱动玩家活跃和付费的核心杠杆——段位排名、赛季结算、好友比拼、全服竞速每一种排行榜都在制造竞争感和目标感。从技术视角拆解游戏排行榜有几个显著特征写多读也多。以千万DAU的游戏为例假设每个玩家平均每小时产生3次分数变更事件那么写入TPS在800010000之间而排行榜的读取查看排名、翻页、查看好友排名可能达到写入的510倍。实时性要求分级。全服排行榜可以接受秒级延迟但好友榜的更新需要在百毫秒内生效否则会出现我刚打了最高分为什么好友看不到的客诉。维度爆炸。同一个玩家可能出现在战力榜、竞技榜、公会榜、赛季榜等十几个榜单中每个榜单的排序规则和更新频率都不同。二、Redis Sorted Set的极限与超越Redis Sorted Set是排行榜的标准答案——ZADD更新分数O(log N)ZREVRANGE查询Top-K也是O(log N K)。对于百万级用户的全服榜单个Redis实例足以承载。但当用户量达到千万级别时问题开始浮现内存膨胀。Sorted Set的底层是跳表字典每个元素的meta开销约80字节。一亿条记录memberscore大约需要8-12GB内存这让纯内存方案的成本变得不可接受。写入热点。所有写入操作集中在一个实例上Redis单线程模型下ZADD的TPS上限约为8-12万取决于value大小但在游戏高峰期可能成为瓶颈。跨分片排名不准。最简单的分片方式是按玩家ID哈希分布到多个Redis实例但这导致无法直接获取全局排名——玩家在分片内排名第3但全局可能排第1000。解决方案是分片排名定期合并的混合架构public class DistributedLeaderboard { private final ListRedisClient shards; private final int shardCount; private final String leaderboardKey; // 全局Top-1000缓存每30秒重建 private volatile ListRankEntry globalTopCache; private final ScheduledExecutorService mergeExecutor; public DistributedLeaderboard(ListRedisClient shards, String leaderboardKey) { this.shards shards; this.shardCount shards.size(); this.leaderboardKey leaderboardKey; this.mergeExecutor Executors.newSingleThreadScheduledExecutor(); // 定期合并 this.mergeExecutor.scheduleAtFixedRate( this::rebuildGlobalTopCache, 5, 30, TimeUnit.SECONDS); } public void updateScore(String playerId, double score) { int shardIndex Math.abs(playerId.hashCode()) % shardCount; shards.get(shardIndex).zadd(leaderboardKey, score, playerId); } public ListRankEntry getGlobalTopK(int k) { if (k globalTopCache.size()) { return globalTopCache.subList(0, Math.min(k, globalTopCache.size())); } // 超出缓存范围的请求走实时合并 return mergeTopKFromAllShards(k); } public long getPlayerGlobalRank(String playerId) { int playerShard Math.abs(playerId.hashCode()) % shardCount; Double playerScore shards.get(playerShard).zscore(leaderboardKey, playerId); if (playerScore null) return -1; // 汇总所有分片中分数高于该玩家的数量 long rank 0; for (int i 0; i shardCount; i) { if (i playerShard) { rank shards.get(i).zcount(leaderboardKey, Range.greaterThan(playerScore)); } else { rank shards.get(i).zcount(leaderboardKey, Range.greaterThan(playerScore)); } } return rank 1; // rank从1开始 } private void rebuildGlobalTopCache() { // 从每个分片拉取Top-K归并排序 ListCompletableFutureListRankEntry futures new ArrayList(); for (RedisClient shard : shards) { futures.add(CompletableFuture.supplyAsync(() - shard.zrevrangeWithScores(leaderboardKey, 0, 999) )); } this.globalTopCache futures.stream() .map(CompletableFuture::join) .flatMap(List::stream) .sorted(Comparator.comparingDouble(RankEntry::getScore).reversed()) .limit(1000) .collect(Collectors.toList()); } }这种方案下Top-K查询K≤1000走本地缓存延迟1ms玩家个人排名查询需要跨分片汇总延迟约10-30ms取决于分片数量写入延迟始终是单个分片的O(log N)没有扩大。三、赛季切换的数据归档与重置赛季制排行榜引入了一个独特挑战赛季结束时当前榜单数据需要归档同时为新赛季创建空榜单。这个过程必须在维护窗口内完成且不能丢失任何赛季末最后一刻的分数更新。设计上采用双写快照切换策略public class SeasonRotationManager { public void rotateSeason(String leaderboardKey, int oldSeasonId, int newSeasonId) { String archiveKey leaderboardKey :season: oldSeasonId; String newSeasonKey leaderboardKey :season: newSeasonId; // Step 1: RENAME原子操作旧榜单变为归档 // RENAME是原子操作不存在切换间隙 redis.rename(leaderboardKey, archiveKey); // Step 2: 归档数据压缩存储 byte[] compressed compressLeaderboardData(redis.dump(archiveKey)); // Step 3: 持久化到对象存储用于后续赛季回顾功能 ossClient.putObject(leaderboard-archive/season- oldSeasonId .dat, compressed); // Step 4: 设置归档数据的TTLRedis中保留30天热数据 redis.expire(archiveKey, 30, TimeUnit.DAYS); // Step 5: 新赛季榜单已就绪空Sorted Set由RENAME自动创建 // 此时新的写入会进入新赛季榜单 } }关键点Redis的RENAME命令是原子操作不会出现既没有旧榜单也没有新榜单的中间状态。如果写入请求恰好在RENAME之前到达分数会随旧榜单一起归档如果恰好在之后到达会写入新榜单。不会丢失数据。四、好友排行榜的实时推送好友排行榜的数据量小通常几十到几百人但实时性要求极高。方案是将好友榜数据加载到本地内存通过事件驱动保证一致性Service public class FriendLeaderboardService { // 每个玩家的好友榜缓存在本地内存 private final CacheLong, ListFriendRankEntry friendRankCache Caffeine.newBuilder() .maximumSize(500_000) .expireAfterWrite(5, TimeUnit.MINUTES) .build(); KafkaListener(topics player-score-changed) public void onScoreChanged(ScoreChangeEvent event) { // 找到该玩家的所有好友失效他们的好友榜缓存 ListLong friendIds friendGraphService.getFriendIds(event.getPlayerId()); friendIds.forEach(friendRankCache::invalidate); // 如果好友在线主动推送排名变化 friendIds.stream() .filter(onlinePlayerRegistry::isOnline) .forEach(fid - pushGateway.push(fid, new RankChangeNotification(event.getPlayerId(), event.getNewScore(), event.getRankDelta()))); } }五、总结排行榜系统看似简单——一个Sorted Set就能跑起来——但在千万级用户的游戏场景下需要解决的问题远不止数据结构的选型。分片排名的近似查询、赛季切换的原子性保证、好友榜单的实时推送、多维榜单的存储爆炸每一个都是需要仔细权衡的工程决策。核心经验是不要试图用一套方案解决所有榜单需求。全服榜追求全局一致性适合定期合并的分片方案好友榜追求实时性适合内存缓存事件驱动赛季榜追求历史可追溯适合归档到对象存储。针对不同的访问模式和一致性要求选择不同的架构才是排行榜系统设计的正确姿势。