B 树、红黑树都不行?MySQL 为何只选 B+ 树

0 阅读20分钟

MySQL 索引为何偏偏选中 B+ 树?这个问题背后藏着不少的设计考量。相信大家面试时,时常被问到:

  • 红黑树 vs B+ 树:为什么不是更常见的红黑树?
  • B 树 vs B+ 树:一字之差,区别到底在哪?
  • 哈希表 vs B+ 树:哈希查找那么快,为什么不用?
  • 数组 vs B+ 树:数组简单直接,难道不够用?

其实,MySQL 选择 B+ 树。这背后既有数据结构的精妙设计,也离不开一个关键的现实约束——磁盘 I/O

因为数据库的数据最终是落在磁盘上的,而磁盘读写比内存操作慢好几个数量级。所以索引结构的设计核心目标之一就是:尽量减少磁盘 I/O 次数。B+ 树的层数少、叶子节点有序相连、非叶子节点仅存键值等特性,恰恰都是为了这个目标服务的。

下次再有面试官问你“为什么是 B+ 树?”,你不妨从“如何减少磁盘 I/O”这个角度开始回答——这才是理解数据库索引设计的钥匙。

一、B + 树的进化之路

索引,作为一种特殊的数据结构,就像是图书馆里的索引目录,为数据库中的数据建立了一条 “快捷查询路径”。它能够大大减少数据查找的时间,通过特定的算法和结构,快速定位到我们所需的数据位置。在 MySQL 数据库中,主要存在 B + 树索引、Hash 索引和全文索引这三种类型 。其中,B + 树索引凭借其独特的结构和出色的性能,在处理大规模数据时展现出了强大的优势,成为了 MySQL 索引体系中的核心选择。

1.1 二叉查找树

二叉查找树是一种基础的树形数据结构,它的每个节点最多包含两个子节点,并且遵循着一个简单而重要的规则:左子树中所有节点的键值都小于父节点的键值,而右子树中所有节点的键值都大于父节点的键值。以用户表的 id 索引为例,若我们按照 id 从小到大的顺序插入数据,二叉查找树会逐渐形成一种 “单边” 结构,退化成单链表。此时,原本期望的对数级查询时间复杂度 O (logn),会急剧下降为线性级的 O (n)。这意味着,在数据量不断增大的情况下,查询效率会变得极其低下,完全无法满足数据库处理大规模数据的需求。

1.2 平衡二叉树(AVL 树)

为了解决二叉查找树的失衡问题,平衡二叉树(AVL 树)应运而生。AVL 树通过巧妙的旋转操作,始终保持着每个节点的左右子树高度差不超过 1,从而确保了树的平衡性,使得查询操作的时间复杂度稳定在 O (logn)。然而,AVL 树也并非完美无缺。每个节点仅能存储一个键值和对应的数据,这就导致树的高度相对较高。当数据量增大时,树的深度会不断增加,使得磁盘 I/O 操作的次数显著增多,严重影响查询效率。此外,在进行插入和删除操作时,AVL 树频繁的旋转操作也会耗费大量的时间,难以适应数据库对磁盘 I/O 特性的要求。

1.3 红黑树

红黑树在平衡二叉树的基础上,引入了颜色属性来维持树的平衡。它通过一系列的颜色调整和旋转操作,减少了旋转的次数,从而在插入和删除操作上表现出更高的效率。红黑树的本质仍然是二叉树,每个节点存储的键值和数据有限,这使得树的高度依然较高。当数据存储在磁盘上时,过高的树高度会导致大量的随机 I/O 操作,在数据库场景下,性能受到了极大的限制,无法满足高效查询的需求。

1.4 B 树

B 树是一种专门为磁盘存储设计的多叉树结构,它的出现是数据库索引发展的一个重要突破。B 树的核心改进在于,每个节点可以存储多个键值和对应的数据,这大大降低了树的高度。在 B 树中,节点对应着数据库中的 “页”,利用磁盘的预读特性,一次 I/O 操作可以读取多个键值,从而减少了磁盘 I/O 的次数,提高了查询效率。B 树的数据分散存储在各个节点中,在进行范围查询时,需要不断回溯树的结构,这在一定程度上影响了查询的效率。

