第 9 章 排行榜服务

本章介绍通用排行榜服务的设计。

本章主要内容:

9.1 排行榜的应用场景
9.2 排行榜技术的特点
9.3 最常见的Redis ZSET排行榜方案
9.4 粗估排行榜的实现
9.5 精确排名与粗估排名结合的超长排行榜
9.6 本章小结

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:排行榜成员
Score:成员积分

Redis 会按照 Score 自动排序,目前 ZSET 是排行榜的首选数据结构。

本节总结

排行榜服务要重点考虑高并发读、高并发写、实时排名和周期滚动。

关系型数据库不适合承载大规模实时排序,Redis ZSET 因为自带按 Score 排序能力,天然适合实现排行榜。


9.3 使用 Redis 实现排行榜

选定 Redis ZSET 后,开始一步步分析如何实现一个支持高并发读写的排行榜服务。

9.3.1 使用 Redis ZSET

一个 ZSET 对象可以表示一个具体排行榜。

Key:排行榜名称
Member:用户 ID,也就是排行实体
Score:用户积分,用于排序

更新排行榜

可以使用 ZINCRBY 为某个成员增加积分。ZINCRBY key score member

例如 ID 为 999 的用户参加“跑步英雄”排行榜,当天跑了 10 千米:

ZINCRBY run_hero 10 999

ZINCRBY 的语义是:

如果 member=999 不存在:
加入 ZSET,并设置 score=10

如果 member=999 已存在:
在原有 score 上增加 10

这正好符合排行榜积分累加的需求。

查询某个用户排名

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

start=0表示从Score最高的Member开始查询,top=-l表示查询整个ZSET对象

分页读取排行榜

如果每页展示 50 个用户,第 3 页的范围是:

start = (3 - 1) × 50 = 100
top = 100 + 50 - 1 = 149

公式总结:start=(当前页数-1)x页大小,top=start+页大小-1

对应命令:

ZREVRANGE run_hero 100 149 WITHSCORES

周期滚动排行榜

周期榜可以在 ZSET Key 中加入周期编号。

例如“每日跑步英雄”排行榜,首次上线时间为:

2023-01-01 00:00:00
baseTime = 1672502400

当前周期可以计算为:

period = (currentTime - 1672502400) / 86400 + 1

其中 86400 是一天的秒数。

不同日期对应不同 ZSET Key:

2023-01-01:run_hero_0
2023-01-03:run_hero_2
2023-02-05:run_hero_35

分钟榜、小时榜、周榜也可以采用类似方式。

ZSET 方案还存在的问题

直接使用 ZSET 可以很快实现基础排行榜,但还要处理两个问题:

1. 更新非幂等
2. 同积分排名不符合用户直觉

9.3.2 幂等更新

直接使用 ZINCRBY 的问题是更新不具备幂等性。

如果上游调用者更新排行榜时遇到网络超时,可能会发起重试。此时同一个积分更新可能被重复累计,影响排行榜公平性。

请求 ID 去重

可以要求上游调用者为每个更新请求提供一个分布式唯一请求 ID。

请求 ID 可以用 Redis String 保存。

Key 设计为 {ZSET Key}_{请求 ID} 这样可以避免不同排行榜之间的请求 ID 互相误判。使用SETNX命令可以实现在Redis中查询请求ID是否存在,如果不存在,则保存。

排行榜服务收到请求后,先判断请求 ID 是否已经处理过:

如果请求 ID 不存在:
执行排行榜更新
保存请求 ID

如果请求 ID 已存在:
认为请求已处理,直接返回成功

请求 ID 要设置过期时间

如果写并发很高,保存所有请求 ID 会占用大量空间。

而重复请求通常来自短时间内的上游重试,所以只需要保存最近一段时间的请求 ID,例如 10 分钟。

可以使用:

SET run_hero_reqID 0 EX 600 NX

含义是:

只有 Key 不存在时才写入
过期时间 600 秒

SET 和 ZINCRBY 必须原子执行

如果先执行 SET,再执行 ZINCRBY,中间发生网络抖动,就可能出现:

请求 ID 已经保存
但排行榜积分还没有更新

后续重试请求会因为请求 ID 已存在而被过滤,导致实际更新丢失。如下图所示:

image-20260527215246297

解决方式是使用 Redis Lua 脚本,让 SETZINCRBY 原子执行。

示意如下:

local res = redis.call("SET", KEYS[1] .. "_" .. ARGV[1], 0, "EX", 3600, "NX")   // 幂等性检查

if res ~= false then
redis.call("ZINCRBY", KEYS[1], ARGV[2], KEYS[2])
end

return 0

执行脚本时传入:

KEYS = {排行榜名称, 用户 ID}
ARGV = {请求 ID, 新增积分值}

需要注意,设置请求 ID 过期时间后,这种幂等不是严格永久幂等,而是过期时间内幂等。

但它已经能覆盖大部分重试场景,可以近似认为满足幂等性。

9.3.3 同积分排名处理

ZSET 对相同 Score 的 Member,会继续按 Member 字典序排序。

这可能违反用户直觉。

例如“每日跑步英雄”排行榜:

08:00 用户 1111 跑了 20 千米,积分 20,排名第一。
12:00 用户 2222 也跑了 20 千米,积分 20。

由于 "2222" 的字典序大于 "1111",ZSET 可能会让用户 2222 排在 1111 前面。

从产品逻辑看,积分相同时,先达到该积分的人排名应该更高

用 Score 小数部分记录时间

ZSET 的 Score 是浮点数。

可以让:

整数部分:真实积分
小数部分:更新时间信息

目标是:

积分越高,排名越高。
积分相同,越早达到该积分,排名越高。

由于时间戳会随时间增大,直接使用当前时间不符合“先到者更大”的要求。

可以预设一个未来时间作为基准值,例如:

2050-01-01 00:00:00
时间戳:2524579200

然后计算:

update_time = 2524579200 - current_time

先达到积分的用户 current_time 更小,因此 update_time 更大。

最终 Score 形态类似:

100.12345

其中:

100     表示用户积分
0.12345 表示更新时间戳转换后的小数部分

为什么不能继续使用 ZINCRBY

因为每次更新都要重写小数部分,ZINCRBY 只能累加 Score,无法单独重置小数部分。

所以更新流程要拆成几步:

1. 使用 ZSCORE 获取当前 Score
2. 截取整数部分,得到真实积分
3. 加上本次新增积分
4. 计算新的更新时间小数部分
5. 使用 ZADD 写回新的 Score (ZADD 就是成员存在则更新分数,不存在则新增)

示意 Lua 脚本如下:

local res = redis.call("SET", KEYS[1] .. "_" .. ARGV[1], 0, "EX", 3600, "NX")

if res ~= false then
local current_score = redis.call("ZSCORE", KEYS[1], KEYS[2])
local integer = 0

if current_score ~= false then
integer = math.floor(current_score)
end

integer = integer + ARGV[2]

local timestamp = "0." .. ARGV[3]
local score = integer + timestamp

redis.call("ZADD", KEYS[1], score, KEYS[2])
end

return 0

执行脚本时传入:

KEYS = {排行榜名称, 用户 ID}
ARGV = {请求 ID, 新增积分值, 更新时间戳}

9.3.4 服务设计

在解决了幂等更新同积分排名处理之后,就可以设计完整的排行榜服务。

