news 2026/5/7 2:15:36

MySQL索引底层——B+树为什么是首选?

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MySQL索引底层——B+树为什么是首选?

前言

在面试中,有一个问题几乎必考:

“MySQL为什么用B+树做索引?为什么不用哈希表?为什么不用二叉树?为什么不用B树?”

这个问题看似简单,但追问到第三层时,很多人就答不上来了。而一旦你能把"B+树的范围查询优势"“页分裂代价”"为什么主键推荐自增"这几个点串起来讲清楚,面试官就知道:这个人是真的理解索引,不是背的。

本文带你从数据结构的角度,彻底搞懂MySQL索引的底层原理。

本文核心问题:

  1. 索引的本质是什么?为什么能加速查询?
  2. 哈希索引、二叉树、B树、B+树各自的优劣?为什么MySQL默认用B+树?
  3. B+树的叶子节点为什么用双向链表连接?
  4. 聚簇索引和二级索引有什么区别?回表是什么?
  5. 为什么DBA建议主键自增?页分裂有什么代价?
  6. InnoDB的一次索引查找经历了什么?从根节点到数据页的完整路径
  7. count(*)为什么慢?索引统计是怎么工作的?

读完本文,你将对MySQL索引拥有从数据结构到存储引擎的完整理解。


一、索引的本质——从全表扫描到快速定位

疑问:索引为什么能加速查询?它的本质是什么?

回答:索引的本质是"预排序的数据结构",它让数据库从"全表扫描"变成"快速定位"。

1.1 没有索引时

SELECT*FROMuserWHEREid=1000;

没有索引时,数据库只能一行一行地找——从第一行开始,逐行对比id是否为1000。如果在磁盘上存了100万行,平均要扫描50万行才能找到目标。

这就是全表扫描——时间复杂度为O(n),数据量越大越慢。

1.2 有索引时

如果在id列上建了索引,数据库就有了一个"目录"。这个目录按id排序,并且有指针指向实际数据的位置。

查找id=1000时,不需要遍历所有行,而是先在目录中快速定位到"1000附近",再精准读取那几行数据。

索引的本质就是"用空间换时间"——额外存储一份排序好的数据结构,让查找从O(n)降到O(log n)甚至O(1)。

1.3 索引的实际存储

索引在磁盘上以"页"为单位存储。InnoDB默认每页16KB,每个页里存多条索引记录。当数据量大到一个页装不下时,就需要分成多个页,并在页之间建立父子关系——这就形成了树形结构

一个页(16KB)的内部结构: ┌─────────────────────────────────┐ │ 页头(页号、页类型、前后页指针) │ ├─────────────────────────────────┤ │ 索引记录1(键值 + 行指针) │ │ 索引记录2(键值 + 行指针) │ │ 索引记录3(键值 + 行指针) │ │ ... │ ├─────────────────────────────────┤ │ 页尾(校验和) │ └─────────────────────────────────┘

所以,索引优化本质上就是让数据库尽可能少地读磁盘页。每一次"减少回表""使用覆盖索引"的优化,最终目的都是省掉不必要的页面读取。


二、常见的索引数据结构——为什么B+树胜出?

疑问:能做索引的数据结构那么多,为什么MySQL选了B+树?

回答:关键衡量标准是"磁盘IO次数"。内存访问是纳秒级,磁盘访问是毫秒级——差了几十万倍。一个好的索引结构,必须尽可能地减少磁盘IO。

2.1 哈希表

查找过程:key → hash(key) → 桶位置 → 取出数据 时间复杂度:O(1)(理想情况)

MySQL其实用了哈希索引——Memory引擎的默认索引就是哈希表,InnoDB的"自适应哈希索引"功能也会自动为频繁访问的页建立哈希索引。

但哈希表有一个致命缺陷——不支持范围查询

SELECT*FROMuserWHEREid>100ANDid<200;

哈希表中,id=101和id=102存储在不同的桶中,它们之间没有顺序关系。要查范围,只能扫描所有桶,退化为O(n)。

2.2 二叉搜索树

50 / \ 30 70 / \ / \ 20 40 60 80

查找id=40:50→30→40,三次比较。查找速度随树的高度线性增长。平衡二叉树的高度大约是log₂(n)——100万条数据,树高约20层。每层是一个独立的磁盘页,最坏情况需要20次磁盘IO。可数据库查询的底线通常是"2-3次磁盘IO完成一次查找"。

