Zset 是 Redis 里最让我觉得“设计得很聪明”的数据结构,没有之一。很多人用 Zset 做排行榜、延迟队列,用得飞起,但真要问一句“Zset 底层到底怎么存的?为什么 ZRANGE 能那么快?为什么它一边能 O(1) 查 score,一边还能 O(logN) 做范围查询?”——能讲清楚的人立刻少一大半。
这篇就从底层实现讲起,把 Zset 的两套存储结构、跳表的核心机制、复杂度推导、以及我在线上实际踩过的坑一次说透。后端开发、中间件维护者、准备面试的朋友都适合看,尤其是想真正理解 Redis 而不只是背八股的人。
1. Zset 到底是干嘛的,为什么要单独设计它
1.1 一个结构同时要两种能力
先回到最基础的问题:Zset(有序集合)和普通的 Set、List、Hash 到底差在哪?
Set 能去重,但无序;List 能有序,但没法按“权重”排序;Hash 能存字段,但字段之间没有先后关系。Zset 的核心诉求是:每个元素关联一个 double 类型的 score,并且集合内部永远按 score 维护好顺序,你随时可以按 score 的范围取出一段元素,也可以按排名取出元素。
这种需求在业务里太常见了:
- 游戏排行榜:玩家 ID 是元素,积分是 score,要“查第 1 名到第 50 名”
- 延迟队列:任务 ID 是元素,执行时间戳是 score,要“取出当前时间之前的所有到期任务”
- 滑动窗口限流:请求 ID 是元素,时间戳是 score,要“删除窗口外的记录,统计窗口内的数量”
问题在于,这些操作对数据结构提出了两种互相矛盾的要求。按元素查 score 需要哈希表的速度;按 score 排序和范围扫描又需要有序结构的能力。如果只用一个有序数组,插入删除的代价是 O(N);如果只用一个哈希表,范围查询就废了。
Redis 的解法很直接:Zset 底层同时维护两个结构,一个 dict 负责精确查找,一个 skiplist 负责有序操作。查“某个元素当前的 score 是多少”走 dict,O(1);查“排名 100 到 200 之间的元素”走 skiplist,O(logN)。
1.2 用图书馆的类比理解双结构设计
打个比方你就明白了。一家图书馆有两套索引:一套是“书名到书架位置的卡片目录”,按书名首字母快速定位——这就是 dict。另一套是“按出版日期排列的书目清单”,每本书都记录了它前后是哪本书——这就是 skiplist。
你要找某本书在哪,走卡片目录,一翻就到。你要查“最近三个月出版的书”,走时间清单,顺着链表从前往后扫就行。各有各的用武之地,谁也替代不了谁。Zset 就是把这两个索引放在同一份数据上,通过指针让同一条记录在两个结构里都能被访问到。
这也解释了为什么 Zset 的内存开销比普通的 Set 大:它要为每个元素同时维护哈希表条目和跳表节点。后面讲内存优化的时候,这个结论会反复用到。
2. 编码方式的演进:从紧凑存储到跳表
2.1 Zset 的两种底层编码
很多人以为 Zset 底层就是跳表,这其实是个常见的误区。Redis 的 Zset 根据元素个数和元素大小,会选择两种完全不同的编码方式:
- OBJ_ENCODING_LISTPACK(老版本是 ZIPLIST):把所有元素按 score 升序排好,紧凑地存在一块连续内存里,成员和 score 交替排列。
- OBJ_ENCODING_SKIPLIST:这才是我们常说的“跳表 + dict”的完整结构。
当一个 Zset 的元素数量很少、每个元素的字符串长度也很短时,Redis 会优先用紧凑编码,把数据打包成一块连续内存。这种做法的好处是内存占用极小,而且局部性好,遍历起来 cache 友好。
但紧凑编码的代价也很明显:插入和删除时要移动后续所有数据,复杂度是 O(N)。所以它只适合小规模数据。一旦元素数量或单个元素大小超过阈值,Redis 就会把它转换成 skiplist 编码,并且这个转换是不可逆的。
触发转换的条件有两个,对应两个配置项:
- zset-max-listpack-entries:默认 128,元素数量超过这个值就转
- zset-max-listpack-value:默认 64 字节,单个成员长度超过这个值就转
想调大还是调小,取决于你的业务。如果你明确知道 Zset 里的数据量会很小,可以适当调大阈值,让更多 Zset 停留在紧凑编码下省内存;如果你知道会写入大字符串,就保持默认甚至调小,避免出现零散的紧凑编码 Zset 频繁触发转换的尴尬。
2.2 为什么新版本用 listpack 替换 ziplist
如果你看过老版本的 Redis 源码,会发现紧凑编码以前叫 ziplist,7.0 之后才替换成 listpack。这个替换背后有一个经典的连锁更新问题。
ziplist 的每个节点会记录前一个节点的长度,而这个长度字段的大小取决于前一个节点的大小。如果前一个节点长度超过 254 字节,字段就得用 5 字节来存,否则只用 1 字节。问题来了:当你插入一个超大节点后,它后面的节点为了记录“前一个节点长度”可能需要扩容,扩容又可能导致再后面的节点也面临同样的问题——像多米诺骨牌一样连锁触发内存搬移,最坏情况下插入一个节点导致后面所有节点都要重新分配内存,复杂度退化到 O(N²)。
listpack 的设计彻底绕开了这个问题:每个节点只记录自己的长度,不记录前一个节点的长度,从根上消除了连锁更新的存在。所以新版本的紧凑编码在大字符串场景下更稳健。这也是我在实际工作中强烈建议使用 Redis 7.0+ 的原因之一,如果你还在用老版本,线上如果出现过 Zset 写入突然变慢的情况,这就是一个值得排查的方向。
2.3 什么时候该关心编码转换
转换条件默认是 128 个元素、64 字节。这里有个容易被忽视的点:score 是 double 类型,不参与“value 长度”的统计,但成员的字符串长度参与。也就是说,如果成员是 20 个字符的用户 ID,那 64 字节的阈值很难触发;但如果成员是 URL、JSON 串、或者带时间戳的复合字符串,很容易超过 64 字节。
我在线上见过一个典型案例:某业务用 Zset 做用户行为时间线,成员存的是“用户ID:行为ID:时间戳”拼接的字符串,平均长度超过 60 字节,一旦某些极端用户的行为串很长,直接触发编码转换。转换本身不是问题,问题是转换发生时 Redis 需要把原有紧凑编码的所有数据遍历一遍,重新构建跳表和 dict,这个过程的耗时跟元素数量成正比。如果一个 Zset 恰好卡在阈值附近,线上会出现偶发的毫秒级延迟尖峰。
所以我的建议是:如果你能预估成员的字节长度,最好在测试环境压一把,观察 Zset 什么时候发生转换、转换耗时多少。别等到线上出了慢查询才来查。
3. 跳表原理深度拆解:结构、查找、插入
3.1 跳表节点的数据结构长什么样
跳表(skiplist)的本质是在有序链表上增加多层索引。Redis 的实现里,节点定义大致是这样的:
typedef struct zskiplistNode { sds ele; // 成员字符串 double score; // 分值 struct zskiplistNode *backward; // 后退指针 struct zskiplistLevel { struct zskiplistNode *forward; // 前进指针 unsigned long span; // 跨度:到下一个节点的距离 } level[]; } zskiplistNode; typedef struct zskiplist { struct zskiplistNode *header, *tail; unsigned long length; // 节点总数 int level; // 当前最大层数 } zskiplist;每个节点除了存 data(score + ele),还有一个 flexible array member,也就是 level[] 数组。这个数组的每个元素代表一层的“前进指针”和“跨度”。一个节点在多少层上出现,是插入时随机决定的。
跟教科书里最简单的跳表相比,Redis 的跳表多了两个设计细节,务必注意。
第一个是 backward 后退指针。普通跳表只有前进指针,但 Redis 的 Zset 需要支持 ZREVRANGE、ZREVRANK 这样的逆序操作,没有后退指针就得从 head 重新遍历,代价很大。有了 backward,逆序操作可以直接从 tail 往前走,配合跨度可以快速定位。
第二个是 span 跨度。每层的 forward 指针不只指向下一个节点,还记录了“从当前节点到目标节点之间跳过了多少个元素”。这个跨度是 ZRANGE 和 ZRANK 能快速算出排名的关键。没有 span,你想知道某个元素的排名,就得老老实实从头数一遍;有了 span,从高层向下累加就能算出来。
3.2 查找过程:从高层往低层走
跳表的查找逻辑其实一句话就能概括:从最高层开始,如果“下一个节点的 score 小于目标”,就往右走,否则往下走,直到最底层仍然找不到目标,就说明不存在。
我画过很多次这个流程,给你描述一下。假设跳表有 4 层,目标 score 是 17。指针从 header 的最高层出发,发现第一个节点的 score 是 5,比 17 小,于是向右移到 score=5 的节点,继续在同一层往后看,发现下一个是 20,比 17 大,说明这一层走过头了,于是下沉到第 3 层。在第 3 层,score=5 的节点的 forward 指向 score=11 的节点,11 小于 17,继续向右;然后发现下一跳是 22,又过头了,再下沉。重复这个过程,直到最低层,最终定位到 score=17 或者确认它不存在。
这个过程的复杂度是 O(logN),前提是层高分布合理。为什么能接近二分查找的效率?因为每一层相当于对下层做了一次“隔一个取一个”的抽样,从最高层开始,每次跳过大约 2^k 个元素,指数级地逼近目标。
实际使用中,我不建议大家死记“平均 O(logN)”这个结论,而是要理解它为什么是 logN:层数越高,跨度越大,查找时能跳过更多的元素;层数越低,越接近线性扫描,用来做精确定位。高层的“大步跳”加上底层的“细步走”,组合起来就是效率的来源。
3.3 插入操作:随机层高与逐层缝合
插入一个节点,比查找多两步:随机决定层高、逐层更新指针和跨度。
Redis 里随机层高的算法是:初始层高为 1,每次有 1/4 的概率增加一层,直到最大层数 64(Redis 7.0 之前是 32)。这个 1/4 的概率是我认为 Redis 设计里很精妙的一个选择。如果概率是 1/2,跳表会变得很“高”,每层节点太多,查询确实更快,但内存浪费严重,因为高层节点重复存储指针的代价很高。如果概率是 1/8,层高太低,跳表的“跳跃”效果变差,查找退化。1/4 是时间和空间权衡下来比较理想的点。按这个概率,平均每个节点层高约 1/(1-1/4) = 1.33 层,内存开销可控。
插入的缝合过程是:从 header 开始,逐层查找插入位置,记录每一层的前驱节点(update[])和从头到该前驱节点的步数(rank[]),然后从底层向上依次调整指针。关键点在于 span 的更新公式:新节点的跨度 = 原前驱到下一个节点的跨度 - (总排名 - 前驱排名);前驱的跨度 = 总排名 - 前驱排名 + 1。这个公式你要是记不住也没关系,核心思想是:插入后,前驱跨向“下一个节点”的跨度,要拆分成“前驱到新节点”和“新节点到下一个节点”两段。这也是 span 让排名计算变快的原因——每次插入都把这个距离信息维护好了。
3.4 更新 score:为什么是“先删后插”
Zset 支持的 ZADD 可以覆盖更新 score,ZINCRBY 可以在原有 score 上增量。很多人以为更新 score 只需要找到节点改一下数值就行,但底层实现并不是这样。
Redis 更新 score 的逻辑是:先通过 dict 找到节点,删除它,再用新的 score 插入跳表。为什么不能原地改?因为跳表的有序性依赖于 score 作为比较键,你改了 score,节点在跳表中的位置可能完全不正确了。虽然可以按照“改 score 后前后移动若干步”的思路去维护,但实现复杂得多,而且极端情况下移动的距离可能很大。先删后插,虽然常数略高,但逻辑清晰,正确性有保证。
顺便说一句,这也是为什么 ZINCRBY 频繁调用时,涉及两个 O(logN) 操作(删除 + 插入),但常数很小,实际性能依然优秀。我在压测里见过单实例每秒几十万次 ZINCRBY,问题不大。
4. 为什么是跳表,而不是红黑树或 B 树
4.1 平衡树的麻烦
如果你看过其它有序数据结构,一定会问:为什么跳表而不是红黑树?红黑树也是 O(logN) 的平衡树,在很多语言的标准库里都有现成实现。
我的理解是:跳表的核心优势不在渐进复杂度,而在工程实现和并发友好的程度上。
红黑树实现一个范围查询(给出 score 范围,返回所有元素),麻烦在于你必须先找到起点,然后中序遍历找后继节点——中序遍历本身不是问题,问题在于红黑树的节点没有“同层链表”结构,找后继可能要回溯多层父节点。跳表就简单得多,底层本来就是一个完整的有序链表,从起点开始一路 forward 扫过去就行。范围查询是所有 Zset 业务的基础操作,这一点直接让跳表胜出。
另外一个在 Redis 的历史版本中曾被考虑过的点是并发性。虽然 Redis 本身是单线程的,但跳表作为数据结构,在并发环境下做无锁读、锁粒度拆分的难度远低于平衡树。这虽然不是 Redis 选择跳表的决定性理由,但至少不添乱。
4.2 为什么不用 B 树
B 树是数据库索引的常客,为什么 Redis 不用它做 Zset?
B 树的核心优势在于磁盘 IO:每个节点能存大量 key,树的高度矮,一次磁盘读取能带出很多数据。但 Redis 是内存数据库,数据都在内存里,B 树的“扇出大”就没什么意义了。相反,B 树的节点分裂、合并逻辑极其复杂,实现成本高,而且在内存里维护这种结构未必比跳表快。
再加上跳表可以通过调整层高生成概率来灵活控制内存和时间的平衡,这在业务调优时非常实用。B 树做不到这种“按概率调节”的灵活性。所以对这个场景而言,跳表是更务实的选型。
4.3 跳表有什么缺点
说实话,跳表不是完美的。最明显的缺点是内存比紧凑编码大、比平衡树也略大。每个节点的 level[] 数组要为每一层存一个指针和一个 span,指针 8 字节,span 8 字节(unsigned long),平均 1.33 层的话,每节点额外增加大约 20 字节左右。百万元素的 Zset,这部分开销就是几十 MB。这在内存型数据库里不算小数目。
跳表的缓存局部性也不如紧凑编码和数组。跳表节点是分散分配的,遍历时可能频繁跳内存地址,cache miss 较多。Redis 的解决办法是当数据量小时用 listpack 编码、数据量大时反正也要遍历很多节点,cache miss 的影响相对有限——但你要知道这个 trade-off 是真实存在的。
5. 核心操作的时间复杂度与真实场景推演
5.1 ZADD、ZSCORE、ZRANGE 的底层路径
把 Zset 的操作和底层结构对应起来,是最容易理解全貌的方式。我用一张表总结:
| 命令 | 时间复杂度 | 底层路径 |
|---|---|---|
| ZADD key score member | O(logN) | 先走 dict 查找是否存在,再走跳表插入/更新 |
| ZSCORE key member | O(1) | 走 dict 直接取分值 |
| ZINCRBY key increment member | O(logN) | dict 找到后,跳表先删后插 |
| ZRANGE key start stop | O(logN + M) | 跳表定位起点,沿底层链表扫描 M 个元素 |
| ZREVRANGE key start stop | O(logN + M) | 利用 backward 从 tail 开始反向扫 |
| ZRANGEBYSCORE key min max | O(logN + M) | 跳表按 score 定位起点,再扫 |
| ZRANK key member | O(logN) | 跳表查找时累加 span,算排名 |
| ZREVRANK key member | O(logN) + O(1) | 总长减正序排名 |
| ZREMRANGEBYSCORE | O(logN + M) | 定位起点后,逐节点删除,更新前驱跨度 |
看起来都是 logN,但真实场景里差别很大。这个表格我建议你直接截图存着,面试和排查问题都用得上。
5.2 排行榜系统里每个命令发生了什么
我用一个实际场景推演。假设你在做某游戏平台的全服积分榜,Zset 的 key 是leaderboard:game_1,里面有几百万玩家。
玩家打完一局,积分变了,客户端调用 ZADD:
- Redis 在 dict 里查这个玩家 ID 是否存在,如果不存在,O(1) 确认他是新玩家,记录在案。
- 如果存在,拿到旧 score,接着走跳表删除旧节点,再插入新节点。删除时先沿高层定位,沿途累加 span 获得排名,然后缝合前后指针。
- 插入时随机生成层高,创建新节点,重新调整受影响的前驱节点的 forward 和 span。
整个过程是两次 O(logN) 跳表操作加一次 O(1) dict 操作。几百万节点的跳表层高通常在十几层到二十几层,每一层做的都是指针比较,常数很小。实测下来,单条 ZADD 的耗时在微秒级别。
查询“当前第 1 名到第 50 名”时使用 ZREVRANGE。Redis 会从 tail 节点的 backward 指针出发,因为它本身就是按 score 升序排列的,tail 对应最大 score。从 tail 往前走 50 步,取出元素和 score,再反转顺序返回。这里的耗时主要取决于 M=50,跟总量 N 无关。排行榜的前几页为什么快?因为这个特性。
查询“我排第几名”使用 ZREVRANK。跳表从 header 最高层出发查找该玩家,每经过一个节点就把它的 span 加总,得到正序排名,再用总长度减去正序排名加一,得到逆序排名。这里没有遍历整个链表,全靠插入时维护好的 span 信息。
5.3 延迟队列与滑动窗口的时间特性
延迟队列是 Zset 的经典用法。score 存的是任务应执行的时间戳,消费者用一个死循环执行 ZRANGEBYSCORE key -inf now LIMIT 0 100,把到期任务取出来。这里的关键是复杂度 O(logN + M),其中 M 是每个批次取出的任务数。即使队列里有千万级别的未到期任务,只要到期任务不多,扫描的成本就很低。
滑动窗口限流的逻辑类似:每个请求的 score 是当前时间戳,每次请求前用 ZREMRANGEBYSCORE key -inf (now-window) 清掉过期记录,再 ZADD 新请求,最后 ZCARD 统计窗口内总数。这个场景里,插入和删除都是 O(logN),清空过期窗口的删除复杂度取决于过期请求数。
有个容易忽略的问题:延迟队列和滑动窗口里的 Zset 会无限增长吗?不会,因为 score 是时间戳,老任务会被 ZREMRANGEBYSCORE 清掉。但如果消费者挂了,Zset 会持续堆积,直到内存报警。所以这种场景必须配监控:Zset 的 cardinality 超过阈值就要告警。我见过不止一次因为消费者故障导致 Zset 撑爆内存的事故,教训就是排行榜和队列类 key 都要设置独立的监控项。
6. 线上调优与常见问题排查
6.1 调整编码转换阈值
前面说过,listpack 编码在内存上有压倒性优势。如果业务里大量 Zset 的规模都小于 128 个元素,可以适当调大 zset-max-listpack-entries,让更多小 Zset 留在紧凑编码。但别调得过于激进。一旦数据超过阈值触发转换,是老数据全量重排,耗时跟老数据量成正比,线上会出现一次明显的延迟尖峰。我见过有人把阈值调到 10000,结果一个 Zset 从 9999 涨到 10001 时,Redis 卡了将近 200 毫秒。这个尖峰足以让依赖方超时。
我的建议是:默认 128 在大多数场景已经够用,如果确实要调,结合预热测试来确定合理值。不要拍脑袋调大。
6.2 大 key 的检测与治理
大 key 是所有 Redis 使用者的噩梦。Zset 的大 key 不只是“元素多”,还包括“单个元素大”。我用 redis-cli --bigkeys 扫过很多次线上实例,经常揪出几百万元素的排行榜。这类 key 的问题不是单条命令慢,而是持久化的时候很慢——RDB 要序列化整个跳表,AOF 重写也要遍历全量数据,大 key 直接拖垮主从同步的延迟。
治理方案无非几种:拆 key,比如把全服榜拆成“前 100 名榜 + 剩余普通榜”;清零历史数据,定期删除过期榜单;或者对超大 Zset 用 scan 分批删除。删除 Zset 里的元素,用 ZREMRANGEBYRANK 一次删一批,比逐条 ZREM 快得多,因为少了很多次跳表定位的开销。
6.3 慢查询排查思路
如果线上 Zset 相关的命令突然变慢,我会按这个顺序排查:
第一,确认编码。执行 OBJECT ENCODING key,看是不是 LISTPACK。如果是,再看元素数量和转换阈值的关系,有没有可能刚触发过转换。
第二,确认大 key。执行 DEBUG OBJECT key,看序列化长度和元素数量。
第三,确认慢命令类型。ZADD 慢可能是节点太多、层高过高;ZRANGE 慢可能是返回太多数据,网络传输占大头。
第四,如果以上都正常,看是不是 Redis 本身的问题:内存碎片、swap、或者同一实例上有其它大 key 干扰。排查慢查询不能只盯着数据结构,Redis 是单线程的,一个慢操作会拖累整个实例。
6.4 跳表层高真的稳定吗
跳表的随机性意味着,极端情况下可能出现层高分布不均。虽然概率极低,但理论上百万级节点全部集中在低层、导致查找近似 O(N) 的情况并非不可能。Redis 的层高生成上限是 64,随机算法已经很成熟,实际中我从未见过因随机性引发的性能灾难。但如果你在压测中发现查找速度异常,可以用 ZSCAN 配合代码分析层高分布,不过这个属于极少数情况,大多数时候不用操心。
7. 我的一些实践体会
最后说点实际的。我带团队维护过不少 Redis 集群,Zset 用的最多的地方就是排行榜和延迟队列。这两类场景的运维差异很明显。
排行榜的 Zset 是典型的“读多写少”,而且读取热点极其集中在前几页。虽然跳表对范围查询很友好,但热 key 问题才是真正的挑战——几百万的访问量打到同一个 Zset 上,单实例扛不住。我的做法是:把排行榜做成两级,全量榜放在主 Redis,前 100 名单独缓存一份,每次查询直接读缓存,写操作异步更新全量榜并同步刷新前 100 名缓存。这比单纯调 Redis 参数有效得多。
延迟队列的 Zset 则是“写多读少”,而且容易堆积。我的经验是给每个业务配独立的 key 前缀,方便排查和清理;同时用 Lua 脚本把“取出到期任务 + 删除”合并成原子操作,避免并发消费者重复处理。Zset 的 score 用毫秒时间戳,不同业务之间错开 key,不要混在一个 Zset 里,否则一个消费者堵住,整个队列都卡死。
还有一个小技巧:如果 Zset 里成员很长、重复前缀很多,可以考虑在业务层做压缩,存短 ID 而不是长字符串,然后另建映射关系。这能显著降低跳表节点的内存占用,同时避免触发 listpack 转 skiplist 的阈值。内存是 Redis 最贵的资源,省一点是一点。
Zset 的底层实现,说到底就是两个结构各司其职、协同工作。dict 是索引,跳表是顺序,span 是排名的捷径,backward 是逆序的后门。理解了这些,你不仅能在面试里聊得深,更能在线上出问题时知道从哪里下手。