本节的核心目标是:基于 Redis ZSET 实现一个排行榜服务,支持:

  1. 更新用户积分;
  2. 分页获取排行榜列表;
  3. 查询某个用户在排行榜中的名次和积分。

需要特别注意的是:

原书 9.3.4 中的 UpdateRanking 示例代码使用了 ZINCRBY,这只能算“基础版排行榜更新”。
如果承接 9.3.3 的“同积分时先达到该积分的人排名更高”方案,那么最终完整实现不能继续用 ZINCRBY,而应该使用 ZSCORE + 重新计算 Score + ZADD


一、本节要解决的问题

排行榜服务主要解决三个问题:

问题 对应接口 核心 Redis 命令
用户积分变化后,如何更新排行榜 UpdateRanking 基础版用 ZINCRBY;完整方案用 ZSCORE + ZADD
如何按积分倒序获取排行榜列表 GetRankingList ZREVRANGE ... WITHSCORES
如何查询某个用户当前排名和积分 GetRank ZREVRANKZSCORE

二、排行榜服务的三个核心接口

UpdateRanking     更新排行榜
GetRankingList 获取排行榜列表
GetRank 获取排行榜上某用户的名次

它们对应的业务场景如下:

接口 作用 说明
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 包名

// 排行榜的单个条目
struct RankItem {
1: required i64 Rank, // 用户名次
2: required i64 UserId, // 用户 ID
3: required i64 Score, // 用户真实积分
}

注意:如果 Redis ZSET 的 Score 中混入了时间小数部分,那么接口返回的 Score 应该是真实积分,也就是 Redis Score 的整数部分,而不是完整的浮点数 Score。


2. 通用响应结构

// 响应内容
struct BaseResponse {
1: required i32 Code, // 是否响应成功
2: string ErrMsg, // 错误原因
}

3. 更新排行榜请求和响应

// 更新排行榜请求
struct UpdateRankingReq {
1: required string Name, // 排行榜名称
2: required i64 UserId,
3: required i64 Score,
4: required i64 ReqId, // 请求 ID
}

// 更新排行榜响应
struct UpdateRankingResp {
1: BaseResponse baseResp,
}

这里的 Score 表示:

本次新增积分

它不是用户最终积分。

例如用户当前真实积分是 100,本次请求的 Score = 10,则更新后用户真实积分应该变成 110。


4. 获取排行榜列表请求和响应

// 获取排行榜列表请求
struct GetRankingListReq {
1: required string Name, // 排行榜名称
2: i64 PageNo, // 分页请求,请求第几页数据
3: i64 PageSize, // 分页请求,一页展示排行榜的几个成员
}

// 获取排行榜列表响应
struct GetRankingListResp {
1: BaseResponse baseResp,
2: list<RankItem> rankingList,
}

5. 获取某个用户排名请求和响应

// 获取排行榜上某用户的名次请求
struct GetRankReq {
1: required string Name, // 排行榜名称
2: required i64 UserId,
}

// 获取排行榜上某用户的名次响应
struct GetRankResp {
1: BaseResponse baseResp,
2: required i64 Rank,
3: required i64 Score,
}

6. 排行榜服务定义

service RankingService {
UpdateRankingResp UpdateRanking(1: UpdateRankingReq req) // 更新排行榜
GetRankingListResp GetRankingList(1: GetRankingListReq req) // 获取排行榜列表
GetRankResp GetRank(1: GetRankReq req) // 获取排行榜上某用户的名次
}

四、接口一:UpdateRanking 更新排行榜

1. 核心逻辑

UpdateRanking 用来更新某个用户在某个排行榜中的积分。

它要解决两个问题:

1. 如何更新用户积分?
2. 如何避免重复请求导致积分重复增加?

幂等问题通常使用:

ReqId + SET NX EX

也就是用请求 ID 生成一个幂等 Key,再通过 Redis 的 SET key value NX EX seconds 判断这个请求是否已经处理过。

整体逻辑是:

SET NX 成功:
说明这是第一次请求,可以更新积分。

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{}

// 更新排行榜
func (r *RedisRankingService) UpdateRanking(
ctx context.Context,
req *ranking.UpdateRankingReq,
) (resp *ranking.UpdateRankingResp, err error) {

// 创建幂等更新排行榜的 Lua 脚本
//
// KEYS[1] = 排行榜名称
// KEYS[2] = 用户 ID
//
// ARGV[1] = 请求 ID
// ARGV[2] = 待更新积分,也就是本次要增加的分数
//
// 这段脚本的目标是:
// 1. 通过 SET NX 判断请求是否重复;
// 2. 如果不是重复请求,则执行 ZINCRBY 更新积分。
script := `
local res = redis.call('SET', KEYS[1] .. '_' .. ARGV[1], '0', 'EX', '50', 'NX')
if res ~= false then
redis.call('ZINCRBY', KEYS[1], ARGV[2], KEYS[2])
end
return 0
`

// 运行脚本
//
// KEYS = {排行榜名称, 用户 ID}
// ARGV = {请求 ID, 待更新积分}
result := redisClient.Eval(
ctx,
script,
[]string{
req.GetName(),
strconv.FormatInt(req.GetUserId(), 10),
},
[]string{
strconv.FormatInt(req.GetReqId(), 10),
strconv.FormatInt(req.GetScore(), 10),
},
)

err = result.Err()

resp = ranking.NewUpdateRankingResp()
resp.BaseResp = ranking.NewBaseResponse()

if err != nil {
resp.BaseResp.Code = -1
resp.BaseResp.ErrMsg = err.Error()
}

return
}

4. 承接 9.3.3 后的完整更新逻辑:ZSCORE + ZADD

如果要把 9.3.3 的“同积分排名处理”真正合入 UpdateRanking,更新流程应该是:

1. 使用 SET NX EX 做幂等判断;
2. 使用 ZSCORE 获取用户当前 Score;
3. 使用 math.floor 取出整数部分,得到当前真实积分;
4. 当前真实积分 + 本次新增积分,得到新的真实积分;
5. 根据当前时间计算时间小数部分;
6. 拼出新的完整 Score;
7. 使用 ZADD 写回 ZSET。

其中时间小数的设计思路是:

预设一个未来时间戳 futureTime
updateTime = futureTime - currentTime

先达到某个积分的人,currentTime 更小,因此 updateTime 更大。

由于排行榜使用 ZREVRANGEZREVRANK 按 Score 从高到低排序,所以同积分时,小数部分更大的用户会排在前面。


5. 推荐版 Go 代码实现:完整方案 ZSCORE + ZADD(含注释)

下面是更符合 9.3.3 思路的整理版实现。它不是截图中原封不动的代码,而是把上一节的同积分排名逻辑合入了服务设计。

type RedisRankingService struct{}

