被误解的 Redis 跳表题:B+ 树是磁盘的答案,跳表是内存的答案

B+ 树为磁盘而生,跳表为内存而生。回到 antirez 原话和 ZSET 源码,拆解'并发优势论'等常见误解,提出'介质决定结构'的认知框架。

封面

先说结论:Redis 选跳表,根本不是因为"实现简单"。

你大概率在面试里被问过这道题,也大概率听过那个标准答案——“跳表实现简单、内存少、并发好、范围查询快”。四条理由,背下来就能过关。

但这个答案有麻烦:Redis 核心命令是单线程执行的,“并发好"从何谈起?antirez 本人在 2010 年的邮件列表里列了三条理由,里面压根没提并发。市面流传最广的解释,恰恰是最不成立的。

我想把这道题拆三层。第一层,回到 B+ 树和跳表各自为谁而生;第二层,逐条审视 antirez 的原话和市面的演绎;第三层,拆开 ZSET 的源码,看看 Redis 真正用了什么。

一、B+ 树是磁盘的答案

要理解 Redis 为什么不选 B+ 树,先得理解 B+ 树是被谁选中的。

为磁盘页而生的紧凑节点

MySQL InnoDB 的默认页大小是 16KB。这不是随便定的——它对应磁盘的一次 IO 单位。B+ 树的每个节点对齐一个页,意味着一次磁盘读取能把整个节点Load 进内存,页内所有 key 都可用。

为慢速块设备做的优化。磁盘的随机访问延迟在毫秒级(机械盘约 10ms,SSD 约 0.1ms),比内存慢百万倍。减少 IO 次数是第一性目标,B+ 树的紧凑多路节点正是为此设计:3 层 B+ 树可以索引约 2000 万行(非叶子节点每页约 1170 个 key,叶子节点每页约 16 行,按每行 1KB 估算,1170 × 1170 × 16 ≈ 2000 万),大多数查询只需 3 次 IO。如果是 4 层,能索引 200 多亿行。InnoDB 表即使有亿级数据,主键查询也能稳定在毫秒级。

介质决定结构框架图——磁盘场景对应 B+ 树,内存场景对应跳表

把 B+ 树的每个特性拆开看,都对应一个磁盘约束:

  • 紧凑节点——对齐磁盘页,摊薄 IO 成本
  • 多路分支——降低树高,减少 IO 次数
  • 叶子链表——支持范围扫描,避免回溯到非叶子节点
  • 节点内排序——二分查找定位,单次 IO 内高效

每一个"优点”,都是为磁盘量身定制的。

到了内存里,这些优点还成立吗

很多人想当然认为"B+ 树在内存里也快"。确实快,但快的逻辑变了。

内存没有 IO 惩罚,随机访问和顺序访问的差距从百万倍缩到了几十倍。CPU 缓存以 cache line(通常 64 字节)为单位加载内存:数据在 64 字节内连续排列时一次就能全部加载,跨 cache line 就需要两次访问。B+ 树的"减少 IO"优势消失了,但"cache locality"(缓存局部性,数据在内存中挨得越近,CPU 缓存命中率越高)优势还在:紧凑节点对齐 cache line,节点内连续内存对 CPU 友好。

我跑了一组 benchmark 验证这一点。用 Go 实现了简化的跳表(参考 Redis t_zset.c,含 backward 指针(支持反向遍历)和 span 维护(记录跨距用于 ZRANK 计算))和简化的 B+ 树(节点 order=64,叶子用连续 slice),在纯内存场景对比插入和范围查询性能。

结果很直接:

操作 N skiplist b+tree 比值
插入 100万 1.19s 105ms 11.4x
范围查询 100万数据 × 1万次查询 1.62s 4.46ms 364x

B+ 树在内存场景显著快于跳表。范围查询快了上百倍(364x,约 2.5 个数量级)。

需要说明实验设计的偏差:跳表实现包含了 Redis 为 ZRANK 维护的 span 字段(插入/删除时的额外开销),B+ 树则用 Go 连续 slice(cache locality 加成),两者简化方向不同。364x 是"带 ZRANK 负重的跳表"对比"理想化 B+ 树"的极端数字,不代表纯数据结构层面的通用差距,真实生产实现的差距会更小。但方向性结论成立:内存场景下 B+ 树确实更快。

跳表 vs B+ 树 benchmark 柱状图——插入和范围查询的耗时对比

