目录

一、没有索引会发生什么

1. 查询性能问题

2. 全表扫描演示

二、认识磁盘存储

1. 为什么数据库依赖磁盘

2. 磁盘的基本结构

3. 随机访问与连续访问

三、MySQL 与磁盘如何交互

1. Page 页的概念

2. Buffer Pool 缓冲池

四、初步认识索引

1. 什么是索引

2. 主键索引实验

3. 为什么插入的数据会自动有序

五、单个 Page 的数据结构

1. Page 内部结构概览

2. 用户数据区与链表

2. 为什么需要页目录

3. Page 内查找过程

六、多个 Page 的数据组织

1. 单页存储的局限性

2. 多个 Page 如何连接

3. 跨页查找问题

七、页目录的升级与 B+ 树

1. 目录项记录的引入与多级目录

2. B+ 树的经典架构与分层职责

3. B+ 树检索流程

4. B+ 树高度分析

八、为什么是 B+ 树

1. 为什么不是顺序表或单链表?

2. 为什么不是二叉搜索树

3. 为什么不是平衡二叉树(AVL)与红黑树?

4. 为什么不是 B 树

5. B+ 树的优势

总结


一、没有索引会发生什么

在现代关系型数据库(如 MySQL)的性能调优中,"索引" 是很常见的词汇。许多开发者知道索引能加快查询速度,也知道底层的核心数据结构是 B+ 树

那么,数据库为何要大费周章地采用 B+ 树结构?这种设计又如何将原本需要数秒的磁盘 I/O 操作优化至毫秒级别?要真正理解索引的价值,我们不妨先观察没有索引的情况


1. 查询性能问题

在一个没有采取任何优化的数据表里,数据的物理存储是无序追加的。新生成的数据被不断地写入到磁盘文件的末尾

如果此时我们要检索一条特定的记录,由于数据本身没有任何排好序的规律,数据库系统必须从数据文件的第一个字节开始,逐行读取、逐行比对,直到翻遍整张表的最后一行。这种原始的查找方式,被称为全表扫描

为了直观量化这种性能退化,我们构建一个完全没有建立索引(甚至连主键都没有)的数据沙盒

-- 创建一张零索引、零主键的纯追加裸表
CREATE TABLE user_mock (
    id INT COMMENT '用户编号',
    name VARCHAR(50) COMMENT '用户姓名',
    email VARCHAR(50) COMMENT '电子邮箱',
    age INT COMMENT '年龄'
) COMMENT '无索引高并发测试模拟表';

-- 构建高效的批处理存储过程,插入 10 万条无序数据
DELIMITER $$
CREATE PROCEDURE proc_batch_insert_mock()
BEGIN
    DECLARE i INT DEFAULT 1;
    -- 开启大事务加速物理写入
    START TRANSACTION;
    WHILE i <= 100000 DO
        INSERT INTO user_mock VALUES (
            i, 
            CONCAT('极客学者_', i), 
            CONCAT('geek_researcher_', i, '@mail.com'), 
            FLOOR(18 + RAND() * 60)
        );
        SET i = i + 1;
    END WHILE;
    COMMIT;
END$$
DELIMITER ;

-- 执行数据灌录
CALL proc_batch_insert_mock();

2. 全表扫描演示

当 10 万条数据物理落盘后,我们尝试执行一条普通的等值条件查询,并利用 MySQL 的 EXPLAIN 执行计划工具来透视其实际的底层运作:

底层行为

  • type = ALL:这表明 MySQL 优化器绝望地发起了全表扫描。

  • rows = 97145:为了在无序的表中找出那条唯一的 email 记录,执行引擎在物理上将整个数据表中的 10 万行记录基本全部扫描并读取了一遍

  • 在算法时间复杂度上,全表扫描是一个典型的 O(N) 线性复杂度。如果数据量 N 从 10 万膨胀到 1000 万,数据库的扫描耗时将呈现完美的线性放大。对于高并发、低延迟的线上系统而言,O(N) 的磁盘查找无异于一场瘫痪式的灾难

二、认识磁盘存储

既然 O(N) 的全表扫描很慢,那为什么不能直接依靠服务器强大的 CPU 算力强行把速度提上去呢?因为数据库的性能从来都不在 CPU,而是在底层硬件——机械磁盘


