想象你走进一座巨大的图书馆——这里是 Redis 的「收藏馆」(Sorted Set,简称 zset)。馆里每一本书都带着一个「热度分值」,从 1 到 100 贴着标签,整整齐齐排成长长的书架。你要找一本热度 67 的书,如果使用入口处的电脑检索机(dict 哈希表),输入书名一秒定位到具体书架——这是「单点查找」的绝活。但如果你想问「热度 60 到 80 之间有哪些书」呢?电脑检索机就无能为力了,你需要一套「楼层导览图」(skiplist 跳表)。 这套导览图分好几层:最底层的 L0 是全部馆藏清单,密密麻麻;往上一层 L1 只列热门书;再往上 L2 只列爆款;最高层 L3 只保留镇馆之宝。找书时从最高层往下跳——先看爆款名录圈定大致区间,再翻热门书目缩小范围,最后落到 L0 精确锁定。每跳一层,候选范围就砍掉一大截,几步之内就能从几百万本书里揪出目标。这就是 Redis zset 的底层秘密:一张多层导览图 + 一台检索机,配合得天衣无缝。本文带你拆解这套设计,看懂跳表的节点、跨度、晋升概率 p=0.25 背后的工程智慧。
理解 zset 底层的 skiplist + dict 双结构,你将能解释 Redis 范围查询为何高效、排行榜与延迟队列为何选它,并在系统设计中做出正确的存储选型判断。
目录
可视化演示
展示Redis Sorted Set底层跳表结构:多层指针索引、节点层级随机选择(p=0.25)、以及新节点插入时的搜索路径与指针更新过程。
本节配有交互动效(进入视口后自动加载,约 3KB)。
跳表多层索引结构 Header/Tail:哨兵节点,占满所有层 高层节点:概率p=0.25逐层提升 P(node at level k) = p^(k-1) 每层都是有序链表,高层索引加速底层遍历,实现O(log n)查找。
双结构设计——检索机与导览图各司其职
走进 zset 图书馆,你会发现它刻意配备了两套检索设施。入口处是一台电脑检索机——输入书名,零点几秒就告诉你它在哪个书架的哪一层,这就是 dict 哈希表。而在每层楼道里贴着好几版「楼层导览图」:最全的一版列了所有馆藏,往上有一版只列热门书,再往上只列爆款书。这就是 skiplist 跳表。一台机器管「精确查找」,一摞导览图管「范围浏览」,二者缺一不可。
Redis 的 Sorted Set 在设计上同样是「一鱼两吃」:dict 保存 member 到 score 的映射,负责 O(1) 的单点写入与查询;skiplist 则按 score 维护一个有序结构,负责区间扫描与排序。同一本书(member 对象)在两个结构里各有一份「档案条目」,但通过指针共享同一对象实体,绝不复制字符串内容。当客户端执行 ZADD 时,Redis 先更新 dict,再在 skiplist 中插入一个新的 (score, member) 节点;ZSCORE 只查 dict;ZRANGE 只走 skiplist。
两个结构通过双向指针保持同步,插入删除都在 O(log n) 内完成。这样设计让「点查」和「范围查」互不拖累——如果只用 skiplist,单点查询要 O(log n);如果只用 dict,范围查询只能全表扫描。正是这种分工,让 zset 在游戏排行榜、延迟队列等场景中游刃有余。
回到图书馆:一位读者想查某本书是否在馆,直接问电脑检索机;另一位读者想浏览「热度 60 到 80」的书架,就拿起导览图逐层定位。两台设施各忙各的,互不干扰,却共享同一套藏书——这正是 zset 双结构最精妙的地方。
- zsetRedis 的 Sorted Set 类型,它的值对象由两种底层结构组合而成
- dict哈希表结构,以 member 为键、score 为值,提供 O(1) 精确查找与更新
- skiplist跳表结构,按 score 排序的多层链表,支持范围遍历与排名
- 组合关系两结构共享节点指针,写操作同步更新,而非复制数据
跳表节点结构——书签上的层层指引
当你展开图书馆的导览图,会发现每一版导览图上的记录都像一个小书签:书签中央写着书名和热度(score),书签下方只延伸出一条「后退箭头」指回前一个书架位;而书签上方伸出好几条「前跳箭头」,分别指向更高层导览图上的下一本书。箭头越长,跳过的中间书架越多。管理员就是靠这些箭头快速移动的。
Redis 的 zskiplistNode 正是这样的书签。它包含四部分:member 和 score 是书的身份;backward 指针指向前驱节点,用于倒序遍历;level 数组则是一个柔性数组,每个 level 元素由 forward 指针和 span 字段组成。forward 指向该层下一个节点,span 记录「从当前节点沿这一层跳到目标,在底层链表上跨越了多少个节点」。level 数组的长度是插入时随机生成的,通常远小于 ZSKIPLIST_MAXLEVEL=32。
span 是跳表实现排名的关键。ZREVRANK 求某成员排名时,查找路径每向右移动一次,就把该层的 span 累加起来,最后得到的就是该节点在底层链表中的位置。由于 p=0.25 下每个节点平均只有 1.33 个 level 元素,这些计数占用非常紧凑,但同时为 O(log n) 的排名查询提供了足够的路径信息。
就像你的书签上标着「从这里跳过去,会经过 7 本书」——管理员不用真的一本本数,就能报出你要找的书排在整条书架的第几本。span 给跳表装上了里程表,让排名查询不再需要遍历全部节点。
- span从 u 沿某一层的指针跳到 v 时,底层完整链表上经过的节点个数
- rank节点在 L0 完整链表中的位置序号,从 0 开始计数
- 路径累计从起点到目标的完整路径上,所有被跳过的节点数逐层累加即得排名
层级晋升机制——四分之一概率的掷骰子
新书上架时,管理员会掷一个特制的四面骰子:掷出 1,这本书就有资格进入「上一层导览图」;再掷一次,又是 1 才能进入再上一层。所以大约 1/4 的书出现在第二层,1/16 出现在第三层,1/64 出现在第四层——层数越高,书目越精。掷骰子完全随机,任何一本书都有机会成为「爆款」,与它的热度分无关。
跳表的层级同样完全由随机算法决定。插入节点时调用 t_random() 生成 level:从 1 开始,每次以概率 p=0.25 继续 +1,直到失败或达到上限 ZSKIPLIST_MAXLEVEL=32。这样得到的层数服从几何分布。Redis 之所以选 p=0.25 而非经典论文中的 0.5,是因为内存数据库里指针占用是主要成本:p 越小,平均每节点指针数越少,空间越省。
p=0.25 时,每个节点平均层数为 1/(1-p)=4/3,即平均只有 1.33 个前向指针;而 p=0.5 时是 2 个指针,跳表整体指针内存节省约三分之一。代价是高层节点更稀疏,查找时垂直下降次数略增,但常数仍然可控。最大层级 32 足以支撑 2^32 量级的元素规模,几乎永远不会触顶。
掷骰子看似随意,却保证了每层导览图上的书目大约是上一层的四倍——下宽上尖,形如金字塔。这份「随机的秩序」让跳表不需要任何旋转调整,就能保持极佳的查找效率,比起红黑树那种需要复杂旋转的平衡方式,实在省心太多。
- P(L=k)一个节点恰好拥有 k 层的概率,等于前 k-1 次晋升成功、第 k 次失败
- p^{k-1}连续 k-1 次掷骰子都命中晋升概率 p 的概率
- E[L]层数的数学期望,对几何分布求和得到
- 代入 p=0.25期望层数为 4/3,即平均每个节点只有 1.33 层
范围查询实现——顺着楼层导览逐排浏览
当读者问「热度 60 到 80 的书有哪些」时,管理员从最高层导览图出发,先找到热度 60 附近的区域,然后落回最全的 L0 清单,从第一本符合的书开始,顺着箭头一排排往后读,读到热度超过 80 才停手。这套「先跳后走」的方法,让范围浏览变得又快又准。
这就是 ZRANGEBYSCORE 的实现:先在 skiplist 中查找 score 的下界(≥ min),再从该位置沿 forward[0] 逐节点遍历,直到超过 max。由于 L0 是完整的有序双链表,向前向后都很自然。ZRANGE 取排名区间 [start, stop] 时则利用 span 累计 rank 快速定位起点,再顺序走 m-1 步。zrevrange 的逻辑完全相同,只是方向反过来:从高层找上界,沿 backward 从大到小收集。
skiplist 天然支持范围遍历——找到一个端点后,范围内每个节点都只需 O(1) 移动一步;而平衡树做范围遍历需要中序遍历,树节点在内存中不连续、cache miss 更多。对于「分页取 Top N」「按时间窗口取样本」这类高频操作,skiplist 的链表式连续步进远比树的穿线遍历友好。
管理员不需要把整座馆藏书目从头翻起——先看爆款名录圈定楼层,再落到完整书单逐排扫过。找起点靠「跳」,扫结果靠「走」:一跳一走的配合,让百万级数据的范围查询也只在眨眼之间。
- T_range范围查询的总耗时,由定位和输出两个阶段构成
- log n从最高层逐层下降到下界附近所需的比较次数
- m命中集合的元素个数,每个元素沿链表移动一步 O(1)
复杂度分析——一步跳三层,对数级收敛
如果把查找过程画成图,就像从图书馆顶楼的大厅往下走螺旋楼梯:每走一步,导览图的「书目密度」就稀疏一些,候选范围缩小到原来的四分之一。从顶层到地面,一路只走了 O(log n) 步,却把几百万本书的搜索空间压缩成寥寥数本。这种感觉,就像查字典时先翻目录、再翻章节、最后翻页码。
在 p=0.25 的跳表中,期望查找路径长度为 O(log_{1/p} n) = O(log_4 n)。因为每向上走一层,候选节点数就乘以 p,即缩小为原来的 1/4,所以从顶层一路查到 L0,需要比较约 log_4 n 次。这与二叉平衡树的 O(log_2 n) 只差一个常数因子 2,对数意义上完全同阶,因此工程上直接写作 O(log n)。
需要强调的是,跳表的 O(log n) 是「期望」复杂度而非最坏保证。极端随机情况下所有节点都在同一层,链表退化为单链表,查找退化为 O(n)。但每个节点的层数独立同分布,这种全面退化在实际中概率极低。内存方面,p=0.25 时每个节点平均 1.33 个前向指针,远小于平衡树每个节点 2-3 个指针加颜色标记的开销。
就像螺旋楼梯每一圈都能帮你过滤掉四分之三的楼层,你永远不用一层层爬完所有书架。偶尔运气差一点,楼梯「只修了一层」,那就只能沿长廊走一遍——但图书馆把这件罕见事件的概率压到了几乎可以忽略。
- E[C]查找过程平均需要比较的次数
- 底数推导每上升一层候选范围缩为原来的 p 倍,n·p^k = 1 时 k = log_{1/p} n
- 最坏情况所有节点层数都等于 1,跳表退化为普通单链表
工程取舍——为什么是跳表而不是平衡树
你可能会问:图书馆为什么不用更「标准」的索书号分类树(红黑树)来组织导览图?管理员会告诉你:树形索引改起来太麻烦——每插一本书,可能要旋转、变色、调整一堆指针;而跳表只需要把骰子一掷、沿路更新几个指针,规则简单到实习生都能一次写对。这就是工程世界对「简单」的偏爱。
这是 Redis 作者 antirez 的真实选择。平衡树(如红黑树)保证严格 O(log n),但实现复杂:插入删除需处理左旋右旋、节点变色,范围遍历需要中序穿线。跳表最坏情况虽差,但实现代码量约为红黑树的三分之一,范围查询直接沿链表遍历,内存上还因 p=0.25 而更紧凑。缓存局部性方面,skiplist 的节点在内存中不连续,但这与树结构相近,没有明显劣势。
更关键的是,zset 的双结构让 ZSCORE 走 dict,常用单点操作达到 O(1),这更是纯树结构做不到的。此外跳表对并发修改更友好:插入只需更新局部前驱的指针,加锁范围小;红黑树的旋转则可能波及局部子树,锁粒度更粗。在 Redis 的单线程模型下,实现简单意味着更少的 bug 与更易维护的源码,这本身就是巨大的工程价值。
zset 图书馆最终选择了「导览图 + 掷骰子」这套质朴方案:不去维护金贵的平衡树,而是用概率让结构近似平衡。少一点理论上的「绝对完美」,换来的是写起来简单、跑起来省内存、并发起来不打架——这正是工程世界的智慧。
- M_ptr跳表全节点前向指针的总数,等于节点数乘以期望层数
- 代入 p=0.25每节点 4/3 个指针,共 4n/3 个前向指针
- 红黑树指针每节点存左孩子、右孩子、父节点三个指针,约 2n+ 个有效指针
AI 应用场景
实时排行榜——玩家分数的实时冲刺
阶段:在线数据存储与高性能查询
解决的问题:排行榜需要高频「更新分数 + 取 TopN」。传统方案用 SQL ORDER BY score DESC LIMIT 100,每次全表排序代价高,高并发下数据库扛不住。zset 用 skiplist 维护有序分数,ZADD 更新 O(log n),ZREVRANGE 取 Top100 为 O(log n + 100),就像图书馆管理员翻一下爆款名录就能报出前 100 名。
具体案例:某日活千万的休闲手游,玩家每局结束把分数 ZADD 进 zset;运营大屏每 5 秒 ZREVRANGE 拉取前 100 名展示;同时用 ZSCORE 查任意玩家当前排名。整套逻辑由 Redis 单节点承担每秒数万 QPS,无需预计算排行榜。
AI 训练样本的时间窗口截取
阶段:数据预处理 / 特征工程
解决的问题:训练序列模型(推荐、风控)需要「取某用户最近 N 天行为」。用 MySQL 的 ts BETWEEN 查询,一旦未命中索引就全表扫描;用 zset 以时间戳为 score、行为 ID 为 member,ZRANGEBYSCORE 一条命令返回有序行为序列,就像沿着导览图快速圈出指定时间段的所有事件书签。
具体案例:推荐团队要构造「用户近 7 天点击过的商品序列」特征:线上每次点击产生一次 ZADD 写入 per-user zset,离线特征任务用 ZRANGEBYSCORE 拉取窗口数据并拼接成序列输入模型,比原先 ClickHouse 按时间戳查询的耗时还低一个量级。
AI 异步任务延迟调度
阶段:任务编排与调度
解决的问题:训练任务或推理任务需要延迟触发。传统定时扫描数据库「到期任务」低效且昂贵。用 zset 以执行时间戳为 score 做延迟队列,后台进程定时 ZRANGEBYSCORE(0, now) 取到期任务执行;任务失败时 ZADD 到未来时间戳即可延迟重试,一切有序可控。
具体案例:某 AI 平台承接批量图片审核推理,任务提交后延迟 30 秒执行以便聚合多图;调度器用 zset 按「应执行时间」排序,每 5 秒取到期待处理任务投递到 GPU 推理队列,任务级 SLA 稳定在秒级,代码量比引入独立消息队列少得多。
Python 演示
import random class Node: def __init__(self, score, member, level): self.score = score self.member = member self.next = [None] * level class Skiplist: MAX_LEVEL = 16 P = 0.25 def __init__(self): self.header = Node(0, "HEAD", self.MAX_LEVEL) self.level = 1 def random_level(self): lvl = 1 while random.random() < self.P and lvl < self.MAX_LEVEL: lvl += 1 return lvl def insert(self, score, member): update = [None] * self.MAX_LEVEL cur = self.header for i in range(self.level - 1, -1, -1): while cur.next[i] and cur.next[i].score < score: cur = cur.next[i] update[i] = cur lvl = self.random_level() if lvl > self.level: for i in range(self.level, lvl): update[i] = self.header self.level = lvl node = Node(score, member, lvl) for i in range(lvl): node.next[i] = update[i].next[i] update[i].next[i] = node return node def search(self, score): cur = self.header for i in range(self.level - 1, -1, -1): while cur.next[i] and cur.next[i].score < score: cur = cur.next[i] cur = cur.next[0] return cur if cur and cur.score == score else None def range_by_score(self, lo, hi): result = [] cur = self.header for i in range(self.level - 1, -1, -1): while cur.next[i] and cur.next[i].score < lo: cur = cur.next[i] cur = cur.next[0] while cur and cur.score <= hi: result.append((cur.score, cur.member)) cur = cur.next[0] return result if __name__ == "__main__": zset = Skiplist() for name, score in [("alice", 65), ("bob", 80), ("carol", 42), ("dave", 90), ("eve", 67)]: zset.insert(score, name) print("范围 [60, 85]:", zset.range_by_score(60, 85)) hit = zset.search(80) print("精确查找 80:", hit.member if hit else "不存在")
while random.random() < self.P and lvl < self.MAX_LEVEL:掷骰子决定节点层数,p=0.25 保证平均只有 1.33 层。for i in range(self.level - 1, -1, -1):从最高层向下逐层搜索,这是「先看爆款名录再落回完整清单」的遍历顺序。while cur.next[i] and cur.next[i].score < score:在当前层向右跳到不超过目标分数的节点,跳过中间大片元素。if lvl > self.level:新节点的层数超过历史最高层时,需要把头节点的高层指针也纳入更新范围。node.next[i] = update[i].next[i]新节点先接住前驱原本指向的下一个节点,断链后不丢数据。update[i].next[i] = node再让前驱指向自己,完成这一层的链表插入。result.append((cur.score, cur.member))从下界定位后沿 L0 逐节点收集,直到超过上界,正是 ZRANGEBYSCORE 的核心循环。
常见误区
误区跳表平均 O(log n),红黑树也是 O(log n),那跳表在哈希表加持下肯定完胜,所有场景都应该更快。
正解跳表与红黑树的对数复杂度只差常数因子。p=0.25 时高层节点更稀疏,查找的垂直下降次数比 p=0.5 略多,常数并不占优。Redis 选跳表不是因为「更快」,而是因为它更简单、范围遍历更自然、配合 dict 后单点查询直接 O(1)。真正的性能优势来自双结构分工,而非跳表本身。
为何常见:只看到「O(log n)」这个记号,忽略了常数因子和系统设计,误把复杂度等价于性能。
误区跳表里层数多的节点,一定是分数高或者被频繁访问的「热门书」。
正解层数完全由插入时的一次随机骰子决定,与 score、访问频率毫无关系。一个低分冷门节点可能随机到 5 层,一个高分热点节点也可能只有 1 层。层数只是「索引密度」的属性,不是「内容热度」的属性。
为何常见:人们容易把结构上的「显眼位置」和语义上的「重要程度」混为一谈,就像把导览图上的大字号等同于书的价值。
误区zset 用了两个底层结构,等于把 member 和 score 各存了两遍,内存翻倍。
正解dict 保存 member→score 映射,skiplist 保存 score→member 的有序节点,两个结构通过指针共享同一个 member 字符串对象,并不复制数据内容。真正多出来的只是 skiplist 的 level 指针数组和 dict 的哈希桶开销,p=0.25 使得这部分非常小。
为何常见:直观上「两个结构 = 两份数据」,忽略了指针共享这一节省内存的关键设计。
误区p=0.5 时每层节点数是 p=0.25 的两倍,高层更密集,查找应该明显更快,所以 0.25 是性能妥协。
正解p 只影响常数因子,不影响 O(log n) 的量级。p=0.25 让每节点平均指针数从 2 个降到 1.33 个,省下约 1/3 的指针内存,而查找常数只增加约一个固定的比例。对 Redis 这种内存数据库,空间就是生命,省内存换取的收益远大于常数上的微小牺牲。
为何常见:只盯着查找延迟单一指标,忽略了内存占用、缓存命中率等同样影响系统吞吐的因素。
面试题
Q1. Redis 的 zset 底层采用哪两种结构组合实现?
- A.B+树与哈希表
- B.跳表与哈希表← 正确答案
- C.红黑树与数组
- D.有序数组与链表
答案:B。zset 用 skiplist 维护按 score 排序的有序链表以支持范围查询,同时用 dict 维护 member→score 映射实现 O(1) 单点操作。B+树是 MySQL 索引的选择,红黑树常见于内存 Map 实现,都不符合 zset 的组合方式。
Q2. Redis 跳表的层级晋升概率 p 的取值是多少?
- A.0.1
- B.0.25← 正确答案
- C.0.5
- D.0.75
答案:B。经典跳表论文采用 p=0.5,但 Redis 为了节省指针内存,将晋升概率定为 0.25。这样每个节点平均层数为 1/(1-p)=4/3,约为 1.33 个指针,比 p=0.5 时的 2 个指针省下约三分之一内存。
Q3. zrevrange 命令返回元素的顺序是?
- A.分数从低到高
- B.分数从高到低← 正确答案
- C.按插入时间先后
- D.随机顺序
答案:B。zrevrange 是 zrange 的反向操作,沿跳表 L0 的 backward 指针从后往前遍历,因此分数从高到低。zrange 是从低到高,二者合起来覆盖了排行榜的正倒序展示需求。
Q4. 跳表查找元素的平均时间复杂度是多少?
- A.O(n)
- B.O(log n)← 正确答案
- C.O(n log n)
- D.O(1)
答案:B。p=0.25 时每向上走一层候选范围缩小为原来的 1/4,期望比较次数为 O(log_4 n),即 O(log n)。它比平衡树的 O(log_2 n) 只差常数因子 2,因此对数意义上同阶。
Q5. 关于 zset 中的 dict 哈希表结构,下列说法正确的有哪些?
- A.键是成员 member← 正确答案
- B.值是分数 score← 正确答案
- C.负责按分数范围查询
- D.实现 O(1) 的单点更新← 正确答案
答案:ABD。dict 以 member 为键、score 为值,解决「这个成员多少分」的单点问题,ZADD 更新分数时 O(1) 改 dict。按分数范围查询是 skiplist 的职责,dict 无法做到,因此 C 错误。
Q6. 跳表节点中 span 字段表示什么?
- A.节点自身的高度
- B.节点到链表末尾的距离
- C.该层指针跨越的底层节点个数← 正确答案
- D.节点存储的分数值
答案:C。span 记录从当前节点沿某一层 forward 指针跳到目标节点,在 L0 完整链表上跨越了多少个节点。它用于在查找时累加计算排名,让 ZREVRANK 等命令无需遍历整个链表即可知道成员位置。
Q7. 若 p=0.25,一个节点恰好有 2 层的概率是多少?
- A.0.25
- B.0.75
- C.0.1875← 正确答案
- D.0.0625
答案:C。恰好 2 层意味着第 1 次晋升成功且第 2 次失败,概率为 (1-p)×p = 0.75×0.25 = 0.1875。选项 A 是单次晋升成功的概率,D 是恰好 3 层的概率(0.75×0.25×0.25=0.046875)的近似干扰项。
Q8. 为什么 Redis 选择跳表而非平衡树来实现 zset 的范围查询?
- A.跳表实现更简单,代码量约为红黑树的三分之一← 正确答案
- B.跳表范围遍历直接沿链表走,比树的中序遍历更自然← 正确答案
- C.跳表能保证最坏 O(log n) 的严格复杂度
- D.跳表通过概率达到近似平衡,无需旋转操作← 正确答案
答案:ABD。跳表的优势在于实现简单、范围遍历直接、靠随机晋升免去旋转。但它的 O(log n) 是期望复杂度而非最坏保证,极端情况下会退化为 O(n),所以 C 错误。红黑树反而有严格最坏保证,这正是跳表在理论上「不完美」的地方。
Q9. 对 n 个元素的 zset 执行 ZRANGEBYSCORE 且命中 m 个元素,时间复杂度是多少?
- A.O(n)
- B.O(log n + m)← 正确答案
- C.O(m)
- D.O(n log n)
答案:B。时间分为两段:先在跳表中从高层向下定位下界节点,耗时 O(log n);再从下界沿 L0 逐个收集命中元素,每个 O(1),共 O(m)。因此总复杂度为 O(log n + m),选项 C 漏掉了定位成本。
Q10. 关于跳表最坏情况与工程权衡,下列说法正确的有哪些?
- A.最坏情况下查找复杂度退化为 O(n)← 正确答案
- B.p=0.25 比 p=0.5 更省指针内存,但查找常数略大← 正确答案
- C.降低 p 会让高层节点更稀疏← 正确答案
- D.Redis 中节点可占用的最大层级为 32← 正确答案
答案:ABCD。若随机晋升全部失败,所有节点只有 1 层,跳表退化为普通链表,查找 O(n),A 正确。p 越小,每层节点越稀疏,指针越省而垂直下降次数略增,B、C 正确。Redis 用 ZSKIPLIST_MAXLEVEL=32 限制最高层数,足以支撑海量数据,D 正确。