二叉树的"分叉太少",树太高,磁盘IO次数太多。这个结构在内存中效率极高(如Java的TreeMap),但迁移到磁盘上就不行了。

2.3 B树——让树"变矮变胖"

B树节点(包含键 + 数据 + 子节点指针): ┌────┬────┬────┬────┬────┐ │ P1 │ K1 │ P2 │ K2 │ P3 │ ← 每个节点存多个键和子指针 └────┴────┴────┴────┴────┘ ↓ ↓ ↓ 子1 数据1 子2 ... 关键特征: - 非叶子节点也存储完整数据 - 每个节点可以有多个子节点(分叉数 > 2) - 所有叶子节点在同一层(平衡)

B树的优势:16KB的一页可以存几百个键和指针。假设一个节点存500个键,分叉数为501——100万条数据,树高只需要3层,3次磁盘IO即可完成查询。分叉越多,树越矮,磁盘IO越少。

但B树还有一个问题:非叶子节点也存储数据。每个16KB的页空间是固定的,如果每个非叶子节点都存一份数据的完整行记录,能存的键和指针数量就变少了。同样存100万条数据,如果每个内部节点都带着完整行记录,分叉数会从500降到几十,树高就会从3层涨到4-5层——多一次磁盘IO。

2.4 B+树——在B树的基础上进一步优化

B+树节点(非叶子节点只存键+指针,叶子节点存完整数据): ┌─────┬─────┬─────┐ 非叶子节点 │ P1 │ K1 │ P2 │ ← 只存键,不存数据 └──┬──┴──┬──┴──┬──┘ ↓ ↓ ↓ ┌──────────────────┐ 叶子节点 │ K1+Data │ K2+Data │ K3+Data │ ← 所有数据在叶子层 └──────────────────┘ ← → 双向链表连接 ← →

B+树在B树的基础上做了两个关键改进:

其一,非叶子节点只存键,不存数据。每个16KB的页可以存更多键——分叉数更大,树更矮。100万行数据,树高只需要2-3层,一次查询最多2-3次磁盘IO。

其二,所有叶子节点用双向链表串联。范围查询时,找到第一个满足条件的数据后,顺着链表向右遍历即可,不需要再回到上层节点。而B树需要回到父节点找"下一个"键,走回头路,可能重复读取已经访问过的页。

所以B+树最终胜出,在于它在磁盘场景下的三项关键优势:更矮的树高、范围查询直接链表遍历无需回溯、每个节点空间利用率更高。


三、B+树的范围查询优势——双向链表的妙用

疑问:B树也能范围查询,B+树的优势到底在哪?

回答:B+树的叶子节点链表,让范围查询从"树上反复横跳"变成"一次定位+链表顺读"。

3.1 B树的范围查询

B树结构: ┌────┐ │ 50 │(存数据) └┬──┬┘ ┌────┘ └────┐ ┌──┴──┐ ┌──┴──┐ │ 30 │ │ 70 │(都存数据) └┬──┬┘ └┬──┬┘ ... ... 查 30 < id < 80: 1. 从根50 → 找到30 2. 从30回到父节点50 3. 从50下到70 4. 从70再回父节点 → 才发现要回根 → 在树上反复上下移动

3.2 B+树的范围查询

B+树范围查询: 非叶子层: ┌─────┐ │ 50 │ ← 只存键 └┬──┬┘ ┌───┘ └───┐ ┌─────┐ ┌─────┐ │ 30 │ │ 70 │ ← 只存键 └┬──┬┘ └┬──┬┘ ↓ ↓ ↓ ↓ 叶子层: [30]→[35]→[50]→[65]→[70]→[80]→... ↑ ↑ 找到起点 沿着链表 → 一直读到80 查 30 < id < 80: 1. 在非叶子层找到30的位置 2. 到叶子层,定位到30 3. 顺着链表一直往右读,直到80 → 一次寻路,顺序读取

范围查询是数据库中最常见的查询模式之一(如WHERE id BETWEEN 1 AND 100ORDER BY id LIMIT 10),B+树对这个场景做了针对性优化。


四、聚簇索引与二级索引——回表的根源

疑问:为什么SELECT *性能差?为什么查询非主键列之后还要再查一次?

回答:这涉及到InnoDB中最核心的两个概念——聚簇索引和二级索引。

4.1 聚簇索引

聚簇索引的叶子节点直接存整行数据。InnoDB默认用主键作为聚簇索引。

