
先说结论: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+ 树的每个特性拆开看,都对应一个磁盘约束:
- 紧凑节点——对齐磁盘页,摊薄 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+ 树确实更快。

这个结果推翻了一个常见说法:“内存场景下跳表和 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+ 树",是另一种思路。
跳表是一个多层链表:最底层包含所有节点,上层每个节点以概率 p 出现,形成稀疏的"快速索引层"。查询时从最高层开始大步跳过节点,逐层下降直到定位目标。期望高度 O(logN)。
概率平衡 vs 严格平衡
B+ 树和红黑树用旋转维护平衡,插入删除可能触发连锁调整。B+ 树的节点分裂会向上传播,最坏情况要重写整条路径;红黑树插入后最多 2 次旋转(但可能伴随 O(logN) 次颜色调整)。跳表用随机层级替代旋转:每个新节点默认 1 层,然后有 25% 概率再升一层,再 25% 概率继续升。大部分节点只有 1-2 层,少数节点有更多层,形成稀疏的高层索引。期望高度 O(logN),无需维护。

这个差异在工程上的意义比性能更大。旋转操作会修改多个节点的指针,并发控制复杂,要么加全局锁,要么用细粒度锁但实现难度陡升。跳表的插入是局部的,只改相邻节点的 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。

三、被误解的三个解释
现在回到市面流传的标准答案,逐条审视。
误解一:“跳表并发性能好”
这是流传最广、也是最不成立的解释。
标准话术是:“跳表插入删除只需局部加锁,红黑树需要旋转可能涉及整棵树,所以跳表并发更好。”
问题在于: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 源码注释和后续演进一致):
- 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.
- 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.
- They are simpler to implement, debug, and so forth.
三条理由分别是:内存可控、cache locality 和其他平衡树一样好、实现简单。并发不在其中。

误解二:“跳表实现简单”
这条 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 面前不成立。它的"省"是和红黑树、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 的双结构是为了同时满足单点和有序两种需求,和"有序部分选哪种结构"是两个正交问题。

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=128,zset-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)。

回到 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+ 树的那些优点,是为谁设计的?

附录:实验代码和原始数据
本文 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