Redis排行榜架构设计:从一个ZSet到亿级排行榜
做排行榜大多数时候一个ZSet就够了ZADD更新分数ZREVRANGE查排行榜看起来不复杂。但真正发版上线后你会发现排行榜并没有一开始想的那么简单。随着用户越来越多、业务越来越复杂同分排序、赛季切换、大Key、热Key、分桶……这些问题会一个接一个出现。排行榜架构从来不是一次设计出来的而是随着业务规模不断演进出来的。为什么排行榜都用ZSetZSet能同时支持按分数排序和快速查找天然适合排行榜场景。底层通过跳表和哈希结构组合实现写入和排名查询的复杂度都比较稳定。常用的命令就三个ZADD写入ZRANGE和ZREVRANGE查列表ZRANK和ZREVRANK查名次。这几个命令覆盖了排行榜绝大多数的操作需求。如果排行榜更新不频繁、查询量也不高数据库完全可以胜任。Redis并不是排行榜的前提而是在数据规模和访问压力上来以后更合适的选择。数据库也能做排序但当排行榜需要频繁更新、频繁查询排名时维护索引和排序的成本会越来越高。你必须解决的第一个问题同分排序排行榜上线以后第一个碰到的问题往往不是性能而是同分排序。这不是Redis的bug而是业务排序规则和Redis排序规则之间的冲突。运营人员希望同分的人谁先达到谁排前面。Redis的规则是score一样时按member字典序排。举个例子。张三和李四都是1000分张三9点达到的李四9点05达到的。如果member用的是user_10086和user_10087Redis按字典序排出来的结果和运营要求的完全对不上。真正要解决的问题是让score同时承载多个排序维度。分数作为主要排序依据时间作为同分时的次要排序依据。常见的做法是利用score的小数部分编码时间信息或者用位运算将分数和时间打包到一起。不过这里有一个容易踩坑的地方。Redis的score使用IEEE754双精度浮点数对于整数值最多只能精确表示约53位有效整数。如果把多个维度编码进score需要综合考虑分数范围、时间精度以及浮点精度限制在它们之间找到平衡。还有一个容易忽略的细节。编码以后就不能直接用ZINCRBY更新分数了。需要先取出旧score解码拿到真实分数更新分数部分重新编码后用ZADD覆盖。真正需要编码进score的不一定是时间也可能是其他业务定义的第二排序规则例如创建顺序。score承载多维排序规则时间只是最常见的第二排序维度。赛季制排行榜不能永远排下去随着业务继续演进运营人员开始要求做赛季制、周榜、月榜。排行榜不能一直累加下去需要有周期性的重置。这时候一个常见的误区是为什么不直接DEL清空ZSetDEL执行的那一瞬间排行榜就没了。用户正在看排行榜刷新一下就变成了空榜。正确的做法是切换key不是清空key。每个周期对应一个独立的ZSet key比如leaderboard:season:2026-07、leaderboard:weekly:2026-W29。新赛季开始时写入新key旧key设TTL自动过期。Redis ZSet不支持单个成员TTL只能整体key过期这是设计时需要注意的。历史赛季数据归档到MySQL供历史赛季回顾页面查询。key命名上建议统一规范业务前缀 周期类型 具体周期值方便批量管理和清理过期数据。排行榜的三种访问模式排行榜真正跑起来以后你会发现它的请求其实就三种访问模式典型场景特点推荐做法Top N排行榜前100名高频、结果集中本地缓存5到10秒TTL我的排名用户查看自己名次分散、每人不同直接查RedisO(log N)附近排名看排在我前后的人组合查询ZREVRANK ZREVRANGETop N最适合做缓存因为所有用户看的都是同一份数据。在前面加一层本地缓存每隔几秒刷新一次就能把这部分读压力几乎降到零。查自己的排名就不同了每个用户请求的都是自己的数据没法缓存。好在ZREVRANK的复杂度是O(log N)单次查询非常快直接打到Redis就行。查附近排名是个组合操作先ZREVRANK拿到当前用户的排名位置再ZREVRANGE取前后几位用户两次查询拼在一起返回。还有一种值得注意的场景好友排行榜。很多好友排行榜采用另一种思路服务端只返回好友数据由客户端本地排序。好友数量有限通常就几百人客户端排序的开销可以忽略比在服务端维护大量好友排行榜简单得多。实时更新还是异步更新接下来还有一个架构层面的决定要做排行榜的分数更新走同步还是异步同步方案的话业务服务处理完用户得分后直接调Redis写入排行榜。实时性好用户操作完就能看到排名变化。但问题是排行榜写入和业务峰值耦合在一起业务高峰期Redis的写入压力也跟着上去。异步方案在游戏行业比较常见。业务服务把分数变更消息发到MQ排行榜服务消费消息后写入Redis。排行榜允许短暂延迟用户感知不到。MQ削峰以后Redis写入压力保持平稳。排行榜服务挂了也不影响核心业务流程。一般来说如果排行榜更新压力不高同步方案就足够了。当更新流量开始明显受到业务高峰影响时再考虑通过MQ做削峰解耦。至于MQ挂了怎么办排行榜是展示数据不是交易数据MQ保证最终一致性即可短暂中断不影响核心业务。大Key和热Key排行榜给Redis带来的隐患即使写入压力通过异步方案缓解了排行榜规模继续增长以后还会遇到Redis层面的两个问题。第一个是大Key。一个ZSet塞了上千万成员ZRANGE和ZREVRANK的操作延迟会逐步上升。更大的问题是大Key在RDB持久化和主从同步时会造成Redis短暂阻塞影响到同一实例上的其他业务。第二个是热Key。全服排行榜的读写全部集中在一个key上如果用的是Redis Cluster这个key只落在一个分片上那个分片承受的压力远高于其他分片。大Key的问题后面的分桶方案会一起解决。热Key的常见处理方式是读写分离加应用层缓存写操作走主节点Top N的读请求走从节点或者直接在应用层缓存。当一个ZSet扛不住的时候业务继续增长以后一个ZSet开始不够用了。从内存角度看这并不是最先遇到的瓶颈。单纯从容量的角度Redis通常还能放得下。但Redis还有AOF文件、复制buffer、内存碎片、其他业务数据实际占用远不止ZSet本身。真正先遇到的瓶颈通常不是内存而是单实例的写入吞吐能力。大量用户同时冲分时写入压力可能超出单个Redis实例的承载上限。另外在Redis Cluster中一个ZSet的key只落在一个分片上。如果这个分片还承载其他业务排行榜的流量会挤占那些业务的资源。按用户ID哈希分片看起来是一个不错的方案但排行榜最终需要计算全局排名跨分片聚合会非常复杂因此更常见的做法是按分数范围固定分桶。分桶的思路是把一个大的ZSet按分数区间拆成多个小的ZSet。每个桶是一个独立的key通过Cluster的哈希槽分散到不同节点。写入分散到多个分片突破了单实例的写入上限。热点也被分散到多个分片不再集中压垮单个节点。具体操作上更新分数时需要三步查到用户当前在哪个桶从旧桶里删除加入新桶。这三步用Lua脚本保证原子性。查Top N时从最高分桶开始取不够就从下一个桶补。查用户排名时先定位到用户所在的桶桶内排名加上高于该桶的所有桶的成员总数就是全局排名。那分桶边界怎么定如果分数分布比较均匀等距分桶就行。实际游戏里分数通常是金字塔分布大部分人在低分段少数人在高分段。这时候低分段的桶要分得细一些高分段的桶可以粗一些。腾讯公开分享过类似的排行榜架构实践感兴趣的可以去看InfoQ上的那篇分享。不过分桶之后系统复杂度会上升更新分数从一次操作变成了三次跨桶查询也需要应用层做聚合。只有当单个ZSet真正成为瓶颈时再考虑这种方案。架构不是越复杂越好而是刚好解决当前的问题。数据丢了怎么办排行榜数据存在Redis里Redis挂了数据就可能丢。数据安全需要分层来做。RDB快照是Redis自身的数据快照能力定期对内存数据做一次持久化。AOF持久化记录每一次写操作通常最多丢失最近一次fsync周期内的数据。Redis的持久化解决的是Redis自己的数据恢复问题。但排行榜的业务兜底最终要靠MySQL。每次分数变更异步写入MySQL这个消息可以复用前面异步更新方案里的同一条MQ消息。Redis挂了以后从MySQL重建排行榜。如果MySQL保存的是最终积分直接ZADD恢复。如果保存的是积分流水按流水顺序回放ZINCRBY。小结不同数据规模对应的方案不一样万级以下数据库加索引不需要Redis十万到百万级单个ZSet 同分排序 赛季管理千万级加上读写分离和缓存策略亿级分桶 集群 异步写入排行榜不同区域的精度要求也不一样。Top100必须实时准确100万名以后没人关心精确名次允许最终一致甚至异步刷新。这个思想在缓存设计和分桶方案里都有体现。很多架构并不是因为先进才被采用而是因为旧方案已经解决不了新的问题。排行榜没有标准答案也不是越复杂越好而是刚好适合当前业务。真正好的架构不是一步设计到位而是能够随着业务一起成长。