Redis 较大的 ZSet 通常用字典与跳表配合。字典支持按成员查分数,跳表支持按分数有序访问与排名。选择跳表可以获得平均 O(log N) 的插入、删除和查找,并且较容易支持顺序遍历、反向遍历和范围查询;不能据此说平衡树无法实现这些操作。
一个排行榜有两种访问需求
查询“用户 u 的分数是多少”适合成员到分数的哈希映射;查询“分数 80 到 100 的用户”和“前十名”则需要有序结构。如果只用字典,每次排序成本高;如果只有跳表,按成员定位时还需要相应查找路径,因此二者互补。
跳表底层连接所有元素,上层通过随机高度形成加速索引。查找时从高层向右,超过目标前向下,最终定位底层节点。Redis 按 score 排序,同分时按成员字典序形成确定顺序。节点的 backward 指针支持反向遍历,各层 span 记录跨越的节点数,用于排名计算。
与平衡树比较
平衡树能提供有序检索和范围遍历,并通常有严格的最坏复杂度保证;跳表以随机化取得平均复杂度,实现不需要复杂的旋转与平衡规则,范围结果可沿底层链表输出。两者都需要内存元数据,不能脱离具体实现断言跳表永远更省内存或更快。
小 ZSet 可采用 listpack,避免字典和多层指针的开销。回答“ZSet 底层是什么”时应先说明版本和规模,不能说所有 ZSet 无条件使用跳表。
常见追问
范围查询复杂度为什么不是单独 O(log N)?
定位起点平均 O(log N),输出 M 个结果还需要 O(M);典型范围查询整体为 O(log N + M),返回大量结果也会带来网络开销。
score 相同会覆盖成员吗?
不会因为同分覆盖不同成员。ZSet 的成员唯一,同一成员再次 ZADD 通常更新分数,不同成员可以拥有同一分数。
ZSet 排名能否直接保存超大整数?
score 使用双精度浮点表示,整数精度有边界;超出可精确表示范围时需要重新设计分值与排序规则,避免把所有 64 位整数都当成精确分数。
为什么删除时要维护字典和跳表?
它们表示同一逻辑集合的两条索引路径。只删除一边会造成查分数与有序查询结果不一致,Redis 在命令执行中维护二者关系。