这个结果推翻了一个常见说法:“内存场景下跳表和 B+ 树性能相近”。不相近,B+ 树更快。B+ 树的 cache locality 优势在内存里依然成立,甚至更突出(因为没有 IO 摊薄这个优势的相对权重)。

那 Redis 为什么不选 B+ 树

如果 B+ 树在内存里更快,Redis 为什么不用?因为瓶颈不在数据结构

Redis 是纯内存数据库,单线程执行命令(6.0 后 IO 多线程,但命令执行仍单线程)。它的性能瓶颈在网络 IO 和单线程的命令串行化,不在跳表 vs B+ 树的几个微秒差距。选哪个数据结构,对 Redis 的端到端延迟影响微乎其微。

但要分场景看:高频的 ZADD/ZSCORE 走 hash table 或跳表 O(logN),端到端是微秒级,数据结构差异不是瓶颈。但 ZRANGEBYSCORE 大范围扫描时,数据结构性能直接决定单线程阻塞时间——364x 的差距在这个场景下有实际意义。Redis 的取舍是"不优化低频大范围操作",而不是"数据结构完全不重要"。RDB/AOF 的序列化是全量遍历+写入操作,与具体数据结构关系不大,B+ 树在持久化场景也无明显优势。

这里有个关键认知:当数据结构不是瓶颈时,选实现成本最低的那个。B+ 树的紧凑节点、分裂合并、平衡维护,在内存场景成了"额外的代码复杂度",换来的性能优势 Redis 用不上。跳表用概率平衡替代严格平衡,代码量少一半,调试简单,足以满足 Redis 的性能需求。

B+ 树不是更差,是用错了场景。它的每一个优点都绑定了磁盘约束,到了内存里这些优点要么不必要,要么换不来值得的收益。

B+ 树节点对齐 4KB 磁盘页 vs 跳表节点对齐 64B cache line 的尺寸对照图

二、跳表是内存的答案

跳表不是"更好的 B+ 树",是另一种思路。

跳表是一个多层链表:最底层包含所有节点,上层每个节点以概率 p 出现,形成稀疏的"快速索引层"。查询时从最高层开始大步跳过节点,逐层下降直到定位目标。期望高度 O(logN)。

概率平衡 vs 严格平衡

B+ 树和红黑树用旋转维护平衡,插入删除可能触发连锁调整。B+ 树的节点分裂会向上传播,最坏情况要重写整条路径;红黑树插入后最多 2 次旋转(但可能伴随 O(logN) 次颜色调整)。跳表用随机层级替代旋转:每个新节点默认 1 层,然后有 25% 概率再升一层,再 25% 概率继续升。大部分节点只有 1-2 层,少数节点有更多层,形成稀疏的高层索引。期望高度 O(logN),无需维护。

概率平衡 vs 严格平衡概念图——跳表无需旋转,B+ 树需要旋转维护

这个差异在工程上的意义比性能更大。旋转操作会修改多个节点的指针,并发控制复杂,要么加全局锁,要么用细粒度锁但实现难度陡升。跳表的插入是局部的,只改相邻节点的 forward 指针,理论上更容易做无锁并发。这也是为什么 LevelDB、RocksDB 的 memtable 选跳表:它们需要并发写入。

但请注意,这个"并发优势"在 Redis 里不成立。Redis 单线程执行命令,根本没有并发写入场景。跳表的并发优势是真实存在的,只是 Redis 用不上。

William Pugh 在 1990 年的论文《Skip Lists: A Probabilistic Alternative to Balanced Trees》(ACM DOI)里提出的核心论点是:概率平衡比严格平衡更简单,性能"几乎一样好"。

注意"几乎一样好"这个限定。跳表不是更快,是"够快且更简单"。

范围查询:跳表的强项,但不是独占

跳表底层是一个有序链表,范围查询就是链表顺序扫描:从最高层快速定位起点(O(logN)),然后沿最底层链表逐个遍历到终点。这个操作天然适合 ZRANGE/ZREVRANGE。

但 B+ 树的叶子节点也是链表,而且叶子内部是紧凑数组。同样是范围扫描,B+ 树的 cache 命中率更高。E2 benchmark 里 B+ 树范围查询快两个数量级,根源就在这里。

antirez 在 2010 年的邮件列表里说:“跳表的 cache locality 至少和其他平衡树一样好。“他说的"其他平衡树"更可能指红黑树等常见平衡树,而非 B+ 树,原话没有明确限定。在 2010 年的讨论语境下,对比对象通常是红黑树/AVL 树这类无叶子链表的指针密集型结构,它们范围查询要中序遍历,cache 不友好。和这些结构比,跳表的范围查询确实不差。但和 B+ 树比,跳表的 cache locality 是劣势——B+ 树叶子是紧凑数组,cache 命中率更高。