1. 为什么数据库依赖磁盘

在计算机的多级存储架构中,内存(RAM)虽然有着接近百 GB/s 的惊人吞吐率和微秒级的延迟,但它存在一个致命的物理物理缺陷:断电易失性

数据库作为企业长期存储的核心资产,其核心是保障数据的持久性。因此,哪怕内存速度再快,数据也必须安全地写入在磁盘等非易失性存储介质中


2. 磁盘的基本结构

尽管现代数据中心大量普及了固态硬盘,但绝大多数高容量分布式存储与传统关系型数据库的底层核心设计逻辑,依然是基于机械磁盘的物理特性演进而来

机械磁盘的内部是一个精密的高度集成机械构造,主要包含以下核心部件:

  • 盘片(Platter):表面涂有磁性材料的圆形金属片,在主轴(Spindle)的驱动下,以每分钟 7200 转(RPM)或上万转的高速进行旋转

  • 磁头臂与磁头:磁头臂受悬浮马达控制,带动磁头在高速旋转的盘片表面做半径方向的微米级悬浮往复运动,负责读取或改变盘片的磁性状态

  • 磁道(Track):盘片表面被划分成的一个个同心圆

  • 扇区(Sector):磁道被等角度划分出的一个个扇形弧段。扇区是磁盘物理读写的最小基础物理单位(传统磁盘为 512 字节,现代高级格式化磁盘为 4KB)

  • 柱面(Cylinder):多张叠加盘片上相同半径的磁道,在垂直空间上组合成的一个虚拟圆柱体


3. 随机访问与连续访问

当数据库发出读取指令时,机械磁头要在盘片上找到并读取目标数据,必须经历两段及其耗时的机械运动:

  1. 寻道时间(Seek Time):磁头臂物理移动,将磁头精准定位到目标数据所在的磁道上。这需要机械马达摆动,通常需要 3ms ~ 9ms

  2. 旋转延迟(Rotational Latency):磁头在磁道上静止不动,等待盘片高速旋转,直到目标扇区转动到磁头的正下方。对于 7200 转的磁盘,平均旋转延迟约为 4.17ms

单次物理 I/O 耗时 = 寻道时间 + 旋转延迟 ≈ 10ms

连续与随机

  • 连续访问(Sequential Access):如果要读取的数据在物理上是挨在一起、紧邻存储的,磁头只需要执行一次寻道定位,随后就能借着盘片的旋转,像流水线一样高速吞吐数据,吞吐量可达数百 MB/s

  • 随机访问(Random Access):如果要读取的数据散落在磁道的各个角落(如无序追加的数据文件),磁头每读取一小段数据,就必须重新摆动磁头臂(寻道)并重新等待盘片旋转。这意味着一次随机 I/O 就要硬生生消耗掉约 10 毫秒

磁盘 I/O 的代价

在 10 毫秒才能完成一次随机物理 I/O 的情况下,哪怕是一台性能优异的服务器,其机械磁盘在一秒钟内也最多只能承受 1000ms / 10ms = 100 次随机 I/O 操作

再回到前面的全表扫描:10 万条无序数据如果零散地分布在磁盘的各个角落,在最坏情况下,可能会触发数千甚至上万次随机磁盘 I/O。这解释了为什么全表扫描会瞬间让整个系统陷入卡死状态

能最大程度地减少磁盘 I/O 次数,将昂贵的 "随机 I/O" 转化为高效的 "连续 I/O",就能大幅提升数据库的性能


 

三、MySQL 与磁盘如何交互

既然理解了磁盘 I/O 的重要性,那么 MySQL 的 InnoDB 存储引擎是如何在工程上对磁盘交互进行改良的呢?它采用了一个核心的策略:按页交互

在 Linux 操作系统中,MySQL 的每张表在磁盘上都对应着一个具体的物理文件。通常是以.ibd 为后缀的独立表空间文件(存储在 /var/lib/mysql/数据库名/ 目录下)。这个文件内部被划分成了无数个格式高度统一的逻辑块


1. Page 页的概念

