排行榜服务
请使用电脑访问
https://myblog-ad4.pages.dev第 9 章 排行榜服务
本章介绍通用排行榜服务的设计。
本章主要内容:
9.1 排行榜的应用场景 |
9.1 排行榜的应用场景
排行榜在互联网产品中非常常见,它的核心作用是增强关键信息曝光,同时提高用户活跃度和参与度。
常见场景包括:
1. 游戏排行榜
游戏排行榜用于玩家之间互相比较,也可能和游戏道具奖励绑定。
游戏排行榜通常按分数排名,需要使用排序算法或其他比较算法计算名次。它可以激励玩家参与更多游戏,提高互动性。
2. 商品排行榜
电商平台中的商品排行榜可以帮助用户快速了解当前热门商品,辅助购物决策。
排行榜上的商品往往也会获得更高曝光和销售量,对促进销售有帮助。
3. 视频排行榜
视频网站可以根据点击量、分享量、评论量等指标生成热门视频排行榜。
这有助于视频推广和广告展示。
4. 社交排行榜
社交类应用中有大量排行榜,通常根据活跃度、关注度等指标生成。
例如:
明星粉丝数排行榜 |
9.2 排行榜技术的特点
互联网应用中的排行榜和现实生活中的静态榜单不同,它一般具有以下特点。
1. 曝光量大
一个成功的排行榜会被大量用户关注,是自带流量的功能场景。
这意味着排行榜服务会面临高并发读取请求。
2. 竞争激烈
为了获得靠前排名、流量优势或奖励,参与者会频繁更新自己的分数。
这要求排行榜服务能应对高并发写请求。
3. 实时变化
互联网应用中的排行榜通常不是一次性生成后固定不变。
参与者会随时关心自己的最新名次,所以排行榜需要实时排名变化。
4. 周期滚动
排行榜可能以月、周、天、小时甚至分钟为周期滚动排名。
技术实现上,需要支持周期切换和不同周期榜单隔离。
为什么不适合关系型数据库
首先,它对高并发读/写的支持较弱。
然后,排行榜通常按积分排序。如果使用关系型数据库,就需要基于积分字段执行:
SELECT ... ORDER BY score DESC; |
这种方式有两个明显问题。
1. 性能开销大
数据量很大时,排序会消耗大量系统资源和处理时间。
如果涉及多字段排序,或者排序字段类型复杂,查询效率会更低。
2. 磁盘 I/O 开销大
大量数据排序可能需要临时表或磁盘存储,排序过程无法完全在内存中完成。
这会带来大量磁盘读写,导致响应时间变长。
为什么选择 Redis ZSET
排行榜是按积分排序的,Redis 的 ZSET 正好适合。
ZSET 是有序集合:
Member:排行榜成员 |
Redis 会按照 Score 自动排序,目前 ZSET 是排行榜的首选数据结构。
本节总结
排行榜服务要重点考虑高并发读、高并发写、实时排名和周期滚动。
关系型数据库不适合承载大规模实时排序,Redis ZSET 因为自带按 Score 排序能力,天然适合实现排行榜。
9.3 使用 Redis 实现排行榜
选定 Redis ZSET 后,开始一步步分析如何实现一个支持高并发读写的排行榜服务。
9.3.1 使用 Redis ZSET
一个 ZSET 对象可以表示一个具体排行榜。
Key:排行榜名称 |
更新排行榜
可以使用 ZINCRBY 为某个成员增加积分。ZINCRBY key score member
例如 ID 为 999 的用户参加“跑步英雄”排行榜,当天跑了 10 千米:
ZINCRBY run_hero 10 999 |
ZINCRBY 的语义是:
如果 member=999 不存在: |
这正好符合排行榜积分累加的需求。
查询某个用户排名
ZREVRANK 用于按分数从高到低(降序)获取指定成员在集合中的排名(索引)。但返回值从 0 开始,所以真实排行榜名次需要加 1。
ZREVRANK key member [WITHSCORE] |
- key:有序集合的键名
- member:要查询排名的成员
- WITHSCORE:同时返回该成员的分数,或者额外执行
ZSCORE key member
注:ZRANK
读取排行榜列表
可以使用 ZREVRANGE 按 Score 从大到小读取:
ZREVRANGE key start top WITHSCORES |
含义如下:
| 参数 | 含义 |
|---|---|
key |
排行榜名称 |
start |
从第 start + 1 名开始 |
top |
到第 top + 1 名结束 |
WITHSCORES |
同时返回积分 |
读取完整“跑步英雄”排行榜:
ZREVRANGE run_hero 0 -1 WITHSCORES |
分页读取排行榜
如果每页展示 50 个用户,第 3 页的范围是:
start = (3 - 1) × 50 = 100 |
对应命令:
ZREVRANGE run_hero 100 149 WITHSCORES |
周期滚动排行榜
周期榜可以在 ZSET Key 中加入周期编号。
例如“每日跑步英雄”排行榜,首次上线时间为:
2023-01-01 00:00:00 |
当前周期可以计算为:
period = (currentTime - 1672502400) / 86400 + 1 |
其中 86400 是一天的秒数。
不同日期对应不同 ZSET Key:
2023-01-01:run_hero_0 |
分钟榜、小时榜、周榜也可以采用类似方式。
ZSET 方案还存在的问题
直接使用 ZSET 可以很快实现基础排行榜,但还要处理两个问题:
1. 更新非幂等 |
9.3.2 幂等更新
直接使用 ZINCRBY 的问题是更新不具备幂等性。
如果上游调用者更新排行榜时遇到网络超时,可能会发起重试。此时同一个积分更新可能被重复累计,影响排行榜公平性。
请求 ID 去重
可以要求上游调用者为每个更新请求提供一个分布式唯一请求 ID。
请求 ID 可以用 Redis String 保存。
Key 设计为 {ZSET Key}_{请求 ID} 这样可以避免不同排行榜之间的请求 ID 互相误判。使用SETNX命令可以实现在Redis中查询请求ID是否存在,如果不存在,则保存。
排行榜服务收到请求后,先判断请求 ID 是否已经处理过:
如果请求 ID 不存在: |
请求 ID 要设置过期时间
如果写并发很高,保存所有请求 ID 会占用大量空间。
而重复请求通常来自短时间内的上游重试,所以只需要保存最近一段时间的请求 ID,例如 10 分钟。
可以使用:
SET run_hero_reqID 0 EX 600 NX |
含义是:
只有 Key 不存在时才写入 |
SET 和 ZINCRBY 必须原子执行
如果先执行 SET,再执行 ZINCRBY,中间发生网络抖动,就可能出现:
请求 ID 已经保存 |
后续重试请求会因为请求 ID 已存在而被过滤,导致实际更新丢失。如下图所示:

解决方式是使用 Redis Lua 脚本,让 SET 和 ZINCRBY 原子执行。
示意如下:
local res = redis.call("SET", KEYS[1] .. "_" .. ARGV[1], 0, "EX", 3600, "NX") // 幂等性检查 |
执行脚本时传入:
KEYS = {排行榜名称, 用户 ID} |
需要注意,设置请求 ID 过期时间后,这种幂等不是严格永久幂等,而是过期时间内幂等。
但它已经能覆盖大部分重试场景,可以近似认为满足幂等性。
9.3.3 同积分排名处理
ZSET 对相同 Score 的 Member,会继续按 Member 字典序排序。
这可能违反用户直觉。
例如“每日跑步英雄”排行榜:
08:00 用户 1111 跑了 20 千米,积分 20,排名第一。 |
由于 "2222" 的字典序大于 "1111",ZSET 可能会让用户 2222 排在 1111 前面。
但从产品逻辑看,积分相同时,先达到该积分的人排名应该更高。
用 Score 小数部分记录时间
ZSET 的 Score 是浮点数。
可以让:
整数部分:真实积分 |
目标是:
积分越高,排名越高。 |
由于时间戳会随时间增大,直接使用当前时间不符合“先到者更大”的要求。
可以预设一个未来时间作为基准值,例如:
2050-01-01 00:00:00 |
然后计算:
update_time = 2524579200 - current_time |
先达到积分的用户 current_time 更小,因此 update_time 更大。
最终 Score 形态类似:
100.12345 |
其中:
100 表示用户积分 |
为什么不能继续使用 ZINCRBY
因为每次更新都要重写小数部分,ZINCRBY 只能累加 Score,无法单独重置小数部分。
所以更新流程要拆成几步:
1. 使用 ZSCORE 获取当前 Score |
示意 Lua 脚本如下:
local res = redis.call("SET", KEYS[1] .. "_" .. ARGV[1], 0, "EX", 3600, "NX") |
执行脚本时传入:
KEYS = {排行榜名称, 用户 ID} |
9.3.4 服务设计
在解决了幂等更新和同积分排名处理之后,就可以设计完整的排行榜服务。
本节的核心目标是:基于 Redis ZSET 实现一个排行榜服务,支持:
- 更新用户积分;
- 分页获取排行榜列表;
- 查询某个用户在排行榜中的名次和积分。
需要特别注意的是:
原书 9.3.4 中的
UpdateRanking示例代码使用了ZINCRBY,这只能算“基础版排行榜更新”。
如果承接 9.3.3 的“同积分时先达到该积分的人排名更高”方案,那么最终完整实现不能继续用ZINCRBY,而应该使用ZSCORE + 重新计算 Score + ZADD。
一、本节要解决的问题
排行榜服务主要解决三个问题:
| 问题 | 对应接口 | 核心 Redis 命令 |
|---|---|---|
| 用户积分变化后,如何更新排行榜 | UpdateRanking |
基础版用 ZINCRBY;完整方案用 ZSCORE + ZADD |
| 如何按积分倒序获取排行榜列表 | GetRankingList |
ZREVRANGE ... WITHSCORES |
| 如何查询某个用户当前排名和积分 | GetRank |
ZREVRANK、ZSCORE |
二、排行榜服务的三个核心接口
UpdateRanking 更新排行榜 |
它们对应的业务场景如下:
| 接口 | 作用 | 说明 |
|---|---|---|
UpdateRanking |
更新用户积分 | 用户积分变化时,更新 ZSET 中该用户的 Score |
GetRankingList |
获取排行榜列表 | 支持分页获取排行榜数据 |
GetRank |
获取某个用户排名 | 查询单个用户当前名次和积分 |
三、协议结构设计
首先是协议部分。排行榜服务提供了 3个核心接口,对应的 Thrift 协议如下:
注:Apache Thrift 是一种轻量级、跨语言的远程过程调用(RPC)框架和数据序列化协议。它通过接口定义语言(IDL)和代码生成引擎,让不同编程语言(如 Java、Python、C++、Go 等)编写的系统能够高效、无缝地互相通信。
核心功能:
- 打破语言壁垒(跨语言代码生成): 开发者只需编写一份
.thrift接口定义文件(IDL),Thrift 就能一键自动生成主流语言(Java、C++、Python、Go、Node.js 等)的客户端和服务端代码。让 Python 调用 Java 写的服务,就像调用本地的一个普通函数一样简单。- 极致的数据序列化: 它默认采用二进制格式(如
TCompactProtocol)对数据进行压缩和序列化。相比于常见的文本格式(如 JSON 或 XML),Thrift 打包的数据体积更小,CPU 解析速度极快。- 开箱即用的网络通信栈: Thrift 不仅仅是一个数据格式,它自带了完整的底层网络传输机制(阻塞/非阻塞 Socket)和服务器并发模型,开发者无需自己编写底层的网络 Socket 代码。
典型使用场景:
- 公司内部的微服务通信: 在大型架构中,内部服务之间往往需要进行高频、大数据量的互相调用。使用 Thrift 的二进制传输能大幅降低网络延迟和带宽开销,性能远超传统的 HTTP + RESTful API 模式。
- 多语言异构系统: 当公司的技术栈变得复杂时(例如:核心交易系统用 Java,AI 算法团队用 Python,高并发网关用 Go),Thrift 能够完美抹平语言差异,让这些系统顺畅协同工作。
- 大数据与高吞吐系统: 非常适合日志收集、流式数据处理和内部监控等对吞吐量要求极高的场景(例如 Apache HBase 和 Cassandra 的底层通信就曾重度依赖 Thrift)。
对外开放给第三方或者前端的接口,通常用 RESTful (HTTP + JSON);但在公司内部服务器之间,追求极致性能和跨语言调用时,Thrift(或 Google 开源的 gRPC)是业界最核心的标准答案。
1. 排行榜条目
namespace go ranking // Thrift 包名 |
注意:如果 Redis ZSET 的 Score 中混入了时间小数部分,那么接口返回的 Score 应该是真实积分,也就是 Redis Score 的整数部分,而不是完整的浮点数 Score。
2. 通用响应结构
// 响应内容 |
3. 更新排行榜请求和响应
// 更新排行榜请求 |
这里的 Score 表示:
本次新增积分 |
它不是用户最终积分。
例如用户当前真实积分是 100,本次请求的 Score = 10,则更新后用户真实积分应该变成 110。
4. 获取排行榜列表请求和响应
// 获取排行榜列表请求 |
5. 获取某个用户排名请求和响应
// 获取排行榜上某用户的名次请求 |
6. 排行榜服务定义
service RankingService { |
四、接口一:UpdateRanking 更新排行榜
1. 核心逻辑
UpdateRanking 用来更新某个用户在某个排行榜中的积分。
它要解决两个问题:
1. 如何更新用户积分? |
幂等问题通常使用:
ReqId + SET NX EX |
也就是用请求 ID 生成一个幂等 Key,再通过 Redis 的 SET key value NX EX seconds 判断这个请求是否已经处理过。
整体逻辑是:
SET NX 成功: |
为了保证“幂等判断”和“更新排行榜”两个动作是原子的,需要把它们放到同一个 Lua 脚本中执行。
2. 原书 9.3.4 中的基础版写法:ZINCRBY
原书 9.3.4 截图中的 UpdateRanking 代码使用了 ZINCRBY:
redis.call('ZINCRBY', KEYS[1], ARGV[2], KEYS[2]) |
这表示:
用户当前积分 += 本次新增积分 |
这种写法适合基础排行榜,因为简单直接。
但它没有处理 9.3.3 提到的“同积分排名”问题。
原因是:在 9.3.3 的方案中,ZSET 的 Score 不再是单纯的整数积分,而是:
真实积分 + 时间小数 |
每次更新时,都需要重新计算整个 Score,尤其是要重写小数部分的时间因子。
而 ZINCRBY 只能做累加,无法单独重置小数部分,所以不能满足完整方案的要求。
3. 原书 Go 代码实现:基础版 ZINCRBY(含注释)
注意:这段代码用于理解原书服务设计的基础实现,但如果要承接 9.3.3 的同积分排名方案,后面应该改成 ZSCORE + ZADD。
type RedisRankingService struct{} |
4. 承接 9.3.3 后的完整更新逻辑:ZSCORE + ZADD
如果要把 9.3.3 的“同积分排名处理”真正合入 UpdateRanking,更新流程应该是:
1. 使用 SET NX EX 做幂等判断; |
其中时间小数的设计思路是:
预设一个未来时间戳 futureTime |
先达到某个积分的人,currentTime 更小,因此 updateTime 更大。
由于排行榜使用 ZREVRANGE 或 ZREVRANK 按 Score 从高到低排序,所以同积分时,小数部分更大的用户会排在前面。
5. 推荐版 Go 代码实现:完整方案 ZSCORE + ZADD(含注释)
下面是更符合 9.3.3 思路的整理版实现。它不是截图中原封不动的代码,而是把上一节的同积分排名逻辑合入了服务设计。
type RedisRankingService struct{} |
6. UpdateRanking 小结
UpdateRanking 有两个版本需要区分:
| 版本 | 写法 | 是否处理同积分先后排名 | 说明 |
|---|---|---|---|
| 基础版 | ZINCRBY |
否 | 原书中的写法,适合普通排行榜 |
| 完整版 | ZSCORE + ZADD |
是 | 承接 9.3.3,需要重算完整 Score |
普通排行榜: |
五、接口二:GetRankingList 获取排行榜列表
1. 核心逻辑
GetRankingList 用来获取排行榜列表,支持分页。
由于排行榜通常是:
积分越高,排名越靠前 |
所以读取排行榜时使用倒序命令:
ZREVRANGE key start stop WITHSCORES |
参数含义:
| 参数 | 含义 |
|---|---|
key |
排行榜名称 |
start |
起始下标 |
stop |
结束下标 |
WITHSCORES |
返回成员的同时返回积分 |
2. 分页规则
PageNo = 0 |
表示不分页,获取完整排行榜。(正常页号是从1开始算的)
此时:
start = 0 |
Redis 中 top = -1 表示一直取到最后。
如果需要分页,则计算方式为:
start = (PageNo - 1) * PageSize |
例如:
PageNo = 1, PageSize = 10 |
3. 完整 Go 代码实现(含注释)
// 读取排行榜列表,支持分页 |
4. 注意点
注意点 1:如果 Score 包含时间小数,接口返回时要隐藏小数部分
承接 9.3.3 后,Redis 中的 Score 可能是:
100.12345 |
但业务方真正关心的是:
真实积分 = 100 |
所以 GetRankingList 返回 RankItem.Score 时,应该返回整数部分。
注意点 2:分页排名可能存在偏移问题
原书代码中返回名次时使用:
Rank: int64(index + 1) |
这意味着每一页都会从 1 开始排名。
例如:
第 1 页: |
如果业务希望返回的是全局排行榜名次,则应该加上 start 偏移:
Rank: start + int64(index) + 1 |
这样第二页的排名才会是:
11, 12, 13, ..., 20 |
所以要区分:
页内排名:index + 1 |
注意点 4:PageNo = 0 获取完整榜单有风险
虽然原书中 PageNo = 0 表示获取完整排行榜,但如果排行榜数据量很大,这种做法会一次性返回大量数据,容易造成:
Redis 查询压力大 |
真实系统中一般不建议对大榜单提供“获取完整榜单”的接口。
六、接口三:GetRank 获取某个用户的排名和积分
1. 核心逻辑
GetRank 用来查询某个用户在排行榜上的:
排名 |
需要使用两个 Redis 命令:
ZREVRANK key member |
其中:
| 命令 | 作用 |
|---|---|
ZREVRANK |
按 Score 从高到低,查询某个 member 的排名 |
ZSCORE |
查询某个 member 的积分 |
2. 为什么用 Lua 脚本
查询排名和积分时,理论上可以分别执行:
ZREVRANK |
但可以把这两个命令放在 Lua 脚本中执行。
这样做有两个好处:
- 减少一次 Redis 网络往返。
- 保证 rank 和 score 来自同一时刻的数据视图。
如果分两次请求 Redis,可能出现:
第一次 ZREVRANK 查到了排名; |
这样返回的排名和积分可能不一致。
使用 Lua 脚本可以避免这个问题。
3. 完整 Go 代码实现(含注释)
// 获取某用户的排名和积分 |
4. 注意点
注意点 1:Redis 排名从 0 开始
ZREVRANK 返回的是从 0 开始的排名。
例如:
Redis 返回 0 → 实际第 1 名 |
所以返回给业务方时,需要:
resp.Rank = rank + 1 |
注意点 2:用户不存在时返回值要注意
Lua 脚本中,如果用户不存在,则返回:
result = {-1, 0} |
表示:
rank = -1 |
但是 Go 代码中统一执行了:
resp.Rank = rank + 1 |
如果 rank = -1,那么最终返回:
resp.Rank = 0 |
所以这里需要注意业务语义。
如果希望用户不存在时仍然返回 -1,应该特殊处理:
if rank == -1 { |
注意点 3:如果 Score 包含时间小数,返回积分时要取整数部分
承接 9.3.3 后,Redis 中的 Score 可能是:
100.12345 |
其中:
100 是真实积分 |
所以接口返回的 Score 应该是:
math.floor(score) |
而不是完整浮点数。
七、为什么选择 Redis ZSET 实现排行榜
1. 自动排序
ZSET 会自动按照 Score 对 Member 排序。
排行榜天然需要按照积分排序,所以 ZSET 非常适合。
member = 用户 ID |
只要用户积分变化,ZSET 内部就会自动维护排序结果。
2. 高性能
Redis ZSET 底层使用跳表实现有序集合。
数据的插入、删除、查询操作的时间复杂度都为 O(logN)。
因此适合排行榜这种读写频率较高的场景。
3. 高并发
Redis服务器天然支持高并发读/写,而且主从模式架构能进一步满足高并发读取的需求,符合排行榜高度曝光的特点。
八、Redis ZSET 方案的缺点和注意点
1. 存储限制
Redis 主要使用内存存储数据。
虽然 Redis 支持数据持久化,但毕竟 Redis 是使用内存构建 ZSET 的,而内存是稀缺资源,当排行榜条目达到一定规模时,可能会出现内存不足的情况。
因此需要保证 Redis 所在机器有足够的内存空间。
2. 大 Key 问题
如果一个排行榜中的成员数量非常多,那么这个排行榜对应的 ZSET 就会变成大 Key。
大 Key 可能带来几个问题:
1. 单次操作耗时变长; |
例如:
一个全站用户积分榜,里面有几千万个用户 |
这种榜单就容易形成大 Key。
九、总结
1. 排行榜为什么适合用 Redis ZSET?
因为 ZSET 天然支持:
member + score |
并且会自动按照 score 排序。
排行榜正好需要:
用户 ID + 用户积分 |
所以 Redis ZSET 是非常合适的数据结构。
2. 如何更新排行榜积分?
需要分场景回答。
普通排行榜可以使用:
ZINCRBY key increment member |
但如果要处理“同积分时先达到该积分的人排名更高”,就不能继续使用 ZINCRBY。
完整方案应该是:
ZSCORE 读取旧 Score |
3. 如何保证排行榜更新幂等?
使用:
ReqId + SET NX EX |
先用请求 ID 生成幂等 Key:
rankingName_reqId |
然后执行:
SET key value NX EX seconds |
如果设置成功,说明是第一次请求,可以更新积分。
如果设置失败,说明是重复请求,不应该再次更新积分。
为了保证“幂等判断”和“积分更新”原子执行,需要放进 Lua 脚本中。
4. 如何分页获取排行榜?
使用:
ZREVRANGE key start stop WITHSCORES |
分页公式:
start = (PageNo - 1) * PageSize |
如果要返回全局排名,返回 Rank 时要加上 start 偏移:
Rank = start + index + 1 |
5. 如何查询某个用户的排名和积分?
使用:
ZREVRANK key member |
其中:
ZREVRANK 获取排名 |
由于 Redis 排名从 0 开始,所以对外展示时需要:
rank + 1 |
如果 Score 包含时间小数,返回积分时要取整数部分:
math.floor(score) |
6. 这个方案有什么缺点?
主要缺点有:
1. Redis 使用内存,数据量太大时成本高; |
9.3.5 关于大 Key 的问题
Redis 中不同数据结构对大 Key 的判断标准不同。
一般可参考:
- String:长度超过 10KB 可认为是大 Key
- ZSET / Hash / List / Set:成员数量超过 10000 可认为是大 Key
这个标准不是绝对的,各公司会根据成员数量和所占字节数制定自己的规则。
大 Key 之所以值得关注,是因为 Redis 是单线程工作模型,一个命令处理完后才会处理下一个命令。
大 Key 可能带来几个问题:
- 在读取大 Key 时,会占用更多的CPU资源和更大的网络带宽;
- 删除大 Key 耗时严重,可能阻塞线程,造成其他请求大量超时;
- 大 Key 有时也是热点 Key,热点 Key 会造成大量的写请求访问同一个Redis 实例,可能会影响稳定性。
假设有一个由5000万个用户参与的排行榜,即 ZSET 的成员数量最多为5000万个,那么这个排行榜在 Redis 中可以算作一个大Key。接下来,我们以这个例子逐一分析上面提到的 Redis 性能损耗问题。
1. 读取大 Key 的问题
产品上几乎不会一次性展示 5000 万人的完整排行榜。
对 5000 万个用户的排行榜都不可能有一次性展示全部用户排名的需求,而是要分页展示,如每页展示100个用户排名。
ZREVRANGE 读取连续 M 个用户排名的时间复杂度是:
O(logN + M) |
其中 N 是 ZSET 成员总数,M 是本次读取数量。
分页读取不会占用过多 CPU 和网络带宽。
2. 删除大 Key 的问题
Redis 4.0 引入了惰性删除 lazyfree——Redis在决定删除某个 Key 时,采用异步方式延迟释放此 Key 使用的内存,即将该操作交给单独的子线程BIO( Backgroup I/O )进行处理,避免 Redis 主线程在删除大Key时被长期占用而影响系统的稳定性。
因此删除大 Key 的问题已也同样不是问题了。
3. 热点 Key 的问题
如果排行榜激发大量用户参与,确实可能出现高并发更新请求都访问同一个 Redis 实例。
如果产品不要求更新实时展示,可以使用消息队列对高并发的更新请求进行削峰——将更新排行榜的事件发送到消息队列,排行榜服务按 Redis 实际处理能力消费并更新排行榜。
如果产品要求实时性,可以把一个大排行榜拆成多个子 ZSET。
例如拆成 10 个子排行榜:
用户 ID 尾号 0 → 第 1 个 ZSET |
这样就可以将高并发的更新排行榜请求打散到 10 个 Redis 实例中,防止出现单个 Redis 实例无法应对更新请求的风险。
代价是读取时需要合并:
要读取总榜前 100 名: |
读取排行榜产生了 10倍的读放大。所以拆分多少个子排行榜,需要根据读写请求量综合权衡。
对 ZSET 大 Key 的结论
即使是几千万用户参与的排行榜,使用 ZSET 也不一定会遇到大问题。
Redis理论上支持 ZSET 存储 2^32-1 个成员,即大约42亿个。如果内存足够,那么即使存储半个地球的用户也绰绰有余。
不能只因为一个 ZSET 排行榜里存了几千万个用户,就直接判断它一定会拖慢 Redis。从实践经验看,几千万成员规模的 ZSET,性能通常不会出现明显下降,仍然是可以接受的。
但对于几亿成员级别的 ZSET,这种需求极其少见,暂时无需担忧。
本章总结
大 Key 的风险不在于“数据量大”本身,而在于它可能导致读取、删除或高并发写入时影响 Redis 主线程。
对于超大排行榜来说,读取通常是分页查询,ZREVRANGE 只取少量数据,因此读压力一般可控。
删除大 Key 可以借助 Redis 4.0 之后的 lazyfree 异步释放内存,避免主线程长时间阻塞。
真正需要重点关注的是热点 Key 问题(量用户同时更新同一个排行榜):
如果产品不要求更新实时展示可以用消息队列削峰;
如果产品要求实时性,可以将总榜拆成多个子 ZSET 分摊写压力。
总体来说,几千万成员的 ZSET 排行榜并不一定会明显拖垮 Redis。
9.4 粗估排行榜的实现
如果由于担心大 Key 会对 Redis 的性能产生影响,公司 Redis 维护团队强制限制单个 ZSET 的大小,例如不能超过 10000 个成员,就无法用一个 ZSET 实现百万、千万人的排行榜。
这时可以换一种产品和技术思路:
前 N 名使用 ZSET 做精确排名 |
现实中,一个几千万人参与的排行榜,尾部用户通常不会在意自己是第 80001 名还是第 80002 名。
他们更关心大致名次,只有进入头部后才会关注精确排名和名次差距。
9.4.1 线段树
如果放宽对排名精度的要求,那么可以通过分段思想来释放排行榜所需的大量存储空间:把积分按固定范围分成多个等长的分段,每个分段都保存当前积分处于此分段的用户数量。
如图,假设积分上限为 500,分成 5 段:

第一个分段:[1,100] |
假设某用户目前积分为150,分段粗估此用户排名的过程如下:
1. 找到积分 150 所在分段:[101,200] |
这里的“粗估”来自一个假设:
每个分段内用户积分均匀分布。 |
分段越短,排名精度越高;但分段数也越多。
为什么需要线段树
如果分段很多,从最高分段逐个遍历,时间复杂度是 O(N),这会降低查询效率。
线段树适合这个场景。
线段树用二叉树维护多个分段,可以在 O(logN) 时间内完成数据修改和区间查询。
线段树结构
线段树的根节点表示的数据区间覆盖了整个数据范围,每个节点表示的数据区间都是它的子节点所表示的数据区间的合集。
以积分上限 400、4 个分段为例:

当给定排行榜的积分上限值和为 2 的幂次方的分段数目时,使用 Go 语言实现的创建线段树的代码如下:
// 线段树节点结构 |
9.4.2 粗估排名的实现
使用线段树实现排行榜排名,需要支持的两个操作分别是查询排名和更新排名。
接下来我们以下图中使用线段树构建的排行榜为例:

查询排名
线段树本身不存储每个用户的真实积分,所以查询排名时需要传入用户当前积分。
假设用户积分为 220,查询流程如下:
1. 根节点发现220在左子节点[1,400]范围内,于是遍历左子节点。 |
查询排名时间复杂度: O(logN)
对应的 Go 语言代码如下:
// 在线段树中查询某积分对应的排名 |
更新排名
在更新用户排名时,需要使用更新前的积分score1和更新后的最新积分score2。
流程如下:
1. 从根节点遍历 score1,经过的所有节点 count 减 1。 |
所以一次更新用户排名由删除(先执行)和插入(后执行)两个操作组成,时间复杂度也是 O(logN)
其Go语言代码如下:
// 遍历线段树,并修改遍历过的节点 |
线段树排行榜的 Redis Hash 存储方案
前面已经说明了线段树如何用于“查询排名”和“更新排名”。不过还存在一个重要问题:
线段树方案把区间内的用户数量存在线段树各节点中,但线段树本身是内存型数据结构,无法被多个服务实例共享。
如果排行榜服务只能由单个服务实例提供,那么无论从高可用、高性能,还是可扩展性来看,都很难接受。
因此需要把线段树拆成两部分来看:
- 线段树结构:固定不变,可以保存在每个服务实例的本地内存中。
- 节点用户数量:会变化,需要放到共享存储中。
线段树结构固定,真正会变化的只是每个节点对应区间内的用户数量。因此,线段树结构可以每个服务实例本地保存;节点人数会变化,必须放到 Redis 这种共享存储里。这样排行榜服务实例本身不再保存独占状态,就可以通过增加实例来水平扩展。 (这里说的服务实例是指排行榜服务的某一个运行实例)
使用 Redis Hash 保存线段树节点数量
使用 Redis Hash 可以很自然地保存线段树节点数据:
一个 Redis Hash 对象表示一棵线段树; |
所以,上例的线段树结构在Redis中会被保存为如下图所示的形式。

这样一来,排行榜服务就可以部署多个实例:
每个服务实例: |
查询用户排名
查询用户排名时,服务实例先使用本地线段树判断本次需要读取哪些节点,然后向 Redis 发送 HMGET 命令,一次性获取多个节点的用户数量。
例如查询积分为 220 的用户排名,需要读取:
201-300:用户所在的叶子分段 |
查询结果分成两部分:
biggerCounter:积分明确大于 score 的用户数量 |
由于线段树只记录“分段人数”,并不知道分段内每个用户的具体积分,所以只能假设该分段内用户积分均匀分布,然后估算该分段内有多少用户积分高于当前 score。
估算公式为:
more = (segment.upper - score) * segCounter / (segment.upper - segment.lower) |
最终粗估排名为:
rank = biggerCounter + more |
更新用户积分
更新用户积分时,需要同时处理旧积分和新积分。
假设用户积分从 score1 更新为 score2:
1. 找到 score1 在线段树中经过的所有节点,这些节点的用户数量都要减 1。 |
例如用户积分从 220 更新到 750:
score1 = 220,需要对以下 Field 的 Value 减 1: |
score2 = 750,需要对以下 Field 的 Value 加 1: |
这样就能保证线段树中所有相关节点的用户数量都被同步更新。
关键代码
// 获取线段树中需要更新的节点,即遍历过的节点 |
线段树方案的优点
线段树方案只用一个成员数量很少的 Redis Hash,就能实现海量用户的粗估排名。无视参与者人数,节约了大量存储空间,可谓是“四两拨千斤”。
它不需要像 Redis ZSET 那样保存每个用户的具体数据,而只保存每个积分区间内的用户数量,因此存储空间非常小。
这种方式的核心价值在于:
用少量区间统计数据,估算海量用户的排名。 |
线段树方案的缺点
不过,线段树方案并不是完整排行榜方案。因为它只保存每个分段的人数,不保存每个用户的具体积分,所以存在以下缺点。
1. 排名不精确
线段树方案在计算排名时,会假设一个分段内的用户积分是均匀分布的。
例如某个分段是 201-300,里面有 31 个用户。线段树只知道这个区间里有 31 人,但不知道这些人的积分到底是集中在 201 附近,还是集中在 300 附近。
因此查询某个用户排名时,只能根据均匀分布进行估算,最终得到的是粗略排名,不是精确排名。
2. 不支持获取排行榜列表
线段树只记录每个分段有多少用户,并不知道:
1. 哪些用户参与了排行榜; |
所以它可以高效支持:
已知某个用户的积分,查询该用户的大致排名。 |
但不支持:
获取排行榜 Top N 列表。 |
适用场景
如果单独使用线段树方案,它更适合“全民参与,并且积分由其他系统维护”的场景。
例如 QQ 等级这类业务:
用户的太阳、月亮、星星数量由专门的等级服务维护; |
如果要对 QQ 的全部用户按照等级排名,并且给每个用户展示自己的大致排名,那么线段树方案就比较适合。
但如果业务要求展示完整排行榜列表,或者要求排名必须精确,就不能单独依赖线段树方案。
本节总结
粗估排行榜的核心思想是:不保存所有用户的精确排名,而是按积分分段保存人数。
线段树可以把分段统计从线性遍历优化为 O(logN) 查询和更新。
为了支持多服务实例共享状态,可以让服务本地保存固定线段树结构,把每个节点的人数存入 Redis Hash。
一句话总结:线段树用“分段人数”换取极低存储成本,适合海量用户只查询大致排名的场景。
9.5 精确排名与粗估排名结合
对于全民参与的超长排行榜,通常没必要为所有用户都展示完整的排行榜列表。
产品上一般可以接受:
1. 只展示前 N 名用户的详细排行榜列表。 |
例如排行榜只展示前 10000 名用户的列表。对于 10000 名以后的用户,不展示其前后分别有哪些人,只告诉他当前名次是多少即可。
因此,超长排行榜可以采用:
ZSET 精确排名 + 线段树粗估排名 |
二者分工如下:
ZSET: |
不过,线段树本身无法存储用户积分,所以还需要一个额外系统来维护用户积分,例如第 8 章介绍的计数服务。
整体架构
计数服务的作用是存储排行榜上的用户积分。
当用户积分更新时,流程不是直接更新排行榜服务,而是:
1. 先更新计数服务中的用户积分。 |

这样,计数服务负责维护用户的真实积分,排行榜服务负责根据积分变化构建排名能力。
整体上可以理解为:
计数服务: |
用户积分更新流程
从用户积分更新,到排行榜各处变更生效,完整流程如下。
1. 用户发起积分更新请求
用户向计数服务发起积分更新请求。
请求中包含:
用户 ID |
2. 计数服务更新用户积分
计数服务收到请求后,本质上是对一个 Redis String 对象执行自增操作。
这个 String 的 Key 可以设计为:
排行榜名称_用户ID |
计数服务对该 Key 的 Value 加上本次积分增量。
处理成功后,计数服务先响应用户,然后将本次积分变更作为事件发送到消息队列。
对应的消息主题为:
change_counter_trigger |
消息事件结构如下:
type ChangeCounterTriggerEvent struct { |
这里各字段含义如下:
KeyPrefix: |
3. 排行榜服务消费积分变更事件
排行榜服务消费 change_counter_trigger 主题中的消息。
如果消息中的 KeyPrefix 字段是排行榜名称,则处理该消息。
如果不是排行榜相关的 KeyPrefix,则丢弃该消息。
4. 排行榜服务更新 ZSET
排行榜服务处理消息时,会使用事件中的:
KeyPrefix |
请求 Redis 执行 ZADD 命令,更新 ZSET。
可以理解为:
ZSET Key:排行榜名称,也就是 KeyPrefix |
也就是把该用户的最新积分写入 ZSET,用于维护头部用户的精确排名。
5. 排行榜服务更新线段树
线段树更新需要知道用户的旧积分和新积分。
事件中已经包含新积分 Score 和本次增量 DeltaScore,所以旧积分可以计算出来:
PScore = Score - DeltaScore |
其中:
PScore: |
然后排行榜服务使用 PScore 和 Score 更新线段树:
1. 根据 PScore 找到旧积分经过的线段树节点,将这些节点人数减 1。 |
这样,线段树中的分段人数也能随着用户积分变化而持续更新。
6. 定时裁剪 ZSET
如果每个发生积分变化的用户都写入 ZSET,那么 ZSET 会不断变大。
但是这个方案中,ZSET 只负责维护前 10000 名用户的精确排名,所以没必要永久保存所有用户。
因此,需要一个定时任务周期性裁剪 ZSET:
定期删除 ZSET 中前 10000 名之外的用户数据。 |
这样可以防止 ZSET 长度无限增长。
查询排行榜列表
如果用户请求的是排行榜列表,排行榜服务只需要读取 ZSET 即可。
因为排行榜列表只展示前 10000 名用户,而这些头部用户的数据都由 ZSET 精确维护。
所以查询排行榜列表的逻辑很简单:
1. 用户请求排行榜列表。 |
查询用户排名
如果用户请求的是自己的排名,排行榜服务需要分情况处理。
1. 用户发起查询排名请求
请求中包含:
排行榜名称 |
2. 先查询 ZSET
排行榜服务先查询 ZSET。
如果用户 ID 存在于 ZSET 中,说明该用户属于头部用户,排行榜服务可以直接返回:
用户积分 |
这种情况下,不需要再查询线段树。
3. 如果 ZSET 中查不到用户
如果在 ZSET 中查不到用户 ID,说明该用户可能排名比较靠后,已经不在前 10000 名的精确排行榜里。
此时,排行榜服务需要从计数服务中获取该用户积分。
4. 根据用户积分查询线段树
如果计数服务返回非零积分,说明该用户确实参与了排行榜。
排行榜服务再使用该积分查询线段树,得到用户的粗估排名。
最后返回:
用户积分 |
完整流程可以概括为:
1. 用户请求查询自己的排名。 |

方案优势
精确排名与粗估排名结合后,可以同时兼顾准确性、存储成本和性能。
1. 保证头部用户积分和排名准确
排行榜中被高频访问的用户,大概率是头部用户。
而头部用户数据由 ZSET 维护,所以可以保证排行榜前 10000 名用户的积分和排名是准确的。
2. 用较低存储成本满足尾部用户查询需求
尾部用户不需要展示完整排行榜列表,只需要知道自己的大致名次。
因此,不需要把所有用户都放进 ZSET。
对于 10000 名以后的用户,可以通过计数服务拿到积分,再通过线段树计算粗估排名。
这样可以用较少的存储成本,满足海量尾部用户的排名查询需求。
3. 支持高并发读写排行榜
这个方案把不同类型的压力拆开了:
积分写入: |
这样既能支持高并发更新积分,也能支持高并发查询排行榜。
需要注意的问题:积分一致性
这个方案中,用户积分会同时存在两个地方:
1. 计数服务中。 |
因此必须保证两边的积分数据一致。
尤其不能出现这种情况:
从计数服务查询到的积分,低于从排行榜服务 ZSET 查询到的积分。 |
这不符合产品预期。
如果出现这种情况,可以在排行榜服务中启动一个定时任务进行校准:
1. 定期查询 ZSET 中每个用户的积分。 |
也就是说,在这个方案中:
计数服务是积分数据的事实来源。 |
如果两者不一致,应当以计数服务为准进行修正。
注意:定时任务不是为了保证“立刻一致”,而是为了防止不一致长期存在。
所以它解决的是:由于消息丢失、消费失败、更新失败导致的长期脏数据问题。
而不是解决:用户刚更新积分后,排行榜立刻 100% 一致的问题。
本节总结
ZSET 和线段树各有优缺点。
ZSET 可以提供精确排名和榜单列表,但超长排行榜会占用较多存储空间。
线段树存储空间极小,但只能提供粗估排名,不能展示排行榜列表。
结合方案用 ZSET 维护前 N 名精确排名,用线段树维护尾部用户粗估排名,再用计数服务保存用户积分,是超长排行榜的更完整方案。
一句话总结:超长排行榜可以让 ZSET 负责“看得见的头部”,让线段树负责“看不见但要能查的尾部”。
9.6 本章小结
本章主要介绍了通用排行榜服务的几种设计方案,以及它们各自适合的场景。
Redis 的 ZSET 是实现排行榜最常见的方案。它底层基于跳跃表实现,天然支持按照积分自动排序,同时也支持高效的数据查询和数据更新。因此,在需要维护精确排名、获取排行榜列表、查询用户名次等场景下,ZSET 是一个非常合适的选择。
不过,使用 ZSET 实现排行榜时,可能会产生大 Key。但这并不意味着一定会带来严重问题,因为 ZSET 的典型使用场景本身就是存储大量有序数据,在常规的读写和分页查询场景下,它的性能表现通常是可以接受的。真正需要关注的是:排行榜规模是否过大、是否需要保存全量用户、存储成本是否可控,以及是否存在极端热点访问。
除了 ZSET,线段树也是一种实现排行榜的方案。
线段树不存储每个用户的具体积分,而是把积分范围划分成多个分段,并记录每个分段内的用户数量。这样就可以根据某个用户的积分,快速估算出该用户的大致排名。
线段树结构固定,可以每个服务实例本地保存;线段树节点存的人数会变化,必须放到 Redis 这种共享存储里。这样排行榜服务实例本身不再保存独占状态,就可以通过增加实例来水平扩展。可以使用 Redis Hash 保存这些节点数据:
Redis Hash: |
这样一来,排行榜服务可以部署多个实例,每个实例本地维护相同的线段树结构,而节点人数统一读写 Redis Hash。由于线段树只保存分段统计信息,不保存每个用户的具体积分,所以存储空间非常小,在海量用户排名场景下可以达到“四两拨千斤”的效果。
但是,线段树也有明显限制。它只能提供粗估排名,不能保证排名完全精确;并且它不知道具体有哪些用户参与排行榜,也不知道每个用户的具体积分,因此无法支持获取排行榜列表。
所以,ZSET 和线段树的能力可以概括为:
ZSET: |
在超长排行榜场景下,可以把二者结合起来使用:
ZSET: |
这种组合方案比较符合实际产品需求。因为排行榜中的重点用户通常排名靠前,产品一般也只需要展示前 N 名用户的详细列表;而对于排名靠后的大量用户,只需要告诉他们自己的大致名次即可。
因此,最终方案可以理解为:
头部用户: |
需要注意的是,由于用户积分既存在计数服务中,又会同步到排行榜服务维护的 ZSET 中,因此必须关注数据一致性问题。如果两边积分不一致,应以计数服务中的积分为准,并通过定时校准任务修正 ZSET 中的数据。
总的来说,排行榜没有唯一固定的实现方式。不同公司的排行榜服务,在用户规模、高并发压力、性能要求、存储成本和功能复杂度上都可能不同。因此,实际设计时应该根据具体业务场景选择合适方案:简单精确排行榜可以直接使用 ZSET;海量用户粗估排名可以使用线段树;如果既要头部精确排名,又要兼顾尾部海量用户查询,则可以采用 ZSET + 线段树 的组合方案。