1.5 B + 树

B + 树在 B 树的基础上进行了进一步的优化,成为了数据库索引的终极选择。B + 树的非叶子节点仅存储键值和指向子节点的指针,不存储实际的数据,这使得节点可以容纳更多的键值,增大了节点的扇出。同时,所有的数据都集中存储在叶子节点中,并且叶子节点通过双向链表连接。这两大设计完美适配了数据库的存储与查询需求,使得 B + 树在范围查询、排序查询等操作上表现出了极高的效率。B + 树的高度通常较低,查询时的磁盘 I/O 次数少,成为了 MySQL 等数据库系统中最常用的索引结构 。

二、B + 树的核心结构

2.1 先搞懂:MySQL 的表存储结构

在 MySQL 的世界里,表的存储结构呈现出一种清晰的层级关系:表>段>区>页>行。

其中,“页” 是数据库操作的基本单位,就像是建筑中的砖块,每一次数据的读取与写入,都围绕着 “页” 来进行。在 InnoDB 存储引擎中,页的默认大小为 16KB,这一固定的大小为 B + 树节点的存储提供了坚实的硬件基础。可以说,B + 树的每一个节点,都对应着数据库中的一个页,这种紧密的对应关系,使得 B + 树能够高效地利用磁盘空间,实现数据的快速存储与读取 。

2.2 B + 树的三层结构

B + 树的结构可以分为三个层次:根节点、非叶子节点和叶子节点。

根节点作为整棵树的入口,通常会被预先加载到内存中,以加快查询的速度。非叶子节点就像是图书馆中的目录,它们不存储实际的数据,仅保存键值和指向子节点的指针。这些键值就像是目录中的索引词,通过它们,我们可以快速定位到下一层节点,从而缩小查询的范围。而叶子节点则是数据的最终归宿,它们存储了全部的键值以及对应的数据(对于聚簇索引),或者是键值与主键(对于非聚簇索引)。同一层的叶子节点之间通过双向链表连接,这使得范围查询变得异常简单,只需沿着链表依次遍历,就能获取到所需的数据范围 。

2.3 页节点的内部优化:槽位 + 二分查找

B + 树的叶节点在存储数据时,采用了一种巧妙的设计:单链表 + 页目录槽位。单链表的结构使得数据的插入和删除操作变得十分便捷,无需大规模地移动数据。然而,单链表在查询效率上存在天然的缺陷,为了解决这一问题,B + 树引入了页目录槽位。页目录槽位就像是一本书的目录索引,每个槽位指向一组记录的最后一条。在查询时,我们首先通过二分查找在页目录槽位中定位到大致的范围,然后再在这个范围内通过链表进行遍历。这种设计既保留了单链表的灵活性,又提高了查询的效率,是一种十分巧妙的优化策略 。

三、B + 树的核心优势

为什么B + 树能碾压其他数据结构呢?

从三个角度来分析: 磁盘 I/O 角度; 范围查询角度; 增删改查角度;

3.1 高效磁盘 I/O

在数据库的世界里,磁盘 I/O 操作的耗时是影响性能的关键因素。B + 树凭借其独特的高扇出特性,成为了优化磁盘 I/O 的佼佼者。由于 B + 树的非叶子节点仅存储键值和指针,不包含实际的数据,这使得一个 16KB 的页能够存储上千个键值和指针组合。这种结构使得 B + 树的高度通常能控制在 3 - 4 层,即便存储了数百万条记录,查询时最多也只需进行 3 - 4 次磁盘 I/O 操作。

以千万级数据量的用户表为例,若采用 B + 树索引,查询某一特定用户信息时,最多经过 3 - 4 次磁盘 I/O 就能定位到目标数据。而二叉树在存储同样量级数据时,会形成 “高瘦” 的结构,查询深度巨大,可能需要数十次的磁盘 I/O 操作,效率远低于 B + 树 。

3.2 范围查询利器

