Redis排行榜架构设计:从一个ZSet到亿级排行榜
做排行榜,大多数时候一个ZSet就够了,ZADD更新分数,ZREVRANGE查排行榜,看起来不复杂。
但真正发版上线后,你会发现排行榜并没有一开始想的那么简单。随着用户越来越多、业务越来越复杂,同分排序、赛季切换、大Key、热Key、分桶……这些问题会一个接一个出现。
排行榜架构,从来不是一次设计出来的,而是随着业务规模不断演进出来的。
为什么排行榜都用ZSet
ZSet能同时支持按分数排序和快速查找,天然适合排行榜场景。底层通过跳表和哈希结构组合实现,写入和排名查询的复杂度都比较稳定。
常用的命令就三个:ZADD写入,ZRANGE和ZREVRANGE查列表,ZRANK和ZREVRANK查名次。这几个命令覆盖了排行榜绝大多数的操作需求。
如果排行榜更新不频繁、查询量也不高,数据库完全可以胜任。Redis并不是排行榜的前提,而是在数据规模和访问压力上来以后更合适的选择。数据库也能做排序,但当排行榜需要频繁更新、频繁查询排名时,维护索引和排序的成本会越来越高。
你必须解决的第一个问题:同分排序
排行榜上线以后,第一个碰到的问题往往不是性能,而是同分排序。
这不是Redis的bug,而是业务排序规则和Redis排序规则之间的冲突。运营人员希望同分的人,谁先达到谁排前面。Redis的规则是score一样时按member字典序排。
举个例子。张三和李四都是1000分,张三9点达到的,李四9点05达到的。如果member用的是user_10086和user_10087,Redis按字典序排出来的结果,和运营要求的完全对不上。
真正要解决的问题,是让score同时承载多个排序维度。分数作为主要排序依据,时间作为同分时的次要排序依据。常见的做法是利用score的小数部分编码时间信息,或者用位运算将分数和时间打包到一起。
不过这里有一个容易踩坑的地方。Redis的score使用IEEE754双精度浮点数,对于整数值最多只能精确表示约53位有效整数。如果把多个维度编码进score,需要综合考虑分数范围、时间精度以及浮点精度限制,在它们之间找到平衡。
还有一个容易忽略的细节。编码以后,就不能直接用ZINCRBY更新分数了。需要先取出旧score,解码拿到真实分数,更新分数部分,重新编码后用ZADD覆盖。
真正需要编码进score的,不一定是时间,也可能是其他业务定义的第二排序规则,例如创建顺序。score承载多维排序规则,时间只是最常见的第二排序维度。
赛季制:排行榜不能永远排下去
随着业务继续演进,运营人员开始要求做赛季制、周榜、月榜。排行榜不能一直累加下去,需要有周期性的重置。
这时候一个常见的误区是:为什么不直接DEL清空ZSet?
DEL执行的那一瞬间排行榜就没了。用户正在看排行榜,刷新一下就变成了空榜。正确的做法是切换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 |
| 我的排名 | 用户查看自己名次 | 分散、每人不同 | 直接查Redis,O(log N) |
| 附近排名 | 看排在我前后的人 | 组合查询 | ZREVRANK + ZREVRANGE |
Top 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万名以后没人关心精确名次,允许最终一致甚至异步刷新。这个思想在缓存设计和分桶方案里都有体现。
很多架构,并不是因为先进才被采用,而是因为旧方案已经解决不了新的问题。
排行榜没有标准答案,也不是越复杂越好,而是刚好适合当前业务。
真正好的架构,不是一步设计到位,而是能够随着业务一起成长。