面试官:ZSet 的底层实现是什么?

面试考察点

  1. 基础掌握度 :面试官不仅仅是想知道 ZSet 是什么,更是想知道你是否了解 ZSet 底层的 两种实现ziplist / listpack + skiplist)以及各自的触发条件。
  2. 原理理解深度 :考察你是否真正理解 跳表(Skip List) 的结构(多层索引、随机层数、O(logN) 查找),以及 Redis 为什么选择跳表而不是红黑树来实现有序集合。
  3. 版本演进认知 :是否知道 Redis 7.0 将 ziplist 替换为 listpack,以及为什么要换。

核心答案

Redis ZSet(有序集合)底层使用 两种数据结构 实现,根据元素数量和大小自动切换:

条件 底层结构 特点
元素数量 ≤ 128 且每个元素 ≤ 64 字节 ziplist(Redis 7.0 前)/ listpack(Redis 7.0+) 紧凑存储,省内存,适合小数据量
元素数量 > 128 或有元素 > 64 字节 skiplist(跳表)+ dict(字典) O(logN) 查找,适合大数据量

一句话结论 :ZSet 的核心是 跳表 + 字典 的组合结构。跳表负责按分数范围查询(O(logN)),字典负责按成员查分数(O(1)),两者配合实现了 ZSet 的所有操作。

深度解析

一、ZSet 的整体架构

img

上图展示了 ZSet 的核心架构 ------ 跳表 + 字典的组合:

  • 字典(dict) :以 member 为 key、score 为 value 的哈希表。作用是实现 O(1) 按 member 查 score ,比如执行 ZSCORE 命令时,直接通过字典查找,不需要遍历。
  • 跳表(skiplist) :按 score 有序排列的多层链表。作用是实现 O(logN) 的范围查询 ,比如执行 ZRANGEBYSCOREZRANK 等命令时,跳表能快速定位。
  • 两个结构共享同一份数据:字典的 value 和跳表的节点都指向同一个包含 member + score 的数据对象,不会重复存储两份,节省内存。

二、跳表(Skip List)详解

跳表是 ZSet 最核心的数据结构,面试必问。

img

上图展示了跳表的核心结构:

  • 多层索引:跳表在有序链表的基础上,建立了多层 "快车道"。高层节点少但跨度大,低层节点多但粒度细。查找时从最高层开始,如果目标比当前节点大就往右走,比当前节点小就往下走,逐层缩小范围。
  • 随机层数:每个新节点插入时,通过随机算法决定它的层数。Redis 中每个节点有 25% 的概率再往上一层延伸,层数越高节点越稀疏。最高 32 层。
  • 查找过程:从最高层开始,逐层二分缩小范围,时间复杂度 O(logN)。比遍历链表的 O(N) 快得多。

跳表节点的源码结构(Redis 7.x)

arduino 复制代码
// 跳表节点定义(源码 t_zset.c)
typedef struct zskiplistNode {
    sds ele;                    // 成员对象(member)
    double score;               // 分值
    struct zskiplistNode *backward;  // 后退指针(Level 1 的前驱)
    struct zskiplistLevel {
        struct zskiplistNode *forward;  // 前进指针
        unsigned long span;             // 跨度(到下一个节点的距离)
    } level[];                  // 层数组,柔性数组
} zskiplistNode;

// 跳表定义
typedef struct zskiplist {
    struct zskiplistNode *header, *tail;  // 头尾节点
    unsigned long length;                  // 节点数量
    int level;                             // 最大层数
} zskiplist;

关键字段解释:

  • ele:存储 member(如 "张三")。
  • score:存储分值(如 85.5),跳表按此字段排序。
  • backward :后退指针,只在最底层(Level 1)使用,支持从尾向头遍历(如 ZREVRANGE)。
  • level[] :柔性数组,每个元素包含 forward(前进指针)和 span(跨度)。span 用于计算排名,ZRANK 命令就是通过累加 span 得到的。
  • header:跳表有一个特殊的头节点,不存储数据,它的层数始终等于跳表的最大层数。

三、跳表的核心操作

img

上图总结了跳表的四种核心操作:

  • 查找 :从最高层开始逐层二分,时间复杂度 O(logN)。这是 ZSCOREZRANK 等命令的基础。
  • 插入:先找到插入位置,然后随机生成新节点层数,更新每层的前后指针。同时往字典中插入一条记录。时间复杂度 O(logN)。
  • 删除:先找到目标节点,更新每层指针跳过被删节点,释放内存。同时从字典中删除。时间复杂度 O(logN)。
  • 范围查询:先用跳表定位到起始 score(O(logN)),然后沿 Level 1 链表向后遍历 M 个元素。总时间复杂度 O(logN + M),M 为结果集大小。

四、为什么 Redis 用跳表而不用红黑树?

这是面试中 最常被追问 的问题。

img

上图对比了跳表和红黑树的核心差异:

  • 范围查询更高效 :跳表的最底层就是完整的有序链表,定位起点后直接向后遍历即可,内存连续访问对 CPU 缓存友好。红黑树做范围查询需要中序遍历,涉及大量左右子树指针跳转,缓存不友好。ZSet 最常用的操作就是范围查询(ZRANGEZRANGEBYSCORE),跳表在这方面有天然优势。
  • 实现更简单:跳表的插入和删除只需要修改前后指针,不需要复杂的旋转和变色操作。Redis 作者 antirez 曾明确表示,跳表的代码更简洁、更容易理解和维护。
  • 排名计算更方便 :跳表通过 span 字段可以 O(logN) 计算排名,而红黑树需要额外维护子树大小信息(类似 Order Statistic Tree),增加实现复杂度。
  • 内存可调节:通过调整随机层数的概率参数(Redis 默认 0.25),可以在内存占用和查找性能之间灵活权衡。