如果我们要读取某一行只有 10 字节的数据,MySQL 会不会只从磁盘读取这 10 个字节?绝对不会

因为如果频繁地按字节或按行去和底层的物理扇区进行交互,将会引发灾难性的随机 I/O 碎片。为此,InnoDB 抽象出了一个核心的逻辑存储概念——页(Page)

  • Page 是 InnoDB 磁盘管理的最小逻辑单位。在默认配置下,一个 Page 的标准物理大小是 16KB

  • 内存与磁盘交互:无论是从磁盘读取数据到内存,还是将内存中的修改刷新回磁盘,InnoDB 必须以页(16KB)为整体单位进行整块的吞吐。哪怕你只想读取一条仅包含 id = 1 的记录,数据库也会一口气将这条记录所在的整个 16KB 页面全部加载进内存

为什么以页为单位能大幅提升效率?

这完美契合了计算机科学中的局部性原理。空间局部性表明:一条数据被访问后,与其紧邻在周围的数据在极短时间内大概率也会被访问。通过一次性加载 16KB 的整页数据,后续对同页内其他数据的访问就能直接在内存中命中,成功将多次磁盘随机 I/O 压缩为一次


2. Buffer Pool 缓冲池

为了让 Page 页的威力发挥到极致,MySQL 在主内存中开辟了一块极为关键的、大容量的内存连续区域——Buffer Pool(缓冲池)

当一条查询语句命中某张表的某一页时:

  1. 检查阶段:执行引擎首先去内存的 Buffer Pool 中探查该 Page 页是否已经存在

  2. 命中返回:如果该页早已被之前的查询加载到了缓冲池中(称为缓存命中),数据库将直接在内存中抓取数据并返回,零磁盘 I/O,耗时从毫秒级降至纳秒级

  3. 换入换出:只有当缓冲池中找不到对应的页时,系统才会发起一次物理磁盘 I/O,将该页读入 Buffer Pool 中

四、初步认识索引

长期受全表扫描导致的执行效率低下问题困扰,并深刻认识到磁盘 I/O 操作带来的高性能开销后,我们意识到索引(Index)即为现代数据库优化查询效率的核心解决方案


1. 什么是索引

把整个 .ibd 数据文件比作一本厚达上千页的《新华字典》,索引就是这本字典前页的 "拼音/部首检字表"

索引的本质

在关系型数据库中,索引的底层本质是一种排好序的、能够极大地加速数据检索速度的数据结构。它独立于业务数据本身,却又建立在业务数据之上。索引文件中不仅保存了被索引列的特征数值,还记录了该数值所在的磁盘页地址指针

索引解决的核心痛点

  • 缩短 O(N) :索引的核心是将原本全表扫描的 O(N) 线性时间复杂度,缩短为 O(\log N) 甚至是 O(1) 的对数级别复杂度

  • 压缩磁盘 I/O 次数:通过借助高内聚的索引目录,执行引擎能够以极少的 Page 读取次数(通常只有 2~4 次),在大量磁盘数据中实现精准定位,从而把宝贵的系统资源从低效的物理摆动(随机 I/O)中彻底解放出来


2. 主键索引实验

让我们通过一个经典实验来直观体验索引的作用,同时揭示一个反直觉的底层现象

乱序插入数据

我们创建一张用户表,但这回我们要为它显式声明一个 PRIMARY KEY。随后,我们故意无顺序的先后插入 5 条数据:

-- 创建带有主键索引的实验表
CREATE TABLE user_indexed (
    id INT PRIMARY KEY COMMENT '用户ID (主键索引)',
    name VARCHAR(20) COMMENT '姓名'
) COMMENT '主键索引行为探查表';

-- 故意以完全乱序的次序插入数据:8 -> 3 -> 5 -> 1 -> 6
INSERT INTO user_indexed VALUES (8, '悟空');
INSERT INTO user_indexed VALUES (3, '八戒');
INSERT INTO user_indexed VALUES (5, '玄奘');
INSERT INTO user_indexed VALUES (1, '沙僧');
INSERT INTO user_indexed VALUES (6, '白龙');

数据插完后,我们直接使用全表扫描

SELECT * FROM user_indexed;

