亿级排行榜架构设计:Redis ZSet分片与冷热分离实战 📅 发布时间:2026/9/17 3:21:22 👁 浏览次数: 做了这么多年后端排行榜这个需求我接过太多回了。小到几千人在线的小活动大到上亿注册用户的全量积分榜不同量级下的解法完全是两码事。今天想把我自己对“亿级排行榜”这套方案设计的思考完整梳理一遍说说我从最朴素的数据库方案一路演化到最终落地的整套架构以及每一版踩过的坑和做选择的理由。如果你正在面对类似体量的榜单需求这篇文章应该能帮你省掉不少试错成本。先明确一下场景这里的“亿级”指的是参与排名的用户数达到1亿以上而且不是那种一次性导入的静态数据是随时有用户行为产生、分数持续变动、榜单实时性要求较高的业务场景。典型的例子像游戏全服战力榜、金融App的成长值排行、运动健康类的步数总榜。这类场景最麻烦的地方在于不光是数据量大写入的QPS和查询的实时性要求也都不低单纯靠某一种存储解决方案很难完全覆盖。这篇文章适合正在做架构选型、或者被排行榜性能问题卡住的后端同学。我会把从需求拆解、底层数据结构选型到分片策略、排序细节、缓存降级再到线上压测和问题排查的完整链路都过一遍。1. 动手之前先想清楚的事你的排行榜到底属于哪种类型很多人一上来就开始选Redis还是数据库这其实搞错了顺序。亿级排行榜第一个要解决的问题不是技术选型而是需求本身的定性。不同性质的排行榜对“亿级”这个词的含义是完全不一样的能接受的实现成本和性能指标也天差地别。1.1 全量精确榜、活跃榜和TopN榜差别比想象中大得多我习惯把排行榜粗暴地分成三类。第一类是全量精确榜所有注册用户都参与排名每一个用户都能查到自己的准确名次。这个最硬核因为一亿用户的排名结果本身就是一个一亿条记录的映射关系而且数据还在实时变化。大多数客户嘴上说要的“排行榜”心理预期其实是这种。第二类是活跃榜或准活跃榜虽然号称亿级用户但真正进入榜单只需要覆盖最近7天有过活跃行为的用户。这个对排名人数其实是打折的可能只有千万级甚至百万级的量需要动态维护剩余用户要么不展示名次要么给一个“超过xx%的用户”这种粗粒度区间。这类排行榜做起来要轻松得多很多优化空间就是从这类需求里抠出来的。第三类是TopN榜核心诉求就是展示前100名、前500名个人名次可以用近似值甚至不查。排行榜在业务上最大的流量入口其实集中在头部榜单的浏览上TopN榜可以把精确计算的范围死死压缩在一个极小集合内。这三类需求在数据量级、更新频率、实时性要求上完全不同方案设计的差异极大。我见过最典型的翻车案例就是拿全量精确榜的存储方案去套TopN榜的需求结果为了一个没人看的第99999999名白白烧掉了几台16G内存的Redis节点。1.2 亿级用户不等于亿级排名数据这是做亿级排行榜时最容易忽略的一个判断。一亿用户注册并不意味着排行榜里真的要维护一亿条记录。很多业务有准入门槛比如游戏全服战力榜只收录30级以上的角色比如信用卡积分榜只收录有积分的用户。真正进入可查询名次的集合常常比总用户数少一个数量级。所以在方案设计前第一件事是把真实参与排名的实体数量估算出来。这个数量的量级直接决定后面的技术路线。如果是千万级以内的活跃排名者一个设计良好的Redis ZSet集群完全兜得住如果超过这个量级才需要考虑分片和合并。先把这个数字估算清楚方案就完成了一半。实际估算的时候可以问产品要数据如果产品也给不出来就按注册量乘以一个业务转化系数来预估宁可多估不要少估因为榜单数据一旦上线增长曲线通常比你想象的要陡。2. 为什么不能靠数据库order by也不能只靠一个Redis ZSet确定了需求之后接下来就是选型。这个环节我不想直接甩结论而是把每种方案的底层原理和瓶颈说清楚这样你们以后遇到类似问题也能自己推导。2.1 MySQL的排序性能瓶颈出在数据访问模式上第一反应上数据库是很正常的事毕竟业务数据本来就存在MySQL里users表加个索引order by score desc limit 100不就行了吗。在数据量小的时候确实行但到了亿级问题就爆发了。首先排序本身要命。哪怕你的score字段上有索引MySQL对亿级数据做order by score desc limit 100这种查询优化器也大概率不会走索引顺序扫描因为你要的不是最大分数段里那批记录而是明确的前100条。即使能走索引每次查询都要从B树的最右侧开始遍历那没问题但问题是只要有用户的分数一更新索引顺序就变MySQL需要实时维护这些更新。单个热点行的瓶颈不一定出现但批量导入、高并发加分时索引维护的开销会很明显地拖慢写入速度。更致命的是随机IO。一张一亿行的表行宽度随便几十个字节光表数据就好几个GB加上二级索引就更多了。要取前100名如果这些用户刚好分布在不同的数据页上你就得反复回表每一次回表都可能触发磁盘IO。这个性能特征极其不稳定在缓存命中率高的时候快如闪电一旦缓存被冲掉就瞬间雪崩。在线上环境里这种不确定性是灾难性的。数据库在这类场景里不是不能用但必须做分层。离线场景比如每日结算一次的名次、每周更新的积分榜用MySQL的只读从库做预计算没问题。但只要涉及在线实时更新和实时查询数据库就不该充当核心存储了。它在亿级排名场景里的角色应该退回到“数据源”而不是“排名引擎”。2.2 Redis ZSet很香但单key的承载能力有明确天花板Redis自带的ZSet有序集合几乎是排行榜场景里最顺手的原生数据结构底层是跳表加哈希表的组合。跳表保证插入、删除、按分数查询排名的操作都在O(log n)完成哈希表用来做member到分数的映射和去重。这个结构用起来是真的舒服一个ZINCRBY把用户分数加进去一个ZREVRANK查到名次ZRANGE取出榜单片段一套流程下来代码不超过10行。但ZSet最隐蔽的坑在内存上。Redis所有数据都在内存里一个亿级成员的ZSet内存开销非常夸张。每个member要存字符串本身跳表节点还要存多个前向指针一个member的总体内存开销在60到100个字节左右。一亿个member光是这个key就得吃掉6到10个GB的内存。这还只是纯数据不算Redis自身的元数据和碎片开销。就算你咬牙给了内存还有两个问题。第一是大Key阻塞包含一亿个元素的ZSet在做持久化RDB快照生成、AOF重写或者主从全量同步时需要遍历整个结构期间Redis主线程的阻塞风险很高。第二是单一热点所有排行查询都打在一个key上这个key所在的分片CPU会率先打满其他分片还很闲集群整体利用率严重失衡。单key方案在百万级以下非常优雅但到了亿级它自己的优秀特性反而变成了掣肘。3. 核心方案设计分片支撑写入分段支撑查询前面铺垫了这么多其实都是为了引出这套主方案。我在亿级排行榜里最终采用的思路可以用两句话概括用分片解决单点写入和内存瓶颈用分段解决TopN查询和排名计算的复杂度。这两者不矛盾可以叠加使用。3.1 按用户维度分片永远不要尝试把1亿人装进同一个ZSet分布式系统里最朴素也最有效的思想就是分而治之。既然一个ZSet装不下1亿人那就拆成多个ZSet。这里的分片键我推荐用userId的哈希值哈希取模之后每个用户的数据只会稳定落在某一个分片上不会出现一个用户的数据跨多个分片的情况这样更新成绩、查询个人名次都只需要访问一个分片。假设拆成256个分片每片维护大约40万用户按一亿总量这个量级的ZSet非常轻松内存占用几百MB写入和读取都在毫秒级完成。分片本身可以分布在同一个Redis实例的不同key里也可以分布到Redis Cluster的不同节点上。分布在不同节点上的扩展性更好如果未来用户量翻倍可以把分片系数从256扩到512甚至1024数据再做一次迁移就行。哈希取模在扩容的时候会有大面积数据重分布但从256到512这个场景下如果提前预留足够大的分片数量很多业务就不需要真的扩容分片数直接定死就行。这里要注意一个细节分片数不能随便拍脑袋。分片太少单key数据量还是过大分片太多跨分片合并查询时网络开销会明显上升。一个ZSet控制在几十万到百万级member是比较合理的。一亿用户256到512个分片是甜点区间。我自己的经验公式是按目标用户数的上限除以50到80万来定分片数然后向上取整到2的幂次方便哈希取模。3.2 按分数段查询TopN榜单的每个请求只扫描“高分区”分片解决了存储和写入问题但带来了一个新的查询问题排行榜按分数降序排可一亿用户经过哈希分片之后分数高的用户被均匀打散到了所有分片里。每次查询Top100你得把256个分片各自的Top100全捞出来再做一次256路的归并排序这一轮下来Redis操作就要几百次虽然单次都很轻但整体延迟和网络往返数还是会比单key方案慢不少。所以在这个环节我引入了一个“分数段”的分层缓存结构。思路是这样的分片内部依然全量存用户的精确分数但每个分片额外维护一个分数段桶计数。比如把分数区间从0到100万分按每1万分切一个桶记录每个分数段里有多少个用户。查询Top100的时候先从分数段计数里定位到最高分区假设最高的非空分数段是90万到100万这个桶那就只需要去每个分片里查这个分数段的用户再合并排序。其实不需要查全部256个分片因为高分段用户人数极少只要在少数几个包含高分段用户的分片里查询即可当然实现上为了简单我通常还是查所有分片但每个分片查询的数据范围已经缩小到一个很小的分数区间压力不大。如果某个高分段的人数超过100那就直接在这个段内取前100完全不用看低分段的数据。这个改动看着不大但查询复杂度直接从“全量扫描合并”降到了“锚定高分区局部扫描”效果是几何级的。分数段的粒度也不是拍脑袋定的。段位太粗一个段里还是几百万人查询还是要大范围扫描段位太细分段计数本身的内存和更新开销会变大。我的实践值是让每个分数段的覆盖宽度差不多是TopN查询所需数量的5到10倍。比如Top100的榜单每分钟大概有300到1000人落在同一个分数段内就是合适的粒度。具体如何计算段内人数需要结合业务实际如果分数分布集中可以再细化分布分散就可以放宽。3.3 个人排名的两种计算路径个人名次的查询比TopN榜复杂一点但也没那么玄乎。精确计算个人名次时先查目标用户所在的分数段取到用户在全局的高一档分数段累计人数再去用户所在分片里算局部排名两者相加。这个过程的复杂度可以认为是对数级加常数级跟总用户量基本解耦非常稳。不过全量精确排名在亿级场景下有一个隐藏问题如果所有用户同时查询个人排名每个请求都要跨分片取计数压力还是不小。所以实际方案里我对个人排名做了分级处理。只有前N名这部分大约百万级用户支持精确排名其余用户排名结果用“近似排名 百分比区间”来代替。比如系统返回“您当前排名约1280万超过了87.3%的用户”这种粗粒度结果用户完全能接受而且成本比精确排名低两个数量级。这个思路核心就是不为没人看的尾部数据付出头部数据的成本。4. 排序细节与一致性越简单越容易出错的地方架构定了真正考验工程师的反而是那些排序语义、并发一致性、边界情况。这些细节出了问题用户端体验会非常糟糕而且排查起来特别费劲。4.1 同分排序规则不要靠修改score来硬凑排行榜业务里最常见的一个争执就是同分怎么办。用户A和用户B都是1000分谁排前面产品在这一点上的需求经常会变今天要“先到先得”明天要“队伍优先”后天可能又改回“并列显示”。最容易踩的坑是直接在score上叠加额外信息比如把score设计成“总分*1e9 时间戳剩余位”这种复合数值。这样做确实能保证排序符合预期但也带来了两个隐患一是如果用户实际分数很大脑子一算就可能溢出ZSet的double精度二是分数更新的时候要把整个复合值拆解再拼装逻辑复杂不说还容易引入bug。我的建议是不要在score上动太多脑筋而是把同分的排序规则放到业务层或查询层去处理。Redis ZSet本身在memberscore相同的情况下是按member名字的字典序来排的。既然排序规则这么简单且可控产品如果要求“先到先得”我们可以设计成member是“userId初始时间戳”的复合体或者单独维护一个二维排序的依据。具体工程设计上可以通过给member名加前缀、或者在另外一张表里维护参与排名的用户ID与初始参与时间的映射来实现时间维度的同分排序。虽然多占了一点额外空间却能避免把score搞得一团糟。用score只做排名的大局切分同分细节尽可能放到数据建模层面解决。4.2 加分的原子性与主从延迟一点都含糊不得排行榜的更新操作非常高频用户每产生一次行为就ZINCRBY一次。这里容易出问题的是“先查后改”的逻辑比如某些场景要判断用户当前分数是否达到某个阈值再决定加多少这就涉及读改写三步。如果不用原子操作或者Lua脚本把这几个操作串起来并发一高就会出现重复加分、加错分的情况。我强烈建议所有涉及排行榜的写操作都收敛到一个更新接口里用Lua脚本保证原子性避免在业务代码里拼凑多条Redis命令。另一个容易翻车的是主从延迟。如果架构里用了读写分离用户刚加完分立刻查榜单结果因为从库还没同步到最新数据而查不到自己的新分数这种体验非常伤。所以排行榜这种实时性要求高的系统我通常是直连主库读或者写完后强制走主库读一次不要抱侥幸心理。当然如果做了分片每个分片的副本之间也存在同样问题规则是一样的实时查询走主离线统计走从。4.3 存储层的降级与过期别让历史包袱拖垮线上排行榜的数据还有一个特点就是它永远在增长但真正热的数据永远只有顶部那一小撮。如果整个榜单所有用户的记录都永远留在Redis里内存迟早会爆。所以需要一套“热冷分层”的过期策略。我一般会把排行榜数据分成三层。第一层是热数据覆盖Top1000的头部实时精确维护常驻内存。第二层是温数据覆盖Top100万以内的用户保留分数但不保证排名严格实时内存里存储有一定延迟地更新。第三层是冷数据即100万名之后的绝大多数用户不保留精确排名只保留分数段计数具体分数回源到数据库或离线数仓。这三层之间的数据会通过定时任务或消息队列做迁移保证Redis里的数据永远是最大化性价比的那部分。这里有一个常见的尴尬场景某些榜单是周期性的比如“本月排行榜”。每个月结束榜单要清零重新计。如果不做处理老数据就会一直堆在Redis里越积越多。最好的做法是用有效期设计榜单的key带上业务周期后缀周期结束后整区间下线和删除成本非常低比逐条清理用户数据快了太多。5. 完整链路落地从Redis数据结构到上层接口的工程化实现理论讲再多不落地都是空中楼阁。我拿一个实际的例子来讲假设业务是一款棋牌类游戏的全服等级榜注册用户1.2亿活跃用户800万核心需求是Top200榜实时展示、个人名次实时查询分数区间在0到100万等级经验之间。我直接说一下这套方案落地时候的完整结构。5.1 整体架构与Redis key设计先看分片基数按活跃量级定我给这个场景定的是128个分片每个分片覆盖大约60万活跃用户加上1.2亿总注册里那些“沉睡用户”的数据会走冷数据通道不会全部压到Redis里。key的规划大致是这样的主数据keyrank:main:{shardId}类型ZSet存用户ID和当前经验值这是最核心的数据。分段计数keyrank:segment:{shardId}类型Hash字段是分段编号值是当前分段人数用于快速定位高分段。全局分段汇总keyrank:segment:global类型Hash是所有分片分段计数的聚合后台任务定时累加刷新。查询Top200的过程就变得很紧凑从rank:segment:global里找到当前最高且人数足够的分数段。并发从128个分片的对应分段里取出局部TopN每个分片取的数据量可以稍微多拉一点比如取Top50128片合计6400条。在本地做归并排序取前200。如果Top200里跨了多个分数段就多取几个段再归并一次。个人想要查自己的名次假设这个“自己”是普通中腰部玩家也可以直接走精确排名的逻辑高分段累计计数加局部排名。如果被查的用户落在了冷数据区就只返回百分比区间不返回精确名次。有一些细节要注意比如并发拉取分片的时候到底谁去拉、是按shardId并行还是串行这取决于你所在团队的技术选型和资源状况。考虑到实现难度串行也并非不能接受因为128个分片即便串行如果平均延迟控制在0.5毫秒一轮也就60多毫秒还在可接受范围。性能要求再严格一点就可以做并行拉取整体延迟能控制在20毫秒以内。5.2 Lua脚本保证多步骤操作的原子性写逻辑这块我再展开一点因为并发加分时如果不做原子控制分数准确性会被破坏。推荐把“更新分数 更新分片分段计数”这两步合并放到一个Lua脚本里执行不要拆成两条Redis命令在业务代码里调两次。下面是一个简化的脚本示意-- KEYS[1]: rank:main:{shardId} 用户主ZSet -- KEYS[2]: rank:segment:{shardId} 分段计数Hash -- ARGV[1]: userId -- ARGV[2]: 分数增量 -- ARGV[3]: 旧分数所属的分段编号 -- ARGV[4]: 新分数所属的分段编号 local oldScore redis.call(ZSCORE, KEYS[1], ARGV[1]) if oldScore false then oldScore 0 end local newScore oldScore tonumber(ARGV[2]) redis.call(ZADD, KEYS[1], newScore, ARGV[1]) -- 如果分段发生变化更新分段计数 if ARGV[3] ~ ARGV[4] then redis.call(HINCRBY, KEYS[2], ARGV[3], -1) redis.call(HINCRBY, KEYS[2], ARGV[4], 1) end return newScore脚本看起来简单但有几个点要提醒。分段编号ARGV[3]和ARGV[4]必须在业务代码里就算好不能放到Redis里现算。我们一般规定分段编号等于“floor(score / 10000)”这样可以保证分段计数和主ZSet里的实际分数永远对得上即使某一次分段计数数据异常也可以从全量ZSet重建。脚本在Redis里执行时是原子的所以不用担心并发问题。还有一个容易犯的错是分段计数更新时的负数问题。比如旧分段计数HINCRBY减1时如果发现减成负数了那说明分段计数和主数据不一致了。这时候不要直接报错而是记录一个异常日志交给后台的校准任务去修复。排行榜这种系统很容易积累这种小偏差定期巡检校准是必须的。5.3 数据预热、冷启动和周期归档榜单上线前还有一个经常被忽略的冷启动问题。如果直接拿历史数据一次性灌进Redis写入量过大很容易拖垮线上。我的做法是分批预热按分数段从高到低往Redis里灌优先保证最高的几个分段能先查。预热期间如果来了线上读请求因为低分段还没灌完会返回一个“排名计算中”或粗略结果体验上可以稍微打点折扣但系统不会崩。这个方案比冰火两重天式的全量导入安全得多。周期归档方面每个周期结束时把Redis里的排行榜快照同步一份到离线存储做历史归档然后直接用DEL删除当期key。删除大key的时候要注意Redis的阻塞问题超过百万元素的key直接DEL可能会卡主线程几百毫秒建议用SCAN分批删或者用UNLINK异步删除。这些小技巧不复杂但对线上稳定性影响非常大。6. 查漏补缺压测数据、真实故障和问题速查方案落地后上线前一定要做一轮压测和故障演练。这部分我分享几个实际用过的指标和踩过的坑能帮你们少走一些弯路。6.1 压测指标怎么定内存怎么估算写操作的压测目标我习惯按峰值QPS的三倍来定。假设业务常说的高峰是每秒5000次加分操作压测就要跑到每秒15000次写持续压10分钟以上看有没有内存抖动或慢请求。因为排行榜的更新链路极短瓶颈往往不在Redis而在网络带宽所以压测的时候一定要监控客户端机器的网卡流量和Redis节点的带宽别把带宽打满了还以为是Redis处理不过来。内存估算有个简单公式可以提前计算一个分片大概需要多少内存。每个用户在主ZSet里占约70字节一个60万活跃用户的分片主ZSet大约消耗42MB分段计数Hash占很小一个分片总内存控制在60MB以内128个分片全量占用约7.6GB。这台机器选16GB内存的实例其实已经宽裕了。如果评估后发现内存超了优先排查自己是不是把低活跃用户也塞进了热key或者member的字符串长度是不是刻意加长了。6.2 线上遇到过的三个典型故障故障一分段计数不准导致TopN榜单出现名次跳号。原因是某一次并发加分时应用层计算的旧分段编号和新分段编号是拿旧分数算的但执行Lua脚本时主ZSet里的分数已经被另一条命令更新了分段计数就会漏减。后来我们把分段编号的计算逻辑改成在Lua脚本内部根据最新分数自行计算彻底解决了这个问题。这也是为什么我上面强调“分段编号必须在业务代码里算好”——其实更严谨的做法是在Lua脚本里算。两种方式都行但一定要选一种并且理解它的边界避免两头都靠结果两头都靠不住。故障二Redis分片节点分摊不均某个分片内存明显偏大。排查下来发现是产品做了一个活动大量新用户涌入时userId的哈希取模导致某几个shardId恰好分到了大批量导入的用户。这个问题的根源在于分片键的选择和用户的分布特征耦合。后续我们把分片键从userId哈希改成了userId和注册时间组合哈希并且给导入任务加了预热限流没有再出现这个问题。故障三周期性榜单清空时直接DEL大key导致Redis阻塞几秒钟业务瞬时超时。当时还没注意到UNLINK这个特性直接在线上执行了DEL结果损失了不少QPS。现在所有超过10万元素的key清空一律用UNLINK问题再也没有出现。6.3 一套可以直接抄的“排行榜问题速查表”症状可能原因排查手段解决方案个人排名查询很慢未走分段计数全量扫描了ZSet查看Redis慢日志确认命令是ZRANGE还是ZREVRANK改为分段计数 局部排名方案TopN查询超时分数段粒度过粗段内人数太多查看segment计数看单个分段人数是否异常加密分段粒度或对主分片加读副本分数更新丢失分片键设计不合理或未用Lua原子更新对比业务日志和Redis里的最终分数改用Lua脚本原子更新分片键增加随机因子内存持续增长未做冷热分离低活跃用户全量驻留统计ZSet成员数与活跃用户数差异落地三层冷热分层和过期归档同分排名忽前忽后依赖ZSet字典序但member拼装了动态字段检查member组成规则固定member命名方案同分规则在业务层排序清榜或淘汰大key卡顿直接执行DEL查看Redis阻塞日志改用UNLINK异步删除或分批SCAN删除跨分片合并结果不对每个分片拉取的TopN数量不够导致归并后丢数据核对每个分片返回条数和合并结果每个分片多拉20%~50%的数据再归并活动上线后榜单更新变慢预热不充分大量历史数据一次性灌入查看写入QPS和延迟曲线分批预热高分段优先进内存加限流这套速查表是我每次排查排行榜问题时的第一参照物很多问题看症状就能直接锁定方向不需要每次从头捋一遍链路。6.4 方案之外的几条实战感悟最后说几条没法写在代码里的经验。排行榜方案没有银弹全量精确、实时、低成本这三个诉求在亿级规模下最多只能满足两个。我在项目评审时习惯直接问产品一个问题用户真的需要看到自己的精确排名吗得到的回答很多时候是“不需要”他们需要的只是“被重视的感觉”。这个时候用百分比、段位、星座分组这些替代方案技术上省一个量级产品体验反而不降。如果确认必须要全量精确排名也建议设置一个“排名查询冷却时间”比如用户一天最多查5次精确名次。这在业务上很合理因为用户的分数不会在几分钟内天翻地覆但系统压力却能明显下降。还有一个很重要的原则排行榜永远是附加功能不是业务核心链路。任何时候都不要因为榜单查询拖垮了主流程。所以降级策略一定要提前设计好Redis抖动的时候就停掉精确排名展示返回上一次缓存的快照或者直接显示“排名更新中”。这比强行查询然后把错误页面甩给用户好太多。做亿级排行榜这件事技术本身并不神秘核心就是分而治之和冷热分离这两个思想加上足够的细节敬畏。把最核心的读写路径抠到极致再对降级、扩容、监控和一致性边界有完整的预案这个方案就能经得住业务高峰的考验。