跳表真正的内存优势

跳表在内存场景的真正优势不是性能,是实现简洁带来的工程收益

antirez 的原话第三条提到一个例子:因为跳表实现简单,有人给他发了一个补丁,用扩展跳表实现了 O(logN) 的 ZRANK 操作,只改了很少的代码。如果是 B+ 树或红黑树,这种扩展的复杂度会高得多。

这个论点被低估了。数据结构不只是"查询性能”,还包括"可维护性"“可扩展性"“出 bug 的概率”。Redis 是单人维护的项目(早期),代码简洁度直接影响开发速度和稳定性。跳表在这方面的优势,比几个微秒的性能差距重要得多:少几千行代码意味着少几百个潜在 bug。

跳表优劣势对比图——优势四条 vs 劣势三条

三、被误解的三个解释

现在回到市面流传的标准答案,逐条审视。

误解一:“跳表并发性能好”

这是流传最广、也是最不成立的解释。

标准话术是:“跳表插入删除只需局部加锁,红黑树需要旋转可能涉及整棵树,所以跳表并发更好。”

问题在于:Redis 核心命令是单线程执行的。6.0 引入 IO 多线程后,网络读写并行了,但命令执行仍然串行。跳表的"并发优势"在 Redis 里无从发挥——根本没有并发场景。

更关键的是,antirez 在 2010 年邮件列表里列了三条选跳表的理由,没有一条提到并发。这至少说明并发不是主要原因——但不能因此断定完全无关。后人把它从跳表的通用文献(Pugh 论文、LevelDB 实践)搬到 Redis 头上,至少是过度演绎:跳表的并发优势真实存在,只是 Redis 单线程模型下用不上。

如果你想验证这一点,去找 antirez 的原话。2010 年 3 月 6 日,有人在 Redis 邮件列表问他:“Is there any particular reason you chose skip list instead of btrees except for simplicity?“他的回答列了三条理由(原帖存档已难以直接定位,但三条原话被 JavaGuide 等多份二手资料完整引用,与 Redis 源码注释和后续演进一致):

  1. They are not very memory intensive. It’s up to you basically. Changing parameters about the probability of a node to have a given number of levels will make then less memory intensive than btrees.
  2. A sorted set is often target of many ZRANGE or ZREVRANGE operations, that is, traversing the skip list as a linked list. With this operation the cache locality of skip lists is at least as good as with other kind of balanced trees.
  3. They are simpler to implement, debug, and so forth.

三条理由分别是:内存可控、cache locality 和其他平衡树一样好、实现简单。并发不在其中。

并发优势论揭穿图——Redis 单线程模型 vs 跳表并发优势的错位

误解二:“跳表实现简单”

这条 antirez 确实说过,但被严重简化了。

antirez 的原话是第三条:“They are simpler to implement, debug, and so forth.“他给的例证是 ZRANK 补丁——因为跳表简单,社区贡献者能用很少的代码扩展 ZRANK 功能。

但"简单"是相对的。antirez 对比的语境是"和 B 树、红黑树比”,不是"和 B+ 树比”。而且他把"简单"排在第三位,不是主要原因。

更重要的是,Redis 的跳表实现并不"简单”。打开 t_zset.c,zslInsert 函数有几十行,要维护 update 数组(记录每层前驱节点)、rank 数组(记录排名)、span 字段(记录跨距)、backward 指针(支持反向遍历)。这不是入门级数据结构。说"Redis 选跳表因为简单”,是把工程权衡压缩成了一句口号。

误解三:“跳表内存占用少”

这条 antirez 也说过,但原意被扭曲了。

antirez 的原话第一条:“They are not very memory intensive. It’s up to you basically. Changing parameters about the probability of a node to have a given number of levels will make then less memory intensive than btrees.”

antirez 原话有两层意思:跳表本身"不太费内存”(无条件判断),调参数后可以比 btree 更省(有条件判断)。市面流传版本只保留了第二层,并简化为"跳表必然省内存”。而且对比的是泛指的 btree,不是 B+ 树。