数据库输出的结果,并没有按照我们插入时的物理先后顺序(8, 3, 5, 1, 6)来排列。相反,MySQL 严密地按照 id 从小到大的升序(1, 3, 5, 6, 8)将这几条记录重新排列好了


3. 为什么插入的数据会自动有序

这一现象的背后,是 InnoDB 存储引擎为了构筑高效索引目录而故意为之的核心策略

为什么一定要在插入时强行排序?

许多人认为,乱序插入时直接追加到文件末尾速度最快,而强行排序会损耗写入性能。InnoDB 顶着性能损耗也要强行排序的原因,在于它在为后续的高效查找页面管理铺路:

  1. 为二分查找打地基:如果数据在 16KB 的页面内部是乱序、随机摆放的,那么要在页内找一条数据,依然只能通过慢速的逐行遍历。而一旦数据在内存和磁盘中是绝对有序的,执行引擎就可以直接使用高效的二分查找,实现跨越式的快速定位

  2. 契合范围查询:在业务中,WHERE id BETWEEN 3 AND 6 这种范围检索高频发生。如果数据自动有序,数据库在定位到起始边界 3 之后,只需要继续向后查询即可,免去了全表检索的二次代价

  3. 多级页目录:自动有序是数据结构演进的铁律。没有行与行之间的绝对有序,就无法在 Page 内部提炼出高级的 "页目录";没有页目录,多个 Page 之间就无法拉展出庞大的 B+ 树架构

五、单个 Page 的数据结构

既然已经知道 InnoDB 以 16KB 的页作为最基础的物理吞吐单位,且在页面内部维持着主键的绝对有序,那么这一小块连续的内存/磁盘空间,究竟是用怎样的空间布局去兼顾高频插入与秒级查找的呢?


1. Page 内部结构概览

如果我们去观察一个刚刚初始化并插入部分数据的 16KB 页面,会发现它的内部被划分为以下 7 个功能独特的区域

区域名称 核心职责
File Header(文件头部) 记录当前页的校验和、物理页号,以及最关键的前后物理页的指针
Page Header(页面头部) 记录当前页的各种状态信息,如包含多少条记录、空闲空间的起始偏移量等
Infimum + Supremum 两个虚拟边界记录,分别代表该页内的绝对最小值绝对最大值
User Records(用户记录区) 核心区:我们实际插入的每一行业务数据都在这里
Free Space(空闲空间) 页面中尚未被使用的空白内存,专门等待新数据的写入
Page Directory(页目录) 查找加速器:用于支持页内二分查找
File Trailer(文件尾部) 存放最终的校验和,用于在页面从内存刷新回磁盘时,判定是否发生数据断电损坏

2. 用户数据区与链表

在上面的实验中,我们看到无序插入的数据会被自动排序。我们会本能地以为,这就像 "向数组中插入元素" 一样,每来一条更小的新数据,就需要把其后方的所有数据在内存中物理地整体向后挪一个位置

这是一个非常危险的误解! > 16KB 空间内如果频繁发生内存的物理整体搬移,其高并发插入性能将会直接崩溃

为了攻克这一瓶颈,InnoDB 在用户数据区采取了 "物理乱序追加,逻辑单向链表" 的设计:

  1. 物理上的追加:当一条新记录到来时,InnoDB 会直接从 Free Space 中划出一小块地方来存放这行数据。它在物理地址上是没有规律的,只是紧跟着上一条新来的数据

  2. 逻辑上的存储:在每一行数据的紧邻头部,都隐藏着一个不被用户看见的控制信息——记录头信息。这其中包含一个极为关键的指针偏移量:next_record

  3. 排序链表:新插入的数据通过修改前后节点的 next_record 指针,将自己插入到由 Infimum(最小边界)作为头节点、Supremum(最大边界)作为尾节点的单向有序链表

通过这种链表解耦,无论主键值多小、多么插空,写入时只需要改变指针的指向即可,物理上完全不需要发生任何数据的挪动


2. 为什么需要页目录

逻辑单向链表虽然极其完美地解决了高并发写入时内存搬移问题,但它同时把另一个致命的软肋留给了 "查询"

我们知道,链表是不支持二分查找的。哪怕整个链表在逻辑上再怎么有序,如果要查找 id = 8 的数据,执行引擎也只能从头节点 Infimum 开始,顺着 next_record 指针一个一个、被动地往后排查