范围查询是数据库中极为常见的操作,比如查询某个时间段内的订单数据,或者查询某个年龄段的用户信息等。B + 树的叶子节点通过双向链表连接,这一设计堪称范围查询的 “神器”。在执行范围查询时,如SELECT * FROM users WHERE id BETWEEN 18 AND 40;,数据库引擎首先通过 B + 树从根节点开始,快速定位到 id = 18 所在的叶子节点,读取该节点的数据。随后,无需再从根节点重新查找,而是直接通过叶子节点的链表指针,顺序向后遍历,直到找到 id > 40 的记录为止。

相比之下,B 树在进行范围查询时,需要不断回溯树的结构,从上层节点重新开始查找,这无疑增加了查询的时间和复杂度。B + 树的叶子链表结构,使得范围查询能够 “一次定位,顺序遍历”,大大提高了查询效率 。

3.3 查询性能稳定

B + 树是严格的平衡树,这意味着所有叶子节点都在同一层,无论查询哪个数据,从根节点到叶子节点的路径长度都是相同的。这种特性使得 B + 树的查询性能非常稳定,无论数据量大小,查询的时间复杂度始终保持在 O (logn)。

在 B 树中,由于数据分散存储在各个节点中,不同数据的查询路径长度可能不同,这就导致了查询效率的波动。在数据量较大时,B 树的查询性能可能会受到较大影响。而 B + 树的稳定性能,使其在处理大规模数据时,能够始终保持高效的查询能力,为数据库的稳定运行提供了有力保障 。

3.4 内存利用率高

B + 树的非叶子节点仅存储键值和指针,不存储实际的数据,这使得它们占用的空间非常小。在系统内存允许的情况下,我们通常可以将索引树的非叶子节点完整地加载到内存中,并长期驻留。当一个查询请求到来时,大部分的索引寻址过程,即从根节点到叶子节点的上层路径查找,都可以在内存中快速完成。只有在最后需要读取真实数据时,才需要进行一次或几次磁盘 I/O 来访问叶子节点。

这种设计大大提高了内存的利用率,减少了磁盘 I/O 的次数,进一步提升了查询性能。在高并发的数据库场景中,B + 树的这一优势能够显著降低系统的响应时间,提高用户体验 。

四、MySQL 中的 B + 树索引

在 MySQL 的索引体系中,基于 B + 树构建的索引主要分为聚集索引非聚集索引 。

4.1 聚集索引

在 InnoDB 存储引擎中,聚集索引是一种特殊的索引结构,它的叶子节点直接存储了完整的数据行。可以说,数据表本身就是按主键组织的一棵 B + 树,这棵树就是聚集索引 。这意味着,一张表只能有一个聚集索引,因为数据的物理存储顺序只能有一种。如果表中定义了主键,那么 InnoDB 会自动以主键作为聚集索引的键值;如果没有定义主键,InnoDB 会寻找一个唯一的非空索引作为聚集索引;若连这样的索引也没有,InnoDB 会自动生成一个隐藏的主键来构建聚集索引 。

聚集索引的最大优势在于,当我们使用主键进行查询时,可以直接在叶子节点中找到对应的完整数据行,无需进行额外的查找操作,这种查询方式被称为 “直接查询”,效率极高。在一个用户表中,若以用户 ID 为主键构建聚集索引,当我们查询SELECT * FROM users WHERE user_id = 123;时,数据库可以通过 B + 树快速定位到 user_id = 123 的叶子节点,直接获取该用户的所有信息 。

4.2 非聚集索引

非聚集索引,也常被称为二级索引或辅助索引。与聚集索引不同,非聚集索引的叶子节点存储的不是完整的数据行,而是对应行的主键值 。除了主键索引外的其他索引,如普通索引、唯一索引等,都属于非聚集索引。

