ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

游戏排行榜系统设计:从MySQL到Redis ZSET的架构演进与性能优化

游戏排行榜系统设计:从MySQL到Redis ZSET的架构演进与性能优化 1. 排行榜系统设计前的核心需求拆解在游戏后端这个圈子里流传着一句话排行榜是最能体现一个团队后端功力的系统。这话一点都不夸张。很多刚入行的同学以为排行榜就是给分数排个序等真正接到手才发现一个看似简单的排行榜背后牵扯到数据结构选型、存储引擎取舍、读写性能平衡、数据一致性保障甚至还有反作弊、分区展示、赛季滚动这些衍生需求。我做过多款休闲游戏和竞技游戏的排行榜系统从日活几千人的小项目到百万DAU的大项目都碰过。今天这篇文章不聊虚的直接从实际项目出发把常见的排行榜系统实现方案掰开揉碎讲清楚。无论你是正在评估技术选型的架构师还是刚接手排行榜模块的后端开发这篇内容都值得收藏。在动手设计排行榜之前先把需求拆清楚。排行榜不是一个功能而是一组需求的集合常见的有这么几类单一维度排行比如积分榜、等级榜、通关数榜。这类最简单一个分数字段就能搞定。多维度排行比如同时看战力、装备评分、关卡进度。不同维度需要不同的排序逻辑。实时排行 vs 定时排行实时榜要求每次查询都看到最新名次定时榜则是每5分钟或每小时刷新一次快照。全量榜 vs 好友榜 vs 分区榜全量榜看的是全球或全服排名好友榜只需要算好友之间的相对名次分区榜则按服务器、渠道、地区等维度切分。单机游戏和弱联网游戏还有本地排行榜但这块偏客户端不在后端讨论范围。很多新手容易犯的错误是一上来就奔着高性能去直接上Redis ZSET结果发现业务场景根本不需要那么高的实时性反而被数据持久化和一致性折腾得够呛。正确的做法是先明确三个关键指标数据规模、实时性要求、QPS预估。这三个指标直接决定了方案走向。我通常用一个简单的判断逻辑数据量在十万级以下、实时性要求不高直接用MySQL排序就够了别整那些花里胡哨的。数据量到了百万级、千万级再考虑上Redis或者分层架构。实时性要求高比如竞技场实时排名Redis ZSET是首选但要注意它的内存开销。如果是那种超大规模游戏比如全渠道上亿玩家那就得用分桶、近似排行这些更复杂的方案。前面这些需求点就是排行榜系统设计的输入参数。参数没理清楚后面的技术选型都是盲人摸象。下面我按从简单到复杂的顺序逐一分析几种常见的实现方案。2. 基于MySQL的经典实现方案2.1 表结构设计与索引思路最传统的方案就是一张排行榜表字段包括玩家ID、分数、更新时间外加一个唯一键防止重复。查询排名时用ORDER BY score DESC就能拿到结果。这种方案的适用场景很明确数据量在几十万以内、并发读写不高、排行榜维度单一。不过这里有个常被忽视的细节排名索引到底怎么建如果查询是取前100名那只需要在score字段上建一个普通索引数据库扫描索引就能快速拿到Top100。但如果业务还需要查询某个玩家的具体名次那就要做COUNT查询统计分数大于该玩家的数量再加1。这个COUNT操作在数据量大时会变得很慢解决方案是把玩家的名次单独存下来每次分数更新时同步刷新。我之前在一个日活大约两万的休闲游戏里就是这么做的。排行榜表里除了玩家ID、分数还加了一个rank字段每次玩家分数变动时只在事务里更新自己的分数和名次。虽然每次更新都要连带做一次COUNT计算但因为数据量在一万以内执行时间都在几十毫秒内完全够用。为什么说MySQL方案适合中小型项目首先是开发成本低一条SQL就能搞定排序和查询不需要额外引入中间件。其次是运维简单DBA团队就能处理不需要专门维护一套Redis集群。第三是数据天然持久化不会因为缓存宕机而丢数据。但这套方案的瓶颈也很明显。当数据量到百万级时每次更新都要做COUNT聚合计算数据库压力会指数级上升。当玩家同时在线人数高时写并发会带来锁竞争排序查询也会拖慢整体性能。所以这个方案只能作为起步方案随着用户量增长必须做迁移。2.2 优化手段分表分库与物化视图如果确定要用MySQL扛更大规模有几个优化手段可以尝试。第一种是按维度分表。比如一个游戏里有积分榜、等级榜、关卡榜不要用一张表加type字段区分而是直接拆成三张物理表。这样每个表的规模和查询压力都小索引体积也小排序性能更好。当初我把一张混合表拆成三张独立表后查询耗时从800ms降到了200ms效果立竿见影。第二种是定期快照与增量计算结合。排行榜的查询和更新天然带有热尾特征——大部分人只看Top100大部分人频繁变动的是中段排名。针对这个特征可以把排行榜的直接查询限制在Top1000以内用一个物化视图或者独立的排名快照表每5分钟重新计算一次Top1000的排名。普通玩家查自己的名次时走的是快照数据即使名次有一两分钟的滞后对玩家感知影响也不大。第三种是异步化更新。玩家分数变动不直接写排行榜表而是先写到Redis队列或者直接更新玩家个人分数表然后由一个后台任务定时汇总到排行榜表。这样能削平高峰期的写请求。不过说句实在话如果数据量已经到了需要分表才能解决的级别那说明你的游戏用户规模已经起来了这时候再死守MySQL性价比就不高了。一般来说我建议把MySQL 定时快照这套组合作为过渡方案预留好接口为后续切换到Redis或者混合架构做好准备。3. 基于Redis的实时排行榜方案3.1 ZSET的核心原理与选型理由聊到实时排行榜Redis的有序集合ZSET几乎是业界标准答案。ZSET是一个基于跳表Skip List实现的数据结构每个成员关联一个分数底层同时维护了按照分数排序的跳表和一个哈希表用于快速查找成员。为什么ZSET能高效实现排行榜关键在于跳表的设计。跳表通过多级索引来加速查找查询某个成员的排名只需要O(log N)的时间复杂度获取TopN则是O(log N N)。这意味着即使在百万级数据量下单次排名查询都是微秒到毫秒级别的响应。相比之下MySQL的ORDER BY加上COUNT在百万级数据下可能要几百毫秒甚至秒级。我用一个生活化的类比来解释MySQL排序好比把一整本书从头翻到尾来找内容虽然书有目录但找不到精确页码ZSET的跳表则像是给书建了多级目录每页还有页码你要查哪个内容直接通过目录定位翻几页就到了。在实际项目中最常见的用法是# 更新玩家分数ZINCRBY是原子操作 ZINCRBY game:rank:weekly 100 player:10001 # 获取Top10排名 ZREVRANGE game:rank:weekly 0 9 WITHSCORES # 查询玩家排名ZSET的名次从0开始记得1 ZREVRANK game:rank:weekly player:10001这里有几个关键点值得展开说。第一分数必须是整数还是可以有小数ZSET的分数使用64位双精度浮点数。在游戏里如果仅用分数排序相同分数会按照字典序成员名排先后玩家之间会出现先到先得的排名差异。为了避免这个问题很多游戏会把分数和时间戳拼接成一个复合值。比如把分数 * 1亿 更新时间戳的某种换算作为实际存入ZSET的score这样分数高的一定排在前面分数相同的先达到的人排在前面。实际操作中要注意整数范围不要溢出。第二ZINCRBY是原子操作非常适合实时计分。玩家在游戏里打了一局后端收到结果后直接INCRBY不需要先读再写天然避免了并发覆盖问题。这也是ZSET方案比MySQL方案在写并发上强很多的核心原因。第三ZSET是纯内存操作但内存不是免费的。每个ZSET成员大约占用 100多字节内存包括成员名、分数、跳表指针等。100万玩家大约要占用100到150MB内存这对一个Redis实例来说不算大但要考虑到排行榜可能按周、按月滚动同时存在多个ZSET内存增长会很快。后面我会专门讲内存优化的方法。3.2 常见的积分合并策略单key还是分桶直接用ZSET存所有玩家是最简单的做法但当同一维度有多个排行榜时比如全服榜好友榜服务器榜就涉及Key的设计问题了。最直观的做法是每个排行榜一个Key比如rank:all、rank:friend、rank:server:001。全服榜全局一个Key好友榜每个玩家一个Key服务器榜每个服务器一个Key。这种设计简单但好友榜如果每个玩家都存一个Key数据重复量会很大。我倾向于用主排行榜 按需聚合的策略。全服榜始终维护一个完整ZSET好友榜不单独存储而是查询时用ZUNIONSTORE临时聚合好友的分数生成一个临时Key给客户端返回后再设置过期时间自动清理。这样做的理由是好友数量少一般不超过几百人聚合计算开销很小却能省下大量冗余存储。还有一种是分桶策略把玩家按分数区间分到不同的ZSET桶中比如0~1000分一个桶、1000~5000分一个桶、5000分以上一个桶。查询全局排名时先找到玩家所在的桶再结合桶之间的数据量估算全局名次。这个方案适合分数跨度极大的游戏能显著减少单Key的规模但实现复杂度高而且名次计算会变成近似值而不是精确值。大部分游戏用不到这么极端了解思路即可。3.3 数据持久化与容灾设计Redis作为排行榜存储最大的争议点就是数据安全。ZSET是内存结构如果Redis重启且没有持久化排行榜数据全丢。游戏里排行榜数据丢了就是事故玩家辛辛苦苦打的分数不见了会直接引发大规模投诉。我的习惯做法是Redis MySQL双写。核心流程是玩家分数有变动时先ZINCRBY更新Redis。同时把谁增加多少分这个事件写入一个异步队列。后台任务消费队列定期把增量落地到MySQL。Redis重启后从MySQL全量恢复ZSET数据。这套方案里Redis的实时性和MySQL的持久性互补代价是系统复杂度上升。如果项目比较小也可以用Redis自带的AOF或是RDB持久化。AOF能保证数据不丢失但文件体积大RDB恢复快但会丢最后一次快照之后的数据。我的建议是排行榜数据可以忍受短时间丢失的话用RDB就够了不能丢的话老老实实双写。4. 应对海量数据的复合架构方案4.1 为什么单靠Redis扛不住超大排行有些同学看到这里可能会问Redis ZSET不是号称可以存千万级数据吗那为什么还需要更复杂的架构问题不在存储量而在热点访问模式和跨维度查询。游戏玩家的排行榜访问分布遵循二八定律甚至一九定律绝大多数请求都集中在Top100。ZSET提供了ZREVRANGE直接访问前N名性能很好但如果同时有上万人在刷Top100榜单这个热Key的访问压力会非常集中Redis单实例可能撑不住即使撑住了网络IO也会成为瓶颈。另一个问题是好友榜。如果每个玩家一个好友榜Key当全服有千万活跃玩家时好友榜Key的数量就是千万级别这些数据大部分是重复的ZSET的存储浪费会严重到让人肉痛。所以当游戏到了这个量级就需要用缓存多级化 数据分层化的复合架构。4.2 经典三层架构数据库、预计算层、缓存层我在一个日活几百万的项目里实践过的架构是这样分层的数据层MySQL等持久化存储负责保存所有玩家分数数据原文以及各个排行榜的最终排名快照。数据层只做全量持久化不承担实时查询压力。预计算层离线任务或准实时任务每当排行榜结算周期比如每日0点、每周一0点到来跑一个批量任务从数据层拉取全量数据计算排名生成排名快照。这个快照可以是一张独立的排名表也可以直接生成JSON文件丢到CDN上。缓存层Redis集群 本地缓存实时TopN榜单从Redis读取Redis里没有的再回源到快照数据。玩家个人名次查询也优先走缓存。这个架构的核心思路是把算排名和查排名分开。算排名是一个批量、准实时、低频的操作查排名是实时、高频、分布式的操作。两者解耦后系统的扩展性就好多了。4.3 近似排行当精确排名变成一种奢侈在某些超大游戏中连精确到个位的名次都是昂贵的。比如有5000万玩家你很难在每次查询时都精确算出某个玩家排第几因为即使ZREVRANK很快频繁查询也会把Redis压垮。这时候就需要引入近似排行方案。常见的思路是分数区间统计 桶内名次把全球玩家按分数分段比如每100分一个区间。通过一个Sorted Set成员是区间名分数是区间编号统计每个区间的玩家数用ZINCRBY维护。玩家查名次时先算出玩家所在区间的编号用ZREVRANGE获取积分比他高的区间玩家总数再加上他所在区间内的精确排名区间内排名一般数据量可控可以放到单Key里。这个方案的名次误差通常在几百名以内在超大游戏中完全可以接受。玩家看到自己排名第123,456名和第123,300名没有本质区别但后端压力能降低一个数量级。4.4 跨服跨渠道的排名架构思路现在很多游戏是大服架构全球玩法、天梯赛、跨服公会战都在同一套排行榜体系里。这就需要把分区维度嵌入到设计里。我的经验是按区域聚合 全局采样来设计而不是全局精确排名。以全球玩家排行榜为例实际实现时并不是所有玩家都在同一个ZSET里。而是先按地理区域或服务器分组组内用精确ZSET。全局榜单只维护每个组的Top100相当于各个组的代表。玩家看全球排名时看到的是自己组的排名 所在组在全球的排位组合成一个近似全球名次。这样既保证了组内数据的实时性又保证了跨组的宏观排名展示。做法不复杂但能省下大量存储和计算成本。5. 排行榜相关的性能隐患与避坑指南5.1 热Key问题Top榜的“流量风暴”排行榜系统最经典的问题是热Key。拿一个日活百万的游戏来说每天晚8点玩家的活跃高峰所有人的客户端都在请求同一个全服排行榜Top100的Redis Key这个Key即使在Redis里单实例每秒收到的GET请求也可能达到几万甚至十几万次。Redis虽然单实例能抗住十万级QPS但这只是理想值真实环境下如果Key后面带着复杂的序列化、网络带宽开销很快会打满。解决方案有三层层层递进第一层是加缓存设置一个较短TTL比如5到10秒同时向客户端返回的时候带上一个版本号如果版本号没变客户端可以直接用本地渲染的上一次数据。这一层就能挡住一半以上的重复请求。第二层是做本地缓存排行榜服务在内存里维护一个Top100缓存每5秒从Redis刷新一次所有查询直接读本地缓存。这样Redis收到的查询压力直接下降了一个数量级。第三层是读写分离主Redis只负责写副本Redis负责读。排行榜查询全部走副本避免读写相互干扰。我印象很深的是某次线上事故新版本上线后Top榜热Key导致Redis单实例卡了整整5秒所有排行榜接口超时。事后复盘发现第一层和第二层的缓存都加了但第三层没有做读写分离因为当时认为Redis够快。事实教育我们系统的快是要靠分层缓存扛出来的不是单点扛出来的。5.2 并发一致性分数更新该如何落地排行榜的写并发和数值系统的写并发是一脉相承的问题。玩家在游戏里打了一局后端需要扣分或加分。如果用读当前分数 → 本地计算新分数 → 写回这个逻辑在并发场景下一定会出问题。最简单的例子玩家同时完成两局游戏两个请求同时读到分数100各自计算10分写回的都是110分白白丢了一局的分。解决方案很明确使用Redis的原子自增操作ZINCRBY或者把更新请求全部抛到队列里串行化处理。如果不走RedisMySQL里也要用UPDATE ... SET score score 10这种原子SQL而不是先SELECT再UPDATE。另外还有一个容易被忽略的细节每日0点结算。如果排行榜是按天滚动的0点左右往往有一波冲榜请求此时既要处理旧榜的封板数据又要接收新榜的数据写入。处理不好就会出现玩家明明打完了比赛分数却被记入了新的一天引发投诉。我的做法是设置一个结算保护期比如0点前10分钟关闭实时榜写入所有分数只入流水表等结算跑完再合并回新榜。5.3 数据一致性与排行榜“幽灵现象”排行榜系统还有一个很常见的bug明明玩家分数已经变了查排名时却还是旧名次。这种现象的根源通常是多级缓存之间的数据一致性问题。本地缓存、Redis缓存、MySQL数据三份数据同步总会有延迟。如果游戏允许玩家在积分变动的瞬间立刻查榜很容易就撞到这个时间窗口空白。解决方案是最终一致性 及时失效。比如玩家分数变化后立即做三件事更新Redis指定Key、删除本地缓存中的对应数据、异步写MySQL后更新版本号。下一次查询发现版本号变化就能拿到最新数据或者即使短暂拿到旧数据版本号也能让客户端之后自动刷新。这里有个小技巧排名数据允许当前不精确但终将精确。玩家对排行榜名次的实时性感知实际上并没有想象中那么敏感。差一次查询看到旧名次下一次查询看到新名次大多数玩家都能理解。真正不能接受的是长期数据错误。所以与其追求强一致不如把精力放在监控延迟和补偿机制上。5.4 一套完整的监控和告警指标聊了这么多架构方案最后一定要讲监控。排行榜系统一旦出问题玩家反馈会非常直接——我排名掉了。我一般在排行榜系统上线前就会把下面这几个监控指标加上ZSET的成员数防止数据被意外清空或异常增长。每次排行榜结算任务的执行耗时和成功率。Top100查询接口的平均延迟、P99延迟。排行榜读取QPS和写入QPS的比值比值异常时多半有人在刷榜。冷热数据的命中率本地缓存命中率、Redis命中率。还有一个值得做的点排行榜数据比对巡检。每天凌晨结算后把MySQL里的快照和Redis里的排行榜做一次全量对比保证两边数据一致。这个巡检任务虽然平时看起来不干活但在真正发生数据灾难时它就是救命的雷达。6. 写在最后选型思路与个人心得聊到这里常见的排行榜实现方案基本都说透了。回顾一下核心就一句话没有最好的方案只有最匹配的方案。如果游戏规模还在早期不要迷信微服务、不要迷信K8s一张MySQL表加定时快照就足够稳定跑一年如果到了需要实时排名的阶段把Redis ZSET用好是关键学会合并Key、优化内存、做持久化设计如果到了几百万日活的规模认真考虑分层解耦、计算与查询分离、近似排行这套组合拳。我个人在实际操作中还有一个体会排行榜项目里最难的部分往往不在技术本身而在于需求的模糊地带。产品说实时排行榜你要追问实时到什么程度产品说好友榜你要追问好友的标准是什么产品说全服榜你要追问全服到底是单服还是跨服。把这些问题在写代码之前搞清楚后续改造成本能节省80%。再分享一个小技巧上线排行榜后建议第一周多留意查询某个玩家名次的接口日志。这个接口的请求模式往往能提前暴露出热Key问题、缓存命中率问题、数据一致性问题。排行榜系统是游戏后端里少有的结构简单、问题复杂的模块值得多花时间打磨。如果后续有条件还可以在排行榜的基础上做更多延伸给玩家生成战绩曲线、按排行榜推送好友周报、基于排行榜做赛季化运营策略……这些方向都很有意思但那是后话了。先把基础方案做扎实排行榜系统就能成为游戏里最稳定、最受玩家信任的核心模块之一。
返回列表