实际上跳表的 per-node 开销不低。Redis 跳表节点包含 ele 指针、score、backward 指针、level 数组(平均 1.33 层,每层含 forward 指针 + span),单节点平均约 45 字节(含 ele 指针 8 + score 8 + backward 指针 8 + level 数组平均约 21 字节,不含 SDS(Simple Dynamic Strings,Redis 的动态字符串实现)和 dictEntry 开销)。对比 listpack 的紧凑存储,跳表是"内存奢侈"的。

我实测了 ZSET 的编码切换:

元素数 编码 内存(字节) 单元素(字节)
100 listpack 1231 12.3
128 listpack 1596 12.5
129 skiplist 12576 97.5
1000 skiplist 86440 86.4

128 到 129 个元素时,编码从 listpack 切换到 skiplist,内存从 1596 字节跳到 12576 字节——7.9 倍跃升。单元素成本从 12.5 字节涨到 97.5 字节。

listpack → skiplist 编码切换示意图——内存跃升 7.9 倍的拐点

跳表"省内存"的说法,在 listpack 面前不成立。它的"省"是和红黑树、B 树等平衡树比,不是和紧凑存储比。而且 antirez 原话限定的是"可调参数后",p 值调小(层数更少)确实能省,但代价是查询性能下降。这是一个权衡,不是绝对优势。

antirez 的原话,和市面的演绎

把 antirez 2010 年的三条原话和市面流传版本对照一下:

antirez 原话 市面流传版本 差异
内存可控(可调参数后比 btree 省) “跳表内存占用少” 简化了"可调参数"的限定
cache locality 和其他平衡树一样好 “跳表范围查询快” 把"和其他平衡树比"换成了绝对判断
实现简单 “跳表实现简单” 保留了,但忽略了是第三位原因
(未提及) “跳表并发性能好” 后人添加,antirez 原文无

市面流传的"四大理由",有一条是 antirez 没说过的(并发),有三条是被简化的(内存、cache locality、简单)。这不是 antirez 的问题,是传播过程中的信号衰减:复杂的工程权衡被压缩成易记的口号,然后口号被当成真理反复引用。

四、ZSET 的真实结构

最后一层:Redis 的 ZSET 根本不是"用跳表实现",而是双结构 + 编码切换

跳表 + 哈希表:双视图

打开 Redis 源码 src/t_zset.c,顶部注释写得很清楚:

ZSETs are ordered sets using two data structures to hold the same elements in order to get O(log(N)) INSERT and REMOVE operations into a sorted data structure.

The elements are added to a hash table mapping Redis objects to scores. At the same time the elements are added to a skip list mapping scores to Redis objects.

ZSET 同时维护两个数据结构:

  • hash table:映射 Redis 对象 → score,支持 O(1) 单点查找(ZSCORE)
  • skip list:映射 score → Redis 对象,支持 O(logN) 有序操作(ZRANGE、ZRANK)

同一个元素在两个结构间共享 SDS 字符串,节省内存。这不是"用跳表",是"跳表 + 哈希表"协作。

为什么要双结构?因为单靠跳表,单点查找是 O(logN),不满足 ZSCORE 的 O(1) 需求。单靠哈希表,无法支持范围查询和排名。两个结构各司其职,写入时双写,读取时按需选择。

所以"Redis 选跳表因为 X"这个命题本身就站不住。Redis 选的是"跳表 + 哈希表"的组合,不是单纯的跳表。当然,这并不否定"有序部分为什么选跳表不选 B+ 树"这个核心问题——ZSET 的双结构是为了同时满足单点和有序两种需求,和"有序部分选哪种结构"是两个正交问题。

ZSET 双结构图——hash table + skip list 的协作关系

listpack:小数据的真正实现

而且跳表还不是 ZSET 的默认实现。Redis 7.0 之后,ZSET 在元素数量少(默认 ≤ 128)且单个元素小(默认 ≤ 64 字节)时,用的是 listpack——一种紧凑的连续内存存储。

实测验证(Redis 8.8.0,默认配置):

  • 1 个元素,成员 64 字节 → 编码 listpack
  • 1 个元素,成员 65 字节 → 编码 skiplist
  • 128 个元素,成员 ~10 字节 → 编码 listpack
  • 129 个元素,成员 ~10 字节 → 编码 skiplist

配置项确认:zset-max-listpack-entries=128zset-max-listpack-value=64(见 Redis 配置文档)。

所以"Redis 用跳表实现 ZSET"这句话,在大多数小数据量场景下根本不成立。listpack 是连续内存块,没有指针开销。它由 header(记录总字节数和元素数,使 LLEN 为 O(1))、紧凑排列的 entries、末尾的 terminator(特殊字节值 0xFF)组成。所有元素的 member 和 score 紧凑排列在一个连续的字节数组里,读取时顺序扫描,写入时移动内存腾出空间。对于小数据量,这种"笨办法"因为 cache 友好和零指针开销,比常规树结构都快。