当我们使用非聚集索引进行查询时,首先会在非聚集索引的叶子节点中找到对应的主键值,然后再通过这个主键值到聚集索引中查找完整的数据行,这个过程被称为 “回表”。在用户表中,若我们为用户的邮箱字段建立了非聚集索引,当查询SELECT * FROM users WHERE email = 'example@test.com';时,数据库会先在邮箱索引的叶子节点中找到该邮箱对应的主键值,然后再根据这个主键值到聚集索引中查找该用户的完整信息 。由于需要进行两次查找,非聚集索引的查询效率通常略低于聚集索引 。

4.3 实战对比

为了更直观地理解聚集索引和非聚集索引的查询差异,我们来看两个具体的例子 。

假设我们有一个用户表users,表结构如下:

CREATE TABLE users (
    user_id INT PRIMARY KEY,
    username VARCHAR(50),
    email VARCHAR(100),
    age INT,
    INDEX idx_email (email)
);

聚集索引查询

当执行查询SELECT * FROM users WHERE user_id = 5;时:

  1. 从 B + 树的根节点开始,根节点存储了键值和指向子节点的指针 。
  2. 通过二分查找,确定 user_id = 5 所在的子节点,然后加载该子节点所在的页到内存 。
  3. 重复上述步骤,逐层向下查找,直到到达叶子节点 。
  4. 在叶子节点中,通过二分查找找到 user_id = 5 的记录,直接返回该记录的完整数据行 。

非聚集索引查询

当执行查询SELECT * FROM users WHERE email = 'test@example.com';时:

  1. 从非聚集索引(email 索引)的根节点开始查找 。
  2. 同样通过二分查找,在各级非叶子节点中确定 email = 'test@example.com' 所在的子节点,加载相应的页到内存 。
  3. 到达叶子节点后,找到 email = 'test@example.com' 对应的主键值,假设为 user_id = 5 。
  4. 拿着这个主键值,到聚集索引中进行 “回表” 操作,重复聚集索引的查找步骤,最终找到 user_id = 5 的完整数据行 。

通过这两个例子可以清晰地看到,聚集索引的查询直接而高效,而非聚集索引的查询则需要额外的 “回表” 步骤,这在一定程度上增加了查询的时间和资源消耗 。

五、B + 树的检索全过程

5.1 聚集索引的检索步骤

在 MySQL 中,当我们使用聚集索引进行数据检索时,其过程犹如一场精密的导航,每一步都紧密相连,高效地引领我们找到目标数据 。

以一个经典的查询为例,假设我们有一个users表,其中user_id为主键,且构建了聚集索引。当执行查询SELECT * FROM users WHERE user_id BETWEEN 18 AND 40;时,具体的检索步骤如下 :

  1. 根节点定位与子节点查找:查询从 B + 树的根节点开始,根节点通常会被预先加载到内存中,这大大加快了查询的起始速度。在根节点中,存储着键值和指向子节点的指针。通过二分查找算法,我们将查询条件中的user_id = 18与根节点中的键值进行比较,快速确定user_id = 18所在的子节点。然后,加载该子节点所在的页到内存中,为下一步的查找做好准备 。
  2. 逐层向下遍历至叶子节点:进入子节点后,重复上述比较和查找过程。继续将user_id = 18与子节点中的键值进行对比,找到第一个大于等于18的键,选择对应的指针,加载下一级子节点页到内存。如此层层递进,直到到达叶子节点层 。
  3. 叶子节点数据获取:在叶子节点中,数据以有序的方式存储,并且通过链表相互连接。对于范围查询,我们首先通过二分查找找到user_id = 18的记录,然后利用链表的特性,顺序向后遍历,直到找到user_id > 40的记录为止。在这个过程中,我们可以获取到所有满足user_id BETWEEN 18 AND 40条件的数据行,完成查询操作 。

5.2 非聚集索引的检索步骤

非聚集索引的检索过程与聚集索引有相似之处,但由于其叶子节点存储的是主键值而非完整数据行,因此多了一步 “回表” 操作 。

