前言
在我看来,各种数据结构建成的高楼大厦,都需要数组和链表作地基,接下来我们从时间复杂度入手,明明 B+树、 跳表、红黑树的平均时间复杂度都是O(log n),MySQL 为什么选了 B+ 树,而 Redis 选了跳表?,而 JDK 1.8 使用红黑树?
各种树
既然提到了树(tree)这个结构了,那我们简单地说一下 二叉树、平衡二叉树
二叉搜索树
*二叉树:只要满足了【一个节点最多只有两个子节点或叶子节点】就是二叉树
二叉搜索树具有以下性质:
左子树的数值小于根节点的数值,根节点的数值小于右子树的数值
平衡二叉树
平衡二叉树是为了解决当二叉树中一个子节点无限延伸,这样子二叉树会退化回链表
平衡二叉树要求,任何节点的两个子树的高度最大差为1。
(图中红色文字为: 左子树高度为 3,右子树高度为 1,不满足平衡二叉树)
如果因为增加或删除导致二叉树中的节点变化,就有可能直接导致子树高度差变化,从而使得平衡二叉树失去平衡,这时候二叉树就会进入旋转,恢复平衡
(更详细的,包括平衡二叉树如何旋转,不多赘述,感兴趣可以去看 Hello 算法 - AVL 树*)
MySQL 为什么使用 B+ 树?
简单一句话就是:
MySQL 数据在磁盘上 → 痛点是 I/O 慢 → 需要树尽量低(矮胖) → B+ 树胜出
说到 B+ 树,那我们就不得不提及 B 树了
简单说说 B 树(平衡多路查找树)
B 树 的设计是为了迎合磁盘( 一个能存储更多数据的物理结构 )。
磁盘
磁盘会被划分成一块一块的(其实是一个扇形一个扇形,但是扇形中又会分成一块一块的),读取数据是一块一块读取的,磁盘的物理结构如下
(再深入一些的知识可以去观看 硬盘的物理结构与磁盘分区原理)
而 B 树中的每一个节点对应就是磁盘上的一块
核心对应关系:
磁盘上的一个块/页 (Disk Block/Page) = B树中的一个节点 (B-Tree Node)
B 树是为磁盘等外存储设备设计的一种平衡查找树
B 树在读取后将对应的键值(同数据,但是比数据更准确)生成节点,下图即是一颗B树。
编辑
B+ 树 是什么
B+ 树采用非叶子节点只存储键值信息,扫描相同磁盘的情况下它能获取的键值更多
B+ 树 是在 B树 基础上的一种优化,使其更适合实现外存储索引结构
为什么使用 B+ 树?
从磁盘的角度出发,一页中同时存储了 key (索引/指针/可以理解成数据库中的主键 id) 和 value(键值对/数据内容),那么能够展示对比的 key 就变少了
就会导致深度升高(树的深度),这里需要好好理解一下
事实上,在 B 树 的生成过程中,磁盘需要频繁返回数据给内存,每需要生成一个节点,都要进行一次 I/O 操作、I/O 操作可以看作磁盘块扫描次数,比如下图的B树,扫描了 11 个磁盘块(进行 11 次 I/O 操作)
如果树的深度过深,就会导致 I/O 操作过久,从而影响性能
编辑
上下对比一下,我们能发现 B+ 树采取了更少的 I/O 处理,但是获得了更多的数据!
编辑
B+ 树的叶子节点(即数据节点)之间是一种链式环结构。
因此可以对B+Tree进行两种查找运算:一种是对于主键的范围查找和分页查找,另一种是从根节点开始,进行随机查找。
比如要查找10 ~ 26 的数据,只需要先找到 10,就能够通过链式结构找到下一个磁盘块 6,最后再查看磁盘块 7,发现 7 的最小值都大于磁盘块 6 的最大值 —> 返回范围查找的结果
MySQL 为什么选择 B+ 树
MySQL 存储的数据需要持久化管理 —> 存储到硬盘,但由于磁盘(mb/s)与 CPU(gb/s) 的数据传输速度与严重不同,所以我们需要让树更矮小,扁平化,减少 I/O 操作的次数,提高性能
总结
“MySQL 选择 B+ 树的核心原因是对 磁盘 I/O 的极致优化:
- 降低树高(减少 I/O) :磁盘读写速度远低于 CPU,所以必须减少磁盘访问次数。B+ 树的非叶子节点不存数据只存索引,使得单页能容纳更多索引,极大提升了分叉率 (Fan-out) ,让树变得极度‘矮胖’(通常 3 层就能存 2000w 数据,3 次 I/O 搞定)。
- 极致的范围查询:数据库常涉及范围查询(Range Query)。B+ 树的叶子节点由双向链表串联,找到起点后只需顺序扫描,避免了树结构中常见的随机 I/O,大幅提升了扫描效率。
相比之下,红黑树太高(I/O多),普通 B 树范围查询不便且非叶子节点占用内存多,所以 B+ 树是 MySQL 的不二之选。”
一篇文章讲透MySQL为什么要用B+树实现索引-腾讯云开发者社区-腾讯云
为什么 Redis 选了跳表?
跳表简单,而且易于理解,跳表就是在一维的链表上增加几个跳跃项,来减少遍历查找的时间,不过空间会因此上升(但这点上升是无关紧要的)。
- 实现简单。
- 区间查找快。跳表可以做到O(logn) 的时间复杂度定位区间的起点,然后在原始链表中顺序往后遍历就可以了。
- 并发环境优势。红黑树在插入和删除的时候可能需要做一些 rebalance 的操作,这样的操作可能会涉及到整个树的其他部分,而skiplist的操作显然更加局部性一些,需要锁住的节点更少,因此在这样的情况下性能好一些。
跳表的结构
红黑树 VS 跳表
在 redis 发明的时候,红黑树已经十分成熟了,而跳表就像是一个刚出头的嫩芽,那为什么不去使用如此成熟的红黑树呢?简单来说不过以下四点
- 范围查找跳表的效率远大于红黑树,由于跳表是有序链表,对于需要频繁改动的数据而言再合适不过了,相对的红黑树十分复杂...
- 红黑树消耗的指针远比跳表高
- 并发简单,红黑树插入一个节点,可能会触发整棵树的旋转调整(Rebalancing)。牵一发而动全身,如果要加锁,可能得锁很大的范围,或者用非常复杂的锁耦合技术。而跳表插入一个节点,只是局部修改前后节点的指针。不会像平衡树那样引起全局的结构变化。
- 代码不复杂,逻辑简单,维护成本低(最主要的点)
- 红黑树:虽然性能稳定,但实现极其复杂(左旋右旋、变色),指针操作繁琐,且为了支持迭代通常需要维护 Parent 指针,导致平均每个节点至少 3 个指针开销。
- Redis 跳表:虽然依赖概率平衡,但平均指针数仅约 1.33 个(加上反向指针也不过 2.33 个)。更重要的是,它的代码实现难度仅为红黑树的 1/3。
"Skip lists are well known to be a simple and efficient data structure... They are not very memory intensive, and... Skip lists are simpler to implement, easier to debug..."
(跳表简单高效... 它们不怎么占内存... 并且跳表更容易实现,更容易调试...
--- Antirez (Redis之父) 的回答)
Redis 不使用红黑树也有很大一部分是因为 Redis 的开发者是一位极简主义者,正如同我们开头所讲的那样,跳表足够简单,而红黑树复杂;另一点就是维护成本了。
redis 为什么不用 B+ 树
redis 不使用跳表是因为会损耗过多的空间,空间利用率太低了,B+ 树内部是需要固定的空间的(不管你里面存不存),所以这对于 redis 是极其不友好的。
B+ 树的结构:
- 它是把数据紧凑地塞在一个个 Page (页) 里的。
- 在一个 Page 内部,数据像数组一样紧挨着排排坐。
- 指针少:它不需要给每个数据都配一个 Next 指针或 Left/Right 指针。它是一堆数据共用少量指针。
所以在存储同样数量的整数时,B+ 树因为“元数据(指针)”占比低,其实是最紧凑的。
之所以不使用有以下几点理由
理由一:昂贵的“维护费” (CPU 消耗)
B+ 树是为了磁盘设计的。为了保证树是“矮胖”的,它有极其复杂的维护逻辑:
- 插入时:如果一个 Page 满了,要分裂 (Split) ,要把一半数据搬到新 Page,还要改父节点。
- 删除时:如果一个 Page 空了,要合并 (Merge) ,要向兄弟借节点。
在内存里:
- 这些“搬运数据”、“分裂合并”的操作,都需要消耗大量的 CPU 运算。
- 而跳表呢?插入/删除只需要改几个指针。
- 结果:在内存这种超高速环境下,B+ 树那种为了省 I/O 而做的复杂操作,变成了纯纯的 CPU 累赘。
理由二:内存碎片的噩梦 (Internal Fragmentation)
这是 B+ 树在内存里真正的“空间陷阱”。
- B+ 树通常按照 Page (比如 4KB) 来申请内存。
- 场景:假设你只存了 1 个 Key,你也得申请一个 4KB 的 Page(或者最小的节点块)。
- 后果:剩下的空间全是空气。这叫 “内部碎片” 。
- Redis:跳表是按需分配。来一个节点,申请一个节点的内存,一点都不浪费。对于 Redis 这种可能存海量小对象的场景,跳表的动态分配更灵活。
理由三:最朴素的理由——代码太难写了!
这不是开玩笑。
- 写一个工业级的 B+ 树(处理各种分裂、并发、锁),代码量可能是跳表的 10 倍以上。
- Redis 的作者 Antirez 是个极简主义者。在性能差距不大的情况下(内存里 O(logN)O(logN)的常数差异可以忽略),他绝对选择代码简单、不易出 Bug 的跳表。
为啥 redis 使用 跳表 (skiplist) 而不是使用 red-black?-腾讯云开发者社区-腾讯云
news.ycombinator.com/item?id=117…
MIT’s Introduction to Algorithms, Lecture 12: Skip Lists
如果想要了解关于跳表的增删改查可以看下面这篇博客
为什么 JDK 1.8 哈希表中选择红黑树?
在 Java HashMap 的使用场景(纯内存、单线程/低并发、无范围查询需求、防碰撞)下,红黑树就是唯一的“真神”。
什么是红黑树
红黑树是一种自平衡的二叉搜索树。每个节点额外存储了一个 color 字段 ("RED" or "BLACK"),用于确保树在插入和删除时保持平衡。
红黑树使用的是相对平衡,不同于平衡二叉树的绝对平衡,红黑树更稳定,而且需要旋转的次数会下降
理由一:空间利用率(打败 B+ 树)
B+ 树的逻辑:非叶子节点不存数据。
- 这对磁盘是好事(让索引更小,一次 IO 读更多)。
- 但在内存(Java Heap)里,这叫“浪费”! 既然节点都在堆内存里,为什么非叶子节点空着不存 Value?
- Java 的引用跳转(Reference Chaining)成本很低,不需要像磁盘 IO 那样为了减少跳转次数而搞得那么“扁”。
红黑树的逻辑:每个节点都存数据。
- 它是二叉的,虽然树高一点(内存里多跳几次微秒级差异),但它没有任何一个节点是只占坑不干活的。
- 在内存极其宝贵的 JVM Heap 中,红黑树比 B+ 树更紧凑。
结论: B+ 树是为了块存储设计的,而 Java HashMap 是对象存储。在内存里,红黑树的空间有效性更高。
理由二:Java 对象的“生理缺陷”(打败跳表)
这是最硬核的底层原因,也是 Java 语言特性决定的。
跳表的硬伤:它的节点是不定长的。
- Level 1 的节点有 1 个指针,Level 4 的节点有 4 个指针。
- 在 C 语言里:你可以用 malloc 动态分配不同大小的内存块,很方便。
- 在 Java 里:这就很尴尬了。Java 的对象模型(Class)是固定结构的!
-
- 你很难定义一个 Node 类,让它一会儿有 1 个 next 引用,一会儿有 4 个。
-
-
- 后果:这会多出一个数组对象头 (Array Header) 的开销。对于成千上万个小对象来说,这个额外内存开销是巨大的。
-
-
-
- 为了实现跳表,你通常得在 Node 里放一个 Node[] next 数组,或者 List next。
-
红黑树的优势:结构固定。
- static class TreeNode<K,V> 只需要定义 left, right, parent, red 就能走遍天下。
- 这对 Java 的内存分配器非常友好,且没有额外的数组对象头开销。
结论: 跳表在 C/C++ 里很香,但在 Java 对象模型下,它的额外内存开销(数组头)会让 HashMap 变得臃肿。
理由三:防御性武器(Hash 冲突)
你必须搞清楚 HashMap 引入红黑树的初衷。
- HashMap 不是一开始就用红黑树,它是先用数组+链表。
- 只有当链表太长(>8,意味着发生了严重的哈希冲突,甚至可能是被黑客攻击了 Hash DoS)时,红黑树才会作为**“特种部队”**登场。
在这种极端情况下:
- 不需要范围查询:HashMap 只需要查 Key=X,不需要查 Key > X。跳表的范围查询优势直接作废。
- 追求确定性:跳表是靠“掷硬币”随机的。在极其倒霉的情况下,跳表可能会退化(虽然概率极低)。但红黑树是绝对平衡的,它能保证最坏情况也 O(logn)
- 单线程场景:HashMap 本身就不是给高并发用的(并发要用 ConcurrentHashMap)。所以跳表的并发优势也作废。
总结:数据结构没有银弹,只有最适合的“战场”
我们回顾了 MySQL、Redis 和 JDK 的选择,会发现 “存储介质” 和 “访问模式” 决定了最终的胜负:
- MySQL (磁盘) :为了对抗缓慢的 I/O,选择了 B+ 树。牺牲了写入速度(复杂的页分裂),换取了极致的 “矮胖” 和 范围查询 能力。
- Redis (内存+高并发) :为了在内存中实现轻量级的高并发,选择了 跳表。牺牲了部分空间(多级索引),换取了 实现简单 和 锁竞争小。
- JDK (内存+极致计算) :作为通用的内存数据结构,选择了 红黑树。在不考虑磁盘 I/O 和极高并发锁竞争的前提下,它是 空间与时间复杂度 的完美平衡点。
不同数据结构的选择跟当前使用的语言也有很大的关系
1. 红黑树 (Red-Black Tree)
—— Java 内存里的“特种兵”
应用场景:
- JDK 1.8 HashMap / ConcurrentHashMap(当链表长度 > 8 时)。
- TreeMap / TreeSet。
- Linux 内核(进程调度 CFS、虚拟内存管理)。
核心特点:
- 一种自平衡二叉查找树 (BST) ,保证最坏情况下的查找、插入、删除时间复杂度都是 O(logN)
优点:
- 极其稳定:绝对的平衡,不会像跳表那样有概率退化。
- Java 内存友好:节点结构固定(Left/Right/Parent),在 Java 对象模型中没有额外的数组头开销,空间利用率比跳表高(在 Java 语境下) 。
- 查找快:对于单点查找(Get Key),性能极佳。
缺点:
- 实现复杂:左旋、右旋、变色,代码量大,难以调试。
- 范围查询慢:中序遍历需要在节点间反复横跳,对 CPU 缓存不友好。
- 并发难做:一次插入可能导致整棵树的旋转重排,锁的粒度很难变小。
为什么要用它?(JDK 视角)
- HashMap 需要解决的是哈希冲突(特别是防止 Hash DoS 攻击)。
- 场景是:纯内存、单线程(HashMap)或分段锁(CHM)、无需范围查询。
- JDK 需要一个确定性强、节省对象头内存的方案,红黑树完美符合。
2. B+ 树 (B+ Tree)
—— 统治磁盘的“矮胖子”
应用场景:
MySQL (InnoDB / MyISAM) 等传统关系型数据库的主键索引和辅助索引。
核心特点:
- 多路平衡查找树。非叶子节点只存索引,叶子节点存数据,且叶子节点之间用双向链表连接。
优点:
- 极低的高度:因为分叉多(Fan-out 高),树非常“矮胖”。存储 2000 万数据通常只需要 3 层。这意味着磁盘 I/O 次数极少。
- 范围查询无敌:扫库(Range Scan)只需要找到起点,顺着叶子节点的链表往后读即可,顺序 I/O 速度飞快。
- 存储紧凑:基于 Page(页)管理,指针少,没有内存碎片(在底层实现视角)。
缺点:
- 写入抖动:插入数据如果导致页满了,需要页分裂 (Page Split) ,开销较大。
- CPU 消耗大:在纯内存场景下,维护这种复杂的页结构是在浪费 CPU。
为什么要用它?(MySQL 视角)
- 磁盘 I/O 是最大的瓶颈(比 CPU 慢几十万倍)。
- 必须把树压扁,减少读盘次数。
- SQL 查询经常有 ORDER BY 或 > <,必须支持高效的范围扫描。
3. 跳表 (Skip List)
—— 简单高效的“内存快车”
应用场景:
- Redis (ZSet) 。
- LevelDB / RocksDB (MemTable 内存表结构)。
- Java JUC 包 (ConcurrentSkipListMap)。
核心特点:
- 基于多级索引的链表。利用概率(掷硬币)来维持平衡,模拟二分查找。
优点:
- 实现简单:代码难度只有红黑树的 1/3,不易出 Bug,易于维护。
- 范围查询快:底层就是链表,虽然比 B+ 树稍微慢一点点(因为内存离散),但比红黑树快得多。
- 并发友好:插入删除只修改局部指针,不需要全局旋转,非常适合高并发无锁/细粒度锁实现。
- 内存分配灵活:按需分配节点,没有 B+ 树的“内部碎片”。
缺点:
- 随机性:极端情况下可能退化成链表(概率极低)。
- 节点开销(Java 中) :在 Java 中每个节点要维护一个索引数组,对象头开销大。
为什么要用它?(Redis 视角)
- Redis 是内存数据库,没有 I/O 瓶颈,不需要 B+ 树那么复杂。
- Redis 需要 ZRANGE (排行榜范围查),红黑树做不到这么爽。
- Antirez(作者)原则:代码必须简单、可读、易维护。