真实面经题目 · 原创解析
Redis ZSet 底层如何实现?
ZSet 的底层实现要抓住两点:它同时需要按 member 快速查分数,又需要按 score 有序范围查询。因此典型实现是字典加跳表,小规模时使用紧凑编码以节省内存。
真实面经题目 · 原创解析
ZSet 的底层实现要抓住两点:它同时需要按 member 快速查分数,又需要按 score 有序范围查询。因此典型实现是字典加跳表,小规模时使用紧凑编码以节省内存。
Redis ZSet 是有序集合,每个 member 对应一个 score,member 唯一但 score 可以重复。为了同时支持快速按 member 查询和按 score 排序范围查询,ZSet 通常由 dict 和 skiplist 共同实现:dict 保存 member 到 score 的映射,查分数、判断成员是否存在接近 O(1);skiplist 按 score 排序,score 相同时再按 member 字典序排序,支持范围查询、排名、删除等操作,复杂度通常是 O(logN) 加返回元素数量。对于元素少、成员较短的小 ZSet,Redis 会使用 listpack 等紧凑编码节省内存,超过阈值后转换为跳表加字典结构。
ZSet 既是集合,又带有排序能力。member 必须唯一,score 用来排序,多个 member 可以拥有相同 score。它要支持 ZSCORE 这种按 member 查分数,也要支持 ZRANGE、ZREVRANGE、ZRANGEBYSCORE 这类按顺序或分数区间取数据。如果只用哈希表,范围查询很差;如果只用有序结构,按 member 更新和查找又不够直接。
dict 的作用是保存 member 到 score 的映射,用于快速判断 member 是否存在、获取当前 score,以及更新分数时先找到旧值。这个结构让 ZSCORE、ZADD 判断覆盖、ZREM 定位成员等操作具备接近 O(1) 的查找能力。没有 dict 的话,每次按 member 操作都要在有序结构中查找,复杂度和实现成本都会明显上升。
skiplist 按 score 维护全局有序关系,score 相同则按 member 字典序打破平局。跳表通过多层前进指针把查找、插入、删除的期望复杂度控制在 O(logN),同时底层链表又天然适合顺序遍历区间结果。它比平衡树实现更简单,范围扫描也直接,因此很适合 ZSet 的排序和排名类命令。
当 ZSet 元素数量少、member 长度也较小时,使用 dict 加 skiplist 的指针开销会显得过高。Redis 会用 listpack 这类紧凑连续内存编码保存 member-score 对,牺牲一定查找复杂度来换取更低内存占用和更好的缓存局部性。超过配置阈值后,再转换为更适合大数据量操作的跳表加字典结构。
执行 ZADD 时,Redis 会先通过 dict 判断 member 是否存在;如果是新成员,就插入 dict 和 skiplist;如果 score 改变,需要从 skiplist 删除旧节点再插入新节点,同时更新 dict。范围查询则主要走 skiplist,先定位起点,再沿底层链表顺序返回结果。两个结构必须同步维护,才能同时保证查询效率和排序正确性。
哈希表能快速按 member 查 score,但不能高效维护 score 顺序。范围查询、排名查询需要排序能力,只用哈希表会退化为全量扫描再排序,成本很高。
跳表适合按 score 查找和范围遍历,但按 member 直接定位不如哈希表方便。ZSet 经常需要判断成员是否存在和读取旧 score,所以需要 dict 补足这条访问路径。
score 相同时会继续按 member 的字典序比较,形成确定的全序关系。这样即使多个元素分数一样,范围遍历和排名结果也可以稳定定义。
大集合编码下,查 member 依赖 dict 接近 O(1),插入或更新 skiplist 通常是 O(logN)。如果返回或处理多个元素,还要加上结果数量带来的线性成本。