如果一个 16KB 的页面内部塞满了上百条甚至数百条复杂的业务数据,这种 "页内全表扫描" 会让 O(1) 的页面命中退化回 O(N) 的行遍历。为了解决这个矛盾,InnoDB 在页面的尾部引入了页目录


3. Page 内查找过程

页目录类似于给一本书划分出了章节。它的提取和加速机制遵循以下逻辑:

1. 提取与分组

InnoDB 会将页内的所有有效记录(包括最大最小虚拟记录)划分为若干个 "小组"

  • 最小边界记录 Infimum 独占一组

  • 普通用户记录每 4 ~ 8 条 划分为一组

  • 每个小组中主键值最大的那一条记录,会被提拔为 "组长"

2. 建立槽位(Slots)

页目录内部是一个连续的、紧凑的数组空间。它会把每一个小组长的内存物理偏移量地址挨个记录下来。每一个偏移量,在底层就被称为一个槽(Slot)

3. 高效的页内查找过程

当执行引擎要在当前 Page 页内查找 id = 5 的记录时,它再也不用去遍历链表,而是直接对页目录发起二分查找

  • 第一步:通过二分法,在连续的页目录槽位中,瞬间锁定 id = 5 应该坐落在哪两个槽之间

  • 第二步:由于槽里记录的是每个小组里组长的地址,数据库会找到前一个 Slot 的组长

  • 第三步:顺着前一个组长的 next_record 指针找到目标小组,在组内的 4 ~ 8 条记录中进行几步极短的遍历,瞬间完成捕获

通过页目录的精妙分层,InnoDB 将原本需要上百次逐行比对的链表遍历,缩短为 "几步二分查找 + 4~8 步小组内排查"。单页内部的查找效率正式蜕变为高内聚的 O(\log N) 对数级复杂度

六、多个 Page 的数据组织

在微观的 16KB 单页世界里,InnoDB 凭借 "逻辑单向链表 + 页目录二分查找" 精妙地实现了页内的高效读写。但在现实生产环境中,面对千万级、亿级的海量数据,单个 Page 显然是沧海一粟


1. 单页存储的局限性

一个页只有 16KB 空间。除去文件头、页面头、页目录等固定的管理开销,留给用户存储行记录的净空间大约在 15KB 左右

假设一条电商订单记录包含了商品详情、买家信息等,平均每行占用 200 字节。那么一个 Page 撑死只能容纳大约 70 ~ 80 条记录

当第 81 条数据尝试挤进这个已经饱满的页面时,就会触发数据库底层页分裂(Page Split)

  1. 申请新页:InnoDB 向操作系统申请一个全新的 16KB 逻辑页面

  2. 数据迁移:为了维持全局主键的绝对有序,引擎会将原页面中大约 50% 的有序数据搬移到新申请的页面中

  3. 纽带重组:对原本物理连续的空间进行逻辑上的分割重组


2. 多个 Page 如何连接

当页面数量因为页分裂而激增到成百上千个时,这些页面在物理磁盘上的分布绝大概率是完全离散、随机散落的。为了确保整个数据库在逻辑上依然是一个井然有序的整体,MySQL 必须在更高维度构建跨页网络

这个网络的骨架,正是我们在第五章开头提到的 File Header(文件头部)。在每一个页面的 File Header 内部,都硬编码着两个指针:

  • FIL_PAGE_PREV:指向当前页面的上一个物理/逻辑页面的页号

  • FIL_PAGE_NEXT:指向当前页面的下一个物理/逻辑页面的页号

通过这两个指针,大量离散页面在逻辑上被组织成了一条双向链表。同时,InnoDB 保证:上一个页面内所有记录的主键值,必须绝对小于下一个页面内所有记录的主键值


3. 跨页查找问题

现在,整个体系在全局上拉展成了一个有序的链表。假设此时我们发出指令:
SELECT * FROM user WHERE id = 500

