2. Redis有序集合(ZSet)核心机制
Redis 的有序集合(Sorted Set,简称 ZSet)是排行榜业务最核心的数据结构。它同时具备两个能力:像集合一样保证 member 唯一,又像映射一样为每个 member 绑定一个 score,并始终按 score 排序。理解 ZSet 的关键,不是记住命令列表,而是理解它“为什么能既快速定位成员、又快速按分数取排名”。
底层结构:跳表 + 哈希表的分工
ZSet 在底层由两种结构协同实现:一个哈希表(dict)和一个跳表(skiplist)。它们存储的是同一批数据,但承担不同职责。
- 哈希表:建立 member → score 的映射。作用是 O(1) 判断某个成员是否存在、读取它的分数。例如查询“用户 U1001 当前积分是多少”,走的就是哈希表。
- 跳表:按 score 从小到大维护有序链表,并在多层索引上跳跃查找。作用是支持按分数范围、按排名顺序高效遍历。例如“取积分最高的前 10 名”,走的就是跳表。
跳表可以理解为“带多级索引的有序链表”。最底层是完整的有序链表,上层是抽取出的稀疏索引。查找时从最高层开始,能跳就跳,不能跳就下降一层,从而把链表 O(N) 的线性查找降到期望 O(log N)。每个节点保存 member 和 score,排序规则是:先比 score,score 相同时再按 member 的字典序排列。这一点非常重要——它保证了同分成员的顺序是确定的,不会因插入顺序不同而抖动。
常用命令与时间复杂度
下面这张图展示了 ZSet 内部哈希表与跳表的分工,以及五条核心命令分别命中哪条路径。
从图中可以看到:ZSCORE 只走哈希表,是 O(1);ZADD、ZINCRBY、ZRANK 需要同时维护哈希表和跳表,是 O(log N);ZREVRANGE 先通过跳表定位起点,再顺序取 M 个元素,是 O(log N + M)。这里的 N 是集合成员总数,M 是本次返回的条数。理解这个差异,才能判断排行榜在高并发下哪一步是瓶颈。
命令的实际用法与参数细节
以游戏积分榜为例,键名用 rank:game:2024,member 是用户 ID,score 是积分。
# 写入或更新:U1001 得 980 分,U1002 得 1250 分
ZADD rank:game:2024 980 U1001 1250 U1002
# 原子加分:U1001 再加 50 分,返回更新后的分数
ZINCRBY rank:game:2024 50 U1001
# 返回 "1030"
# 取积分最高的前 3 名(降序,带分数)
ZREVRANGE rank:game:2024 0 2 WITHSCORES
# 查 U1002 的升序名次(从 0 开始)
ZRANK rank:game:2024 U1002
# 查 U1001 的当前分数
ZSCORE rank:game:2024 U1001
# 返回 "1030"
几个容易踩坑的细节:
- ZADD 默认是“更新”语义。如果 member 已存在,新 score 会覆盖旧 score,而不是累加。要累加必须用 ZINCRBY,或 ZADD 的
INCR选项。这是排行榜加分场景最常见的错误来源。 - ZRANK 是升序名次,从 0 开始。积分越高排名越靠前,所以“第 1 名”对应的是 ZREVRANK 返回 0,而不是 ZRANK。做“我的排名”功能时,通常用
ZREVRANK,再 +1 展示给用户。 - ZREVRANGE 的区间是闭区间,
0 2表示取 3 条。它按排名取,不按分数取;如果要按分数区间取,用ZREVRANGEBYSCORE。 - ZSCORE 返回字符串,需要自行转成数值;成员不存在时返回 nil,而不是 0,业务代码要区分“没这个人”和“分数为 0”。
为什么排行榜适合用 ZSet
假设有 100 万玩家。如果用普通数据库按积分排序取前 10,每次都要全表排序,代价极高。而 ZSet 的跳表已经维护好顺序,取前 10 只需 O(log N + 10),几乎与总量无关。同时,ZINCRBY 是单线程原子操作,多个请求同时加分不会丢更新,天然适合“点赞数、投票数、积分”这类高频计数场景。
但也要注意边界:ZSet 的每个 member 都占用内存,跳表还有索引开销。百万级成员的单键通常占用几十到上百 MB,需要评估内存。另外,ZSet 不适合做“按时间窗口滑动”的复杂统计,那类需求往往要配合 ZREMRANGEBYSCORE 清理过期成员,或改用其他结构。