// 更新排行榜:完整方案
//
// 目标:
// 1. 使用 ReqId 保证幂等;
// 2. 使用 ZSCORE 读取当前 Score;
// 3. 取整数部分作为真实积分;
// 4. 加上本次新增积分;
// 5. 重新计算时间小数部分;
// 6. 使用 ZADD 写回完整 Score。
func (r *RedisRankingService) UpdateRanking(
ctx context.Context,
req *ranking.UpdateRankingReq, // 之前 Thrift 定义的
) (resp *ranking.UpdateRankingResp, err error) {

// 假设调用方或服务端已经计算出 updateTime。
//
// updateTime 的含义:
// futureTime - currentTime
//
// 它会作为 Score 的小数部分,用于处理同积分时的先后顺序。
//
// 注意:真实项目中要确保小数位长度固定,避免排序含义混乱。
updateTime := getUpdateTime()

script := `
local res = redis.call('SET', KEYS[1] .. '_' .. ARGV[1], '0', 'EX', '3600', 'NX')

-- SET NX 成功,说明这是第一次请求,才允许更新积分
if res ~= false then
-- 读取当前 Score
local current_score = redis.call('ZSCORE', KEYS[1], KEYS[2])

-- integer 表示真实积分
local integer = 0

-- 如果用户已经在排行榜中,则取当前 Score 的整数部分作为真实积分
if current_score ~= false then
integer = math.floor(current_score)
end

-- 加上本次新增积分
integer = integer + ARGV[2]

-- ARGV[3] 是时间因子,拼成小数部分
local timestamp = '0.' .. ARGV[3]

-- 新的完整 Score = 真实积分 + 时间小数
local score = integer + timestamp

-- 使用 ZADD 写回完整 Score
-- 不能使用 ZINCRBY,因为这里需要重写小数部分
redis.call('ZADD', KEYS[1], score, KEYS[2])
end

return 0
`

result := redisClient.Eval(
ctx,
script,
[]string{
req.GetName(),
strconv.FormatInt(req.GetUserId(), 10),
},
[]string{
strconv.FormatInt(req.GetReqId(), 10),
strconv.FormatInt(req.GetScore(), 10),
strconv.FormatInt(updateTime, 10),
},
)

err = result.Err()

resp = ranking.NewUpdateRankingResp()
resp.BaseResp = ranking.NewBaseResponse()

if err != nil {
resp.BaseResp.Code = -1
resp.BaseResp.ErrMsg = err.Error()
}

return
}

6. UpdateRanking 小结

UpdateRanking 有两个版本需要区分:

版本 写法 是否处理同积分先后排名 说明
基础版 ZINCRBY 原书中的写法,适合普通排行榜
完整版 ZSCORE + ZADD 承接 9.3.3,需要重算完整 Score
普通排行榜:
可以用 ZINCRBY 直接累加积分。

需要处理同积分先后顺序的排行榜:
不能用 ZINCRBY;
应该用 ZSCORE 读取旧 Score,
取整数部分作为真实积分,
加上新增积分,
拼接新的时间小数,
最后用 ZADD 写回。

五、接口二:GetRankingList 获取排行榜列表

1. 核心逻辑

GetRankingList 用来获取排行榜列表,支持分页。

由于排行榜通常是:

积分越高,排名越靠前

所以读取排行榜时使用倒序命令:

ZREVRANGE key start stop WITHSCORES

参数含义:

参数 含义
key 排行榜名称
start 起始下标
stop 结束下标
WITHSCORES 返回成员的同时返回积分

2. 分页规则

PageNo = 0

表示不分页,获取完整排行榜。(正常页号是从1开始算的)

此时:

start = 0
top = -1

Redis 中 top = -1 表示一直取到最后。

如果需要分页,则计算方式为:

start = (PageNo - 1) * PageSize
top = start + PageSize - 1

例如:

PageNo = 1, PageSize = 10
start = 0
top = 9

PageNo = 2, PageSize = 10
start = 10
top = 19

3. 完整 Go 代码实现(含注释)

// 读取排行榜列表,支持分页
func (r *RedisRankingService) GetRankingList(
ctx context.Context,
req *ranking.GetRankingListReq,
) (resp *ranking.GetRankingListResp, err error) {

pageNo := req.GetPageNo()

var start, top int64

if pageNo == 0 {
// 不分页时,获取完整的排行榜数据
//
// Redis ZREVRANGE 中:
// start = 0 表示从第一个元素开始
// top = -1 表示一直取到最后一个元素
start = 0
top = -1
} else {
// 分页时,获取指定区间的数据
//
// PageNo 从 1 开始计数:
// 第 1 页:start = 0
// 第 2 页:start = PageSize
// 第 3 页:start = 2 * PageSize
start = (pageNo - 1) * req.GetPageSize()
top = start + req.GetPageSize() - 1
}

// 请求 ZREVRANGE key start top WITHSCORES 命令
//
// ZREVRANGE:按 Score 从高到低返回
// WITHSCORES:同时返回每个成员对应的分数
result := redisClient.ZRevRangeWithScores(
ctx,
req.GetName(),
start,
top,
)

err = result.Err()

resp = ranking.NewGetRankingListResp()
resp.BaseResp = ranking.NewBaseResponse()

if err != nil {
resp.BaseResp.Code = -1
resp.BaseResp.ErrMsg = err.Error()
} else {
// 获取 ZSET 结果,拼接返回值
for index, memberItem := range result.Val() {
// Redis ZSET 的 Member 存的是用户 ID
userId, _ := strconv.ParseInt(memberItem.Member.(string), 10, 64)

// 如果承接 9.3.3 的完整方案,
// Redis ZSET 的 Score 可能是 “真实积分 + 时间小数”。
//
// 对业务方返回时,应该只返回真实积分,
// 因此这里转成 int64,相当于取整数部分。
score := int64(memberItem.Score)

resp.RankingList = append(resp.RankingList, &ranking.RankItem{
// 原书代码中使用 index + 1。
//
// 如果只展示当前页内排名,这样没问题。
// 但如果要返回全局排名,分页时应该使用:
//
// Rank: start + int64(index) + 1
Rank: int64(index + 1),

UserId: userId,
Score: score,
})
}
}

return
}

4. 注意点

注意点 1:如果 Score 包含时间小数,接口返回时要隐藏小数部分

承接 9.3.3 后,Redis 中的 Score 可能是:

100.12345

但业务方真正关心的是:

真实积分 = 100

所以 GetRankingList 返回 RankItem.Score 时,应该返回整数部分。


注意点 2:分页排名可能存在偏移问题

原书代码中返回名次时使用:

Rank: int64(index + 1)

这意味着每一页都会从 1 开始排名。

例如:

第 1 页:
1, 2, 3, ..., 10

第 2 页:
1, 2, 3, ..., 10

如果业务希望返回的是全局排行榜名次,则应该加上 start 偏移:

Rank: start + int64(index) + 1

这样第二页的排名才会是:

11, 12, 13, ..., 20

所以要区分:

页内排名:index + 1
全局排名:start + index + 1

注意点 4:PageNo = 0 获取完整榜单有风险

虽然原书中 PageNo = 0 表示获取完整排行榜,但如果排行榜数据量很大,这种做法会一次性返回大量数据,容易造成:

Redis 查询压力大
网络传输数据量大
服务端内存占用上升
接口响应变慢

真实系统中一般不建议对大榜单提供“获取完整榜单”的接口。


六、接口三:GetRank 获取某个用户的排名和积分

1. 核心逻辑

GetRank 用来查询某个用户在排行榜上的:

排名
积分

需要使用两个 Redis 命令:

ZREVRANK key member
ZSCORE key member

其中:

命令 作用
ZREVRANK 按 Score 从高到低,查询某个 member 的排名
ZSCORE 查询某个 member 的积分

2. 为什么用 Lua 脚本

查询排名和积分时,理论上可以分别执行:

ZREVRANK
ZSCORE

但可以把这两个命令放在 Lua 脚本中执行。