聚簇索引(以主键id构建的B+树): 非叶子节点: [id范围 + 子页指针] 叶子节点: [id=1, name='张三', age=25, email='...'] ← 完整行数据 [id=2, name='李四', age=30, email='...'] [id=3, name='王五', age=28, email='...']

"聚簇"的含义:数据本身的物理存储顺序和主键的顺序一致。这解释了为什么插入随机主键会导致页分裂——新数据的主键在已有页的中间,迫使一个页拆成两个,插入成本远高于追加到最后的自增主键。

4.2 二级索引(辅助索引)

二级索引的叶子节点存的不是完整行数据,而是主键值

二级索引(以name列构建的B+树): 非叶子节点: [name范围 + 子页指针] 叶子节点: [name='李四', id=2] ← 只存键+主键 [name='王五', id=3] [name='张三', id=1]

4.3 回表

SELECT*FROMuserWHEREname='张三';

执行过程:

  1. 在name的二级索引上找到"张三" → 拿到主键id=1
  2. id=1去聚簇索引上查找完整行数据 → 拿到name='张三', age=25, email='...'
  3. 返回完整结果

第二步就是"回表"——从二级索引回到聚簇索引,取出完整行。每次回表都是一次独立的磁盘IO。如果查询返回100行,且每行的主键都不在同一数据页中,最坏情况可能需要100次额外的磁盘IO。

这解释了为什么大厂强制不准用SELECT *:不是因为传输带宽,而是因为SELECT *会强迫MySQL走回表路径。如果你只需要nameid,直接建一个(name, id)联合索引,索引叶子节点本身就有所有需要的数据,根本不需要回表。涉及几个字段就建几个字段的联合索引,让查询只在一个索引里完成,性能和主要成本都在磁盘IO。


五、为什么推荐自增主键?——页分裂的代价

疑问:UUID和自增ID做主键有什么区别?为什么DBA推荐自增?

回答:核心原因是"页分裂"的代价。自增主键是在原有数据之后追加,UUID是往已有数据中插入——后者会导致频繁的页分裂。

5.1 自增主键的插入

已有叶子页: [1] [2] [3] [4] [5](页已满) 插入 id=6: 新叶子页: [1] [2] [3] [4] [5] [6] ← 追加到末尾

只在最后一页追加,偶尔需要分配新页。插入成本稳定且低,且主键连续意味着物理上相近的行数据也存储在相近的磁盘位置,空间局部性好。

5.2 随机主键的插入

已有叶子页A: [1] [3] [5] [7] [9](页已满) 插入 id=4: 需要: 叶子页A:[1] [3] [4] ← 页A只保留前一半 叶子页B:[5] [7] [9] ← 后一半移到新页B 父节点:需要新增指向页B的指针 ← 父节点也可能需要分裂 这是"页分裂"——一个页变成两个页,父节点也要更新。

5.3 页分裂的代价

影响自增主键随机主键
插入性能稳定随机的,可能触发分裂
页空间利用率高(页填充率高)低(分裂后各页只有半满)
磁盘碎片
二级索引大小较小较大(二级索引叶子节点存主键,长主键意味着更大的索引文件)

自增主键还影响二级索引的大小——二级索引的叶子节点存的是主键值。UUID是32字节字符串,INT是4字节。1亿条记录,光是主键大小就差2.8GB的存储空间,对应的二级索引文件也会等比例膨胀。


六、InnoDB一次索引查找的完整路径

疑问:SELECT * FROM user WHERE id = 100,InnoDB到底做了什么?

回答:SQL语句的执行分为Server层和引擎层分工协作。Server层负责解析和优化,引擎层负责数据存取。

6.1 整体流程

┌─────────────────────────────────────────────────────────┐ │ Server层 │ │ │ │ SQL → 连接器 → 分析器 → 优化器 → 执行器 │ │ │ │ │ 选择索引,生成执行计划 │ └─────────────────────────────────┼───────────────────────┘ │ 调用引擎接口 ▼ ┌─────────────────────────────────────────────────────────┐ │ InnoDB引擎层 │ │ │ │ Buffer Pool(内存缓冲池) │ │ ┌─────────────────────────────────────┐ │ │ │ 数据页1 数据页2 ... 索引页1 ... │ │ │ └─────────────────────────────────────┘ │ │ ↑ 命中返回 ↓ 未命中则读磁盘 │ │ 磁盘(.ibd文件) │ └─────────────────────────────────────────────────────────┘

6.2 查找id=100的完整路径

