尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

游戏排行榜系统实战:Redis ZSet与高并发方案全解析

游戏排行榜系统实战:Redis ZSet与高并发方案全解析 游戏排行榜系统这活儿看着简单不就是把分数排个序嘛真做起来才知道水有多深。我前后经手过好几套游戏排行榜的实现方案了从最初单机排行到后来千万级日活的全服榜从限时活动冲榜到好友社交排行可以说是把常见的坑都踩了个遍。这篇文章就以游戏排行榜系统实现方案为主题把这几年的实战经验包括技术选型逻辑、核心代码思路、线上排障过程一次性梳理清楚。先说结论排行榜做得好不好七八成取决于需求拆解得细不细剩下两三成才是技术选型的事。你会发现同样是“排行榜”三个字全国争第一的全服战力榜、只持续一周的限时活动冲榜、好友之间的小圈子比拼这三者的数据模型和压力模型完全不同方案自然也不同。下面逐个拆解。1. 动手之前先把排行榜需求拆成三类很多团队上来就翻Redis文档研究ZSet语法其实顺序反了。我见过不止一个项目因为没把需求问清楚导致数据模型反复改这次上线换了方案下次活动又换回来白白浪费大量人力。所以第一步永远是分类需求。1.1 时效型、累计型、分段型我一般会把游戏排行榜需求切成三类时效型榜单最典型的就是限时活动榜、周榜、赛季榜。比如“本周竞技场积分排行”、“本次公会战伤害排行”它的特征是榜单生命周期短有明确的开始结束时间且数据在结算后要么清空要么归档。这类榜单对实时性要求高但总量可控因为只统计一段时间内的数据。累计型榜单典型的是“全服战力榜”、“总充值榜”它从开服第一天一直统计到现在数据只增不减。这类榜单往往量级巨大但更新频率反而不一定高因为玩家实力的变化没那么频繁。社交型榜单典型的是“好友排行榜”、“同城排行”、“公会内部排行”。它的特征是数据范围小但实时性要求非常苛刻玩家可能随时查看而且和社交关系链绑定意味着每个玩家看到的榜单内容都不一样这给缓存设计带来的麻烦比前面两类都要大。把需求归好类你才知道要优化的核心指标是什么。时效型榜单要重点解决“更新快”和“结算准”这两个问题累计型榜单要解决“数据量大”和“查询效率”的矛盾社交型榜单则要解决“个性化视图”下的缓存和延迟问题。1.2 需求三问榜单粒度、统计口径、并发压力归类之后我还会逼着策划和运营回答三个问题基本能决定技术方案的走向第一问榜单位次到底是看全量排名还是只看区间排名这句话听起来有点反直觉但很关键。有些榜单产品侧只需要展示“第1名到第50名”外加“我的排名是多少”而有的产品要求能翻到第1万名去看。前者的存储和查询压力非常小后者则需要额外的数据结构支撑。对大部分游戏而言普通玩家看到的榜单价值远低于头部所以“只维护头部排名 单独存个人排名”是一种非常划算的降级策略。第二问排名的统计口径是什么是按累积分值排还是按最高单次成绩排是只算胜利场次还是计算胜率是只统计PVP还是PVE共通别小看这个问题口径一变数据模型就跟着变。比如“按最高单次成绩排”那你只需要记录每个玩家历史最佳值更新时做一次比较写入但如果是“按累积分值排”你就得实时累加且处理并发写方案复杂度完全不同。第三问单服的玩家规模上限和读多写多比例这个问题决定Redis够不够用以及要不要上分片。如果一个榜的玩家量只有十万级单实例Redis ZSet绰绰有余如果到了千万级必须考虑分片和合并读。同时读写比例也很不一样活动冲榜的写请求密集好友榜的读请求密集这直接影响你使用什么缓存策略。2. Redis ZSet大多数方案都绕不开的主干聊完了需求分类进入技术选型环节。目前游戏排行榜系统实现方案里Redis ZSet是当之无愧的主力方案因为它天然支持“按分数排序”这一个排行榜最核心的动作平均时间复杂度为O(log N)在百万量级下表现依然非常好。2.1 ZSet到底怎么排序和排名ZSet有序集合的每个元素包含两个部分成员member和分数scoreRedis内部使用跳跃表skiplist按分数从小到大维护顺序。做排行榜时我们会把ZSet果断反着用查询时使用ZREVRANGE让分数最高的排在最前面。这里有一个很容易被忽视的细节ZSet的score是双精度浮点数。很多人在这个点上吃过亏稍后第4章我会讲具体怎么踩进去的。先看一个最朴素的代码流程import redis r redis.Redis(host10.0.0.5, port6379, db0) # 玩家打了一局获得1200分 player_id uid_10086 score 1200 # 累加到ZSet中 r.zincrby(rank:global:arena, score, player_id) # 取前100名 top100 r.zrevrange(rank:global:arena, 0, 99, withscoresTrue) # 查询某玩家当前排名 rank r.zrevrank(rank:global:arena, player_id)三行命令从数据写入到榜单展示的核心链路就通了。ZINCRBY解决累加ZREVRANGE解决取排名区间ZREVRANK解决查个人名次。这也是为什么Redis会成为默认选项因为它把排行榜最底层的数据结构直接暴露成命令了不需要自己再实现一遍跳表。2.2 一道命令背后的性能考量我们通常说ZSet快但快在哪里很多人说不清。ZINCRBY的时间复杂度是O(log N)意思是在一个十万玩家的榜单里做一次累加操作大约只需要十几次跳表节点的比较相比之下如果直接用MySQL的ORDER BY每次查询都面临全表扫描加文件排序数据一大就撑不住。但需要特别强调的是zrevrange取前50名的复杂度是O(log N M)其中M是取出条数。如果你每次都要从百万级别的榜里取前100Redis可以轻松做到毫秒级返回。这背后是跳跃表加哈希表的组合结构哈希表保证member到score的O(1)定位跳跃表保证有序区间遍历的高效。理解了这个原理你就知道为什么在排行榜领域Redis方案如此占据统治地位。2.3 多条件排序的实现时间戳压缩与分数编码实际需求往往比“按分数排”复杂。最常见的需求就是分数相同的情况下先达到该分数的人排前面。ZSet只支持一个score字段怎么表达两个排序维度一个比较成熟的方案是将分数和时间戳合并编码成一个复合分数import time # 假设当前时间戳 current_ts int(time.time()) # 定义一个基准时间用于倒转 base_ts 2000000000 # 分数权重把分数放在高位把时间放在低位 # 时间戳越小越早补偿值越大所以同分下先到者排前 rank_score score * 10000000 (base_ts - current_ts)这里有一个合理设计的量化过程。假设排行榜分数最高不超过一百万我预留七位十进制给时间戳补偿也就是10^7这样分数部分最大为1,000,000 × 10^7 10^13加上base_ts - current_ts的最大值也远小于这个数量级不会产生进位干扰。实际使用中要根据业务上限反推权重先确定“最大理论分”再留出时间戳的位宽。这个大数要特别注意精度问题双精度浮点数在超过2^53之后就无法精确表示每一个整数了。所以复合分数设计完成后必须用边界值测试一遍用最大分值和最小分值的组合做加减确认Redis返回的score没有丢失精度。如果发现精度不行解决方案是把分数拆成两段存原始分存放在一个ZSet相同分数的玩家单独再建一个按时间排序的小ZSet查询时先按原始分取集合再对小集合做时间排序。2.4 ZSet的边界与误区这里必须泼一盆冷水ZSet并非万能的它存在两个主要天花板。第一个是数据量。虽然ZSet单实例存几十万成员毫无压力但当你一个榜单攒了一千万玩家时跳跃表的内存占用会变得很高。每个memberscore至少需要几十字节一千万就是数百MB内存。此时要么分片要么接受淘汰策略比如只保留前10000名超出后用一个变量记录阈值分后续玩家只有在分数超过阈值时才允许入榜。这个“阈值入榜”机制我在活动榜单里用过很多次效果很明显能直接砍掉大半无效写入。第二个是更新频率。ZINCRBY虽然是O(log N)但如果你需要在同一时刻批量更新一万人的分数比如公会战结算单线程下这一万次命令还是会形成瓶颈。此时更好的做法是用Pipeline或者Lua脚本批量提交把网络往返时间压缩到接近一轮。实战中我用Lua脚本封装“累加分数 返回最新排名”两个动作一个原子操作搞定比先ZINCRBY再ZREVRANK省掉一次RTT在冲榜活动高峰期的效果尤其明显。3. 榜单位次背后的三种玩法全服、分区、好友技术框架搭好了却发现不同榜单形态需要不同的策略。这一章把三类核心榜单玩法的方案差异讲透。3.1 全服榜勇气可嘉门槛在存储层全服榜单是很多游戏策划的执念但也是工程上最需要谨慎的设计。当所有玩家都写入同一个ZSet时最简单但限制最大。我见过一些休闲游戏全服玩家上千万但同一时间在线的就几十万因此实际在ZSet中的成员仍然是可控的活跃玩家离线很久的玩家分数不需要一直保留在榜单里这给了“只维护活跃玩家”这一策略生存空间。实现上可以给ZSet键加一个过期时间不行——Redis的键过期是针对整个键而不是针对里面单个member。所以要做的是定期清理非活跃成员。实际操作时我会维护一个“活跃玩家”的集合比如一个不精确去重的HyperLogLog或一个带last_active_time字段的Hash表每天定时将超过N天未登录的玩家从全服ZSet中移除。清理动作放到凌晨低峰期执行用ZREM批量删掉代价可控。3.2 分区榜分服、分赛季、分玩法另一种缓解全服榜压力的手段是“分区”。最常见的分区维度是服务器、赛季和玩法。分服榜每个区一个ZSet键名如rank:server:{server_id}:arena。总数据量和写入量被均匀拆分到几十个键每个键的规模都小得多。缺点是“跨服王者”的排名没法直接做需要额外做合并汇总或者在跨服场景换一种方案。赛季榜每个赛季一个ZSet键名如rank:season:2025s1:arena。这样做的好处是数据天然隔离赛季结算后可以把旧ZSet直接归档或者重命名保留不需要清洗数据。玩法榜同一套排名代码、不同的玩法入口只要在键名里带上玩法ID即可。这个扩展成本几乎为零但收益很大能让线上Redis只保留当前活跃玩法的数据。分区后的数据看似碎片化但在查询侧可以通过多键合并实现“跨区总榜”。比如有32个区服你可以对32个键执行ZUNIONSTORE生成临时总榜也可以让每个区选Top100再把这3200条记录在应用层做一次归并排序。后者代价小得多也是我更推荐的方式除非你的实时性要求非常高。3.3 好友榜社交关系的实时性代价好友榜是看起来简单但实则在缓存设计上最容易翻车的一种。因为每个玩家的好友列表都不一样如果直接往Redis里为每个玩家存一份完整榜单存储量就是玩家数乘以每人的好友平均数这个量级非常恐怖。我的实践方案是不为每个玩家单独建榜而是采用“动态实时计算”的思路。查看好友榜时先从社交关系服务里拉取当前玩家的好友ID列表然后对每个好友在ZSet中执行ZSCORE获取分数最后在本地按分数排序取Top N。好友数量如果是几十个这几十次ZSCORE的耗时合起来也就是一次毫秒级的网络往返完全在可接受范围内。这个方案的潜在隐患是如果社交关系服务不可用好友榜就挂了。所以我会把好友ID列表在本地缓存一份设置较短过期时间比如5分钟这样好友榜查询时可以容忍关系的轻微滞后但不至于整个功能不可用。4. 不只有ZSetMySQL方案与混合降级方案虽然Redis ZSet是主力但有些场景下它并不是唯一选择甚至不是最优选择。比如团队没有Redis运维能力或者某些榜单数据必须和业务库保持一致这时候MySQL方案也能扛起大梁。4.1 为什么有人偏要用数据库MySQL方案的本质就是把排名逻辑交给SQL排序。表结构大致是这样CREATE TABLE player_rank ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, player_id BIGINT UNSIGNED NOT NULL, score BIGINT UNSIGNED NOT NULL, update_time DATETIME NOT NULL, KEY idx_rank (score DESC, update_time ASC) ) ENGINEInnoDB;查询榜单时SELECT player_id, score FROM player_rank ORDER BY score DESC, update_time ASC LIMIT 50;优点很明显数据持久化、天然和业务库一致、同一份数据既是榜单又是存档。缺点也同样明显当数据量达到百万级时即使有索引ORDER BY的深度分页和频繁更新会造成写放大和读放大。实测中百万行数据下查询Top50还能在几十毫秒内返回但一旦玩家频繁写入分数导致索引节点大量分裂性能会急剧恶化。所以我的判断是如果玩家总量十万级更新频率不高MySQL方案完全够用如果玩家量百万以上且更新频繁MySQL只能作为底层存储必须加Redis作为加速层。4.2 榜单计算与DB流水线的划分即使以MySQL为基础我也不建议直接用SQL实时算排名更好的做法是把MySQL当作流水线中的“存档层”而把Redis当“计算加速层”。写入流程大致为玩家产生分数变化时先写更新消息到消息队列如Kafka、RabbitMQ或Redis Stream避免高并发直接打到数据库计算服务消费消息队列批量累加Redis中的ZSet分数定时把Redis中的ZSet最终数据同步到MySQL作为赛季结算、审计和冷数据的唯一事实来源。这套流水线在断电、宕机、换榜等异常场景下MySQL始终兜得住底Redis则负责保证在线查询的实时性。很多成熟项目的赛季榜单都采用这种双写模式读侧走Redis写侧先MQ再DB降低了单点的可靠性风险。4.3 混合降级Redis主路DB兜底再进一步说我强烈建议所有排行榜方案都设计一条降级链路。Redis挂了玩家应该看到的不是“榜单加载失败”的空白页而是回退到MySQL或备份缓存里的最近一次快照。实现上可以在Redis写入ZSet的同时异步把榜单Top100序列化写入一个备份键或DB表一旦Redis主路不可用就直接返回这份近似的快照。可能有人会问既然Redis都挂了那异步备份链路是不是也悬了所以要做一个降级开关Redis正常时走实时计算Redis异常时切换到DB只读模式。DB模式下的榜单可能滞后几分钟但对于大多数游戏场景来说远比直接报错好得多。上线前一定要把“Redis故障时自动切换DB模式”这一整条链路演练一遍不要等到真出事时手忙脚乱。5. 实时性陷阱分页、缓存、更新频率的平衡方案定了、代码写完了还远远没有到收工的时候。线上环境里真正折磨人的往往是榜单页面的实时性和性能之间的拉锯战。5.1 榜单分页的两种模式排行榜页面的翻页方式和贴吧完全不一样。玩家很少会从头翻到尾大部分时候只想看到头部排名和自己当前的名次。因此我常建议做“分区查询”而不是“全局分页”头部榜用ZREVRANGE直接取前50或前100这是全站热点数据必须加缓存。个人榜用ZREVRANK查个人名次再取该名次前后各10名的记录这就是“我前后是谁”的体验模式。这两种模式对应了ZREVRANGE和ZRANGEBYSCORE两种不同取法。个人榜前后10名的完整写法是rank r.zrevrank(rank:global:arena, player_id) if rank is not None: start max(0, rank - 10) end rank 10 neighbors r.zrevrange(rank:global:arena, start, end, withscoresTrue)注意rank是0基的第1名的rank是0处理边界时别算错了。这个查询的耗时与排名位置基本无关始终是O(log N M)所以玩家翻了很久也丝滑。5.2 更新策略实时写vs定时刷很多团队在设计排行榜时都会陷入一个焦虑榜单数据必须一秒都不差。但实际上不同玩法对实时性的要求是不一样的实时更新成本太高时完全可以退一步做定时快照。实时写适用于活动冲榜、竞技场这种玩家每打完一局都要看到名次变化的场景。此时ZSet的每次ZINCRBY都可以直接作用于排行本身不另外做缓存或者只缓存容量极小的Top100设置1~3秒过期。定时刷适用于累计型、内容型榜单。比如“总战力榜”没必要实时计算每小时同步一次足够。做法是每5分钟从业务库拉取玩家最新战力批量写入ZSet同时更新一个缓存失效时间。这种方式能明显降低写Redis的QPS让排行榜的稳定性大幅提升。如何选择原则清晰看“名次变化对玩家决策有没有即时影响”。如果答案是“有”实时写如果“没有”定时刷。有些团队一上来就全实时结果冲榜活动把Redis写到了极限却不知道头部排名其实可以容忍3秒延迟。5.3 常见坑位榜单缓存穿透与热点榜单页面的缓存坑通常出在“防穿透”和“热点读”两个地方。防穿透很好理解玩家查自己排名时如果该玩家根本不在榜上每次都触发一次ZREVRANK这就是穿透。解决方式是维护一个“已入榜用户”的BloomFilter或Set集合查询前先判断在不在不在就直接返回“未上榜”。热点读则是另一个问题Top榜的榜首区域可能同时被几十万玩家查看。此时对同一个ZSet键执行ZREVRANGE并无大碍但如果每次查询都触发一次网络IO到Redis依然会造成不必要的压力。更好做法是在应用层加一层进程内缓存维护“Top100快照”每隔几秒更新一次。榜单页的高频读全部打到这个快照上Redis只负责写和低频读取热点瞬间被化解。6. 高并发榜单的扩展路径总结游戏排行榜系统做到中后期不可避免要面对“单点扛不住”的问题。这里的扩展路径有迹可循按照从易到难来排。6.1 分片与一致性哈希当单个ZSet的数据量超过千万级或者单个ZSet的写流量达到每秒数万时就可以考虑把同一个榜单按照一定规则拆到多个Redis节点上。常见的做法是按玩家ID做哈希分片比如shard_id hash(player_id) % 32然后ZSet键名变成rank:global:arena:shard_{shard_id}。这样每个分片只维护几万到几十万成员写压力被均摊到多台Redis上。但这会带来一个读侧的代价查询全局Top100时需要对32个分片分别取Top100然后合并排序查询个人排名时也需要对32个分片分别执行ZREVRANK再求和。这个成本不低所以分片方案一定要配合“高频读走缓存”的策略不然合并读的复杂度会吃掉分片带来的大部分收益。6.2 写入剥离与批量高峰期冲榜活动中写入往往是主要压力。分片解决了“分桶”问题但单分片内的高并发写入仍然可以用批量写来进一步削峰。具体做法是玩家分数变化不直接ZINCRBY而是先用一个Hash或普通累加器暂存增量再通过定时任务每秒钟批量执行一次Pipeline一次性把上千个增量合并提交到ZSet。这样单实例的写请求量从每秒几万降到每秒几十Redis负载指数级下降。这里必须仔细处理一个业务细节批量写入时玩家查排名应该看到的是已经提交的数据还是包含暂存增量的数据我的做法是暂存量只留在内存查询前先把它合并到Redis中的分数再执行查排名操作这样玩家看到的名次始终包含自己最新的贡献而Redis的实际写入则是异步批量进行的。6.3 读多写多的优化读多写多的场景最忌讳的就是把所有访问打到同一个键上。分片解决写分散缓存解决读聚集两者必须同时使用否则只做一半会引入新瓶颈。举个例子你不分片、只加缓存写流量会从ZSet层面涌到Redis你分片了、不加缓存合并读的复杂度会从应用层消耗CPU。只有“分片 顶部快照缓存 个人名次缓存”三者配合才能在高并发下保持稳定。6.4 监控要点最后说监控。排行榜线上最需要盯的四个指标ZSet键的成员数量、每秒钟写请求量、读请求的平均延迟、缓存命中率。我一般会对“成员数超过阈值”设置告警因为ZSet大了不仅内存涨ZADD/ZINCRBY的耗时也会明显上升写请求量突然暴涨通常标志着冲榜活动异常或刷分脚本在跑读延迟超过50ms就要警惕可能是Redis集群网络抖动或者分片合并逻辑有问题缓存命中率低于90%则说明缓存策略和热点区域错配了需要重新设计预热逻辑。这几个指标都是可以在上线前就纳入监控平台的别等出了事再补。7. 我踩过的坑与最终建议最后用几个真实踩坑经历收尾每一个都对应一个核心教训希望能帮你省下不必要的排查时间。7.1 同分排序与浮点精度坑第一次做竞技场排行榜时我用复合分数做了“同分先到者优先”上线后北京区发现第100名和第101名的名次经常在玩家端闪跳。排查过程很典型先看代码复合分数计算没问题再看数据发现Redis返回的score打印出来末尾出现了类似123456789.12345678的浮点尾数。原因就在这里。score超过2^53后双精度浮点数无法精确表示连续的整数导致我存入的时间补偿值发生了进位或舍入同分排序瞬间失效。修复方案是重新设计权重把复合分数的总位数压缩到2^53之内并主动用边界测试验证最大分值和最大时间差组合下的精度。此后我每次设计复合分数第一件事就是打开Python算一遍是否在安全范围内这个习惯一直保留到今天。7.2 赛季结算和换榜时数据清理坑另一次印象深的坑是赛季榜换榜。当时旧榜数据直接覆盖新榜结果导致新赛季前半小时部分玩家还能看到自己上个赛季的战力和名次。原因是新键还没有数据时查询代码走了兜底缓存把旧键的历史快照当成新榜返回了。现在我的做法是新赛季开始前先主动创建新赛季的ZSet键并写入一个空的占位成员旧赛季键在结算完成后立即RENAME成归档键并给Redis加一条延迟删除策略而不是在查询路径里做缓存判断。这样新老赛季数据的切换在存储层就完成了应用层逻辑变得非常简单不会再出现串数据的问题。7.3 线上监控与告警还有一个比较隐蔽的坑排行榜的键没有统一命名规范导致监控面板上展示的键数量爆炸根本分不清哪条是活动榜、哪条是赛季榜。后来我把键名规范定为rank:{类型}:{业务域}:{分片ID}比如rank:season:arena:shard_01同时在每个键上挂业务标签。这样不仅监控清晰了排障时也能快速定位问题是发生在哪个具体玩法上。如果只让我留一条核心建议那就是排行榜系统实现方案一定要围绕“数据写在哪里、名次怎么算、缓存怎么设、降级怎么走”这四个问题来设计。不把这四个问题想透就动手写再多ZSet命令也只是在堆砌复杂度上线后迟早要付出返工代价。先把需求拆清楚、方案边界画明白剩下的实现其实都不会太难。
返回列表