五、ziplist / listpack(小数据量的紧凑存储)

img

上图展示了 ZSet 在元素较少时的紧凑存储方式:

  • listpack(Redis 7.0+) :一块连续内存,member 和 score 交替排列,没有指针开销。元素按 score 有序排列,查找需要遍历但 N 很小(≤128),完全可以接受。
  • ziplist 的问题 :旧版 ziplist 的每个元素头部存储了前一个元素的长度(prevlen),当前一个元素大小变化时,可能引发后续所有元素的 prevlen 字段连锁更新,最坏 O(N²)。
  • listpack 的改进 :不再存储 prevlen,每个元素只记录自己的长度,彻底消除了级联更新问题。
  • 阈值可配置zset-max-listpack-entries 控制最大元素数量,zset-max-listpack-value 控制最大元素大小。超过任一阈值就会转换为跳表。

六、ZSet 常用命令与底层操作对应

命令 功能 底层操作 复杂度
ZADD 添加元素 dict 插入 + skiplist 插入 O(logN)
ZSCORE 查分数 直接查 dict O(1)
ZRANK 查排名 skiplist 累加 span O(logN)
ZRANGE 按排名范围查 skiplist 遍历 O(logN + M)
ZRANGEBYSCORE 按分数范围查 skiplist 定位 + 遍历 O(logN + M)
ZREM 删除元素 dict 删除 + skiplist 删除 O(logN)
ZCARD 元素总数 直接读 length O(1)
ZCOUNT 分数范围内数量 skiplist 定位两端 O(logN)

关键点:

  • ZSCOREZCARD 是 O(1),因为字典直接查、length 字段直接读。
  • 范围查询都是 O(logN + M),先用跳表定位起点,然后沿链表遍历 M 个元素。

面试高频追问

  1. 追问一:跳表的层数是怎么确定的?

    每次插入新节点时,通过随机算法决定层数。Redis 使用的是 幂次定律(power law) :每个节点有 25% 的概率再往上一层延伸,最多 32 层。这意味着大约 75% 的节点只有 1 层,25% 有 2 层,6.25% 有 3 层......层数越高节点越稀疏,从而保证查找效率接近 O(logN)。

  2. 追问二:ZSet 中 member 相同、score 不同会怎样?

    ZSet 中每个 member 是唯一的。如果 ZADD 一个已存在的 member 但 score 不同,Redis 会 更新 score(先从跳表旧位置删除,再按新 score 插入新位置),同时更新字典中的 score。

  3. 追问三:ziplist 和 listpack 的区别?为什么要替换?

    核心区别在于 prevlen 字段。ziplist 每个元素头部存储了前一个元素的长度,当前一个元素从 < 254 字节变为 ≥ 254 字节时,prevlen 从 1 字节变为 5 字节,可能引发后续所有元素的连锁更新(级联更新),最坏 O(N²)。listpack 去掉了 prevlen,每个元素只记录自身长度,彻底消除了这个问题。Redis 7.0 将所有使用 ziplist 的地方都替换成了 listpack。

常见面试变体

  • 变体一:"Redis 为什么用跳表而不用红黑树?"
  • 变体二:"ZSet 的底层实现是什么?"
  • 变体三:"跳表的时间复杂度是多少?查找过程是怎样的?"
  • 变体四:"Redis 7.0 的 listpack 和 ziplist 有什么区别?"

记忆口诀

ZSet 底层 :小数据用 listpack(省内存),大数据用 skiplist + dict(高性能)。

跳表核心:多层索引 + 随机层数,O(logN) 查找,范围查询天然友好。

选跳表不选红黑树:范围查询快、实现简单、排名方便、内存可调。

两个结构各司其职:dict 管 O(1) 按 member 查 score,skiplist 管 O(logN) 按 score 范围查询。

总结

Redis ZSet 底层采用 跳表 + 字典 的组合结构(元素少时用 listpack)。字典实现 O(1) 按 member 查 score,跳表实现 O(logN) 的范围查询和排名计算。Redis 选择跳表而非红黑树,是因为跳表在范围查询、实现复杂度、排名计算等方面都有优势。Redis 7.0 用 listpack 替换了 ziplist,彻底解决了级联更新问题。

相关推荐
码云数智-大飞2 小时前
C++ RAII机制:资源管理的“自动化”哲学
java·服务器·php
2601_949816582 小时前
Spring+Quartz实现定时任务的配置方法
java
计算机毕设指导63 小时前
基于SpringBoot校园学生健康监测管理系统【源码文末联系】
java·spring boot·后端·spring·tomcat·maven·intellij-idea
mysuking3 小时前
springboot与springcloud对应版本
java·spring boot·spring cloud
希望永不加班3 小时前
SpringBoot 数据库连接池配置(HikariCP)最佳实践
java·数据库·spring boot·后端·spring
迈巴赫车主3 小时前
蓝桥杯3500阶乘求和java
java·开发语言·数据结构·职场和发展·蓝桥杯
身如柳絮随风扬4 小时前
Lambda、方法引用与Stream流完全指南
java·开发语言
yaoyouzhong4 小时前
基于SpringBoot和PostGIS的云南与缅甸的千里边境线实战
java·spring boot·spring
姗姗的鱼尾喵4 小时前
Spring/SpringBoot 面试高频(含IOC/AOP/事务)
java·spring boot·面试