但 listpack 在大数据量下会暴露问题:插入和删除是 O(N)(要移动后面的所有元素),当 N 超过 128 时,这个成本开始不可忽略。这就是切换阈值的来源:128 推测是 Redis 团队在性能和内存之间做的权衡点。

单元素成本约 12.5 字节,比跳表的 97.5 字节省了 87%。

只有当数据量超过阈值,跳表才接管。而跳表接管的真正原因,是 listpack 在大数据量下插入删除的 O(N) 复杂度会成为瓶颈。这时候才需要跳表的 O(logN)。

ZSET 分层设计图——小数据用 listpack,大数据用跳表+哈希表

回到 antirez 的设计哲学

把 ZSET 的完整设计拼出来:

  • 小数据:listpack,紧凑存储,O(N) 操作但常数极小
  • 大数据:跳表 + 哈希表,O(logN) 有序 + O(1) 单点
  • 切换阈值:128 个元素 / 64 字节单个值

分层的、按需升级的设计。不是"选了跳表",是"在需要跳表的时候才用跳表"。

antirez 的工程哲学在这里体现得很清楚:用最简单的结构解决问题,等到不够用了再换复杂的。listpack 够用就用 listpack,跳表需要了才上。B+ 树从头到尾不是选项,因为 Redis 是内存数据库,B+ 树的磁盘优化在这里是负担。

结语:介质选了数据结构

回到开头的问题:为什么 Redis 选跳表不选 B+ 树?

最诚实的回答是:介质选了数据结构

B+ 树是为磁盘块存储优化的结构,它的紧凑节点、多路分支、叶子链表,每一个特性都绑定了磁盘约束。到了内存里,这些特性要么不必要,要么换不来值得的复杂度。B+ 树在内存里确实更快,但 Redis 的瓶颈不在数据结构,几个微秒的差距对端到端延迟无足轻重。

跳表是为内存场景设计的结构:概率平衡替代严格平衡,链表天然支持范围扫描,实现简洁易扩展。它不是最快的,但"够快且简单"。配合哈希表弥补单点查找的短板,配合 listpack 处理小数据场景,ZSET 的设计是分层的、按需升级的。

如果要把这个判断推广成一个框架:选数据结构,先看介质。介质是首要因素,但不是唯一因素,workload 和工程约束决定边界内的具体选择。磁盘数据库(MySQL、PostgreSQL)选 B+ 树,因为它的优化对应磁盘约束;内存数据库(Redis)选跳表,因为磁盘优化在这里是负担;LSM-tree 的 memtable(LevelDB、RocksDB)选跳表,因为它们确实需要并发写入——跳表的并发优势在这些场景里是真实的(前文揭穿的是"把这条理由搬到 Redis 头上",不是否定跳表本身的并发优势)。

当然,antirez 本人对简洁代码有强烈偏好,跳表的选择可能也有"我就是喜欢跳表"的成分。但"介质决定结构"作为事后可解释的框架,仍有独立价值。

下次再有人问"为什么 Redis 选跳表",别再背那四条了。先问一句:B+ 树的那些优点,是为谁设计的?

数据库选型矩阵——磁盘数据库选 B+ 树,内存数据库选跳表,LSM memtable 选跳表


附录:实验代码和原始数据

本文 4 组实验的代码和原始输出已开源:

GitHub:zhiyulab-evidence/redis-skiplist-vs-btree

子目录 内容 对应正文实验
code/zset-encoding-test/ ZSET 编码切换实测脚本(listpack → skiplist 内存跃升) E1:128→129 元素内存 1596→12576 字节
code/skiplist-vs-btree-bench/ 跳表 vs B+ 树内存场景 benchmark(Go 实现) E2:插入 11.4x、范围查询 364x
output/ 两组实验的原始输出文本 表格数据来源
data/ 节点大小 vs 介质对齐分析、t_zset.c 源码拆解 E3/E4:介质对照框架 + antirez 原话考证

每个子目录都有独立 README,说明如何复现。二进制编译产物不入库,跑实验前自己 go build / redis-server


原文发布于 止语Lab


关于止语Lab

一个工程师的深度技术笔记。

不写入门教程,不追热点。只写那些真正折腾过、想通了的东西。

了解更多 →