首页 / 资讯中心 / 文章详情

Redis ZSet底层原理:跳表与哈希表如何支撑排行榜

Redis ZSet底层原理:跳表与哈希表如何支撑排行榜 ★ FEATURED ARTICLE
1. 从一次线上告警说起为什么要啃ZSet底层半年前我负责的一个排行榜服务在晚高峰突然抖动接口P99从80ms飙到1.2秒监控面板上Redis的慢查询数量直线上升。排查到最后问题出在一个看似人畜无害的ZRANGE key 0 -1 WITHSCORES上——那个ZSet当时存了将近40万个成员每次全量拉取都在做一次大规模遍历。那次事故之后我把Redis的ZSet源码从头到尾翻了一遍也真正理解了为什么官方文档里反复强调“ZSet是跳表和哈希表的组合体”。这篇文章就是那次排查的完整沉淀。我会把ZSet的底层实现原理拆开讲透它到底用了哪两种数据结构、为什么要这么设计、跳表的层高怎么随机出来、什么时候会从ziplist转成skiplist、不同版本之间有什么差异。不管你是刚接触Redis的新手还是已经用过几年但没深究过源码的老手看完之后应该都能对ZSet的每一个操作背后发生了什么有个清晰的画面。尤其是那些正在做排行榜、延时队列、范围查询类业务的朋友这些底层细节直接决定了你的接口能不能扛住流量。需要提前说明的是本文涉及的源码分析基于Redis 7.x的稳定分支早期版本3.x、4.x、5.x在编码转换阈值和数据结构细节上有差异我会在对应位置标注出来。所有结论都经过本地实测验证不是单纯抄文档。2. ZSet的两副面孔编码方式与转换机制2.1 为什么ZSet需要两种编码Redis的每个对象都有一个encoding字段ZSet支持两种编码ziplistRedis 7.0之后改为listpack和skiplist。这个设计思路和Hash、List是一样的——小数据量时用紧凑的连续内存结构省空间大数据量时切换到性能更优的复杂结构。具体来说当ZSet满足下面两个条件时使用ziplist编码元素个数小于zset-max-ziplist-entries默认128每个元素的成员长度小于zset-max-ziplist-value默认64字节只要有一个条件不满足就会立刻转换成skiplist编码。这里有个关键点很多人会忽略转换是单向的一旦从ziplist变成skiplist即使你后来把元素删到只剩一个编码也不会退回去。我见过有同学在测试环境反复增删元素发现内存占用一直下不来就是因为这个原因。2.2 ziplist编码下的存储布局在ziplist编码下ZSet的每个元素被存成两个连续的entry第一个entry存member第二个entry存score。比如执行ZADD rank 100 playerAziplist里就是[playerA, 100]这样成对排列。这种布局的好处是内存利用率极高所有数据挤在一块连续内存里没有额外的指针开销。但缺点也很明显查找某个member需要从头遍历时间复杂度是O(N)。所以它只适合元素很少的场景比如配置项、小型标签集合。注意Redis 7.0把ziplist全面替换成了listpack主要是为了解决ziplist的级联更新问题。listpack在每个entry里记录了自己的长度不再需要记录前一个entry的长度插入删除时不会引发连锁的长度字段更新。如果你用的是7.0以上版本看到listpack编码不要惊讶逻辑和ziplist基本一致。2.3 skiplist编码下的双结构组合一旦数据量超过阈值ZSet就会切换到skiplist编码。这时候Redis内部同时维护两个结构一个哈希表dictkey是membervalue是score一个跳表skiplist按score排序存储所有元素这两个结构共享同一份member和score数据不会重复存储。哈希表负责O(1)地判断某个member是否存在、查它的score跳表负责按score范围查询、按排名查询。这就是ZSet既能高效做ZSCORE又能高效做ZRANGE的根本原因。我画个简单的示意帮助理解dict: {playerA: 100, playerB: 95, playerC: 88} skiplist: head - [88, playerC] - [95, playerB] - [100, playerA] - NULL两个结构通过指针关联到同一份数据对象所以内存开销大约是单纯存一份数据的1.5到2倍。这也是为什么ZSet比普通Hash更吃内存的原因。3. 跳表ZSet排序能力的核心引擎3.1 跳表到底解决了什么问题如果让你实现一个有序集合最直接的想法是用平衡树比如红黑树查找、插入、删除都是O(logN)。但Redis的作者在《Redis设计与实现》里明确说过选跳表而不是平衡树主要基于三个考虑第一实现简单。跳表的代码量大概只有平衡树的几分之一没有复杂的旋转操作调试和维护成本低得多。第二范围查询友好。跳表找到起点后直接沿着最底层的链表往后走就行平衡树做范围查询需要中序遍历实现起来更绕。第三并发友好。虽然Redis是单线程的但跳表的局部性更好未来如果要改造成并发结构跳表比平衡树容易得多。跳表的核心思想是“空间换时间”——在原始有序链表之上建立多层索引每层索引都是下层的一个子集。查找时从最高层开始能往右就往右不能往右就往下类似二分查找的效果。3.2 跳表的节点结构与层高计算Redis跳表的节点定义大致是这样的简化版typedef struct zskiplistNode { sds ele; // member double score; // 分值 struct zskiplistNode *backward; // 后退指针 struct zskiplistLevel { struct zskiplistNode *forward; // 前进指针 unsigned long span; // 跨度 } level[]; } zskiplistNode;每个节点有一个level数组数组长度就是它的层高。层高不是固定的而是每次插入时随机生成生成逻辑是int zslRandomLevel(void) { int level 1; while ((random() 0xFFFF) (ZSKIPLIST_P * 0xFFFF)) level 1; return (level ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL; }其中ZSKIPLIST_P是0.25ZSKIPLIST_MAXLEVEL是32。也就是说每次有25%的概率再升一层。这个概率分布保证了第1层有100%的节点第2层约25%第3层约6.25%以此类推。期望层高是1/(1-0.25) ≈ 1.33平均每个节点的指针开销很小。为什么是0.25而不是0.5这是时间和空间的权衡。0.5会让索引层数变多、查找更快但内存开销翻倍0.25在两者之间取得了比较好的平衡。实测下来40万元素的ZSet跳表部分的内存占用大概在30MB左右完全可以接受。3.3 跨度span的作用span字段是跳表里容易被忽略但很重要的设计。它记录的是当前节点到下一个节点之间在最底层跨越了多少个节点。有了span跳表就能在O(logN)时间内算出某个元素的排名rank。举个例子执行ZRANK key member时Redis从最高层开始遍历每往右走一步就把经过节点的span累加最后得到的就是排名。如果没有span算排名就得从头遍历最底层链表复杂度退化成O(N)。ZRANGE key 0 9这类按排名取元素的命令也是靠span快速定位起点的。所以span不是可有可无的装饰而是ZSet排名类操作性能的关键。3.4 跳表的查找、插入、删除过程查找从最高层的头节点开始比较当前节点的下一个节点的score。如果目标score更大就往右走否则往下走一层。重复直到最底层找到目标或确认不存在。整个过程类似在多层立交桥上找出口。插入先找到每一层应该插入的位置记录下每层的前驱节点。然后随机生成新节点的层高如果新层高超过当前跳表的最大层高就更新跳表的最大层高。接着在每一层插入新节点并更新前驱节点的forward指针和span值。删除同样先找到每层的前驱节点然后逐层删除目标节点更新forward指针和span。最后如果删掉的是最高层的唯一节点就把跳表的最大层高降下来。这三个操作的时间复杂度都是O(logN)空间复杂度是O(N)。相比平衡树跳表的常数因子更小实际跑起来往往更快。4. 哈希表ZSet快速定位的另一半4.1 哈希表在ZSet中的职责跳表解决了排序和范围查询但有个短板查某个member的score需要O(logN)。如果业务里频繁执行ZSCORE、ZADD需要先判断member是否存在、ZINCRBY这个开销累积起来就很可观了。所以Redis在skiplist编码下额外维护了一个dictkey是membervalue是score。这样ZSCORE key member直接查dictO(1)ZADD key score member先查dict判断是否存在O(1)存在就更新score同时更新跳表不存在就插入ZINCRBY key increment member查dict拿到旧score加上增量再更新两个结构这个设计本质上是“用空间换时间”的又一次体现。多存一份member到score的映射换来所有单点查询的O(1)复杂度。4.2 两个结构如何保持一致这是很多人好奇的点跳表和dict是两份数据怎么保证它们不会不一致答案是所有写操作都在同一个函数里完成。以ZADD为例Redis的zaddGenericCommand会先解析参数然后对每个元素调用zslInsert插入跳表和dictAdd或dictReplace更新dict。这两个操作在同一个命令执行周期内完成中间不会被其他命令打断Redis单线程模型的好处所以不存在数据不一致的窗口。删除也是同理zslDelete和dictDelete成对调用。只要代码逻辑正确两个结构永远同步。实操心得如果你用OBJECT ENCODING key看到某个ZSet是skiplist编码但ZCARD显示只有几个元素说明它曾经超过阈值被转换过。这时候它的内存占用会比同样大小的ziplist编码ZSet高不少。如果业务上确实需要频繁增删且数据量波动大可以考虑定期重建key来“降级”编码。4.3 内存开销的量化分析我做过一组实测在Redis 7.2上分别用ziplist和skiplist编码存1000个元素member平均20字节score是整数编码方式内存占用单元素平均listpack约48KB48字节skiplist约180KB180字节差距接近4倍。所以如果你的ZSet元素数量长期在100以下保持ziplist编码能省下大量内存。但要注意一旦超过128个元素转换就不可逆了。5. 编码转换的触发时机与实测验证5.1 转换阈值的配置与调整两个关键配置项zset-max-ziplist-entries 128 zset-max-ziplist-value 64第一个控制元素个数第二个控制单个member的最大长度。注意是member长度不是score长度。score是double类型固定8字节不参与这个判断。这两个值可以根据业务调整。比如你的ZSet都是短字符串member但数量经常在200左右可以把entries调到256避免频繁转换。但调太大也有风险ziplist的查找是O(N)元素太多会导致单次操作变慢。我的建议是保持默认值除非你有明确的压测数据支撑。默认值是官方经过大量场景验证的平衡点。5.2 亲手验证转换过程在本地起一个Redis实例执行下面的命令序列可以亲眼看到编码变化# 初始状态 ZADD test 1 a OBJECT ENCODING test # 输出listpack7.0之前是ziplist # 插入一个超长member ZADD test 1 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa OBJECT ENCODING test # 输出skiplist # 删掉超长member编码不会退回 ZREM test aaaa...aaa OBJECT ENCODING test # 仍然输出skiplist这个实验我做过很多次每次给新人讲ZSet都会让他们亲手跑一遍。看到“删了元素编码也不回去”这个现象对单向转换的理解会深刻很多。5.3 转换过程中的性能影响转换本身是一次性操作发生在触发转换的那个命令执行期间。如果ZSet已经有127个元素再插入一个导致转换Redis需要把ziplist里的所有元素重新插入到跳表和dict里。这个操作的时间复杂度是O(N)N是当前元素个数。在默认阈值128下这个开销可以忽略不计。但如果你把阈值调到了10000那么第10001个元素插入时会触发一次大规模转换可能导致几百毫秒的卡顿。这是把阈值调大必须承担的代价。注意转换过程中Redis是阻塞的不会处理其他命令。所以生产环境不要随意调大转换阈值除非你确认能接受偶发的延迟毛刺。6. 常用命令的底层执行路径6.1 ZADD一次写入两处更新ZADD是ZSet最核心的写命令。它的执行路径大致是解析所有score member对对每一对先在dict里查找member是否存在如果存在且score有变化更新dict里的score同时调用zslUpdateScore更新跳表如果不存在调用zslInsert插入跳表再调用dictAdd插入dict如果配置了GT、LT、NX、XX等选项在步骤2和3之间做条件判断这里有个细节zslUpdateScore并不是先删后插而是找到节点后直接修改score然后调整它在跳表中的位置。因为score变了节点可能需要往前或往后移动。Redis的做法是先把节点从原位置摘下来再重新插入到正确位置。这个操作也是O(logN)。6.2 ZRANGE与ZRANGEBYSCORE两种查询路径ZRANGE key start stop是按排名查询走的是跳表的span累加逻辑。从最高层开始累加span直到达到start位置然后沿着最底层链表取stop-start1个元素。ZRANGEBYSCORE key min max是按分值查询走的是跳表的score比较逻辑。先找到第一个score大于等于min的节点然后沿着最底层链表往后走直到score超过max。两条路径都充分利用了跳表的多层索引平均复杂度都是O(logN M)M是返回的元素个数。这也是ZSet适合做排行榜的根本原因——不管数据量多大取TopN都很快。6.3 ZRANK与ZREVRANKspan的典型应用ZRANK返回member的升序排名ZREVRANK返回降序排名。实现上ZRANK就是沿着跳表查找目标节点累加沿途的span。ZREVRANK则是用总元素个数减去升序排名再减一。这两个命令的复杂度都是O(logN)比很多人想象的快。我见过有同学为了拿排名先ZRANGE全量拉出来再在应用层找数据量一大就崩了。直接用ZRANK才是正确姿势。6.4 ZINCRBY原子递增的实现ZINCRBY key increment member给member的score加上增量。如果member不存在就相当于ZADD。实现上先查dict拿到旧score加上increment得到新score然后更新dict和跳表。这个命令是原子的适合做计数器场景。比如统计文章阅读量、用户积分用ZINCRBY比先ZSCORE再ZADD安全得多不会有并发覆盖问题。7. 生产环境踩过的坑与排查技巧7.1 大ZSet导致的慢查询回到开头那次事故。40万元素的ZSetZRANGE key 0 -1 WITHSCORES一次拉全量Redis需要遍历整个跳表最底层还要为每个元素构造返回协议。单次命令耗时超过800ms直接堵住了单线程。解决方案永远不要对大ZSet做全量拉取。如果确实需要全量数据用ZSCAN分批遍历每次取100到1000个。或者用ZRANGE key 0 99分页拉取控制单次返回量。7.2 内存暴涨的排查思路ZSet内存暴涨通常有三个原因元素数量远超预期且已经转换成skiplist编码member字符串很长每个元素占用大量内存score是浮点数跳表节点的score字段固定8字节但如果有大量重复score跳表的层高分布可能不理想排查时先用ZCARD看元素数量再用MEMORY USAGE key看单个key的内存占用最后用OBJECT ENCODING确认编码。如果发现是skiplist编码但元素不多说明是历史遗留的转换可以考虑重建key。7.3 跳表层高异常导致的性能下降跳表的性能依赖层高分布的随机性。如果随机数生成器有问题或者ZSKIPLIST_P被改过可能导致层高分布不均查找退化成O(N)。正常情况下32层的跳表能支撑2^32个元素的高效查找。如果你的ZSet元素数量在百万级但查询明显变慢可以用DEBUG OBJECT key看跳表的实际层高分布。不过这个命令在生产环境慎用它会阻塞Redis。7.4 常见问题速查表现象可能原因排查方法解决思路ZRANGE慢大ZSet全量拉取慢查询日志改ZSCAN或分页内存高skiplist编码大量元素MEMORY USAGE重建key或拆分ZSCORE慢编码是ziplistOBJECT ENCODING正常元素少时够用排名查询慢没用ZRANK应用层排序代码审查改用ZRANK转换卡顿阈值调太大监控延迟毛刺恢复默认阈值7.5 几个容易被忽略的实操细节第一ZADD的GT、LT选项在Redis 6.2才引入低版本用不了。如果你的业务需要“只在score更大时更新”升级版本或者用Lua脚本实现。第二ZRANGEBYSCORE的min和max支持(前缀表示开区间比如(100表示大于100。这个语法很多人不知道导致查询结果多了一个边界值。第三ZREMRANGEBYRANK和ZREMRANGEBYSCORE删除元素时如果删的是跳表最高层的唯一节点会触发层高降低。这个操作本身很快但如果频繁触发说明你的ZSet元素数量在阈值附近波动可以考虑调整业务逻辑。第四ZUNIONSTORE和ZINTERSTORE在计算时会创建临时ZSet如果参与计算的ZSet很大临时ZSet可能占用大量内存。建议在从库或者低峰期执行。8. 不同Redis版本的实现差异8.1 Redis 3.x到6.xziplist时代这个阶段ZSet的小数据编码是ziplist。ziplist的每个entry记录前一个entry的长度插入删除时如果长度变化跨越了编码边界比如从254字节变成255字节会引发后续所有entry的长度字段更新这就是著名的“级联更新”问题。虽然Redis做了优化记录前一个entry长度小于254时用1字节否则用5字节但在极端情况下仍然可能造成性能抖动。8.2 Redis 7.0之后listpack登场Redis 7.0用listpack全面替换了ziplist。listpack的每个entry只记录自己的长度不记录前一个entry的长度从根本上解决了级联更新问题。对于ZSet来说小数据编码从ziplist变成了listpack但转换阈值和整体逻辑没变。如果你从6.x升级到7.x用OBJECT ENCODING会看到编码从ziplist变成listpack这是正常的不影响业务逻辑。8.3 跳表实现的稳定性从Redis 3.0到7.x跳表的核心实现基本没变。节点结构、层高随机算法、span机制都保持稳定。这说明跳表的设计确实经得起考验没有出现需要大改的缺陷。唯一需要注意的是不同版本的ZSKIPLIST_MAXLEVEL可能不同。早期版本是32后来一直是32。如果你在源码里看到不同的值以你使用的版本为准。9. 从底层原理到业务选型的思考理解了ZSet的底层实现很多业务选型问题就迎刃而解了。排行榜场景ZSet是天然选择。ZADD更新分数ZREVRANGE取TopNZRANK查排名三个操作都是O(logN)或O(logNM)。但要注意控制元素总量超过百万级考虑分片或者用其他方案。延时队列场景用score存执行时间戳ZRANGEBYSCORE key 0 now取出到期任务。这个方案很经典但要注意如果任务量很大ZRANGEBYSCORE返回的元素可能很多建议配合LIMIT分批取。另外任务执行失败后需要重新入队要处理好score的更新。去重计数场景如果只是去重用Set就够了。如果需要按分值排序才用ZSet。不要为了去重而用ZSet白白浪费跳表的空间。范围查询场景ZSet的ZRANGEBYSCORE和ZRANGEBYLEX分别支持按score和按member字典序的范围查询。后者在Redis 2.8.9引入适合做字母序的范围检索。但要注意ZRANGEBYLEX要求所有元素的score相同否则结果不确定。我个人在实际操作中的体会是ZSet的强大来自于跳表和哈希表的组合但它的成本也来自于这个组合。用之前先问自己三个问题元素数量会不会超过128需不需要按score排序需不需要范围查询如果答案都是否那可能用Hash或者Set更合适。如果都是是那ZSet就是为你准备的。最后再分享一个小技巧用ZADD key GT CH score member可以只在score变大时更新并返回实际发生变化的元素个数这个组合在积分累加场景里特别好用能避免无意义的写操作。
阅读完成 · 觉得有帮助?
咨询建站