我们来看看此时执行引擎要面对的问题:

  1. 盲目摸索:虽然页内部有二分查找,但数据库首先需要知道 id = 500 究竟在哪一个具体的 Page 页里

  2. 多页串联遍历:由于各个 Page 之间仅仅是通过双向链表穿起来的,执行引擎不得不先读取 Page 1,读取它的最大主键值,发现不够;再顺着指针读取 Page 2……以此类推

致命的性能陷阱

  • 逻辑看似升级,物理依旧缓慢:虽然这种方式避免了单行单行的全表扫描,但在页面层级,它依然是 O(N) 线性扫描(Page 维度的全表扫描)

  • 磁盘 I/O 瞬间拉满:每一次顺着指针去查找下一个 Page,由于页面在磁盘上是离散的,都会触发一次磁盘随机 I/O。如果我们要找的数据恰好在链表的第 1000 个节点,数据库为了找这一条数据,需要发起 1000 次交互

虽然多页双向链表的结构解决了存储容量问题,但查找效率却大幅降低。为此,InnoDB 对页目录机制进行了改进,由此诞生了索引目录树这一设计

七、页目录的升级与 B+ 树

在多页双向链表的结构中,跨页检索依然需要遍历各个页面,其时间复杂度为 O(N)(此处 N 为页面数量)。为了消除多页遍历引发的随机磁盘 I/O 损耗,InnoDB 引入了多级索引目录的设计思想,并最终演进为 B+ 树结构


1. 目录项记录的引入与多级目录

为了精确定位目标数据所在的页面,必须对页面本身建立索引目录。InnoDB 的解决方案是:使用专门的页面来存储普通数据页的结构特征,这种页面被称为 "索引页" 或 "非叶子节点"

目录项记录的拓扑结构

普通数据页存储的是业务行记录,而索引页存储的是目录项记录。目录项记录的元数据结构极其精简,主要由以下两部分组成:

  • 页内最小主键值(Key):指向的目标数据页中所包含的最小主键数值

  • 页号(Page Number):目标数据页在磁盘/内存中的唯一物理识别号

从一级目录到多级目录的演进

当数据库中的行记录不断增加,导致普通数据页数量激增时,单一的索引页也将无法容纳所有的目录项记录。此时,索引页自身也会发生物理分裂

为了管理多个索引页,系统会基于相同的逻辑,在上层继续构建更高层级的索引页,用以存放下层索引页的目录项。通过这种横向扩展、纵向分层的嵌套递进,最终在多维空间中形成了一个严密的分层路由网络。这种多层级的逻辑拓扑架构,在计算机科学中被称为 B+ 树(B+ Tree)


2. B+ 树的经典架构与分层职责

一个完整的 InnoDB B+ 树索引结构,按照功能和所处层级可以划分为三大核心物理层级:

非叶子节点(索引页 / 内部节点)

  • 空间特性:不存储任何实际的业务行记录(如姓名、订单详情等),仅存储用于路由的主键键值与子页号指针

  • 价值:由于不含业务大数据,单一非叶子节点页能够容纳极高密度的目录项。这极大地提升了树的扇出度,使得树的横向跨度极广,纵向高度极低

叶子节点(数据页)

  • 空间特性:位于 B+ 树的最底层,完整存放了用户插入的所有业务行数据(User Records)

  • 连接行为:所有的叶子节点页面之间,依然遵循第六章所述的规则,通过文件头部的物理指针,组装成一条全局有序的双向链表


3. B+ 树检索流程

当执行查询操作(例如:SELECT * FROM user WHERE id = 105)时,InnoDB 执行引擎将从根节点开始,进行自上而下的检索:

  1. 根节点定位:系统首先读取 B+ 树的根节点页面

  2. 页内二分路由:在根节点页面内,利用页目录提供的二分查找法,比对目录项中的 Key 值,确认 id = 105 所对应的下一层子页号

  3. 逐层向下:根据获取的子页号,加载对应的中间层非叶子节点,重复执行页内二分查找,直至定位到最底层的某一个确切的叶子节点(数据页)页号

  4. 行记录捕获:将该叶子节点页加载进内存,利用该页内部的页目录完成最后的定位,精准获取主键为 105 的完整行数据

在整个检索路径中,执行引擎是跨越式地下探,直接跳过了所有不相关的页面。时间复杂度由单链表遍历的 O(N) 收敛至 O(log N) 对数级别


