一提起 Redis 里的 Zset,很多人第一反应就是“跳表”。这个答案放在面试里能拿一半分,但放到线上排查场景里基本帮不上忙。因为 Zset 的底层实现并不是一个固定结构:数据量小的时候,它是一段紧凑的连续内存;数据量涨上去之后,才变成我们常说的 dict + skiplist 的复合结构。作为写排行榜、延时队列、在线列表时最常依赖的数据结构,Zset 的底层实现决定了它的内存占用、写入延迟和范围查询效率。这篇不打算写成面试八股,而是把 listpack、跳表、字典这三样东西拆开讲清楚,再补上我在调参和排查慢查询时用过的方法。搞清楚这套东西,至少能帮你在线上遇到 Zset 相关问题时少走几个弯路。
1. Zset 想同时解决的问题,决定了底层不能只有一种结构
1.1 常用命令背后藏着两种完全不同的访问模式
我们先别看代码,先想想 Zset 平时都在干什么。用排行榜举例:要向集合里添加一个成员和分数,用 ZADD;要看某个成员的排名,用 ZRANK;要看分数区间内的成员,用 ZRANGEBYSCORE;要更新某个成员的分数,用 ZINCRBY;要删除某个成员,用 ZREM。
这些操作归纳下来,其实就是两类需求。
第一类是按成员操作:你需要根据一个唯一的成员名,快速找到它的分数,或者判断它到底存不存在。典型命令是 ZSCORE、ZINCRBY、ZREM。这类操作的最优形态是哈希表,查一次 O(1)。
第二类是按顺序操作:你需要把整个集合按分数排好序,然后支持按排名取一段、按分数区间取一段。典型命令是 ZRANGE、ZREVRANGE、ZRANGEBYSCORE、ZRANK。这类操作的最优形态是有序结构,比如跳表、平衡树,至少也得是个能顺序遍历的链表。
这两种访问模式是同时存在的。也就是说,Zset 不能只满足一种需求,它必须同时保证“按成员查得快”和“按分数排得稳”。这正是它底层结构复杂的根本原因,也是你理解后面所有设计细节的出发点。
1.2 如果只用一种数据结构,会怎样
假设只用一个普通链表来存 Zset,顺序遍历很舒服,添加的时候也可以插到有序位置。但你想查某个成员的分数,最坏情况要遍历整个链表,数据量一上来就是灾难。
假设只用一个哈希表,按成员查找是秒的,但排序怎么办?每次 ZRANGE 都要把全部成员捞出来做一次排序,这在热路径上完全不可接受。
有人会说,那用红黑树或 B+ 树不就行了?红黑树确实能保持有序,也能在 O(log n) 时间内插入和查找,但实现一个带排名功能的红黑树并不轻松,每个节点还得维护子树大小,旋转和删除的边界情况非常多。B+ 树更适合磁盘页这种块设备场景,Redis 的数据全部在内存里,为了磁盘扇区去设计并不划算。
所以 Redis 的选择是:小数据量用紧凑编码省内存,大数据量用哈希表 + 跳表的组合。前者解决成本问题,后者解决效率和复杂度问题。这也是很多人理解 Zset 时最容易漏掉的关键点:你看到的跳表,只是它在大数据量阶段的一半结构,不是全部。
2. 数据量还小的时候:listpack 的紧凑编码
2.1 它是怎么组织内存的
早期版本里,Zset 小数据量用的是 ziplist,从 Redis 7.0 开始,ziplist 逐渐被 listpack 取代。不管是老的 ziplist 还是新的 listpack,思路都是一样的:牺牲写入时的移动成本,换来极致的连续内存布局和缓存友好。
你可以把 listpack 理解成一段连续字节数组,里面把 zset 的成员和分数按顺序排列,默认按照分数升序排列,分数相同的按成员字典序排列。每个条目由编码类型、元素字节数、真实数据和尾部长度字段组成。这样做的好处是,不需要像跳表那样为每个元素维护一堆指针,一个成员和一个分数在内存里就是紧密贴在一起的几段字节。
这种结构特别适合小集合。比如一个只有几十个元素的排行榜,整个 listpack 可能就几百字节,一次内存读取就能把全部热点数据命中在 CPU 缓存里。相比跳表那种节点到处分散、访问要靠指针跳跃的结构,缓存命中率完全不是一个量级。
2.2 ziplist 升级为 listpack:为了消灭一个隐蔽的“级联更新”
如果你在旧版本上看到过“ziplist 级联更新导致耗时飙升”的分析,应该知道这里的痛点在哪。ziplist 的每个条目里保存的是“前一个条目的长度”,存储这个长度所用的字节数是不固定的。假设某个条目的 prevlen 只用 1 字节就能表示,后来前面的条目因为数据变大,长度超过了 1 字节的表示范围,这个条目的 prevlen 字段就必须从 1 字节扩成 5 字节。一旦它变大,它又会影响下一个条目的 prevlen,于是连锁反应一直传播到整个列表尾部。这就是级联更新。
listpack 的改进是把长度信息放到当前条目自己的尾部,而不是记录上一个条目的长度。于是某个条目自身的长度变化,不会反过来影响后面那条数据的头部。这样就从设计层面上把级联更新彻底消灭了。虽然这种影响在小型 zset 里概率不高,但在极端情况下确实会让一次插入变成 O(n) 级别的批量内存移动。listpack 的出现,相当于把这一块不稳定性给抹掉了。
2.3 什么时候退出 listpack,什么时候该把阈值调大
Zset 不会一直停在 listpack 上。触发转换的是两个配置参数,在 Redis 7 版本里是zset-max-listpack-entries和zset-max-listpack-value,默认值分别是 128 和 64。意思就是:如果 listpack 里的元素个数超过 128,或者某个成员的字节长度超过 64,那么新元素插入或者分数更新时,整个 Zset 会被转换成 skiplist 编码。
为什么默认值这么保守?因为 listpack 的插入是线性定位加 memmove 式移动。在几十个元素时这个开销可忽略,但当你把阈值调到几千甚至几万,每次插入都要在这段连续内存里找到正确位置,再把后面的数据整体移动。Redis 是单线程处理命令的,这个移动过程是同步进行的,等于一个命令卡住,后面所有请求都在排队。
如果你维护的是一个小量、读多写少的 Zset,比如存一个固定长度的活跃主播列表,几十个元素反复读,那调大zset-max-listpack-entries确实能省掉跳表的指针开销,内存占用也会明显下降。但如果你那是一个持续大量写入的排行榜,就不要轻易调大,保持默认或者调小更稳妥。我在实际调试里见过最典型的翻车现场,就是有人把zset-max-listpack-entries调到 10000,结果一次 ZADD 的延迟从微秒级变成了几十毫秒,整个业务直接被拖住。
3. 真正撑起海量有序数据的,是 dict + skiplist 双引擎
3.1 进入大集合后,内存布局长什么样
当元素个数超过阈值,或者成员长度超过阈值,Redis 会把 Zset 转成一个组合对象:里面同时维护一个 dict 和一个 zskiplist。
dict 负责按成员找分数,键是成员字符串,值是这个成员的分数;skiplist 负责按分数排序,每个节点里也存了成员和分数。也就是说,同一个成员的分数在组合结构里会存两份。听起来像是浪费内存,其实这是刻意为之:dict 让 ZSCORE、ZINCRBY、ZREM 这些按成员操作变成 O(1);skiplist 让 ZRANGE、ZRANGEBYSCORE、ZRANK 这些按顺序操作保持 O(log n)。缺了任何一半,另一半的性能都撑不住。
比如只有 skiplist 没有 dict,ZSCORE 要在跳表里按“分数+成员”做一次搜索,复杂度从 O(1) 变成 O(log n),虽然也能接受,但 Redis 又是排行榜又是计数器的场景里,ZSCORE 是极高频率操作。多维护一个 dict 能把这个操作压到常数级,值。
反过来,只有 dict 没有 skiplist,排序查询就全废了。所以这个双引擎结构不是懒设计,是被两类访问模式逼出来的。
3.2 跳表节点里为什么有 level[].span 和 backward
如果你去看过 zskiplistNode 这个结构,会发现它跟我们教学里常见的跳表还有区别。常用教学版跳表节点只有 forward 指针和元素值,而 Redis 里的跳表节点多出几个东西:一个 backward 指针、一个 score 字段、一个成员的 sds 字符串,还有一个 level 数组,数组里每个元素包含 forward 指针和 span。
span 是“跨度”,表示当前层的这个 forward 指针往后跳了“多少个元素”。有了 span,Redis 就可以在 O(log n) 时间内算出某个成员的名次,也就是 ZRANK 的实现基础。普通跳表要拿到排名,往往得从头数到尾,复杂度退化成 O(n)。Redis 因为每个节点都记录了每一层走过的步数,所以查排名时顺着搜索路径把 span 累加一下就行了。
backward 指针用来支持从尾向头遍历,也就是 ZREVRANGE 这类倒序命令。只保留第一层的 backward 就够了,因为倒序遍历就是沿着第一层链表从后往前走。
另外,跳表的 header 是一个不存储实际数据的头节点,它的 level 数组长度固定为最大值,也就是 32 层。这样在查找的时候,代码可以从最高层开始往下走,不需要动态判断当前跳表是否已经升到这么高。
3.3 每次增删改,dict 和 skiplist 是如何配合的
拿最常见的 ZADD 举例。命令进来之后,Redis 先查 dict,看成员是不是已经存在。
如果成员已经存在,那就看分数有没有变化。分数没变,几乎什么都不做,或者只做很小的更新;分数变了,就要先在 skiplist 里把旧节点删掉,再按新分数插入到正确位置,同时还要更新 dict 里存着的分数值。这一步虽然是“删了再插”,但因为跳表的插入和删除都是 O(log n),整体开销可以接受。
如果成员不存在,那就先往 skiplist 里插入新节点,再往 dict 里添加成员和分数。注意顺序,Redis 对这个顺序是很敏感的,出了错就会出现“dict 里有成员但跳表里找不到”的脏状态。为了保证这一层正确性,删除时也必须两边同步删:先从跳表找到节点并移除,再从 dict 删除成员。
所以你可以看到,这个复合结构在单条命令里是自洽的,但代价就是每一份数据都被存了两遍。这也是为什么 Zset 在成员特别长的时候,内存占用会比普通哈希结构高出一大截。
下面这张表可以更直观地看到不同操作在两种编码下的性能表现,方便你合理预期:
| 操作场景 | listpack 小集合 | dict + skiplist 大集合 |
|---|---|---|
| 按成员查分数 ZSCORE | 线性扫描,小量下很快 | dict 查,O(1) |
| 插入新成员 ZADD | 找到位置后移动内存,O(n) 但 n 很小 | skiplist 插入 O(log n),再写 dict |
| 取排名 ZRANK | 从头数,O(n) 但 n 很小 | 利用 span 累加,O(log n) |
| 按分数区间取成员 | 线性扫描,连续内存读取快 | 跳表定位起点后沿链表走,O(log n + m) |
| 删除成员 ZREM | 线性扫描后删除,O(n) 但 n 很小 | 跳表删除 O(log n),再删 dict |
4. 随机层高与复杂度:跳表的时间复杂度是怎么来的
4.1 每个节点的层数是怎么定的
跳表之所以能实现 O(log n),靠的不是“保证树的左右子树平衡”,而是“层高随机”。
Redis 在插入每个跳表节点时,会从第一层开始,按概率 p=1/4 决定是否继续往上加一层,最高不超过 32 层。也就是说,大约 1/4 的节点会有第 2 层,1/16 的节点会有第 3 层,1/64 的节点会有第 4 层,以此类推。这个概率设计得比 1/2 更保守,牺牲了一点搜索速度,换来了更少的内存浪费。
从概率上讲,节点层高的期望值约等于 1.33,也就是大多数节点其实只有一或两层。而高度达到 32 层的概率是 (1/4)^31,小到几乎可以忽略不计。所以虽然跳表的最坏情况理论上是 O(n),但在随机化层高的设计下,实际运行接近 O(log n),这也是平衡树用旋转强制平衡,而跳表靠概率自然平衡的核心思路。
如果你更严谨一点,可以这样理解:查找一个元素时,从 header 的最高层出发,每次遇到“前方节点的值已经超过目标”就往下层走,否则就向右走。高层节点就像一个“高速路网”,一次能跨过很多低层节点。层数越高,跨越范围越大,所以整体搜索路径的长度就近似于 log n 级别。
4.2 插入过程里 update 数组和 span 的联动
插入新节点的时候,Redis 先走一遍查找流程,沿途记录下每一层需要更新的“前驱节点”,存在一个叫 update 的数组里。等找到插入位置后,再根据新节点的随机层高来确定新节点的每一层 forward 应该指向谁。
这里有个细节:如果新节点的随机层高比当前跳表的最高层还高,那么超出部分对应的 update 元素要填成 header。因为新节点会成为新的最高层节点,它的前驱就是头节点。
update 数组确定之后,还要反向调整 span。新节点每一层的 span 可以先简单理解成“原来前驱节点在这一层跨过的距离,减去从起点到新插入位置的偏移”,而前驱节点每层的 span 也要同步剪开。删除节点时逻辑类似,只是把 forward 和 span 的调整反向处理。这块代码看起来弯弯绕绕,但你只要记住一句话:span 不是一个摆设,它是维护 ZRANK 和 ZRANGE 效率的关键,所以插入删除时必须紧跟前驱节点做精确地对齐。
4.3 为什么不是红黑树,也不是 B+ 树
聊到这里,一定会有人问:既然要 O(log n) 的有序结构,红黑树或者 B+ 树不是更正统吗?
红黑树的问题在于实现复杂。为了让树保持平衡,插入删除都要做左旋右旋,还要处理红黑节点的颜色约束,这在高频迭代中是很大的维护成本。跳表的实现相对直观,代码里无非就是指针的换向和 span 的关系更新,写错的可能性低很多。
B+ 树是为磁盘 IO 优化的,所有叶子节点串成链表存储在一批固定大小的页里。但 Redis 是内存数据库,它不需要考虑磁盘扇区对齐,反而更在意指针跳跃带来的缓存开销。既然内存里随机访问成本远低于磁盘,那跳表这种“用链表模拟二分搜索”的做法就足够坚韧了。
还有一点很关键:Redis 的 Zset 经常要支持 ZRANGE 这种按排名随机访问。跳表加上 span 字段后,按排名取节点非常自然;而红黑树或者 B+ 树虽然也能维护子树节点数和排名信息,但操作起来就没那么顺手。综合考虑实现、查错、排名、内存这几个维度,跳表确实是 Zset 场景下的一个相当工程化的选择。
5. 从排查慢查询到参数调优:这些细节能实实在帮到你
5.1 先查编码,再怀疑底层
线上遇到 Zset 相关的慢查询,我习惯第一步就查看这个 key 当前的编码方式,而不是直接猜跳表有问题。
命令很简单:OBJECT ENCODING key。如果返回的是listpack,说明这个 Zset 还处于小数据量编码;如果返回的是skiplist,说明已经转成了复合结构。这一步非常关键,因为它决定了你处理问题的思路完全不同。
比如一条 ZRANGEBYSCORE 慢查询,如果 key 编码是listpack,那么不管这个集合名义上有多大,它都在走线性扫描。问题不是“跳表变慢了”,而是“它根本还在用 listpack,阈值配得太大”。如果返回的是skiplist,那慢的原因可能是匹配区间过大,返回的成员数太多,或者命令本身设计得有问题,比如在循环里高频地做区间查询造成 CPU 浪费。
老版本里也可以用DEBUG OBJECT key看信息更全,但线上很多时候不会有 debug 权限,OBJECT ENCODING是大面上兼容的。
5.2 慢日志里的 ZRANGEBYSCORE 可能不是跳表的锅
我还见过一个挺有意思的案例。某个项目把zset-max-listpack-entries调到了 5000,理由是“想省内存”。结果线上出现了不少 ZRANGEBYSCORE 慢查询,看慢日志时第一时间以为跳表查找效率不行。实际上用OBJECT ENCODING查了一下,这些 key 全都在 listpack 编码里。
为什么会慢?因为 listpack 编码下,ZRANGEBYSCORE 只能从头开始遍历整个 listpack,逐个解码元素再判断分数是否落在范围内。5000 个元素听起来不多,但每个元素都要走一遍解析过程,而且这个解析不是 O(1) 的数组取值,要处理变长编码。整个操作算下来,延迟比跳表编码要高一截。问题不在跳表,而在你强行把一个本来不该待在 listpack 里的集合按在 listpack 里。
处理办法也很简单:要么把这几个 key 的zset-max-listpack-entries调回默认值 128,要么主动触发转换,把已有大 key 从 listpack 编码变成 skiplist。
5.3 内存估算和 zset-max-listpack-entries 的调节策略
内存方面,listpack 因为所有数据挤在一段连续内存里,每个元素的开销主要就是数据和必要的长度字段,非常省。跳表则是另一套账:每个节点除了成员字符串之外,还要维护 level 数组里的 forward 指针和 span,层高虽然是随机的,平均下来每个节点要额外吃几十字节甚至上百字节。再叠加 dict 里的指针开销,整体内存放大很明显。
如果你想精确点,可以用MEMORY USAGE key实测某个 key 当前占用的内存。改造前后可以做一个简单对照:
| 配置方向 | 内存表现 | 写入性能 | 适用场景 |
|---|---|---|---|
zset-max-listpack-entries调大 | 明显降低 | 插入线性移动,可能变慢 | 只读或读多写少的小集合 |
zset-max-listpack-entries保持默认 | 常规 | 小集合正常,大集合自动转跳表 | 通用场景,推荐 |
zset-max-listpack-entries调小 | 略增 | 更早转为跳表,写入更稳 | 高并发写排行榜 |
我的建议是,如果你没有特别强烈的内存诉求,就别动它。默认 128 是一个在小数据量缓存友好和大数据量稳定结构之间比较平衡的阈值。真要调,先把要调的 key 用业务量级估算清楚,别一上来就拍脑袋。
5.4 分数用 double 的精度坑
Zset 的 score 底层是 double 类型。这在常规业务里一般够用,但如果你在分数里塞了小数运算,比如用 ZINCRBY 做累加,反复加 0.1 这种数值,迟早会踩到浮点误差。
举个例子,0.1 在二进制浮点里本来就是无限循环小数,你累加好几次之后,实际存储的分数跟想象中的整数不一定完全一致。它也许不影响最终排序,但在某些排行榜里,它可能让分数出现 1.999999 这种丑陋结果,影响回显和判断。
实际项目里我更推荐用整数来设计分数。比如把比分扩大 100 倍或 1000 倍,用整数累加,或者直接用毫秒时间戳做排序依据。还有一个技巧是,当你需要“分数相同”时再按插入时间排,可以给分数加上一个逐渐递减或者递增的小数后缀,但是 double 有精度上限,电商别太复杂。更稳妥的做法是在成员 ID 上附加一个时间权重字段,让排序天然稳定。
6. 边界行为和面试里值得主动讲出来的点
6.1 从大编码缩小后不会回到 listpack
Zset 从 listpack 转换成 skiplist 之后,即使你把元素一个个删掉,删到只剩几个甚至空集合,它也不会自动退回 listpack。
这个设计是有意的。如果进行数据量缩小就能自动降级,那在高频删除和新增交替的场景下,zset 会反复在两种编码之间横跳,每次转换都要做全量数据重建,带来的性能抖动远比一直维持 skiplist 严重。所以 Redis 用“编码只升不降”来换取稳定。
实际影响就是:如果一个 key 曾经大过,那么即使现在很小,它的内存占用也不会恢复到紧凑编码的水平。如果你在线上做容量优化,发现某些 Zset 明明只有几十个成员,内存却居高不下,先查查它是不是已经跌进了 skiplist 的坑。实在要缩内存,只能把 key 重建一次,比如用新 key 重新 ZADD。
6.2 分数相同时的排序与字典序细节
很多人以为 Zset 只会按 score 排序,这个理解不完整。当两个成员的 score 完全一样时,Redis 用成员的字符串字典序作为第二排序关键字。
这就带来一个容易犯错的地方:如果你的业务里大量成员的分数相同,排序结果看起来像“乱的”,其实是稳定的字典序。想控制顺序,就不要让分数频繁撞车;想让某个成员排前面,可以把它做成字典序更靠前的字符串,但工程上更好的办法还是我刚才说的,让分数带有足够区分度。
6.3 聊底层时值得主动补充的加分点
如果在技术交流或面试里被问到 Zset 底层,别只背结构名。比较好的回答路径是:先讲 Zset 面对的两类访问模式,解释为什么需要哈希表加有序结构;再讲小数据量的 listpack 和大数据量的 skiplist 切换,解释阈值参数和级联更新;最后落到跳表的随机层高、span 对排名的支持,以及为什么不用红黑树和 B+ 树。
这条路径下来,你不仅能回答“是什么”,还能把“为什么这么设计”“在什么场景下出问题”“怎么调优”都串起来。比单纯丢出一句“底层是跳表”要有价值得多,也更能看出你对这中间权衡是真懂还是背过。
我自己看完这块实现的感受是,Zset 的底层更像是一组为了特定场景而做的工程取舍。listpack 为了小数据量牺牲写入性能换取内存和缓存,跳表和字典的组合为了大数据量牺牲冗余内存换取稳定查询。没有哪个结构是绝对最好的,只有在特定数据量和访问模式下最合适的。下次线上再遇到 Zset 变慢,也别急着怀疑 Redis 是不是出了问题,先查编码、再看命令模式、最后看配置,这一套排查下来,大多数问题都能定位到具体原因。