ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

游戏排行榜实现方案深度拆解:Redis ZSet、分片与跨服高并发实战

游戏排行榜实现方案深度拆解:Redis ZSet、分片与跨服高并发实战 开头做了这么多年游戏后端排行榜是我见过被讨论最多、也最容易被低估难度的系统之一。很多新人一听到“排行榜”第一反应就是“Redis ZSet一把梭”等到线上出问题才明白根本不是那么回事。尤其是当单服人数过万、跨服玩法开启、或者出现“福利场榜”这种需要瞬间定输赢的场景时排行榜的技术选型和实现细节直接决定项目是上线还是回滚。这篇内容我结合自己的实践经历把目前主流游戏圈里常见的排行榜实现方案完整拆一遍。从最基础的实时榜单到千万级日活背后的分片方案再到那些文档里没人会告诉你的同分排序、清理策略、跨服合并细节全都用大白话讲清楚。适合正在做游戏后端、或者打算从零搭建排行榜的团队参考也适合那些想把现有榜单优化得更稳的开发者。我会按五个方向来展开方案全景对比、Redis ZSet的实战细节、跨服榜单的架构设计、福利榜这类高并发场景的特殊应对以及最后整理一份选型建议和踩坑记录。每一段都是我自己实际线上环境和压测过程中得出的经验希望能帮大家少走几步弯路。1. 全局认知排行榜到底难在哪1.1 榜单类需求的两条主线聊实现方案之前先把需求本身拆清楚。游戏圈的排行榜表面看就是“按某个分数排序取Top N”但实际上所有需求都可以归到两条主线上实时榜和周期榜。实时榜的意思是玩家分数一变排名立刻要反映出来。典型场景如竞技场排名、副本伤害实时榜、跨服天梯榜。这类榜单对一致性要求极高玩家打完一把抬头看排名发现和自己刚刚算的不一样那体验就崩了。周期榜则完全不同比如“周活跃榜”“赛季贡献榜”每周或每赛季结算一次结算前榜单基本是封闭的玩家只关心自己最终在不在榜上过程里略微滞后完全无感。这两条主线的技术难度差了一个量级。实时榜需要直面写放大和读放大的矛盾——玩家每打一场就要更新分数同时又有海量玩家在刷新查看排名周期榜则天然适合离线计算、快照存储、结算展示三段式处理。很多团队一上来就选Redis ZSet做实时榜然后让同一个ZSet也去扛周期榜结果赛季结算那一刻Redis CPU飙到100%主从延迟拉满最后只能紧急扩容甚至回滚。根本原因不是Redis不行而是方案选型一开始就没区分这两条线。区分清楚之后选路的时候思路就清晰了实时性要求高的核心玩法榜用支持高性能增量更新的存储周期榜就老老实实走批处理路线数据落库结算时一次性计算避免把不必要的热点压在线上链路里。1.2 读多写多场景下的性能瓶颈排行榜本质上是“高并发读 高并发写”同时存在的场景。普通业务可能读多写少或者写多读少榜单偏偏两头都占。玩家每局结束会写分数同时大量玩家在榜单页翻页——第一页、我的排名、好友排名都产生读请求。一天里的峰值又往往集中在晚上八点到十一点恰好是玩法开启和玩家活跃的双高峰。先看写放大。假如一款日活50万的游戏核心竞技玩法人均每天打10局一天就是500万次分数写入。如果集中在4小时的黄金时段QPS轻松超过300。单从300的QPS看Redis毫无压力但这里真正的瓶颈不是QPS而是单个Key的更新复杂度。ZSet的ZADD虽然是O(logN)但当Key里member数达到百万量级时每次写入都要走一次跳跃表的路径查找CPU消耗明显上涨。多线程并发写同一个Key还会触发Redis内部的锁竞争表现出来就是操作延迟从零点几毫秒涨到两三毫秒。再看读放大。榜单接口“获取Top100”在Redis里是ZREVRANGE一次命令返回100个member这个操作本身很快但它在网关层会产生巨大的带宽消耗。如果Top100里还带玩家头像、公会名这类额外信息那就没办法只靠Redis返回得再回查一次玩家服务等于一次榜单请求背后要打两次以上存储接口RT自然被拉到几十毫秒。所以性能瓶颈从来不是某一个组件不够快而是整个链路的放大效应。实现排行榜的第一步就是想清楚什么数据放进热路径什么数据留在冷路径以及热点Key如何拆分这些我会在后面几个章节逐一展开。2. 方案全景从最简单到最复杂的四代选型2.1 第一代数据库排序方案的适用边界聊到排行榜实现很多人第一个想到的是用数据库排序也就是在玩家表上建一个分数索引然后每次查询都执行ORDER BY score DESC LIMIT 100。这种方案最直接代码量也最少两三天就能上线。但它的适用边界非常窄——只适合那种服务器内部工具榜、或者在线人数常年只有几百人的小型独立游戏。数据库排序的问题在于它把“读TopN”和“更新分数”的两类压力全部压在同一套B树上。分数索引虽然能让排序走索引但每次更新分数都意味着索引节点的修改行锁、页分裂、写放大全都跟着来。更关键的是当榜单页频繁被刷MySQL要反复执行排序操作哪怕是走了索引也要把索引页加载到内存里Buffer Pool一旦不够用磁盘IO就上去了。我见过一个实际案例某中型游戏用MySQL做周活跃榜日活在5万左右赛季结算那天玩家集中刷新榜单一条简单的排序SQL把数据库CPU打到了90%以上主从延迟突破十秒。最后不得不临时把榜单接口切成静态页才熬过结算那一小时。所以数据库方案不是不能用而是要想清楚它只适合“低读写频率、数据量小、不要求秒级一致”的场景。一旦在线人数超过一万或者写频率超过每秒几十次就该考虑切到内存型方案了。2.2 第二代内存排序与定时快照机制第二代方案是在应用层内存里维护一份全量分数表定时排序生成快照供查询。这种方案的典型实现是游戏启动时从数据库加载所有玩家的分数进HashMap维护一个后台线程定时比如每5分钟做一次全量排序把结果写到一个有序数组或TreeMap里玩家的排名查询直接走内存完全不碰数据库。这套方案的优点是查询性能极高TopN排名和单玩家排名都是纯内存操作能扛非常大的读流量。缺点是它扛不了高频率、高并发的写——每次分数更新虽然只是改HashMap里的值但这个值会影响排名意味着下一次“定时排序”前的所有变化都是“未生效”的玩家看到的排名永远是上一次快照的排名。对于实时竞技类玩法来说这种延迟不可接受。所以这代方案的真实位置是“准实时榜”。适合那些对排名实时性要求不高的场景比如“历史最高战力榜”“累计登录天数榜”一天甚至一周更新一次。它最大的价值是提供了一个极简的思路排名数据和业务数据分离用快照隔离读写压力。即便后面换Redis方案这种快照思维依然值得保留——后面提到的同分排名、跨服榜单合并本质上都是快照玩法。2.3 第三代Redis ZSet当前的主流王者Redis的有序集合ZSet几乎是现代游戏排行榜的代名词。它用跳跃表加哈希表实现底层天然支持按分数排序和Rank查询ZADD是O(logN)ZREVRANGE取TopN是O(logNM)ZREVRANK查玩家排名是O(logN)。在member数小于百万的情况下这三个核心操作都能稳定在亚毫秒到毫秒级别性能吊打数据库方案。ZSet最核心的用法是score的设计。常规做法是score 玩家分数比如战力99999ZADD就写入99999。这样最简单但有一个容易被忽略的小坑默认按分数升序存储取TopN要记得用ZREVRANGE从高到低另一个坑更隐蔽——当两个人同分时排名先后顺序是以member的字典序决定的但字典序对玩家来说没意义玩家也看不懂“为什么同样是100名他排我前面”。解决办法是给score做编码把时间和业务规则揉进score里。比较常用的编码公式是score 业务分数 * 固定系数 (最大时间戳 - 当前时间戳)。比如业务分数上限设定在1亿以内系数取100000000那么score高位是业务分数低位是时间因子业务分数相同的时候时间戳更小更早达到的玩家排在前面。这种方式能让ZSet在原生能力范围内做到“同分先到先得”而且完全不用改Redis纯应用层就能搞定。但ZSet也有它的天花板最突出的就是单Key容量问题。当一个赛季榜把全服几十万玩家都塞进同一个Key时ZADD单次操作的CPU开销会越来越重。虽然Redis官方号称单Key可以存上亿个member但实际压测中当member数超过百万ZREVRANGE TOP100的延迟会从0.5ms左右涨到3~4msZADD也平均变慢一倍。所以这笔账必须提前算清楚否则运营大推把全服玩家导入同一个榜单后线上必然出事。还有一个非常常见的坑滥用ZSet存额外信息。不少开发图省事把玩家头像、公会名、VIP等级全部塞进member字符串里比如playerId:avatar:guild:vip以为一次ZREVRANGE就能拿到全部展示数据。这种做法短期内没问题但member体积膨胀后跳跃表节点变大内存开销和比较成本都会上升而且一旦要改展示字段还得写数据迁移脚本。正确做法是member里只存playerId展示信息通过批量接口回查玩家服务这也是我在后面章节详细展开的读写分离设计。2.4 第四代倒排索引与近似排名算法再往上走当数据量到达“亿级用户、全服一个榜单、且需要实时排名”的场景Redis ZSet就有点扛不住了。这时候工业界一般会往两个方向走倒排索引和近似排名算法。倒排索引的思路是把分数区间离散化成若干个桶每个桶对应一个分数段桶内再维护玩家的有序列表。查询TopN相当于遍历若干个桶取前N个查询玩家排名先算高分区间的总人数再加上自己桶内的排名。这个方案的关键在于桶的划分粒度桶太大排名精度差桶太小心存开销高。实际项目中常用的变体是“分桶桶内小顶堆”即每个桶只保留TopN的热数据冷数据定期落库这样内存占用可控查询和写入都能保持不错的速度。近似排名算法则是通过概率性数据结构估算排名典型代表是Redis Stack里的RedisBloom模块的Count-Min Sketch或者TiKV生态里的TopN估算。例如用Count-Min Sketch记录每个玩家的分数频次查询排名时估算比当前分数高的人数误差在可接受范围内。这种思路在直播App的礼物榜、短视频播放量榜中已经有落地案例游戏里更适合“全球排行榜”“全服总榜”这种不需要精确排名的场景。老实说第四代方案在大部分游戏项目里是用不上的但如果你负责的是一款大DAU产品的全服通道又不想按渠道拆榜了解这个方向会很有帮助。从我的经验看大多数团队不需要算法层面的炫技把Redis ZSet用熟练、用规范已经能解决95%的需求。3. Redis ZSet榜单的实战细节3.1 score编码的艺术时间权重与业务规则ZSet用起来简单真正拉差距的是score的编码方式。前面提到过一种“同分先到先得”的编码思路这里我把规则说得更完整一点。假设我们要做一个“赛季积分榜”业务分数是一个整型数值范围预计在0到1亿之间。我们期望的排序规则是先按积分从高到低积分相同的情况下先达到这个积分的人排前面。那么score可以采用score 业务积分 * 100000000 (1000000000 - 当前Unix时间戳)这里有几个细节需要刻意设计。第一业务积分乘的系数必须大于时间部分的取值范围否则时间低位会污染积分高位排序直接错乱。第二时间戳取最大值减当前值是为了让“更早到达”的玩家时间因子更大配合ZSet默认的升序排列时更早的玩家排更前。第三1000000000这个值能否覆盖当前时间戳实际上2024年后的Unix时间戳已经超过17亿直接用当前时间戳会溢出。所以更严谨的写法是定义TIMESTAMP_BASE 2000000000用TIMESTAMP_BASE - now这样在未来几十年内差值都能保持整数范围可控同时系数取1亿也能兜住。还要注意一种边界情况如果业务分数本身可能超过定义的上限比如运营活动突然临时把积分上限放开到10亿那么编码会彻底失效。所以上线前要跟策划明确分数上限并且在写入链路里加一个保护逻辑——一旦业务分数超过编码系数立刻告警宁可活动临时改规则也不能让数据静默错乱。这个坑我踩过一次排查了很久才发现是score编码上界被突破数据从某个阈值后全部排错位。3.2 榜单Key设计与碎片化策略用过ZSet的人都知道一个Key就是一个排行榜。但当“全服榜”“跨服榜”“赛季榜”“周榜”同时存在时Key的设计就变成了一门学问。我的习惯是采用分层命名大区维度隔离rank:{zoneId}:{seasonId}:{type}或者匿名化处理rank:global:{seasonId}:{type}用于跨服榜按照赛季或周期来拼接Key的好处是新旧赛季的数据天然隔离清理数据在代码里通过DEL或者过期时间完成即可。比如“S5赛季竞技榜”的Key是rank:s5:arenaS6开启时直接DEL rank:s5:arena不给Redis留垃圾数据。流量大的时候单个Key会成为热点。即使ZSet底层是跳跃表Redis的单线程模型决定了所有写操作必须串行执行一个Key的访问集中会把CPU的某个核心打满。缓解方案就是做“分片”。分片方式一般有两种节奏按哈希分片对playerId取模散列到N个子Key比如rank:arena:0到rank:arena:15。写入时只更新对应子Key。查询“我的排名”时需要并行读全部16个子Key然后汇总计算排名。按区间分片按分数区间拆分比如0到10万一个Key、10万到50万一个Key以此类推。写入时按分数找对应Key查询TopN时只需要定位到前几个高分Key快速拿数据。两种方式各有优劣。哈希分片写负载均衡但查TopN会比较麻烦必须把所有分片里的TopN都拉出来再归并排序区间分片查TopN很快但分数分布不均会导致某些Key特别大、某些Key特别小负载不均衡。实际项目中我更喜欢先用区间分片解决读问题然后配合定期Rebalance——把过大的区间再切成子区间。这需要一些工程投入但效果立竿见影。如果时间紧、项目小直接用哈希分片再加一个“TopN缓存”也能把短板补上。3.3 读链路优化合并回查与本地缓存榜单Server拿到ZSet返回的playerId列表后下一步就是回查玩家昵称、头像、公会名字。很多人会写一个循环一条一条查结果就是一次榜单请求变成了几十次数据库查询。正确姿势是批量接口——把所有的playerId一次性传给玩家服务玩家服务内部再走批查逻辑最终返回一个Map。这一步做完接口RT基本能降一半。再进一步TopN的展示数据其实变化不频繁。玩家名字、头像这类资料不可能每秒钟都在变所以可以在榜单Server加一层本地缓存。用Caffeine或者自研的HashMap加过期时间按“玩家ID 字段类型”做key过期时间设置几分钟这样即使TopN被刷爆绝大多数回查都打在缓存上。我曾经压测过加上这层缓存后纯查询接口的QPS从几千涨到了两万多RT依然稳定在个位数毫秒。本地缓存需要小心的是数据一致性。自家玩家改了头像榜单Server不知道本地缓存里还是旧头像要等过期时间到了才刷新。如果产品对头像一致性要求高可以在玩家服务里加一个版本号机制或者干脆把过期时间调短到30秒。排名数据永远走Redis实时拿只有展示类的资料走缓存这是我认为最稳妥的分工。3.4 写链路优化异步批量更新玩家每打完一局就写一次ZADD这是最典型的写法。但如果你压测过会发现这种“每局一写”的模式不仅把Redis的QPS拉高还会造成不必要的CPU浪费——因为玩家在一局结束后的短时间内可能还会连续打几局而中间每一局的分数变化对排名的影响都很微弱。优化的思路是异步批量更新。玩家的分数更新先落到一个内存队列或者消息队列里榜单服务按照一个固定时间窗口比如1秒批量将这些更新一次性写入Redis。这一步能把写QPS降低好几倍而且对玩家感知影响很小——1秒的延迟在排行榜上几乎无法察觉。当然异步批量更新要处理玩家离线或崩服的边界如果玩家更新分数写入队列后还没刷进Redis此时玩家下线分数就丢失了。所以队列和DB之间要做一个可靠性兜底——建议在业务侧先写操作日志或者依赖消息队列的重投机制保证最终一致性。我自己实际线上用的方案是玩家服务先把“分数变更事件”发到Kafka榜单消费服务聚合1秒内的所有事件按playerId合并后批量执行ZADD。这样即使Kafka偶尔堆积玩家下一次登录时重新计算分数的校准任务也会把数据对齐。做到“实时榜秒级更新、离线任务兜底”是目前平衡实时性和性能的最佳实践。3.5 同分排序细节字典序陷阱与自定义权重同分排序是排行榜产品经理最喜欢“加需求”的地方。常用的规则有“同分先到先得”“同分战力高者优先”“同分取等级高者”等等。ZSet原生只支持按分数和member字典序来排序所以要实现这些规则就必须在score层面做文章。前面讲过的“分数乘系数 时间权重”是最通用的做法。如果要再叠加其他规则比如“同分先看时间、再看等级”数学上可以做两层score 业务分数 * 系数A 时间权重 * 系数B 等级权重这种多层编码需要保证每一层的取值范围严格小于下一层的系数否则高位会被低位污染。工程上调试这种score非常痛苦出了bug肉眼很难看出来所以建议写单元测试直接断言排序结果。我在项目里就是这么干的把“同分不同时间”“同分同时间不同等级”“跨区间边界”这些用例全部固化到测试集里每次改动score编码逻辑先跑一遍排序契约测试。这里还有一个很容易踩的坑浮点数score。Redis的ZSet支持double类型的score但浮点数在比较时存在精度问题。如果业务分数是浮点数比如伤害值“12345.67”直接写入ZSet经过多次运算后精度可能丢失排序会出现诡异偏差。踩过这个坑之后我的原则是一切业务分数在写入前都转成整数。伤害值可以放大1000倍后取整战力值干脆直接用整型字符串解析或者计算时再用固定精度工具处理。4. 跨服榜单与分布式扩展架构4.1 跨服架构的三个层级单机玩法换成跨服玩法后难度直接跳一个档位。跨服排行榜在架构上可以简单分为三个层级。第一个层级是“逻辑上跨服存储上合服”。所有服务器的玩家数据在跨服玩法开启时合并写入同一个Redis ZSet或者同一个数据库表。这种方案最简单适合跨服人数不是特别大的场景。常见的实现是跨服玩法开启前跑一个全服数据同步任务把各服玩家的原始分数同步到跨服Redis同步完成后直接走单服同样的查询逻辑。缺点是数据量大、同步耗时长而且对“实时跨服”的需求没有办法做到动态更新。第二个层级是“逻辑上分片查询时合并”。按服务器或者按哈希把数据分散到多个Redis分片查询TopN时并行访问所有分片归并出全局榜单。这种方案一定程度上解决了单点的写瓶颈但查询链路变长需要通过并发归并排序来保证RT。第三个层级是“代理层 全量分片 缓存计算结果”。在分片之上再加一个聚合服务定时把各分片的TopN结果汇总成全局榜单存放在本地缓存或另一个Redis里。玩家查询时直接读汇总结果而不是实时去归并所有分片。这已经接近第四代“倒排索引”的思路只是工程实现上更务实。我实际负责的跨服玩法用的是第三个层级因为玩法要求“跨服竞技场实时排名”单服写QPS也很高。聚合服务每3秒跑一次全局Top500的计算把结果存到rank:global:cache玩家客户端看到的界面就是3秒延迟的榜单。玩家自己排名仍然实时计算通过各分片ZREVRANK汇总这样既保证了Top榜单的稳定展示也保证了“我的排名”绝对实时。4.2 跨服数据同步的一致性问题跨服榜单的核心难点是“各服的分数如何收敛到同一个全局视图”。如果你用的是“合服同步”方案最麻烦的问题是同步时机。活动刚结束那一瞬间各服玩家都打了最后一局分数产生在高频写入区间同步任务如果跑在玩家活跃时段会读到不一致的数据——A服同步的是10点整的数据B服同步的是10点01分的数据最后结算榜单会出现“错位”。解决这个问题比较常见的做法是“截止时间对齐”。跨服玩法开始前定义一个统一的截止时间比如活动结束的23:59:59所有服务器在截止时间后再执行同步并且在同步前把所有增量事件“回放”到源数据中。如果同步任务来不及跑完宁可先锁定玩法入口也不能出结算错位的BUG。如果用的是“实时分片动态更新”方案一致性挑战就变成“如何保证各分片之间的全局排名在跨服维度是对的”。这个问题的答案是——不需要保证实时一致只需要保证“最终一致”。玩法设计上跨服榜通常固定在活动结束时结算所以平时玩家看到的“跨服实时排名”可以允许有几秒的延迟但最终结算一定要以“冻结数据”为准不能直接拿实时分片数据做最终结算。所以在结算前我会把Redis分片里的数据全量导出到数据库再基于库里的一致快照做结算避免任何“分数还在飞”的尴尬场景。4.3 热key与分片Rehash的坑Redis分片架构最蛋疼的问题之一就是热key和Rehash。当你准备把单Key拆成16个分片Key时如果按playerId % 16的方式哈希那么所有玩家的访问都会被均匀打散到16个Key上这是理论上最优的负载均衡。但现实是玩家并不是均匀分布的——冲榜期的头部玩家高活玩家集中在某个分数区间他们的写入会集中在某些分片Key上最终造成某些分片热、某些分片冷。应对办法是“分数区间分片 动态迁移”。比如初始按照积分区间分片0~100万一个Key100万~500万一个Key500万以上一个Key。运营一搞大活动玩家积分集体暴涨原先冷清的高分区间Key突然变成热Key。这时候需要一个监控系统盯着每个分片Key的访问量和大小一旦发现某个Key的请求量超过阈值就把它再拆成两个更小的区间Key原Key的数据按迁移计划搬到新Key里。这里最忌讳的是“一把梭直接Rehash”——把所有分片Key全部重新映射会导致一瞬间所有榜单数据全部失效。正确的做法是增量迁移。例如把一个大Key按区间拆成“100万~300万”“300万~500万”两个新Key时先停写原大Key短时间然后扫描原Key里的数据按分数区间写入两个新Key写完把读流量切到新Key最后删除老Key。整个过程控制在几十秒内配合监控告警玩家几乎无感知。5. 福利场榜与高并发场景的极限应对5.1 福利场榜带来的特殊挑战如果说普通排行榜是“难”那福利场榜就是“极限难”。什么是福利场榜简单说就是在活动时间内玩家通过打福利场一种低门槛、高频率的玩法积累积分活动结束时按积分排名发放奖励。福利场的核心特征是“全服所有玩家同时参与、同时高频写分、同时高频刷榜”。这种场景下写QPS轻松过万读QPS更加夸张一瞬间所有玩家都盯着同一个榜单页。之前讲的“Redis ZSet一把梭”在这里会直接崩——单Key的热点、单线程的CPU瓶颈、网络带宽的耗尽每一个都能把你按在地上摩擦。更麻烦的是福利场榜对实时性要求极高因为玩家每打一局都会立刻看到自己的排名变化特别是活动结束前最后10分钟那简直是灾难级别的高峰。5.2 高峰期写链路的削峰填谷福利场榜的写流量通常不是一个平滑曲线而是一个尖峰形状——活动开放期间恒定高流量最后10分钟指数级上升。面对这种曲线单纯扩充Redis实例是不够的真正的解法是“削峰填谷”。我在实际项目中采用的方案是“数据分级写入”。玩家在福利场中的每一局积分先写到一个轻量级的内存数据库中比如Redis的Hash结构以playerId为keyvalue存总分不直接更新排行榜。同时一个后台聚合任务以固定节奏比如每2秒从Hash里拉取增量数据批量更新ZSet排行榜。这样排行榜ZSet面对的写入频率不再是“每局一次”而是“每2秒一次”的批量写QPS直接降低一个数量级。更要紧的是把“最终刷榜请求”和“真实写分请求”分离。玩家看到的排名可以来自一个独立缓存比如定时生成的Top500快照而“我的排名”接口可以接受1秒到2秒的延迟。这样就把“玩家感受上的实时”和“底层写入的实时”解耦了。5.3 最终排序的公平性与数据校准福利场榜最大的舆论风险是“排名错误导致奖励发错”。活动结束那一刻所有玩家分数都还在飞UGC社区里已经有人在晒截图说自己是第100名如果最终结算和截图不符运营事故没跑。因此最终排序必须建立在“收敛”的数据上。我的建议是活动结束时先“冻结水位”。也就是活动截止时间一到立即停止玩家分数写入玩法入口关闭再执行一次全量数据收敛——把内存中的增量Hash数据合并到ZSet再把ZSet快照落库。整个收敛过程越快越好但必须保证数据不丢。收敛完成后最终排名以数据库里的快照为准发奖也走这个快照。另一个容易忽略的点是“奖励账本”。发奖前最好生成一份“排名奖励明细表”包含排名、玩家ID、奖励内容、发奖状态逐条记录发奖结果。这样一旦玩家申诉“我明明是第50名”可以拿明细表快速核对而不是去Redis里翻已经过期的数据。福利场榜的容错设计核心不是“每一步都不出错”而是“出错后能快速定位、快速修复、快速解释”。5.4 多级缓存架构在冲榜场景的落地福利场榜还有一个特点Top榜的展示量极其巨大而且集中在少部分头部玩家上。这给了我一个天然的优化机会——把所有玩家对Top榜的读请求全部打到“预生成缓存”上。具体做法是聚合服务每3秒生成一次Top500快照放一份到Redis String或者远端的Cdn缓存玩家请求Top榜时直接读这个String。同时玩家自己的排名单独走ZREVRANK这个操作的QPS虽然也不小但相对于全榜扫描来说已经轻非常多。如果活动峰值实在太高连ZREVRANK都顶不住那就加一层“玩家排名本地缓存”。比如在榜单服务的内存里维护一个“最近一分钟活跃查询玩家”的排名快照查询时先查本地缓存缓存过期再走Redis。反正福利场玩家的排名在短时间内不会巨变牺牲几秒的一致性换来几十倍的容量这笔账划算得很。6. 方案选型建议与踩坑记录6.1 如何根据游戏规模选择合适的方案讲了这么多最后落到实操建议上。不同规模的游戏对应的排行榜方案真的不一样游戏规模典型场景推荐方案核心关注点小游戏/单服在线几百人数据库排序或内存快照开发成本低能跑就行中型游戏日活几万到十几万Redis ZSet 异步批量更新score编码、同分规则、单Key容量大型游戏日活五十万以上Redis分片 聚合缓存热key拆分、跨服一致性、削峰填谷超大型游戏全服/全球榜分桶索引或近似排名算法工程复杂度高慎选如果你正在为项目选型我给到的建议是先想想这几个问题玩法是否需要秒级实时排名活跃玩家大概有多少排行榜类型是核心玩法还是运营活动前两个问题决定了你是否需要ZSet第三个问题则决定了你是否需要分片和聚合缓存。6.2 五条常见的翻车现场我在各种项目里见过、踩过的坑值得单独列出来同分排序没提前约定。策划和开发各说各话上线后才发现同分时排名顺序和预期不符只能临时改score编码还得做数据迁移极其狼狈。member里塞了太多业务字段。最后改需求要新增字段所有历史数据必须重写Redis内存爆掉迁移脚本跑了一整天才结束。直接用ZSet做跨服实时榜。数据量稍微一大单Key热点直接拖垮Redis实例跨服玩法整个瘫痪。周期榜和实时榜共用同一个Key。赛季结束时清理数据把正在用的实时榜也删了导致玩家排名瞬间全部消失重大线上事故。忘记考虑冷热数据分离。老赛季的数据不清理ZSet越积越大ZADD和ZREVRANGE的耗时逐步恶化最后性能劣化到不可用的程度。6.3 实测下来最稳的组合拳如果让我给一个“不追求炫技但求稳”的推荐组合我会选数据库做最终存储 Redis ZSet做实时榜单 定时任务做数据聚合 本地缓存做读放大保护。分数更新链路玩家打一局 - 更新数据库玩家总分 - 发送增量事件到消息队列 - 消费服务异步聚合 - 批量ZADD到ZSet。查询链路Top榜 - 聚合任务每N秒生成快照缓存 - 玩家请求直接读缓存我的排名 - 实时ZREVRANK 本地缓存兜底。这个组合的好处是每一层都有明确的职责出问题时可以快速定位是数据库、Redis、队列还是本地缓存的问题。相比各种“高级算法”这套务实方案能覆盖绝大多数游戏项目的需求而且团队维护成本可控。收尾的几句经验把这个话题从方案到细节翻来覆去聊了一遍最后分享一点个人体会。排行榜系统之所以难从来不是因为某一个技术点有多复杂而是因为它把高并发读、高并发写、强一致性、排序规则、跨服合并、运营活动等所有难题同时凑到了一起。任何单一组件都解决不了全部问题真正的解法永远是“架构分层 按场景拆解 细节设计”。我在每一次上线前都会把最关键的数据链路画出来把每一步的容量估算做一遍把同分排序和清洗数据的测试用例反复跑几遍。这些功夫看起来笨但恰恰是它们让我在大部分冲榜活动里都能睡个安稳觉。如果这篇分享能让你少踩一个坑哪怕只是提前想到“同分怎么排”这种事那就值了。
返回列表