这样做有两个好处:

  1. 减少一次 Redis 网络往返。
  2. 保证 rank 和 score 来自同一时刻的数据视图。

如果分两次请求 Redis,可能出现:

第一次 ZREVRANK 查到了排名;
中间有其他请求更新了排行榜;
第二次 ZSCORE 查到的是更新后的积分。

这样返回的排名和积分可能不一致。

使用 Lua 脚本可以避免这个问题。


3. 完整 Go 代码实现(含注释)

// 获取某用户的排名和积分
func (r *RedisRankingService) GetRank(
ctx context.Context,
req *ranking.GetRankReq,
) (resp *ranking.GetRankResp, err error) {

// 执行 ZREVRANK 命令获取排名
// 执行 ZSCORE 命令获取积分
//
// 这两个命令使用 Lua 脚本依次执行,脚本返回值是排名和积分。
//
// KEYS[1] = 排行榜名称
// KEYS[2] = 用户 ID
script := `
local rank = redis.call('ZREVRANK', KEYS[1], KEYS[2])
local result = {}

if rank ~= false then
local score = redis.call('ZSCORE', KEYS[1], KEYS[2])

-- 如果承接 9.3.3 的完整方案,
-- score 可能包含时间小数部分。
-- 对业务返回时,只返回真实积分,所以取 math.floor(score)。
result = {rank, math.floor(score)}
else
result = {-1, 0}
end

return result
`

// 运行脚本
//
// KEYS = {排行榜名称, 用户 ID}
result := redisClient.Eval(
ctx,
script,
[]string{
req.GetName(),
strconv.FormatInt(req.GetUserId(), 10),
},
nil,
)

err = result.Err()

resp = ranking.NewGetRankResp()
resp.BaseResp = ranking.NewBaseResponse()

if err != nil {
resp.BaseResp.Code = -1
resp.BaseResp.ErrMsg = err.Error()
} else {
// 获取脚本运行结果
//
// arr[0] = rank
// arr[1] = score
arr := result.Val().([]interface{})

rank, score := arr[0].(int64), arr[1].(int64)

// Redis ZSET 排名从 0 开始。
// 业务上一般希望排行榜从第 1 名开始展示,
// 所以这里需要 +1。
resp.Rank = rank + 1

resp.Score = score
}

return
}

4. 注意点

注意点 1:Redis 排名从 0 开始

ZREVRANK 返回的是从 0 开始的排名。

例如:

Redis 返回 0 → 实际第 1 名
Redis 返回 1 → 实际第 2 名
Redis 返回 2 → 实际第 3 名

所以返回给业务方时,需要:

resp.Rank = rank + 1

注意点 2:用户不存在时返回值要注意

Lua 脚本中,如果用户不存在,则返回:

result = {-1, 0}

表示:

rank = -1
score = 0

但是 Go 代码中统一执行了:

resp.Rank = rank + 1

如果 rank = -1,那么最终返回:

resp.Rank = 0

所以这里需要注意业务语义。

如果希望用户不存在时仍然返回 -1,应该特殊处理:

if rank == -1 {
resp.Rank = -1
} else {
resp.Rank = rank + 1
}

注意点 3:如果 Score 包含时间小数,返回积分时要取整数部分

承接 9.3.3 后,Redis 中的 Score 可能是:

100.12345

其中:

100      是真实积分
0.12345 是时间因子

所以接口返回的 Score 应该是:

math.floor(score)

而不是完整浮点数。


七、为什么选择 Redis ZSET 实现排行榜

1. 自动排序

ZSET 会自动按照 Score 对 Member 排序。

排行榜天然需要按照积分排序,所以 ZSET 非常适合。

member = 用户 ID
score = 排行榜排序分数

只要用户积分变化,ZSET 内部就会自动维护排序结果。


2. 高性能

Redis ZSET 底层使用跳表实现有序集合。

数据的插入、删除、查询操作的时间复杂度都为 O(logN)。

因此适合排行榜这种读写频率较高的场景。


3. 高并发

Redis服务器天然支持高并发读/写,而且主从模式架构能进一步满足高并发读取的需求,符合排行榜高度曝光的特点。


八、Redis ZSET 方案的缺点和注意点

1. 存储限制

Redis 主要使用内存存储数据。

虽然 Redis 支持数据持久化,但毕竟 Redis 是使用内存构建 ZSET 的,而内存是稀缺资源,当排行榜条目达到一定规模时,可能会出现内存不足的情况。

因此需要保证 Redis 所在机器有足够的内存空间。


2. 大 Key 问题

如果一个排行榜中的成员数量非常多,那么这个排行榜对应的 ZSET 就会变成大 Key。

大 Key 可能带来几个问题:

1. 单次操作耗时变长;
2. 数据迁移、备份、持久化压力变大;
3. 主从同步压力变大;
4. 删除大 Key 时可能阻塞 Redis;
5. 热门排行榜会造成访问集中。

例如:

一个全站用户积分榜,里面有几千万个用户

这种榜单就容易形成大 Key。


九、总结

1. 排行榜为什么适合用 Redis ZSET?

因为 ZSET 天然支持:

member + score

并且会自动按照 score 排序。

排行榜正好需要:

用户 ID + 用户积分

所以 Redis ZSET 是非常合适的数据结构。


2. 如何更新排行榜积分?

需要分场景回答。

普通排行榜可以使用:

ZINCRBY key increment member

但如果要处理“同积分时先达到该积分的人排名更高”,就不能继续使用 ZINCRBY

完整方案应该是:

ZSCORE 读取旧 Score
math.floor 取真实积分
真实积分 += 本次新增积分
计算新的时间小数
ZADD 写回完整 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
top = start + PageSize - 1

如果要返回全局排名,返回 Rank 时要加上 start 偏移:

Rank = start + index + 1

5. 如何查询某个用户的排名和积分?

使用:

ZREVRANK key member
ZSCORE key member

其中:

ZREVRANK 获取排名
ZSCORE 获取积分

由于 Redis 排名从 0 开始,所以对外展示时需要:

rank + 1

如果 Score 包含时间小数,返回积分时要取整数部分:

math.floor(score)

6. 这个方案有什么缺点?

主要缺点有:

1. Redis 使用内存,数据量太大时成本高;
2. 单个排行榜过大时会产生大 Key 问题;
3. 热门榜单读压力可能集中;(读写分离)
4. Score 小数部分承载时间因子时,需要注意浮点精度和小数位设计。

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
用户 ID 尾号 1 → 第 2 个 ZSET
...

这样就可以将高并发的更新排行榜请求打散到 10 个 Redis 实例中,防止出现单个 Redis 实例无法应对更新请求的风险

代价是读取时需要合并:

要读取总榜前 100 名:
先读取 10 个子榜各自前 100 名
再合并筛选真正的前 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 段:

image-20260528222805720

第一个分段:[1,100]
第二个分段:[101,200]
...

假设某用户目前积分为150,分段粗估此用户排名的过程如下:

1. 找到积分 150 所在分段:[101,200]
2. 累加更高分段人数:95 + 72 + 29 = 196
3. 假设分段内用户积分均匀分布
4. 在 [101,200] 内估算排名:(200 - 150) × 38 / 100 = 19
5. 最终排名:196 + 19 = 215

这里的“粗估”来自一个假设:

每个分段内用户积分均匀分布。

分段越短,排名精度越高;但分段数也越多。

