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 的“有序”来自 score,而不是插入顺序。score 可以是整数、浮点数,也可以是能解析为数字的字符串。member 本身不参与数值比较,只在同分时用于稳定排序。

常用命令与时间复杂度

下面这张图展示了 ZSet 内部哈希表与跳表的分工,以及五条核心命令分别命中哪条路径。

ZSet 内部结构与命令路径 核心命令 ZADD 写入/更新 score ZINCRBY 原子增减分数 ZREVRANGE 按排名取区间 ZRANK 查升序名次 ZSCORE 查成员分数 哈希表 dict member → score O(1) 定位成员 U1001 → 980 U1002 → 1250 跳表 skiplist 按 score 有序,多层索引 O(log N) 范围/排名 底层全量链表 + 上层稀疏索引 时间复杂度 ZADD O(log N) / 个成员 ZINCRBY O(log N) ZREVRANGE O(log N + M) ZRANK O(log N) ZSCORE O(1) N = 成员总数 M = 返回条数

从图中可以看到: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 清理过期成员,或改用其他结构。

一句话总结:哈希表负责“按成员找分数”,跳表负责“按分数找排名”,两者共享同一份数据。ZADD/ZINCRBY 写入并维护两份结构,ZSCORE 只查哈希表,ZRANK/ZREVRANGE 走跳表。记住这个分工,就能准确判断每条命令的成本和适用场景。