一、有无索引的查找
当表中没有索引时,查找记录的过程会变得非常低效。具体来说可以分为以下两步:
- 定位数据页 :因为没有索引,我们无法快速定位记录所在的页,因此需要从第一个数据页开始,沿着数据页的双向链表依次查找,直到找到包含目标记录的页
- 查找数据页中的记录 :在目标数据页中,查找记录只能通过顺序遍历。由于没有页目录,也没有针对非主键列的索引,因此需要从页的最小记录(
infimum
)开始,逐条遍历记录,直到找到符合条件的记录或遍历完整个页
这种查找方式非常低效,特别是在表数据量较大时,可能需要遍历大量的数据页和记录,导致查找速度极慢。这种方式(即全表扫描)的时间复杂度通常为 O(N 页 × M 记录) ,其中 N 是数据页的数量,M 是每个页中的记录数。
✅ 单页按主键查找
- 页目录是针对"页内主键链表"建立的索引结构(slot)
- 可以使用二分法快速定位某个槽,槽里是多个记录组成的小段链
- 在该段链表中线性扫描,即可迅速定位
- 性能:高效(O(log N) + O(k),k 为槽内记录数)
⚠️ 单页按非主键列查找
- 页目录对主键建立排序索引 ,无法用于非主键字段
- 只能从
infimum
开始,顺序遍历整个记录链表 - 对每条记录比对是否匹配目标字段值
- 性能:低效(线性遍历,O(N))
🔴 多页无辅助索引(二级索引)查找
- 不论查主键还是非主键列,都只能顺序遍历所有页: 从第一个页开始,页内依次查找目标记录,到页尾找不到就跳转到下一页,继续查
✅ 多页有主键或辅助索引,可以借助 B+ 树结构
- 二分法定位到目标值所在的页,在页中再通过页目录 + 链表查找记录。查主键用主键 B+ 树 ,查其他列用该列的辅助索引(如果建了)。再到存储用户记录的页中根据二分法快速定位到对应主键的用户记录
二、B+树
而如果是水平的线性结构,当表中的数据非常多时,就会和普通的数据页一样产生很多条,这个时候再根据根据主键值快速定位一个存储目录项记录的页,效率就会降低。怎么解决呢?InnoDB 为这些存储目录项记录的页再生成一个更高级的目录,形成一个多级目录结构通过 B+树来解决这一个问题
1. B+ 树的结构和工作原理
B+ 树是一个多级目录结构,看起来像一棵倒过来的树:顶部是根节点,底部是叶子节点
- 叶子节点:存储实际的用户记录(也就是表中的数据行),按主键排序,通过双向链表连接(便于范围查找)。这些节点位于 B+ 树的最底层,称为第 0 层
- 非叶子节点(内节点):存储目录项记录,用于指导搜索路径,同样按主键排序,通过双向链表连接。这些节点位于树的中间层和顶层(根节点)
- 页内排序:每个页面内的记录按主键排序,组成单向链表,支持二分查找(借助页目录)
- 节点是页面:无论是叶子节点还是内节点,每个节点都对应存储中的一个数据页(page)。这些页面在物理存储中不一定相邻
通过这种层级结构,B+ 树将大量的目录项记录组织起来,使得搜索可以快速定位到存储用户记录的页面
多级目录的例子
假设我们有页 30 和页 32 存储较低级的目录项记录,而页 33 是一个更高级的目录页,包含以下信息:
- 如果主键值在 [1, 320) 之间,指向页 30
- 如果主键值 ≥ 320,指向页 32
搜索时,从页 33 开始,根据主键值选择下一级的页面(页 30 或页 32),然后继续向下,直到找到存储用户记录的叶子节点。这种分层设计大大减少了需要检查的页面数量。
2. B+ 树的层级和存储容量
B+ 树的层级数(高度)决定了它能存储的数据量,同时也影响搜索效率。让我们用一个假设的例子来说明:
- 假设每个叶子节点(存放用户记录的页面)最多能存 100 条记录。
- 假设每个内节点(存放目录项记录的页面)最多能存 1000 条目录项,指向下一级的 1000 个节点。
根据层级计算存储容量:
- 1 层:只有一个叶子节点,最多存 100 条记录
- 2 层:一个根节点(内节点)指向 1000 个叶子节点,总共存 1000 × 100 = 100,000 条记录
- 3 层:一个根节点指向 1000 个中间内节点,每个中间内节点再指向 1000 个叶子节点,总共存 1000 × 1000 × 100 = 100,000,000 条记录
- 4 层:再加一层,总共存 1000 × 1000 × 1000 × 100 = 100,000,000,000 条记录
在实际数据库(如 InnoDB)中,B+ 树的层级通常不超过 4 层。这意味着,即使表中有数十亿条记录,查找某条记录最多也只需要访问 4 个页面:3 个目录项页和 1 个用户记录页
3. 如何快速定位记录
B+ 树的高效性来自于它的层级结构和搜索机制:
- 从根节点开始:根据主键值,在根节点的目录项中选择正确的下一级节点
- 逐层向下:重复这个过程,沿着树的高度逐步缩小搜索范围,直到到达叶子节点
- 页面内的快速查找 :每个页面内部有一个 页目录(Page Directory),支持二分查找,能快速定位到具体的记录
例如,假设我们要找主键值为 150 的记录:
- 从页 33(根节点)开始,发现 150 在 [1, 320) 范围内,跳转到页 30
- 在页 30 中继续查找,找到指向某个叶子节点的目录项
- 到达叶子节点后,利用页目录二分查找,定位到主键 150 的记录
整个过程只需要访问少数几个页面,效率非常高
4. B+ 树的优点
B+ 树在数据库中广泛应用(如 InnoDB 的索引和表存储),原因在于它有以下优势:
- 高效搜索:搜索复杂度是对数级的,即使数据量巨大,也只需少数页面访问,点查询和顺序遍历十分高效
- 范围查询支持 :叶子节点是有序的,适合执行范围查询(如
WHERE id BETWEEN 100 AND 200
) - 动态管理:支持高效的插入和删除操作,树会自动调整平衡
- 优化磁盘 I/O:节点是数据页,设计适合磁盘存储,能最大化 I/O 效率
三、聚簇索引
1. InnoDB中的聚簇索引
在 InnoDB 存储引擎中,聚簇索引 是基于 B+ 树构建的表主要存储结构,索引和数据合二为一
- 定义 :聚簇索引是 InnoDB 自动为表创建的基于主键的 B+ 树索引,无需用户显式使用
CREATE INDEX
语句 - 索引即数据:聚簇索引的叶子节点存储完整的用户记录(包括所有列和隐藏列,如事务 ID、回滚指针),因此索引本身就是数据的存储方式
- 自动生成:如果表定义了主键,InnoDB 使用它构建聚簇索引;若无主键,则选择第一个唯一非空索引;若无此类索引,InnoDB 生成一个隐藏主键(如 6 字节的 rowid)
例子 :假设表 users
有主键 id
和列 name
、age
,聚簇索引的叶子节点存储完整的记录(如 {id: 1, name: "Alice", age: 25}
),而非仅存储索引键
2. 聚簇索引的组织方式
聚簇索引基于 B+ 树,继承了其排序和链表特性:
- 页内记录排序:每个页面内的记录按主键大小顺序排列,组成单向链表,页面内通过页目录支持二分查找
- 叶子节点排序 :所有叶子节点按主键顺序组成双向链表,便于范围查询(如
SELECT * FROM users WHERE id BETWEEN 100 AND 200
) - 内节点排序:内节点存储目录项(包含主键值和指向下一级节点的指针),同一层内按主键排序,组成双向链表
- 层级结构 :从根节点到叶子节点,通常不超过 4 层。例如,查找
id = 150
时,从根节点逐层导航至叶子节点,最多访问 4 个页面
形象比喻:聚簇索引像一本书的目录(内节点)和正文(叶子节点)。目录按页码指引正文位置,正文按页码顺序排列,查找和翻页都很快
3. 聚簇索引的工作原理
- 查询过程 :
- 从根节点开始,根据主键值选择正确的内节点
- 逐层向下,直到到达叶子节点
- 在叶子节点内,通过页目录二分查找定位记录
- 示例 :查找
id = 150
:- 根节点(页 33)记录:
[1, 320) → 页 30
,≥ 320 → 页 32
。因 150 ∈ [1, 320),跳转页 30 - 页 30 的目录项指向某叶子节点
- 在叶子节点内,通过页目录找到
id = 150
的完整记录
- 根节点(页 33)记录:
- 效率:查询只需访问少数页面(通常 3-4 次 I/O),得益于 B+ 树的低高度和页目录的二分查找
4. 聚簇索引的优点
- 高效查询:主键查询直接返回完整记录,无需额外 I/O。范围查询因双向链表高效
- 空间效率:数据仅存储一次(在叶子节点),避免冗余
- 动态管理:插入、删除操作通过 B+ 树的平衡机制高效完成
- 磁盘优化:页面大小(通常 16KB)与磁盘 I/O 匹配,减少读取开销
5. 与非聚簇索引的区别
聚簇索引:
- 叶子节点存储完整记录,查询主键直接返回数据
- 每张表只有一个聚簇索引(基于主键)
非聚簇索引(也称二级索引或辅助索引):
- 不同于聚簇索引在叶子节点中存储完整的记录,非聚簇索引的叶子节点仅存储索引键和主键值,查询完整记录需"回表"(通过主键再查聚簇索引,如MyISANM存储引擎就全部都是二级索引,索引是索引数据是数据,查到了索引还需要回表查数据)
- 可创建多个非聚簇索引,基于其他列,联合索引也属于二级索引的范畴,特殊在只维护一颗B+树,受限于最左前缀原则(假设你有一本电话簿,按"姓氏 + 名字"排序。查找"张伟"时,先按姓氏"张"找到对应部分,再按名字"伟"精准定位,这就是联合索引的思路,多个列按顺序组成一个"整体键"),一般适用于在多列条件查询中快速定位、在需要排序的查询中直接返回结果避免额外排序、无需回表的覆盖索引、范围查询等
- 例子 :若表有非聚簇索引(
name
列),查询WHERE name = 'Alice'
找到主键id = 1
,再通过聚簇索引获取完整记录
6. 聚簇索引的实际意义
- 索引即数据,数据即索引:在 InnoDB 中,聚簇索引就是表的物理存储结构,查询主键时无需额外查找数据
- 适用场景 :适合主键查询、范围查询和顺序遍历。例如,电商订单表按
order_id
构建聚簇索引,查询订单详情或批量订单高效 - 注意事项 :
- 主键选择影响性能:短且递增的主键(如自增
id
)能减少页面分裂,提高插入效率 - 大主键(如 UUID)可能增加存储和维护开销
- 主键选择影响性能:短且递增的主键(如自增
7.B+树内节点中目录项记录的唯一性
在 B+ 树的二级索引(非聚簇索引)中,内节点存储的是目录项记录 ,用于指引下一级节点的查找路径。你提到,目录项记录的内容如果是"索引列 + 页号"的搭配,可能会导致唯一性问题。以下表为例:
c1 (主键) | c2 | c3 |
---|---|---|
1 | 1 | 'u' |
3 | 1 | 'd' |
5 | 1 | 'y' |
7 | 1 | 'a' |
假设为 c2
列建立二级索引,B+ 树的内节点如果只存储"c2 值 + 页号",就会出现问题。如,
假设二级索引的 B+ 树中,页 3(内节点)包含两条目录项,结构如下:
(c2=1, 页 4)
:指向页 4,包含记录(c1=1, c2=1, c3='u')
和(c1=3, c2=1, c3='d')
(c2=1, 页 5)
:指向页 5,包含记录(c1=5, c2=1, c3='y')
和(c1=7, c2=1, c3='a')
现在插入一条新记录 (c1=9, c2=1, c3='c')
,其 c2=1
。在更新二级索引的 B+ 树时,问题来了:
- 页 3 中两条目录项的
c2
值都是 1((1, 页 4)
和(1, 页 5)
) - 新记录的
c2=1
,但我们无法确定它该插入到页 4 还是页 5,因为c2
值相同,目录项无法区分
导致 B+ 树无法判断新记录该去哪个页面,索引的唯一性出了问题,为了解决这个唯一性问题,InnoDB 在二级索引的内节点目录项中加入了主键值 ,使目录项记录的内容变为索引列值 + 主键值 + 页号 ,这样,同一层内节点的目录项记录(除页号外)是唯一的
让我们重新看看修正后的 B+ 树结构(同样的例子):
页 3(内节点)包含目录项:
(c2=1, c1=3, 页 4)
:指向页 4,包含记录(c1=1, c2=1, c3='u')
和(c1=3, c2=1, c3='d')
(c2=1, c1=7, 页 5)
:指向页 5,包含记录(c1=5, c2=1, c3='y')
和(c1=7, c2=1, c3='a')
排序规则:
- 目录项先按
c2
值排序 c2
值相同时,按c1
(主键)值排序- 这样,
(c2=1, c1=3)
和(c2=1, c1=7)
是唯一的
插入新记录的过程
现在插入记录 (c1=9, c2=1, c3='c')
,更新二级索引 B+ 树:
- 在页 3(内节点)中,比较新记录的
c2=1
:页 3 的目录项(c2=1, c1=3, 页 4)
和(c2=1, c1=7, 页 5)
都有c2=1
- 进一步比较主键值
c1=9
:c1=9 > c1=7
,所以新记录应插入到(c2=1, c1=7, 页 5)
之后的页面 - 最终确定新记录插入到页 5(或新分裂的页面,视页面容量而定)
通过引入主键值,B+ 树确保了目录项的唯一性,新记录可以准确找到插入位置。
二级索引与聚簇索引的目录项对比
聚簇索引:
- 内节点目录项:
主键值 + 页号
- 唯一性:主键天然唯一,无需额外字段
- 例子:
(c1=3, 页 4)
、(c1=7, 页 5)
,主键值保证唯一
二级索引:
- 内节点目录项:
索引列值 + 主键值 + 页号
- 唯一性:索引列(如
c2
)可能重复,加入主键值确保唯一 - 例子:
(c2=1, c1=3, 页 4)
、(c2=1, c1=7, 页 5)
关键点:二级索引引入主键值,弥补了索引列可能重复的问题,保持 B+ 树结构的严谨性
B+树与索引的简单总结:
每个索引对应一棵 B+ 树,分为多层:最底层是叶子节点 ,存储所有用户记录 ;其余为内节点 ,存储目录项记录 。在 InnoDB 中,聚簇索引 由主键自动构建,叶子节点包含完整用户记录(若无主键,InnoDB 自动生成)。二级索引 的叶子节点存储索引列 + 主键 ,查询完整记录需通过主键"回表"到聚簇索引。B+ 树节点按索引列值从小到大排序,组成双向链表 (节点间)和单向链表 (页内记录)。联合索引 按列顺序逐级排序,先按前序列,值相同时按后续列。查找从根节点开始,逐层导航,借助页目录实现快速定位,效率极高

四、索引除查询外带来的其他好处
1. ORDER BY 排序机制
当 MySQL 无法利用索引直接获取有序数据时,会在内存或磁盘上对查询结果进行排序,这种操作称为文件排序
- 文件排序通常在内存中进行,使用
sort_buffer_size
定义的缓冲区(默认值通常为 256KB 或 2MB,视 MySQL 版本而定) - 如果结果集大小(行数 × 每行数据大小(包括
SELECT
返回的列和排序所需的列))超过sort_buffer_size
,MySQL 会将中间结果写入磁盘上的临时文件(temporary files),通过多路合并排序完成操作 - 磁盘 I/O 速度远低于内存,导致文件排序性能显著下降,尤其在处理大结果集时。例如,10 万行 × 100 字节/行 ≈ 10MB,若
sort_buffer_size
为 2MB,则触发磁盘排序。 - 具体阈值取决于
sort_buffer_size
、表结构、查询列和行数,可以通过EXPLAIN
(检查Extra
列,Using index
:表示利用索引排序,Using filesort
:表示触发文件排序(可能在内存或磁盘))或状态变量(如Sort_merge_passes
(Sort_merge_passes
> 0:表示使用了磁盘临时文件,Sort_rows
:显示排序的行数,可估算数据量))确认是否使用磁盘
而 B+ 树索引中的记录按索引列的顺序预先排好,ORDER BY
子句如果与索引列顺序和排序规则一致,可直接从索引读取有序数据,避免文件排序。优势:避免内存或磁盘排序,显著提升性能,尤其适用于大结果集或高并发场景
示例:
vbnet
SELECT * FROM person_info ORDER BY name, birthday, phone_number LIMIT 10;
假设存在联合索引 idx_name_birthday_phone_number (name, birthday, phone_number)
,索引按 name
(升序)、birthday
(升序)、phone_number
(升序)排列,查询可直接从索引提取数据并回表获取其他列。
注意事项 :列顺序要求 :ORDER BY
子句中的列必须按索引定义的顺序出现,如联合索引中:
sql
ORDER BY name, birthday, phone_number -- 有效
ORDER BY name, birthday -- 有效(前缀匹配)
ORDER BY phone_number, birthday, name -- 无效(顺序不匹配)
同样的,ASC、DESC不能混用之类(结合前面提到过的存储结构来看)也需要注意
2. GROUP BY 分组
GROUP BY
用于将查询结果按指定列分组,MySQL 会对每组记录进行聚合计算(如 COUNT
、SUM
等),根据 GROUP BY
子句中的列,将记录划分为不同的组,对每组记录应用聚合函数(如 COUNT(*)
)计算结果
性能问题 :如果没有索引,MySQL 需在内存中对所有记录进行分组(可能涉及文件排序,Using filesort
),数据量大时可能需要磁盘临时表(temporary table),导致性能下降,同时分组操作通常涉及扫描大量记录,效率较低,尤其是当分组列未被索引时
而B+ 树索引中的记录已按索引列顺序预排序 ,如果 GROUP BY
子句中的列顺序与索引列顺序一致,MySQL 可以直接利用索引的有序性进行分组,避免额外的内存或磁盘操作
索引优化的核心 :GROUP BY
的列顺序和规则需与联合索引一致,利用 B+ 树索引的预排序特性直接分组,避免文件排序和临时表
关键限制:分组列顺序必须匹配索引,非索引列、表达式或排序规则不一致会导致索引失效。
在设计中建议:
- 设计覆盖
GROUP BY
和WHERE
的联合索引 - 避免表达式,必要时物化结果并建索引
- 使用
EXPLAIN
和状态变量分析性能,优化查询或配置
通过合理设计索引和查询,可以显著提升 MySQL 分组操作的性能,尤其在大规模数据场景下
3. 回表
回表是指在通过二级索引(非聚簇索引,如idx_name_birthday_phone_number
)查询到部分记录(通常包含主键id
)后,由于二级索引不包含查询所需的所有字段,需要根据这些id
到聚簇索引(主键对应的B+树)中获取完整记录的过程
步骤1 :通过二级索引的B+树,快速定位符合条件(如name > 'Asa' AND name < 'Barlow'
)的记录,可以利用了B+树中name
字段的有序性,读取是顺序I/O,效率较高
步骤2 :根据步骤1获取的id
,到聚簇索引中查找完整记录,由于id
可能不连续,这一步涉及随机I/O,效率较低(分布在不同的数据页,导致需要多次磁盘寻址,性能开销较大)
于是执行计划就会区分为:
- 全表扫描:直接扫描聚簇索引,适合需要访问大部分记录的查询(如符合条件的记录占90%以上)。虽然读取所有数据,但避免了回表的随机I/O开销
- 二级索引+回表 :适合需要访问少量记录的查询。回表记录越少,性能优势越明显(如添加
LIMIT 10
)
查询优化器通过统计数据(如表中记录数、索引选择性)估算回表成本,决定使用哪种方式
例子 :对于ORDER BY name, birthday, phone_number
的查询,如果使用idx_name_birthday_phone_number
索引,排序可以直接利用索引的有序性,无需额外排序,但若查询SELECT *
,仍需回表获取完整记录,而若回表记录较多,优化器可能选择全表扫描+文件排序(filesort),因为回表成本可能高于全表扫描,添加LIMIT 10
后,回表记录减少,优化器更倾向于使用二级索引+回表