为什么需要线段树

如果分段很多,从最高分段逐个遍历,时间复杂度是 O(N),这会降低查询效率。

线段树适合这个场景。

线段树用二叉树维护多个分段,可以在 O(logN) 时间内完成数据修改和区间查询。

线段树结构

线段树的根节点表示的数据区间覆盖了整个数据范围,每个节点表示的数据区间都是它的子节点所表示的数据区间的合集。

以积分上限 400、4 个分段为例:

image-20260529000744631

当给定排行榜的积分上限值和为 2 的幂次方的分段数目时,使用 Go 语言实现的创建线段树的代码如下:

// 线段树节点结构
type SegmentTreeNode struct {
lower, upper int64 // 区间左值与区间右值
left, right *SegmentTreeNode // 左子节点与右子节点
count int64 // 位于此区间内的数据量
}

// 根据区间的最大值和分段数目创建线段树
// maxScore:区间最大值
// segCount:分段数目,值为 2 的幂次方
func BuildSegmentTree(maxScore, segCount int64) *SegmentTreeNode {
// 先计算出每个分段的长度
var segLen = maxScore / segCount
if maxScore%segCount != 0 {
segLen++
}

var parentLayerNodes, currentLayerNodes []*SegmentTreeNode

// 创建各个分段,保存到 currentLayerNodes 数组中
for i := int64(1); i < maxScore; i += segLen {
currentLayerNodes = append(currentLayerNodes, &SegmentTreeNode{
lower: i,
upper: i + segLen - 1,
})
}

// 循环构建完整的线段树
for len(currentLayerNodes) >= 2 {
// 取出被保存到 currentLayerNodes 数组中的前两个节点
leftNode, rightNode := currentLayerNodes[0], currentLayerNodes[1]
currentLayerNodes = currentLayerNodes[2:]

// 将这两个节点作为子节点,创建父节点,合并数据区间
parentNode := &SegmentTreeNode{
lower: leftNode.lower,
upper: rightNode.upper,
left: leftNode,
right: rightNode,
}

// 将父节点添加到 parentLayerNodes 数组中
parentLayerNodes = append(parentLayerNodes, parentNode)

// 如果 currentLayerNodes 为空,则说明某层节点全部构建完成,需要上一层继续构建
if len(currentLayerNodes) == 0 {
currentLayerNodes = parentLayerNodes
parentLayerNodes = nil
}
}

// 最终 currentLayerNodes 的首个节点是根节点
return currentLayerNodes[0]
}

9.4.2 粗估排名的实现

使用线段树实现排行榜排名,需要支持的两个操作分别是查询排名更新排名

接下来我们以下图中使用线段树构建的排行榜为例:

image-20260529001755736

查询排名

线段树本身不存储每个用户的真实积分,所以查询排名时需要传入用户当前积分。

假设用户积分为 220,查询流程如下:

1. 根节点发现220在左子节点[1,400]范围内,于是遍历左子节点。
2.[1,400]节点发现220在右子节点[201,400]范围内,于是遍历右子节点。
3.[201,400]节点发现220在左子节点[201,300]范围内,于是继续遍历左子节点。
4.[201, 300]是叶节点,即最终的分段,此时计算出该用户在此分段的预估排名为(300-220)x31/100 ≈ 25
5. 在从根节点开始的完整遍历过程中,如果某节点选择继续遍历左子节点,则说明右子节点代表的数据区间大于220,此时需要累加右子节点的用户数量,最终得到[201, 300]分段之外的大于220的用户总数。对于本例来说,就是[401, 800]和[301, 400]这两个节点
的累加和,即177+49=226。
6. 将全部大于220的用户数量加和,得到此用户的最终排名为226+25=251。

查询排名时间复杂度: O(logN)

对应的 Go 语言代码如下:

// 在线段树中查询某积分对应的排名
// root:线段树的根节点
// score:积分
// 返回值:排名
func getRankFromSegmentTree(root *SegmentTreeNode, score int64) int64 {
var currentNode = root

// 记录分数高于 score 的节点的用户数量总和
var biggerThanMe int64

for currentNode != nil {
if currentNode.lower > score || currentNode.upper < score {
break
}

if currentNode.left == nil {
// 查询到分段了,开始预估 score 在此分段内的排名
numerator := (currentNode.upper - score) * currentNode.count
fuzzyRank := float64(numerator) /
float64(currentNode.upper-currentNode.lower+1)

// 在此分段内排名与 biggerThanMe 求和得到最终排名
return int64(fuzzyRank) + biggerThanMe
}

// 在向下遍历时,以左子节点的右值进行划分
split := currentNode.left.upper
if score <= split {
// score 在左子节点范围内,接下来遍历左子节点
// 右子节点的数据范围大于 score,需要累加到 biggerThanMe 中
right := currentNode.right
biggerThanMe += right.count
currentNode = currentNode.left
} else {
// score 在右子节点范围内,接下来遍历右子节点
currentNode = currentNode.right
}
}
return 0
}

更新排名

在更新用户排名时,需要使用更新前的积分score1和更新后的最新积分score2。

流程如下:

1. 从根节点遍历 score1,经过的所有节点 count 减 1。
2. 从根节点遍历 score2,经过的所有节点 count 加 1。

所以一次更新用户排名由删除(先执行)和插入(后执行)两个操作组成,时间复杂度也是 O(logN)

其Go语言代码如下:

// 遍历线段树,并修改遍历过的节点
// root: 线段树的根节点
// score: 积分
// f: 对遍历过的每个节点执行的操作
func traverseSegmentTree(root *SegmentTreeNode, score int64, f func(*SegmentTreeNode)) {
var currentNode = root

// 遍历 score1,执行删除操作
for currentNode != nil {
if currentNode.lower > score || currentNode.upper < score {
break
}

// 对遍历过的节点进行修改
f(currentNode)

// 遍历结束
if currentNode.left == nil {
break
}

// 向下遍历
split := currentNode.left.upper
if score <= split {
currentNode = currentNode.left
} else {
currentNode = currentNode.right
}
}
}

// 从线段树的根节点开始查询某积分对应的排名
// score1: 更新前的积分
// score2: 更新后的积分
func updateRankInSegmentTree(root *SegmentTreeNode, score1, score2 int64) {
// 遍历 score1,执行删除操作
traverseSegmentTree(root, score1, func(node *SegmentTreeNode) {
node.count--
})

// 遍历 score2,执行插入操作
traverseSegmentTree(root, score2, func(node *SegmentTreeNode) {
node.count++
})
}

线段树排行榜的 Redis Hash 存储方案

前面已经说明了线段树如何用于“查询排名”和“更新排名”。不过还存在一个重要问题:

线段树方案把区间内的用户数量存在线段树各节点中,但线段树本身是内存型数据结构,无法被多个服务实例共享。

如果排行榜服务只能由单个服务实例提供,那么无论从高可用、高性能,还是可扩展性来看,都很难接受。

因此需要把线段树拆成两部分来看:

  • 线段树结构:固定不变,可以保存在每个服务实例的本地内存中。
  • 节点用户数量:会变化,需要放到共享存储中。

线段树结构固定,真正会变化的只是每个节点对应区间内的用户数量。因此,线段树结构可以每个服务实例本地保存;节点人数会变化,必须放到 Redis 这种共享存储里。这样排行榜服务实例本身不再保存独占状态,就可以通过增加实例来水平扩展。 (这里说的服务实例是指排行榜服务的某一个运行实例)