同样以users表为例,假设我们为email字段建立了非聚集索引。当执行查询SELECT * FROM users WHERE email = 'test@example.com';时,具体步骤如下 :

  1. 非聚集索引树查找主键值:从非聚集索引(email索引)的根节点开始查找。与聚集索引类似,通过二分查找在各级非叶子节点中确定email = 'test@example.com'所在的子节点,加载相应的页到内存。到达叶子节点后,找到email = 'test@example.com'对应的主键值,假设为user_id = 5 。
  2. 聚集索引树 “回表” 获取数据:拿着在非聚集索引树中找到的主键值user_id = 5,到聚集索引中进行 “回表” 操作。这一步就如同在聚集索引的检索过程中,执行SELECT * FROM users WHERE user_id = 5;的查询。从聚集索引的根节点开始,重复聚集索引的查找步骤,逐层向下查找,直到在叶子节点中找到user_id = 5的完整数据行 。
  3. 获取完整数据行,完成查询:通过 “回表” 操作,我们最终获取到了满足email = 'test@example.com'条件的完整数据行,完成了整个查询过程。由于需要在非聚集索引树和聚集索引树中进行两次查找,非聚集索引的查询效率通常会略低于聚集索引,但在某些场景下,仍然能显著提升查询速度 。

六、为什么 MySQL 偏偏选中 B + 树?

6.1 对比 Hash 索引:不支持范围查询,场景受限

Hash 索引利用哈希函数将索引键值映射到一个固定大小的数组中,在等值查询场景下,其效率堪称一绝,时间复杂度可达 O (1)。在一个用户表中,若要查询user_id = 123的用户信息,Hash 索引可以迅速通过计算哈希值定位到目标数据,速度极快 。但 Hash 索引的局限性也十分明显,它不支持范围查询,无法处理如SELECT * FROM users WHERE user_id BETWEEN 100 AND 200;这样的查询语句。这是因为哈希函数不保留键值的顺序,无法直接进行范围判断。Hash 索引还存在哈希冲突的问题,当不同的键值被映射到相同的哈希值时,需要通过链地址法等机制来解决冲突,这无疑增加了额外的开销,降低了查询效率 。在复杂的数据库查询场景中,范围查询、排序等操作极为常见,Hash 索引的这些缺陷使其无法满足数据库的多样化需求,而 B + 树索引则能很好地弥补这些不足 。

6.2 对比 B 树:范围查询效率低,空间利用率不足

B 树和 B + 树虽然都是多路平衡查找树,但在结构和性能上存在显著差异 。在 B 树中,数据存储在所有节点中,而 B + 树的数据仅存储在叶子节点,内节点只存储键值用于导航 。这一差异导致在范围查询时,B 树需要对树进行一次次遍历,不断回溯上层节点,访问每个符合条件的节点,效率较低。在查询SELECT * FROM orders WHERE order_id BETWEEN 120 AND 230;时,B 树需要多次回溯树的结构来确定范围,而 B + 树只需从根节点定位到order_id = 120的叶子节点,然后通过链表顺序向后遍历,直到找到order_id > 230的记录为止,效率大大提高 。B + 树的内节点只存储键而不存储数据,使得每个节点可以包含更多的键值,提高了节点的扇出。较高的扇出意味着树的高度较小,数据访问时需要的磁盘 I/O 次数也会减少,进一步提升了查找性能 。

6.3 对比红黑树:树高过高,I/O 开销大

红黑树是一种自平衡二叉树,在插入和删除操作时,通过旋转操作保持树的平衡性,时间复杂度为 O (log n) 。在处理大规模数据时,红黑树的局限性就暴露无遗。由于红黑树每个节点通常存储一个键值对,这使得树的高度较大。在存储大量数据时,树的深度会不断增加,导致需要更多的磁盘 I/O 来进行查找,性能大幅下降 。对于一个拥有千万级数据的数据库,若采用红黑树索引,树高可能达到 20 层以上,每次查询需要进行 20 余次磁盘 I/O 操作 。而 B + 树凭借其高扇出特性,树的高度通常能控制在 3 - 4 层,查询时最多只需 3 - 4 次磁盘 I/O 操作,大大提高了查询效率 。在数据库系统中,磁盘 I/O 的开销是影响性能的关键因素,B + 树在这方面的优势使其成为更优的选择 。