1. Server层解析SQL → 优化器选择主键索引 → 调用引擎的ha_innobase::index_read() 2. InnoDB从根节点开始,以页为单位逐层定位: 根节点页(常驻Buffer Pool): 判断100在哪个区间 → 找到子节点指针 → 进入下一层 非叶子节点页: 判断100在哪个区间 → 找到叶子节点指针 → 进入叶子层 叶子节点页: 在页内通过二分查找定位到id=100 → 此行数据所在位置 3. 如果页在Buffer Pool中 → 直接返回(内存速度) 如果页不在Buffer Pool中 → 从磁盘读取(一次随机IO,约5-10ms) 4. 返回完整行数据给Server层 → 返回客户端

关键认知:一次主键查找,在100万行数据下只需要2-3次磁盘IO。索引优化的目标就是让树更矮、节点更紧凑、尽量减少磁盘访问次数。


总结

  • 索引的本质是预排序的数据结构,让查找从O(n)变成O(log n)。用空间换时间
  • B+树胜出的原因:树更矮(非叶子节点不存数据,分叉更多)、范围查询更高效(叶子层双向链表)、节点空间利用率更高
  • 聚簇索引叶子存完整行数据,二级索引叶子存主键值SELECT *导致回表——在二级索引和聚簇索引之间额外走一个来回,多一次磁盘IO
  • 自增主键减少页分裂——新数据总是追加在最后,插入稳定;UUID往中间插,页分裂导致碎片和空间浪费,且长主键让二级索引文件成比例膨胀
  • 一次索引查找经历Server层(解析→优化→执行)和引擎层(Buffer Pool→磁盘页定位→二分查找→返回数据)两步
  • 索引优化的最终目标:减少磁盘IO次数。覆盖索引、页分裂预防、前缀索引等技巧,本质都是"少读几页"

下一篇预告:MySQL索引原理(二)——联合索引与最左前缀原则:从Explain看索引命中。拆解联合索引的存储结构、Explain输出项的含义、以及索引失效的常见场景和原因。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/5/7 2:14:28

FastAPI-Admin:快速构建管理后台的声明式框架实战指南

1. 项目概述&#xff1a;一个为FastAPI应用快速构建管理后台的利器如果你正在用FastAPI开发一个Web应用&#xff0c;无论是内部的管理系统、内容发布平台&#xff0c;还是带有复杂数据模型的业务后台&#xff0c;迟早会面临一个绕不开的需求&#xff1a;需要一个界面友好、功能…

作者头像 李华
网站建设 2026/5/7 2:14:28

在多模型聚合场景下利用 Taotoken 实现智能降级与容灾

在多模型聚合场景下利用 Taotoken 实现智能降级与容灾 1. 多模型聚合架构的核心挑战 在构建高可用 AI 服务的场景中&#xff0c;依赖单一模型供应商存在明显的服务连续性风险。当某个主流模型服务出现暂时不可用时&#xff0c;缺乏备选方案的架构会导致核心业务功能中断。Tao…

作者头像 李华
网站建设 2026/5/7 2:12:29

Astack:基于角色扮演与状态管理的AI开发工作流框架

1. 项目概述&#xff1a;Astack&#xff0c;一个模型与栈无关的通用AI开发工作流层如果你在过去两年里深度使用过Claude Code、Cursor或者GitHub Copilot这类AI编程助手&#xff0c;你肯定经历过这种挫败感&#xff1a;你让它“review一下我的PR”&#xff0c;它可能花五分钟夸…

作者头像 李华
网站建设 2026/5/7 2:08:27

Windows可执行文件资源编辑技术实现方案

Windows可执行文件资源编辑技术实现方案 【免费下载链接】rcedit Command line tool to edit resources of exe 项目地址: https://gitcode.com/gh_mirrors/rc/rcedit rcedit是一个命令行工具&#xff0c;用于编辑Windows可执行文件和动态链接库的资源信息。该项目由Git…

作者头像 李华
网站建设 2026/5/7 2:07:05

JeecgBoot低代码平台:Java开发者如何用代码生成器提升企业级开发效率

1. 项目概述&#xff1a;一个面向企业级应用的低代码开发平台如果你是一名Java后端开发者&#xff0c;或者是一名中小型企业的技术负责人&#xff0c;那么你一定对“快速开发”这个词有着深刻的体会。业务需求变化快&#xff0c;市场窗口期短&#xff0c;但传统的Java企业级开发…

作者头像 李华