使用 Redis Hash 保存线段树节点数量

使用 Redis Hash 可以很自然地保存线段树节点数据:

一个 Redis Hash 对象表示一棵线段树;
Hash 的 Field 表示线段树中的某个区间节点;
Hash 的 Value 表示该区间节点中的用户数量。

所以,上例的线段树结构在Redis中会被保存为如下图所示的形式。

image-20260529095500510

这样一来,排行榜服务就可以部署多个实例:

每个服务实例:
1. 本地通过 BuildSegmentTree 创建一棵结构相同的线段树;
2. 查询或更新节点数量时,统一读写 Redis Hash。

查询用户排名

查询用户排名时,服务实例先使用本地线段树判断本次需要读取哪些节点,然后向 Redis 发送 HMGET 命令,一次性获取多个节点的用户数量。

例如查询积分为 220 的用户排名,需要读取:

201-300:用户所在的叶子分段
301-400:比 220 分数更高的相邻分段
401-800:比 220 分数更高的大区间

查询结果分成两部分:

biggerCounter:积分明确大于 score 的用户数量
segCounter:score 所在分段内的用户数量

由于线段树只记录“分段人数”,并不知道分段内每个用户的具体积分,所以只能假设该分段内用户积分均匀分布,然后估算该分段内有多少用户积分高于当前 score。

估算公式为:

more = (segment.upper - score) * segCounter / (segment.upper - segment.lower)

最终粗估排名为:

rank = biggerCounter + more

更新用户积分

更新用户积分时,需要同时处理旧积分和新积分。

假设用户积分从 score1 更新为 score2

1. 找到 score1 在线段树中经过的所有节点,这些节点的用户数量都要减 1。
2. 找到 score2 在线段树中经过的所有节点,这些节点的用户数量都要加 1。
3. 使用 Redis Lua 脚本批量执行 HINCRBY,保证更新过程集中完成。

例如用户积分从 220 更新到 750

score1 = 220,需要对以下 Field 的 Value 减 1:

1-800
1-400
201-400
201-300
score2 = 750,需要对以下 Field 的 Value 加 1:

1-800
401-800
601-800
701-800

这样就能保证线段树中所有相关节点的用户数量都被同步更新。


关键代码

// 获取线段树中需要更新的节点,即遍历过的节点
// root: 线段树的根节点
// score: 积分
// 返回 segmentFields: 所有要更新节点的 Field
func getSegmentToUpdate(root *SegmentTreeNode, score int64) (segmentFields []string) {
var currentNode = root
for currentNode != nil {
if currentNode.lower > score || currentNode.upper < score {
break
}

// 将遍历过的节点加入数组中
segmentFields = append(segmentFields, fmt.Sprintf("%d-%d",
currentNode.lower, currentNode.upper))

if currentNode.left == nil {
break
}

split := currentNode.left.upper
if score <= split {
currentNode = currentNode.left
} else {
currentNode = currentNode.right
}
}

return
}

// 在查询 score 排名时,计算需要在线段树中读取哪些节点
// root: 线段树的根节点
// score: 积分
// 返回 segmentFields: 所有待读取节点的 Field
// 返回 segment: score 所在的分段
func getSegmentToRead(root *SegmentTreeNode, score int64) (segmentFields []string, segment *SegmentTreeNode) {
var currentNode = root
for currentNode != nil {
if currentNode.lower > score || currentNode.upper < score {
break
}

if currentNode.left == nil {
// currentNode 就是 score 所在的分段
segment = currentNode

// 将 Field 加入 segmentFields 中
segmentFields = append(segmentFields, fmt.Sprintf("%d-%d",
currentNode.lower, currentNode.upper))
break
}

split := currentNode.left.upper
if score <= split {
right := currentNode.right

// 当选择左子节点遍历时,需要查询右子节点,于是将 Field 加入 segmentFields 中
segmentFields = append(segmentFields, fmt.Sprintf("%d-%d",
right.lower, right.upper))

currentNode = currentNode.left
} else {
currentNode = currentNode.right
}
}

return
}

// 更新排行榜积分
// root: 线段树的根节点
// score1: 更新前的积分
// score2: 更新后的积分
// 返回: 错误信息
func UpdateScore(ctx context.Context, root *SegmentTreeNode, score1, score2 int64) error {
// 获取用户数量需要减 1 的节点列表
reduces := getSegmentToUpdate(root, score1)

// 获取用户数量需要加 1 的节点列表
adds := getSegmentToUpdate(root, score2)

// Lua 脚本:在 Hash 中对每个传入的 Field 执行加 1 或减 1 操作
// KEYS = {排行榜名次, M 个待减 1 的 Field, N 个待加 1 的 Field}
// ARGV = {M, N}
script := `
local M = tonumber(ARGV[1])
local N = tonumber(ARGV[2])
for i=1,M,1
do
redis.call("HINCRBY", KEYS[1], KEYS[1+i], -1)
end
for i=1,N,1
do
redis.call("HINCRBY", KEYS[1], KEYS[1+M+i], 1)
end
return 0
`

var keys = []string{"fuzzy_rank"}

// 将需要减 1 的节点 Field 依次加入 keys 中
for _, key := range reduces {
keys = append(keys, key)
}

// 将需要加 1 的节点 Field 依次加入 keys 中
for _, key := range adds {
keys = append(keys, key)
}

// 将待减 1 的 Field 个数、待加 1 的 Field 个数作为 ARGV,执行脚本
cmd := redisClient.Eval(ctx, script, keys, []interface{}{len(reduces), len(adds)})
return cmd.Err()
}

// 获取用户排名
// root: 线段树的根节点
// score: 用户积分
// 返回: 排名,错误信息
func GetRank(ctx context.Context, root *SegmentTreeNode, score int64) (int64, error) {
// 需要读取哪些节点
segmentKeys, segment := getSegmentToRead(root, score)
if len(segmentKeys) == 0 {
return 0, nil
}

// 读取这些节点对应的 Field 值
result := redisClient.HMGet(ctx, "fuzzy_rank", segmentKeys...)
if result.Err() != nil {
return 0, result.Err()
}

// segCounter 表示 score 在所在分段内的排名
// biggerCounter 表示大于 score 的用户总数
var segCounter, biggerCounter int64
for i, val := range result.Val() {
// 找到 score 所在的分段
if segmentKeys[i] == fmt.Sprintf("%d-%d", segment.lower, segment.upper) {
segCounter, _ = strconv.ParseInt(val.(string), 10, 64)
} else if val != nil {
cnt, _ := strconv.ParseInt(val.(string), 10, 64)
biggerCounter += cnt
}
}

// 计算预估排名
more := float64((segment.upper-score)*segCounter) /
float64(segment.upper-segment.lower)

// 得到最终排名
return biggerCounter + int64(more), nil
}

线段树方案的优点

线段树方案只用一个成员数量很少的 Redis Hash,就能实现海量用户的粗估排名。无视参与者人数,节约了大量存储空间,可谓是“四两拨千斤”。

它不需要像 Redis ZSET 那样保存每个用户的具体数据,而只保存每个积分区间内的用户数量,因此存储空间非常小。

这种方式的核心价值在于:

用少量区间统计数据,估算海量用户的排名。

线段树方案的缺点