4. B+ 树高度分析

B+ 树之所以能拥有惊人的查询性能,核心在于其物理高度被控制在极低的范围内(通常维持在 2 到 4 层之间)。以下通过代数推导来量化一棵 B+ 树能承载的数据容量上限

数学模型假设

  • 假设单个 Page 的标准大小为 16KB,即 16 * 1024 = 16384 字节

  • 假设主键类型为 BIGINT,占用 8 字节;指针 InnoDB 中占用 6 字节。则单条目录项记录占用 8 + 6 = 14 字节

  • 除去页面头尾等固定管理开销,暂设非叶子节点页用于存储目录项的净空间为 15000 字节

  • 假设单条业务行记录较大,包含多个字段,平均每行占用 160 字节。则一个叶子节点页大约可容纳 15000 / 160 ≈ 93 条行记录

树高容量推导

根据上述条件,一个非叶子节点(索引页)所能拥有的最大扇出度(即容纳的目录项数量)为:

单页目录项数量 = 15000 / 14 ≈ 1071 个

根据树形结构的乘积效应,我们分别计算不同高度 H 下的系统容量:

  • 当高度 H = 2 时(1层非叶子节点 + 1层叶子节点):根节点可以指向 1071 个叶子节点页

    总数据容量 = 1071* 93 ≈ 99,603 条记录

  • 当高度 H = 3 时(1层根节点 + 1层中间索引页 + 1层叶子节点)

    根节点指向 1071 个中间索引页,每个中间索引页又各自指向 1071 个叶子节点页。

    总数据容量 = 1071 * 1071 * 93 ≈ 106,674,813 条记录

推导结果表明,一棵仅有 3 层高度的 B+ 树,其极限吞吐容量就已经跨越了 1 亿条记录的门槛

由于非叶子节点(前 2 层)的空间占用极小,它们可以常驻在服务器的内存(Buffer Pool)中。这意味着,在面对一个拥有上亿条数据的庞大物理表时,定位任意一条随机记录,最多只需要执行 1 次物理磁盘 I/O(即加载第三层的叶子节点页)。这种绝对可控的 I/O 代价,正是索引能够将查询耗时压制在毫秒级的底层物理保障

八、为什么是 B+ 树

在经典的算法与数据结构中,能够实现快速查找的方案有很多,例如顺序表、链表、各类二叉树以及哈希表等。然而,MySQL 的 InnoDB 引擎最终在众多方案中决选出了 B+ 树

为了理解这一决策的必然性,本章从磁盘 I/O 代价、内存空间利用率以及业务场景匹配度等维度,逐一对比 B+ 树与其他主流数据结构的底层缺陷


1. 为什么不是顺序表或单链表?

顺序表

  • 优势:支持随机访问,在拥有连续物理内存时,可以通过二分查找实现 O(log N) 的检索效率

  • 致命缺陷:顺序表要求内存空间具有绝对的连续性。在高并发的数据库写入场景下,频繁的插入与删除操作会引发大规模的数据物理搬移,其时间复杂度高达 O(N)

单链表

  • 优势:插入与删除操作极为高效,只需改变指针指向即可

  • 致命缺陷:链表不支持随机访问与二分查找。检索任意数据均需从头节点开始执行 O(N) 的全遍历。更严重的是,链表节点在磁盘上是离散存储的,每次指针的后移都极大概率触发一次独立的磁盘随机 I/O,这在商业级数据库中是无法接受的


2. 为什么不是二叉搜索树

二叉搜索树(Binary Search Tree)在理想状态下能够提供 O(log2 N) 的查找与插入效率

       5 (Root)
      / \
     3   7
    /     \
   2       8  <--- 理想状态下的平衡树

致命缺陷:二叉搜索树没有平衡控制机制。当面对数据库高频出现的 "自增主键插入"(例如递增插入 1、2、3、4、5)时,二叉搜索树会发生严重的拓扑退化,演变成一条单向倾斜的线性链表

   1
    \
     2
      \
       3
        \
         4
          \
           5  <--- 顺序自增插入时,BST 退化为慢速链表

此时,其查找时间复杂度会从 O(log2 N) 彻底退化回 O(N),丧失了树形结构的查找加速优势


