排行榜系统的数据库架构演进:从Redis Sorted Set到自研排名引擎

发布时间:2026/7/22 10:43:13
排行榜系统的数据库架构演进:从Redis Sorted Set到自研排名引擎 排行榜系统的数据库架构演进从Redis Sorted Set到自研排名引擎一、Redis Sorted Set的甜蜜期与崩溃点当排名不只是取Top100游戏排行榜的初级阶段总是从Redis Sorted Set开始。ZADD rank:score 1500 player_1001然后ZREVRANGE rank:score 0 99 WITHSCORES3行命令搞定一个排行榜。简单、高效、优雅。但排行榜从来不只取Top100这么简单查看我的排名——ZREVRANK rank:score player_1001在1000万成员的Sorted Set上需要O(log N)查看我的前后各10名——需要先ZREVRANK再ZREVRANGE全服排名百分比——你超过了87.3%的玩家需要精确知道总人数和自身排名按职业分类的排名——每个职业一个Sorted Set10个职业就是10个Set实时名次变动推送——排名每变一次都要通知客户端光一个Top100的变动就是每秒数百次Sorted Set在百万级成员时开始吃力千万级时P99飙升到数百毫秒亿级时ZADD操作都可能超时。更致命的是跳跃表的重平衡开销——Sorted Set底层是跳跃表哈希表每个ZADD操作不仅需要O(log N)查找插入位置还需要在跳跃表的每一层随机决定是否向上传播。在高并发写入下这种随机行为会触发大量的内存分配和指针更新。二、从数据结构优化到排名引擎重构Phase 1: 分桶是最朴素但最有效的优化。原理是将排行榜按分数段切成N个桶如0-1000分一个桶1000-2000分一个桶每个桶是一个独立的Sorted Set。查询全服排名第500名时先确定它落在哪个桶再在桶内查询。跨桶查询通过一个额外的桶索引来定位。public class BucketedRanking { private static final int BUCKET_SIZE 5000; // 每桶5000分 private final JedisCluster redis; private final MapInteger, String bucketKeys new ConcurrentHashMap(); public void addScore(String playerId, int score) { int bucket score / BUCKET_SIZE; String key bucketKeys.computeIfAbsent(bucket, k - rank:bucket: k); redis.zadd(key, score, playerId); // 更新桶索引 redis.sadd(rank:buckets:active, key); } public long getPlayerRank(String playerId, int score) { int bucket score / BUCKET_SIZE; String currentKey rank:bucket: bucket; // Step 1: 在当前桶内排名 Long inBucketRank redis.zrevrank(currentKey, playerId); if (inBucketRank null) return -1; // Step 2: 累加更高分桶的人数 long offset 0; for (int b bucket 1; ; b) { String higherKey rank:bucket: b; if (!redis.exists(higherKey)) break; offset redis.zcard(higherKey); } return offset inBucketRank 1; // 排名从1开始 } }Phase 2: 自研RankTree。分桶有天花板——当桶的数量超过1000时跨桶排名的开销不可忽略。此时需要更底层的数据结构优化。自研的RankTree是一棵带子树大小的平衡二叉搜索树AVL或红黑树每个节点记录其左子树的大小Node { playerId: long score: int leftSize: int // 左子树节点数 left, right: Node height: int }查询排名时从根节点遍历如果目标分数小于当前节点的分数进入右子树排名在右子树的节点之后所以名次 当前排名 leftSize 1 右子树中比它小的节点数如果大于进入左子树。public class RankTree { private Node root; private final ReentrantReadWriteLock lock new ReentrantReadWriteLock(); public int getRank(int score) { lock.readLock().lock(); try { return getRankRecursive(root, score, 0); } finally { lock.readLock().unlock(); } } private int getRankRecursive(Node node, int score, int count) { if (node null) return -1; if (score node.score) { return count node.leftSize 1; } else if (score node.score) { // 当前节点分数更高向左找 return getRankRecursive(node.left, score, count); } else { // 当前节点分数更低向右找累加排名 return getRankRecursive(node.right, score, count node.leftSize 1); } } public void insert(long playerId, int score) { lock.writeLock().lock(); try { root insertRecursive(root, playerId, score); } finally { lock.writeLock().unlock(); } } private Node insertRecursive(Node node, long playerId, int score) { if (node null) return new Node(playerId, score); if (score node.score) { node.leftSize; node.left insertRecursive(node.left, playerId, score); } else { node.right insertRecursive(node.right, playerId, score); } return rebalance(node); } private Node rebalance(Node node) { // AVL旋转逻辑保持树平衡 node.height 1 Math.max(height(node.left), height(node.right)); int balance height(node.left) - height(node.right); // LL if (balance 1 height(node.left.left) height(node.left.right)) { return rotateRight(node); } // LR if (balance 1 height(node.left.left) height(node.left.right)) { node.left rotateLeft(node.left); return rotateRight(node); } // RR if (balance -1 height(node.right.right) height(node.right.left)) { return rotateLeft(node); } // RL if (balance -1 height(node.right.right) height(node.right.left)) { node.right rotateRight(node.right); return rotateLeft(node); } return node; } }三、RankService从库内计算到独立服务当排名逻辑复杂到需要跨多个数据源合并时将排名引擎抽离为独立服务RankService是必然选择。RankService的接口设计public interface RankService { // 写入 CompletableFutureVoid updateScore(String dimension, String playerId, long score); // 查询排名 CompletableFutureLong getRank(String dimension, String playerId); // 查询排名区间 CompletableFutureListRankEntry getRange(String dimension, int start, int end); // 查询周边排名前后各N名 CompletableFutureRankWindow getWindow(String dimension, String playerId, int n); }RankService内部按维度一致性哈希分片每个分片是一个独立的RankTree实例。数据持久化通过WALWrite-Ahead Log异步写入MySQLpublic class RankServiceNode { private final ConcurrentHashMapString, RankTree dimensions; private final WALWriter walWriter; private final ScheduledExecutorService snapshotExecutor; public RankServiceNode(int shardId) { this.dimensions new ConcurrentHashMap(); this.walWriter new WALWriter( /data/rank_wal/shard_ shardId .wal ); // 每5分钟做一次快照到MySQL this.snapshotExecutor Executors.newSingleThreadScheduledExecutor(); this.snapshotExecutor.scheduleAtFixedRate( this::snapshot, 300, 300, TimeUnit.SECONDS ); } public void updateScore(String dimension, String playerId, long score) { walWriter.append(new WalEntry(dimension, playerId, score, System.currentTimeMillis())); RankTree tree dimensions.computeIfAbsent(dimension, k - new RankTree()); // 如果已存在先删除旧记录 RankEntry old tree.find(playerId); if (old ! null) { tree.remove(playerId); } tree.insert(playerId, score); } private void snapshot() { for (Map.EntryString, RankTree entry : dimensions.entrySet()) { String dimension entry.getKey(); RankTree tree entry.getValue(); try { // 批量写入MySQL ListRankEntry entries tree.getAllEntries(); jdbcTemplate.batchUpdate( REPLACE INTO rank_snapshot (dimension, player_id, score, rank, snapshot_time) VALUES (?, ?, ?, ?, NOW()), entries, 1000, (ps, entry) - { ps.setString(1, dimension); ps.setString(2, entry.playerId); ps.setLong(3, entry.score); ps.setLong(4, entry.rank); } ); } catch (Exception e) { // 快照失败不影响线上服务 errorLogger.error(Snapshot failed for dimension: dimension, e); } } } }四、排名系统的多层边界边界一排名的时效性幻觉。玩家看到的排名永远是查询时刻的快照不是实时的。如果100个玩家在1秒内同时查询排名他们看到的是同一时刻的排名快照但每个玩家的排名实际上在查询返回之前就已经变了。产品上应该明确标注排名数据延迟3秒管理预期。边界二树结构的OOM风险。RankTree全量加载在内存中每个节点约40字节1000万节点就是400MB。但加上JVM对象头和对齐填充实际内存可能膨胀到1.5GB。需要做内存预估和上线压测。边界三启动预热的雪崩。RankService节点重启后空RankTree从WAL回放数据可能需要10分钟。这段时间内排名查询全部穿透到MySQL导致数据库雪崩。解决方案是双Buffer设计——启动时先从MySQL加载最新的快照到备用Buffer切换后异步回放WAL修复增量。边界四多维排名的空间爆炸。按职业按服务器按赛季的3维排名组合维度基数为10×100×2020000个独立排行榜。每个排行榜单独维护RankTree内存需求指数增长。实际的优化是只在查询时才计算交叉维度排名——用Bitmap交集找到候选集再在候选集中计算排名。五、总结游戏排行榜的架构演进遵循一条清晰的路径单维度Sorted Set → 分桶优化 → 自研RankTree → RankService服务化 → 多维排名引擎。每一步升级的触发条件都是性能瓶颈的出现——当Sorted Set的延迟超过阈值、当内存占用逼近极限、当维度组合的查询让简单结构无法胜任。值得强调的是排行榜的性能优化只有50%在写代码另外50%在产品需求的控制上。所有人实时看到所有人的排名在产品上是一个伪需求技术上也是一个灾难。定义清楚排名的刷新频率和展示范围往往比优化数据结构本身更有价值。本文属于「行业场景与项目复盘」系列解析游戏排行榜系统从Redis到自研引擎的完整演进路径。