不过,线段树方案并不是完整排行榜方案。因为它只保存每个分段的人数,不保存每个用户的具体积分,所以存在以下缺点。

1. 排名不精确

线段树方案在计算排名时,会假设一个分段内的用户积分是均匀分布的。

例如某个分段是 201-300,里面有 31 个用户。线段树只知道这个区间里有 31 人,但不知道这些人的积分到底是集中在 201 附近,还是集中在 300 附近。

因此查询某个用户排名时,只能根据均匀分布进行估算,最终得到的是粗略排名,不是精确排名。

2. 不支持获取排行榜列表

线段树只记录每个分段有多少用户,并不知道:

1. 哪些用户参与了排行榜;
2. 每个用户的具体积分是多少;
3. 某个排名位置对应的是哪个用户。

所以它可以高效支持:

已知某个用户的积分,查询该用户的大致排名。

但不支持:

获取排行榜 Top N 列表。
获取某一页排行榜列表。
展示排行榜中具体有哪些用户。

适用场景

如果单独使用线段树方案,它更适合“全民参与,并且积分由其他系统维护”的场景。

例如 QQ 等级这类业务:

用户的太阳、月亮、星星数量由专门的等级服务维护;
排行榜系统只需要根据这些等级数据,为全量用户展示一个大致排名。

如果要对 QQ 的全部用户按照等级排名,并且给每个用户展示自己的大致排名,那么线段树方案就比较适合。

但如果业务要求展示完整排行榜列表,或者要求排名必须精确,就不能单独依赖线段树方案。

本节总结

粗估排行榜的核心思想是:不保存所有用户的精确排名,而是按积分分段保存人数。

线段树可以把分段统计从线性遍历优化为 O(logN) 查询和更新。

为了支持多服务实例共享状态,可以让服务本地保存固定线段树结构,把每个节点的人数存入 Redis Hash。

一句话总结:线段树用“分段人数”换取极低存储成本,适合海量用户只查询大致排名的场景。


9.5 精确排名与粗估排名结合

对于全民参与的超长排行榜,通常没必要为所有用户都展示完整的排行榜列表。

产品上一般可以接受:

1. 只展示前 N 名用户的详细排行榜列表。
2. 对于 N 名之后的用户,只告诉他自己的大致名次。

例如排行榜只展示前 10000 名用户的列表。对于 10000 名以后的用户,不展示其前后分别有哪些人,只告诉他当前名次是多少即可。

因此,超长排行榜可以采用:

ZSET 精确排名 + 线段树粗估排名

二者分工如下:

ZSET:
负责维护前 10000 名用户的详细排名和排行榜列表。

线段树:
负责为 10000 名以后的用户提供粗估排名。

不过,线段树本身无法存储用户积分,所以还需要一个额外系统来维护用户积分,例如第 8 章介绍的计数服务。


整体架构

计数服务的作用是存储排行榜上的用户积分。

当用户积分更新时,流程不是直接更新排行榜服务,而是:

1. 先更新计数服务中的用户积分。
2. 计数服务把积分变更事件发送到消息队列。
3. 排行榜服务消费积分变更事件。
4. 排行榜服务根据事件持续更新 ZSET 和线段树。

image-20260529105503569

这样,计数服务负责维护用户的真实积分,排行榜服务负责根据积分变化构建排名能力。

整体上可以理解为:

计数服务:
维护用户积分,是积分数据的源头。

消息队列:
传递积分变更事件,解耦计数服务和排行榜服务。

排行榜服务:
消费积分变更事件,更新 ZSET 和线段树,对外提供排行榜列表和排名查询能力。

ZSET:
保存头部用户的精确排名。

线段树:
保存全量用户的分段统计数据,用于尾部用户的粗估排名。

用户积分更新流程

从用户积分更新,到排行榜各处变更生效,完整流程如下。

1. 用户发起积分更新请求

用户向计数服务发起积分更新请求。

请求中包含:

用户 ID
排行榜名称
待增加的积分

2. 计数服务更新用户积分

计数服务收到请求后,本质上是对一个 Redis String 对象执行自增操作。

这个 String 的 Key 可以设计为:

排行榜名称_用户ID

计数服务对该 Key 的 Value 加上本次积分增量。

处理成功后,计数服务先响应用户,然后将本次积分变更作为事件发送到消息队列。

对应的消息主题为:

change_counter_trigger

消息事件结构如下:

type ChangeCounterTriggerEvent struct {
KeyPrefix string // Redis Key 前缀,对于排行榜来说,就是排行榜名称
UserId int64 // 用户 ID
DeltaScore int64 // 积分的增加值
Score int64 // 增加后的积分
}

这里各字段含义如下:

KeyPrefix:
排行榜名称。

UserId:
发生积分变化的用户 ID。

DeltaScore:
本次增加的积分。

Score:
增加后的总积分。

3. 排行榜服务消费积分变更事件

排行榜服务消费 change_counter_trigger 主题中的消息。

如果消息中的 KeyPrefix 字段是排行榜名称,则处理该消息。

如果不是排行榜相关的 KeyPrefix,则丢弃该消息。


4. 排行榜服务更新 ZSET

排行榜服务处理消息时,会使用事件中的:

KeyPrefix
UserId
Score

请求 Redis 执行 ZADD 命令,更新 ZSET。

可以理解为:

ZSET Key:排行榜名称,也就是 KeyPrefix
ZSET Member:用户 ID,也就是 UserId
ZSET Score:用户增加后的总积分,也就是 Score

也就是把该用户的最新积分写入 ZSET,用于维护头部用户的精确排名。


5. 排行榜服务更新线段树

线段树更新需要知道用户的旧积分和新积分。

事件中已经包含新积分 Score 和本次增量 DeltaScore,所以旧积分可以计算出来:

PScore = Score - DeltaScore

其中:

PScore:
更新前的积分。

Score:
更新后的积分。

然后排行榜服务使用 PScoreScore 更新线段树:

1. 根据 PScore 找到旧积分经过的线段树节点,将这些节点人数减 1。
2. 根据 Score 找到新积分经过的线段树节点,将这些节点人数加 1。

这样,线段树中的分段人数也能随着用户积分变化而持续更新。


6. 定时裁剪 ZSET

如果每个发生积分变化的用户都写入 ZSET,那么 ZSET 会不断变大。

但是这个方案中,ZSET 只负责维护前 10000 名用户的精确排名,所以没必要永久保存所有用户。

因此,需要一个定时任务周期性裁剪 ZSET:

定期删除 ZSET 中前 10000 名之外的用户数据。

这样可以防止 ZSET 长度无限增长。


查询排行榜列表

如果用户请求的是排行榜列表,排行榜服务只需要读取 ZSET 即可。

因为排行榜列表只展示前 10000 名用户,而这些头部用户的数据都由 ZSET 精确维护。

所以查询排行榜列表的逻辑很简单:

1. 用户请求排行榜列表。
2. 排行榜服务读取 ZSET。
3. 返回前 N 名用户的精确排名列表。

查询用户排名

如果用户请求的是自己的排名,排行榜服务需要分情况处理。

1. 用户发起查询排名请求

请求中包含:

排行榜名称
用户 ID

2. 先查询 ZSET

排行榜服务先查询 ZSET。

如果用户 ID 存在于 ZSET 中,说明该用户属于头部用户,排行榜服务可以直接返回:

用户积分
用户精确排名

