资讯动态

游戏排行榜技术选型:从 MySQL 到 Redis ZSET 的完整实践

发布时间:2026/10/7 3:38:49 来源:尧图企业网站定制
前几天跟几个做游戏服务端的同行聊技术选型有人问排行榜到底用 MySQL 还是 Redis群里直接吵了一晚上。排行榜这个需求从小游戏的好友榜到百万 DAU 的竞技天梯我前前后后做过四五个完整方案踩过的坑比写过的代码还多。这篇文章就把“游戏排行榜实现”这件事从里到外讲透覆盖需求拆解、方案选型、Redis ZSET 实战、同分排序、数据分桶、活动期运维这些关键环节。如果你想直接抄作业重点看第二章和第三章如果正卡在技术选型或者架构评估阶段建议完整过一遍很多坑都是提前避开的。先说清楚一个容易被忽略的事实排行榜不是“一张表按分数排序”那么简单。它本质上是一个写多读多、对实时性有要求、还掺着各种业务规则的排序系统。玩家打一局上报一次分数然后服务端要在海量数据里毫秒级回答“我排第几”“前一百名是谁”。需求方嘴上说的是排行榜实际要的东西可能千差万别所以动手前的需求拆解比写代码更重要。1. 需求拆解与方案选型1.1 先搞清楚你的排行榜属于哪一类我在接排行榜需求时第一件事永远是做需求画像。把所有榜单类型列出来一个一个对号入座因为不同类型的榜单技术侧重完全不同。榜单类型典型场景数据量级核心痛点全服总榜累计积分、总战力百万到千万数据量大更新频繁日/周/月榜每日竞赛、周冲榜千万以上Key 数量多需要归档清理好友排行榜微信小游戏、社交游戏百万级需要做关系链交集运算竞技天梯段位赛、积分对战十万级活跃实时性高公平性要求高活动冲榜限时运营活动千万到亿级瞬时流量大必须削峰需求拆解的关键不是看榜单长什么样而是看读写比例和实时性要求。有的榜单“每小时更新一次排名”就够了那完全可以走“MySQL 缓存”的老路子但大部分游戏排行榜尤其是运营活动期间的冲榜玩家打完一局就立刻能看到自己的名次变化这种实时性需求直接把你逼到内存型数据结构的路上。还有一类问题经常被忽略榜单要不要做历史归档。很多项目上线时只做了当期榜单活动结束就忘了数据。过了一个月运营要复盘问你要上周的活动榜单数据这时候没有归档方案就麻烦了。我建议在需求阶段就确认清楚榜单数据要不要保留、保留多久、按什么粒度保留。1.2 三种可行的技术方案我按实际项目踩过的坑把排行榜的可行方案分成三档你可以根据团队和业务体量对号入座。第一档MySQL 直接排序。适合用户量十万以内、排行榜更新频率低、实时性要求不高的场景。实现就是一张表存用户分数查榜时ORDER BY score DESC LIMIT N更新时直接 UPDATE。这个方案最大的问题是当你想同时支持“查询我的排名”和“查询某区间榜单”这两类请求时数据库的负载会迅速上升。查询我的排名需要COUNT(*) WHERE score 我的分数在百万级数据上做聚合统计本身就很不划算如果你为了展示排名编号强制在每次分数更新时去重排所有人的排名那更是一场更新风暴。我在早期项目里用这个方案上线半个月日活过了 30 万榜单接口的 P99 延迟直接飙到 3 秒多后来加了缓存也只是缓解了读压力数据一致性又成了新问题。第二档Redis ZSET。这是当下最常见的方案也是这篇内容的重点。ZSET 是 Redis 的有序集合底层用跳跃表加哈希表实现——跳跃表负责按分数排序和范围查询哈希表负责按成员快速定位分数。它把“存分数”和“按分数排序”合并成一个原子操作几乎不需要额外业务代码就能实现排行榜全部基础功能。这一档我会在第二章实战展开。第三档专门的分部落榜组件或分布式架构。当数据规模到千万甚至亿级或者一个排行榜服务要同时支撑多个游戏项目时单靠一个 Redis 实例就有风险了。这种场景需要引入分片、分桶、多级缓存甚至自研排行榜服务。第四节我会给出设计思路这里先放个引子。1.3 为什么优先选 Redis ZSET先放结论对绝大多数中等规模游戏项目Redis ZSET 是排行榜场景下性能、开发效率、运维成本三者最均衡的选择。理由不复杂ZADD 写入是 O(log N)集合里有一百万个成员时一次写入也就几十微秒级。ZREVRANGE 取前 N 名、ZREVRANK 查某个玩家排名都是 O(log N)。不需要自己写排序算法Redis 内部已经按分数排好了。命令组合丰富ZINCRBY、ZUNIONSTORE、ZINTERSTORE 能覆盖几乎所有的排行变体需求。用生活化的比喻ZSET 就像一份自动按成绩排好队的班级名单你随时问“张三排第几”或者“把前三名念出来”它都能立刻回答。你每次往里添成绩它会自己插到该在的位置不需要手动重排。这里也提醒一句不是所有项目都必须上 Redis。如果团队本来就没有 Redis 运维能力业务量又很小一档的 MySQL 方案撑住早期版本完全没问题。技术选型最忌讳“为了用而用”我见过不少项目因为盲目引入 Redis 导致运维成本翻倍这都属于自找麻烦。2. 基于 Redis ZSET 的核心实现2.1 环境准备与五个核心命令动手之前先确认环境。我建议至少用 Redis 6.0 以上版本很多脚本特性和老版本相比好用太多。生产环境强烈建议开启 AOF 持久化否则 Redis 一旦重启ZSET 里的数据全没了排行榜只能回源数据库重算那可是灾难级的修复过程。ZSET 的五个核心命令实际项目中 90% 的操作都围绕它们展开命令用途时间复杂度ZADD key score member新增或更新成员分数O(log N)ZINCRBY key increment member原子增加某个成员分数O(log N)ZREVRANGE key start stop按分数从高到低取区间成员O(log NM)ZREVRANK key member查询成员在榜单中的倒序排名O(log N)ZSCORE key member查询成员当前分数O(1)两个容易忽视的细节。第一ZREVRANK返回的排名从 0 开始前端展示榜单一般从 1 开始返回时记得加 1。第二ZSET 在分数相同时按 member 的字典序排序而排行榜通常希望“先到达该分数的玩家排前面”原生命令做不到这个需要通过分数编码去实现这个坑我会在 3.1 详细讲。2.2 接口设计写入、查询、排名一个最小可用的排行榜系统服务端至少要有三个接口上报分数、查榜单前 N 名、查某个玩家排名。我以 Java 为例写核心逻辑因为游戏服务端用 Java 和 Go 的最多但你换成 Go 其实也是同一套思路。Service public class RankService { private static final String RANK_KEY_PREFIX rank:global:; Autowired private StringRedisTemplate redisTemplate; /** * 玩家上报分数新成员插入老成员覆盖更新 */ public void reportScore(Long playerId, int score, String date) { String key RANK_KEY_PREFIX date; redisTemplate.opsForZSet().add(key, String.valueOf(playerId), score); } /** * 查询榜单前 N 名带分数值 */ public ListRankItem topN(String date, int n) { String key RANK_KEY_PREFIX date; SetZSetOperations.TypedTupleString tuples redisTemplate.opsForZSet().reverseRangeWithScores(key, 0, n - 1); // 拼装 RankItem 返回给前端 } /** * 查询玩家排名和分数rank 为 null 表示不在榜上 */ public RankInfo getPlayerRank(String date, Long playerId) { String key RANK_KEY_PREFIX date; Long rank redisTemplate.opsForZSet().reverseRank(key, String.valueOf(playerId)); Double score redisTemplate.opsForZSet().score(key, String.valueOf(playerId)); return new RankInfo(rank null ? -1 : rank 1, score); } }核心代码量其实非常小这就是 ZSET 的威力。但这套同步调用在活动大流量下会面临压力通常配两个思路优化。第一个是同步写加异步读玩家上报分数的请求先进消息队列RocketMQ、Kafka 都行消费者再写入 Redis。排行榜对分数的时序性要求不会严格到毫秒级延迟几百毫秒玩家根本感知不到但写入洪峰被成功削掉。第二个是读写分离Redis 主从架构下写走主实例榜单查询走从实例把读流量从主实例上剥离开避免互相干扰。2.3 Key 设计别让 Redis 内存爆炸Key 设计是很多团队栽跟头的地方。如果只用rank:global一个 key 存全量数据日榜、周榜、月榜全混在一起数据清理和定向删除都会变得很痛苦。我推荐的 Key 模式是“业务前缀:榜单类型:时间粒度:具体时间”rank:global:20250101 -- 全服日榜日期为 2025-01-01 rank:global:week:20250101 -- 全服周榜 rank:activity:act123 -- 活动 ID 为 act123 的活动榜 rank:friend:uid123 -- 玩家 123 的好友榜用日期做 key 的最大好处是过期清理很自然。日榜 key 设置 48 小时 TTL周榜设置 7 天月榜设置 31 天到点自动消失不需要写定时任务扫描删除。如果你用的是不带日期的那种 key历史数据只能靠脚本过期清理非常被动。这里有一个实实在在的坑用 ZADD 往里加新成员时不会重置 TTL。如果你的业务逻辑里有“活动快结束时重新激活排行榜”的场景一定要记得手动EXPIRE刷新过期时间。我处理过一次线上事故活动排行榜的 key 在活动进行到一半时到期Redis 把所有玩家数据删了个干净玩家端一片空白。那个晚上排查到凌晨三点最后就是一条EXPIRE的事。多榜单聚合还有一个实用技巧周榜不一定要独立维护一个 key可以直接用日榜聚合生成。比如本周一到周日一共 7 天的日榜执行一次 ZUNIONSTORE 就能生成周榜ZUNIONSTORE rank:global:week:20250101 7 rank:global:20250101 rank:global:20250102 rank:global:20250103 rank:global:20250104 rank:global:20250105 rank:global:20250106 rank:global:20250107ZUNIONSTORE 会把多个 ZSET 按成员合并分数。这种“日榜聚合周榜”的方式省掉了每周单独维护 key 的成本历史数据回溯也很方便。3. 排名规则进阶同分排序、好友榜、防刷3.1 同分排序的分数编码方案排行榜最容易被产品经理提需求搞崩溃的就是“分数相同怎么排”。最常见需求是两种一是先到达该分数的玩家排前面二是同分玩家随机但不抖动。ZSET 本身在 score 相同时按 member 字典序排明显不符合直觉所以必须对 score 做编码。先说“先到先得”这个需求。思路是把玩家真实得分和时间信息揉进同一个 score 里。假设真实得分最大不超过 1 亿8 位数字时间精度到秒score 拆成两段score 玩家得分 * 100000000 (99999999 - 当前时间戳 % 100000000)这个公式的排序逻辑是主体权重是玩家得分得分高的一定排在前面得分相同的时候时间戳更小意味着更早到达而我对时间取模部分做了翻转99999999 - 时间取模越大代表到达越早于是排名越靠前。举个例子玩家 A 在时间戳取模为 50000000 时拿到 100 分玩家 B 在时间戳取模为 60000000 时也拿到 100 分。A 的 score 是100 * 100000000 (99999999 - 50000000) 10049999999B 的 score 是100 * 100000000 (99999999 - 60000000) 10039999999。A 大于 B所以 A 排在前面完全符合“先到先得”。展示给玩家时真实得分通过score / 100000000整除解码即可public class ScoreCodec { private static final long SCORE_BASE 100_000_000L; private static final long TIME_MASK 99_999_999L; // 编码真实得分 时间倒序偏移 public static long encode(int realScore, long timestampSec) { long timePart TIME_MASK - (timestampSec % SCORE_BASE); return realScore * SCORE_BASE timePart; } // 解码拿到真实得分用于展示 public static int decode(long rankScore) { return (int) (rankScore / SCORE_BASE); } }这个方案我用了很久排稳定效果在真实项目里验证过。但有一个重要限制ZSET 的 score 是 double 类型超过 2^53约 9 千万亿会出现精度丢失。设计编码方案时一定要先估算最大真实得分确保真实得分 * SCORE_BASE 偏移量不超过这个上限。如果你游戏的单局分数会上亿SCORE_BASE 就得调小或者换成分桶方案。再说“同分随机但不抖动”的需求。这个看似更简单其实反而更麻烦。不能用真随机真随机会导致玩家每次刷新榜单顺序都在变。常规做法是加盐score 玩家得分 * 100 random(0~99)这个 random 值在玩家第一次上报分数时固定下来。这样同分玩家有一个稳定、伪随机的内部排序每次看到的结果都是一致的。3.2 好友排行榜ZINTERSTORE 交集运算好友排行榜微信小游戏最常见的形态在 ZSET 之上只需多做一步交集计算。Redis 原生支持ZINTERSTORE和ZUNIONSTORE我们用一个 Set 存好友关系再用交集命令生成好友榜单ZINTERSTORE rank:friend:uid123 2 rank:global:20250101 friend:set:uid123这条命令的意思是取全服日榜和该玩家好友集合的交集结果写入rank:friend:uid123分数取交集中成员在全服榜里的分数。执行完这个 key 就是该玩家的好友排行榜。但ZINTERSTORE是耗时操作复杂度 O(N×K)N 是集合大小K 是有序集合个数。对好友几百人的玩家来说毫无压力但如果某个大 V 有几十万好友这个操作可能要几十毫秒不能每次点开好友榜都实时执行。我的做法是加缓存好友榜单结果缓存 5 分钟用“好友变化事件”和“榜单分数变化事件”做缓存失效。好友榜的实时性要求远低于全服排行5 到 10 分钟的延迟玩家基本无感。3.3 防刷校验与数据一致性排行榜系统上线后最烦的就是刷子和作弊常见两种通过脚本反复打低难度关卡刷积分直接改包上报篡改客户端分数参数。防刷首先要有分数合理性阈值校验。比如单局最高得分设计为 100 分那一个小时内玩家的总分涨幅就不能超过合理上限比如 600 分。这种校验放到接入层做一个简单拦截器即可成本低效果好。更严格的做法是业务校验每局游戏结束后客户端上报的不是分数而是对局记录 ID 或对局结果摘要服务端根据对局记录算出分数再写入排行榜。这个方案架构改动稍大但对竞技类、排位赛游戏几乎必须。我之前做一个棋牌项目时服务端增加了对局摘要校验逻辑作弊率肉眼可见下降了九成。数据一致性这块要明确一点排行榜对一致性的要求不是“严格实时”而是“最终一致”。玩家分数进 Redis 之后没有立即同步到 MySQL 没关系等定期任务把快照刷回库就行TOP N 缓存短暂延迟也没有大碍。但你要防的是“Redis 重启后数据全丢”这种极端不一致所以 AOF 持久化和定期快照备份必须做这不是可选项是保命项。4. 性能优化与大规模扩容4.1 读多写少场景的 TOP N 缓存排行榜有个特点榜单数据存在明显热点。绝大部分玩家只看前 100 名而前 100 名在整个榜单里占比极小。你可以用很小的缓存成本换极大的读性能提升。具体做法是启动一个定时任务每隔 5 秒执行一次ZREVRANGE key 0 99把前 100 名结果缓存在本地内存Java 用 Caffeine或 Redis 的 String key 里。前端查榜直接打缓存不再实时查 ZSET。这样即使 ZSET 数据量很大查询前 100 名的接口也只是读一小段内存缓存。玩家查“我的排名”这个操作没法走 TOP N 缓存但可以通过 ZREVRANK 直接拿速度也很快。这里还有个优化点给“玩家个人排名”也做二级缓存比如缓存 3 秒内的结果一局结束后玩家查自己排名直接返回最近一次缓存避免短时间内的重复 ZREVRANK 调用打满 Redis。4.2 冷热数据分离分桶 ZSET 与归并查询当全服榜单数据体量涨到单个 ZSET 有几千万甚至上亿成员时就算是 O(log N) 的操作也会面临两个问题内存开销巨大、单个 key 的操作出现慢查询。这种场景建议分桶。思路是把成员按规则拆成 N 份每份是一个独立 ZSET。规则可以是玩家 ID 哈希也可以是分数区间。查询前 100 名时先从每个桶里取前 100 名内存里做归并排序得到最终 Top N。查询某个玩家排名时先定位到他所在的桶桶内查排名再加上之前所有桶的总成员数。伪代码大概是这个意思ListRankItem topN(int n) { ListRankItem merged new ArrayList(); for (int i 0; i BUCKET_COUNT; i) { merged.addAll(bucketTopN(i, n)); } merged.sort(Comparator.comparingLong(RankItem::getScore).reversed()); return merged.subList(0, Math.min(n, merged.size())); }这个方案本质是用复杂性换扩展性牺牲了“排行榜天然维护全局顺序”的特性换来横向扩容能力。分桶数量要根据业务数据分布来定。我之前做过一个约 2000 万成员的榜单分了 64 个桶每桶约 30 万成员查询延迟稳定在 10ms 内效果很好。4.3 多级缓存与集群部署再往上如果排行榜读取量巨大每秒几十万次查询单靠 Redis 实例撑不住就需要在 Redis 之上加一层本地缓存。Java 技术栈最常见的是 Caffeine每个服务节点在自己的 JVM 内存里保存一份榜单缓存命中直接返回不命中去查 Redis。这种架构下Redis 的实际 QPS 会大幅下降从几十万掉到几千压力小了一个数量级。缓存一致性用“过期 主动刷新”模式缓存 5 秒过期定时任务每 3 秒刷新一次。这样即使 Redis 数据在更新客户端看到的数据最多延迟 5 秒。排名数据不需要强一致这个方案完全够用。我用这套支撑过日活百万的游戏排行榜接口 P99 延迟保持在 20ms 以内压测数据非常稳。4.4 亿级数据量的分布式架构方向超大规模体量下国民级手游那种单 Redis 集群不够需要更复杂的分布式架构。典型设计是游戏服务有状态排行榜服务无状态存储层做分片。排行榜服务用一致性哈希把玩家分到不同的排行榜分片上每个分片独立维护一份 ZSET。查询全榜时并行从各分片拉取前 N 名聚合后返回。玩家排名查询先定位分片再在分片内查排名并加上前面分片的总人数。麻烦在于聚合性能和数据倾斜。比如某个大区玩家特别多分片之间负载不均需要做动态负载均衡。这个方案的运维成本非常高如果不是到了真正的亿万级体量我不建议采用。就我带的团队经验ZSET 加多级缓存撑到 1000 万级数据量没有任何问题先把这个量级吃透再去考虑分布式不是迟是理智。5. 常见问题与排查实录5.1 排名错乱怎么查接到最多反馈的线上问题就是“排行榜名次不对”。我处理过的案子八成出在分数编码上。比如前面说的“得分加时间偏移量”方案如果时间部分补数设计反了得分相近的玩家就会出现逆序。排查方法很简单先ZSCORE看两个相邻玩家的实际 score把 score 解码成“得分 偏移量”两部分检查偏移量是否和预期一致。如果偏移量反了排名自然就乱。另外再次提醒 ZSET 的 score 是 double超过 2^53 精度会丢失编码后的 score 一定不要超出这个范围。5.2 运营问“为什么排名没刷新”这通常不是技术问题而是链路问题。玩家打完一局上报分数界面还显示旧排名八成是客户端缓存或服务端 TOP N 缓存没有过期。我遇到过一次运营反馈“排行榜 10 分钟没更新”最后查出来是定时任务挂了TOP N 缓存根本没刷新。建议把刷新链路彻底梳理清晰客户端上报 → 服务端接口 → 写 Redis → 排行榜服务感知分数变化 → 刷新 TOP N 缓存。任何一环断了界面就会“假死”。对实时性要求高的榜单可以用 Redis pub/sub 推送变更或者把本地缓存 TTL 缩短到 1 至 2 秒。5.3 活动上线前的 Checklist这份清单我每次运营活动前必过一遍照着做基本能避开大部分坑确认排行榜 key 的过期时间覆盖整个活动期活动期间每天手动刷新 TTL。TOP N 缓存刷新频率是否满足活动需求最好至少比业务更新频率快 3 倍。检查 Redis 实例内存余量用 INFO memory 看水位。写入和查询是否已做读写分离。分数编码规则是否跑过单元测试。防刷拦截和分数合理性校验是否生效。核心榜单数据是否定期刷 MySQL 快照做到有备份可恢复。5.4 常见问题速查表症状可能原因解决方案排名错乱分数编码设计错误、score 精度丢失检查编码规则score 不要超 2^53榜单长时间不刷新TOP N 缓存更新任务挂了检查刷新任务缩短缓存 TTLKey 提前过期TTL 设置错误活动期间手动 EXPIRE 刷新写入变慢单个 ZSET 成员过多分桶拆 ZSET查询变慢Redis 实例负载过高加从节点、上多级缓存数据丢失未开持久化开启 AOF做定期快照同分排序不稳定没有做分数编码使用得分 时间偏移量的编码方案排行榜这个功能做多了会发现它本质是在工程简洁性和业务丰富性之间做平衡。早期我也觉得“写个 SQL 排序就完了”等数据量涨起来才发现问题远没那么简单从 MySQL 到 Redis ZSET再到分桶、缓存、防刷每一步都是踩坑踩出来的。最后分享一个小技巧排行榜上线前一定准备一个模拟百万数据的压测脚本提前把数据灌进去跑一遍接口看 P99 延迟。预算充足的团队直接用真实历史数据回放效果更好。这个动作每轮活动前都做能提前暴露 Redis 连接数、CPU 负载、内存水位这些问题别等榜单一上线才发现扛不住那时候真的手忙脚乱。排行榜没有银弹适合自己的业务体量和团队资源才是最好的方案。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