ZSet 是 Redis 最容易被神化的数据类型之一。排行榜、延迟队列、滑动窗口、优先级任务都能往它身上套,但它并不是一个没有代价的排序容器。

ZSet 的关键在于同时满足两类访问:按 member 找 score,以及按 score 找排名和范围。小 ZSet 可以用 listpack,规模上来后则要同时维护 dict 和 skiplist。

先把机制边界说清楚

这一篇只讲 ZSet 的排序结构和工程边界,不把所有延迟任务问题都归结为 ZSet。延迟任务还需要 ack、重试、幂等和补偿,这些不是 ZSet 本身提供的。

整体路径

ZSet 的双索引结构

上面这张图先把主线铺开:dict 查成员,skiplist 查排名,一次写入维护两份结构。ZSet 的成本不只是命令本身,更取决于它当前在哪种编码、成员规模有多大、以及你用哪种访问方式(点查/范围/排名)——这些决定了主线程要走多远、内存要占多少。

底层机制

  • 小 ZSet 用 listpack 顺序存储 member/score,节省指针和节点开销。超过 zset-max-listpack-entries(默认 128)或任一 member 长度超过 zset-max-listpack-value(默认 64 字节)后,转换为 dict + skiplist 组合编码。这两个阈值是 ZSet 编码切换的硬门槛。
  • 大 ZSet 使用 dict + skiplist:dict 负责按 member 查 score(O(1)),skiplist 负责排序、范围和排名(O(logN))。
  • 为什么是 dict + skiplist 双结构而不是只用一个——这是 ZSet 的设计核心,单用 skiplist 时 ZSCORE 要 O(logN) 去查太慢,单用 dict 时又做不了范围/排名查询。双结构把单点查的 O(1) 和范围查的 O(logN) 同时拿下,代价是一次写入要维护两份结构、内存占用更高。
  • 跳表的层高怎么决定:每个节点插入时按概率 p = 1/4(0.25) 随机决定层数,最高 32 层。这个随机层高换来实现简单和范围遍历便利,平均查找复杂度 O(logN),接近平衡树但避免了平衡树调整时的旋转开销。
  • 一次 ZADD、ZREM 或分数更新要同时维护两套结构,写入成本高于普通 Set。

判断一个 ZSet 划不划算,要看它是停在 listpack 还是已经升级到 dict+skiplist——后者内存翻倍但换来范围能力;同时要看访问方式是点查分数、范围拉取还是排名,不同访问落在不同结构上。

取舍与边界

ZSet 的优势是范围查询和排名,弱点是深分页、超大范围返回和频繁重排。它适合热榜窗口,不适合无限历史排名。

典型问题:用机制化例子排查

  • 排行榜要有时间窗口或分桶,避免一个 ZSet 无限增长。
  • 分页尽量用分数游标,不要长期依赖深 offset。
  • 延迟队列要额外设计消费确认和失败重试,不要只靠 ZRANGEBYSCORE + ZREM
  • 慢查询里看到大范围 ZRANGEZREMRANGEBYSCORE 时,先看返回量和 key 大小。

收束:一句判断

ZSet 能让排序变简单,但不会让排序成本消失。


关于十三Tech

我是十三,All in AI Agent 方向的架构师,专注 AI 工程实践。

我相信 AI 是程序员的最佳搭档,也希望帮助每一位开发者更好地驾驭 AI。

如果你想继续跟完这套「图解 Redis」,欢迎关注公众号 「十三Tech」。后续会继续按数据结构、底层机制、持久化、高可用和实战排查这条线更新。

十三Tech公众号二维码