3. 为什么不是平衡二叉树与红黑树?

为了解决 BST 的退化问题,计算机科学家引入了平衡二叉树(AVL 树)和红黑树(自平衡二叉查找树)。它们通过严格或近似的旋转平衡机制,确保树的高度始终维持在 O(log2 N) 级别

致命缺陷:出度过小导致树高失控

不论是 AVL 树还是红黑树,它们的本质都是二叉树,即每个节点的出度最大只能为 2

若一张表拥有 1000 万条行记录,在红黑树结构下,其理论高度约为:

log2(10,000,000) ≈ 24 层

由于树的每一层节点在物理磁盘上通常是离散分布的,这意味着每次下探检索都可能触发一次随机磁盘 I/O。为了定位一条记录,系统需要执行多达 24 次磁盘物理读取,其时间延迟将达到数百毫秒级别,系统吞吐量会急剧下降


4. 为什么不是 B 树

B 树(B-Tree,多路平衡查找树)成功解决了二叉树高度失控的问题。它允许一个节点拥有数个甚至上千个子节点,从而将树高压缩至 2~4 层。然而,B 树与 B+ 树在数据存放位置上存在着本质区别:

B 树的物理布局:无论是叶子节点还是非叶子节点,每个节点内部不仅存放主键索引值,还完整存放了该主键对应的整行业务数据

这一差异给 B 树带来了两个无法调和的性能痛点:

  1. 扇出度大幅降低: 一个标准 InnoDB 页面的大小固定为 16KB。由于 B 树的非叶子节点包含了体积庞大的业务数据,导致一个 16KB 的页面只能存放极少数的目录项。这极大地减小了树的扇出度,在相同数据量下,B 树的高度会明显高于 B+ 树,从而增加了磁盘 I/O 的次数

  2. 范围查询效率极低: B 树的叶子节点之间是相互独立的,没有物理链路连接。如果执行范围查询,系统在检索到 id = 10 后,无法直接在最底层横向顺序推进。它必须频繁地回溯到上层父节点,甚至重新从根节点发起新的多次中序遍历。这种频繁的跨页跨层回溯会引发严重的磁盘随机 I/O 震荡


5. B+ 树的优势

通过上述对比,可以总结出 B+ 树作为索引核心结构的绝对优势:

特性维度 B+ 树的设计方案 带来的性能红利
数据与目录分离 非叶子节点仅存储 Key 和指针,不含 Data 单页容纳目录项极多,扇出度极大,树高被死死压制在 3~4 层内,上亿数据仅需 3~4 次 I/O
底层双向链表 所有叶子节点通过双向链表串联,且数据全局有序 完美支持高效的范围查询与排序,只需一次边界定位,即可通过链表实现高效的连续 I/O 顺序吞吐
查询性能稳定 任何行数据的检索路径长度均相同(必须下探至叶子节点) 每一次查询的磁盘 I/O 次数高度一致,消除了系统由于性能波动产生的长尾延迟

总结

综上所述,我们从数据库查询效率问题出发,逐步分析了磁盘存储结构、MySQL 与磁盘的交互方式、Page 页组织形式以及多级目录的演化过程,最终理解了 B+ 树索引产生的根本原因

通过这一系列推导可以发现,索引本质上并不是某种神秘的高级技术,而是数据库为了减少磁盘 IO 次数、提高数据检索效率而设计的一套高效数据组织方案。而 B+ 树之所以能够成为 MySQL 默认索引结构,也正是因为它在磁盘访问场景下兼顾了查询效率、范围查询能力以及存储空间利用率等多方面因素

不过,理解了 B+ 树为什么存在,仅仅只是索引学习的第一步

例如:

什么是聚簇索引和非聚簇索引?主键索引和普通索引有什么区别?什么是回表查询?联合索引为什么会受到最左匹配原则的限制索引为什么会失效?MySQL又是如何选择索引执行查询的?

这些问题都与索引的实际使用密切相关

因此,在下一篇中,我们将正式进入索引应用篇,深入学习聚簇索引、非聚簇索引以及常见索引操作,真正理解 MySQL 索引是如何参与数据查询与优化过程的

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