这种情况下,不需要再查询线段树。


3. 如果 ZSET 中查不到用户

如果在 ZSET 中查不到用户 ID,说明该用户可能排名比较靠后,已经不在前 10000 名的精确排行榜里。

此时,排行榜服务需要从计数服务中获取该用户积分。


4. 根据用户积分查询线段树

如果计数服务返回非零积分,说明该用户确实参与了排行榜。

排行榜服务再使用该积分查询线段树,得到用户的粗估排名。

最后返回:

用户积分
用户粗估排名

完整流程可以概括为:

1. 用户请求查询自己的排名。
2. 排行榜服务先查 ZSET。
3. 如果用户在 ZSET 中,直接返回精确排名和积分。
4. 如果用户不在 ZSET 中,则向计数服务查询用户积分。
5. 如果用户积分非零,则用积分查询线段树,得到粗估排名。
6. 返回用户积分和排名。

image-20260529144649838


方案优势

精确排名与粗估排名结合后,可以同时兼顾准确性、存储成本和性能。

1. 保证头部用户积分和排名准确

排行榜中被高频访问的用户,大概率是头部用户。

而头部用户数据由 ZSET 维护,所以可以保证排行榜前 10000 名用户的积分和排名是准确的。


2. 用较低存储成本满足尾部用户查询需求

尾部用户不需要展示完整排行榜列表,只需要知道自己的大致名次。

因此,不需要把所有用户都放进 ZSET。

对于 10000 名以后的用户,可以通过计数服务拿到积分,再通过线段树计算粗估排名。

这样可以用较少的存储成本,满足海量尾部用户的排名查询需求。


3. 支持高并发读写排行榜

这个方案把不同类型的压力拆开了:

积分写入:
由计数服务处理。

积分变更同步:
通过消息队列异步传递。

头部精确排名:
由 ZSET 维护。

尾部粗估排名:
由线段树维护。

这样既能支持高并发更新积分,也能支持高并发查询排行榜。


需要注意的问题:积分一致性

这个方案中,用户积分会同时存在两个地方:

1. 计数服务中。
2. 排行榜服务维护的 ZSET 中。

因此必须保证两边的积分数据一致。

尤其不能出现这种情况:

从计数服务查询到的积分,低于从排行榜服务 ZSET 查询到的积分。

这不符合产品预期。

如果出现这种情况,可以在排行榜服务中启动一个定时任务进行校准:

1. 定期查询 ZSET 中每个用户的积分。
2. 再向计数服务查询这些用户的真实积分。
3. 对比两边积分是否一致。
4. 如果不一致,则以计数服务中的积分为准。
5. 将 ZSET 中的积分修正为计数服务中的积分。

也就是说,在这个方案中:

计数服务是积分数据的事实来源。
ZSET 只是用于维护头部用户精确排名的派生数据。

如果两者不一致,应当以计数服务为准进行修正。

注意:定时任务不是为了保证“立刻一致”,而是为了防止不一致长期存在。

所以它解决的是:由于消息丢失、消费失败、更新失败导致的长期脏数据问题。

而不是解决:用户刚更新积分后,排行榜立刻 100% 一致的问题。

本节总结

ZSET 和线段树各有优缺点。

ZSET 可以提供精确排名和榜单列表,但超长排行榜会占用较多存储空间。

线段树存储空间极小,但只能提供粗估排名,不能展示排行榜列表。

结合方案用 ZSET 维护前 N 名精确排名,用线段树维护尾部用户粗估排名,再用计数服务保存用户积分,是超长排行榜的更完整方案。

一句话总结:超长排行榜可以让 ZSET 负责“看得见的头部”,让线段树负责“看不见但要能查的尾部”。


9.6 本章小结

本章主要介绍了通用排行榜服务的几种设计方案,以及它们各自适合的场景。

Redis 的 ZSET 是实现排行榜最常见的方案。它底层基于跳跃表实现,天然支持按照积分自动排序,同时也支持高效的数据查询和数据更新。因此,在需要维护精确排名、获取排行榜列表、查询用户名次等场景下,ZSET 是一个非常合适的选择。

不过,使用 ZSET 实现排行榜时,可能会产生大 Key。但这并不意味着一定会带来严重问题,因为 ZSET 的典型使用场景本身就是存储大量有序数据,在常规的读写和分页查询场景下,它的性能表现通常是可以接受的。真正需要关注的是:排行榜规模是否过大、是否需要保存全量用户、存储成本是否可控,以及是否存在极端热点访问。

除了 ZSET,线段树也是一种实现排行榜的方案。

线段树不存储每个用户的具体积分,而是把积分范围划分成多个分段,并记录每个分段内的用户数量。这样就可以根据某个用户的积分,快速估算出该用户的大致排名。

线段树结构固定,可以每个服务实例本地保存;线段树节点存的人数会变化,必须放到 Redis 这种共享存储里。这样排行榜服务实例本身不再保存独占状态,就可以通过增加实例来水平扩展。可以使用 Redis Hash 保存这些节点数据:

Redis Hash:
Field 表示线段树中的某个积分区间
Value 表示该积分区间内的用户数量

这样一来,排行榜服务可以部署多个实例,每个实例本地维护相同的线段树结构,而节点人数统一读写 Redis Hash。由于线段树只保存分段统计信息,不保存每个用户的具体积分,所以存储空间非常小,在海量用户排名场景下可以达到“四两拨千斤”的效果。

但是,线段树也有明显限制。它只能提供粗估排名,不能保证排名完全精确;并且它不知道具体有哪些用户参与排行榜,也不知道每个用户的具体积分,因此无法支持获取排行榜列表。

所以,ZSET 和线段树的能力可以概括为:

ZSET:
支持精确排名
支持获取排行榜列表
但如果保存全量用户,存储成本较高

线段树:
存储成本极低
支持根据积分查询粗估排名
但不支持精确排名,也不支持获取排行榜列表

在超长排行榜场景下,可以把二者结合起来使用:

ZSET:
负责前 N 名用户的精确排名和排行榜列表

线段树:
负责 N 名之后用户的粗估排名

计数服务:
负责维护用户的真实积分

这种组合方案比较符合实际产品需求。因为排行榜中的重点用户通常排名靠前,产品一般也只需要展示前 N 名用户的详细列表;而对于排名靠后的大量用户,只需要告诉他们自己的大致名次即可。

因此,最终方案可以理解为:

头部用户:
使用 ZSET 保存,提供精确排名和排行榜列表。

尾部用户:
使用线段树保存分段统计数据,提供粗估排名。

用户积分:
由计数服务统一维护,排行榜服务消费积分变更事件,同步更新 ZSET 和线段树。

需要注意的是,由于用户积分既存在计数服务中,又会同步到排行榜服务维护的 ZSET 中,因此必须关注数据一致性问题。如果两边积分不一致,应以计数服务中的积分为准,并通过定时校准任务修正 ZSET 中的数据。

总的来说,排行榜没有唯一固定的实现方式。不同公司的排行榜服务,在用户规模、高并发压力、性能要求、存储成本和功能复杂度上都可能不同。因此,实际设计时应该根据具体业务场景选择合适方案:简单精确排行榜可以直接使用 ZSET;海量用户粗估排名可以使用线段树;如果既要头部精确排名,又要兼顾尾部海量用户查询,则可以采用 ZSET + 线段树 的组合方案。