浅析 MySQL 索引
2026/7/18 23:52:19 网站建设 项目流程

MySQL 索引

此博客不是对索引技术的底层进行讲解,仅仅讲解初阶知识,方便理解索引和了解索引原理,不涉及底层与深层次知识(之后可能会写)

在使用 MySQL 的场景下,数据库往往存储着海量数据(几万条、几百万条,甚至上千万条)。
如果不对数据做任何处理,查询时就只能从头到尾逐行遍历(全表扫描),时间效率极低。

> 就像一本几千页的书没有目录,我们想找到某一页内容,只能从头翻到尾。

索引的出现很好地解决了这个问题。它本质上是一种数据结构,能够帮助数据库在不扫描全部数据的情况下,快速定位到目标记录。后面我们将结合原理和实验来深入理解。


1. 没有索引会怎样?

MySQL 的数据最终是持久化存储在磁盘文件中的(例如 InnoDB 引擎的独立表空间.ibd文件,或系统共享表空间)。因此,对表进行增删改查,本质上都会涉及磁盘 I/O

而磁盘 I/O 的瓶颈在于:

  • 机械硬盘存在磁头寻道和盘片旋转延迟,随机读取效率很低;
  • 即使使用 SSD,其读写速度也与内存相差几个数量级;
  • InnoDB 以(默认 16KB)为单位读取数据,如果目标记录分散在不同页中,会产生大量随机 I/O

如果没有索引,查询就必须进行全表扫描:将磁盘上的数据页逐页加载到内存,再逐行比对筛选。对于 800 万量级的表,这意味着可能要加载数百 MB 甚至上 GB 的数据。如果此时再叠加高并发,磁盘 I/O 和 CPU 资源会被迅速耗尽,系统基本丧失可用性。

因此,索引不是"可选项",而是大数据量下保证查询性能的必需品

实验预告:下文将基于 800 万条测试数据,对比有索引和无索引情况下的查询性能差异,直观感受索引的作用。

-- ============================================ -- 0. 开启函数创建权限(MySQL 8.0 必需,只需执行一次) -- ============================================ SET GLOBAL log_bin_trust_function_creators = 1; -- ============================================ -- 1. 创建 EMP 表(根据插入字段推断的表结构) -- ============================================ DROP TABLE IF EXISTS EMP; CREATE TABLE EMP ( empno INT PRIMARY KEY, ename VARCHAR(255), job VARCHAR(255), mgr INT, hiredate DATE, sal DECIMAL(10,2), comm DECIMAL(10,2), deptno INT ); -- ============================================ -- 2. 创建随机字符串函数 -- ============================================ DELIMITER $$ CREATE FUNCTION rand_string(n INT) RETURNS VARCHAR(255) BEGIN DECLARE chars_str VARCHAR(100) DEFAULT 'abcdefghijklmnopqrstuvwxyzABCDEFJHIJKLMNOPQRSTUVWXYZ'; DECLARE return_str VARCHAR(255) DEFAULT ''; DECLARE i INT DEFAULT 0; WHILE i < n DO SET return_str = CONCAT(return_str, SUBSTRING(chars_str, FLOOR(1 + RAND() * 52), 1)); SET i = i + 1; END WHILE; RETURN return_str; END $$ DELIMITER ; -- ============================================ -- 3. 创建随机数字函数 -- ============================================ DELIMITER $$ CREATE FUNCTION rand_num() RETURNS INT(5) BEGIN DECLARE i INT DEFAULT 0; SET i = FLOOR(10 + RAND() * 500); RETURN i; END $$ DELIMITER ; -- ============================================ -- 4. 创建批量插入存储过程 -- ============================================ DELIMITER $$ CREATE PROCEDURE insert_emp(IN start INT(10), IN max_num INT(10)) BEGIN DECLARE i INT DEFAULT 0; SET autocommit = 0; REPEAT SET i = i + 1; INSERT INTO EMP VALUES ( (start + i), rand_string(6), 'SALESMAN', 0001, CURDATE(), 2000, 400, rand_num() ); UNTIL i = max_num END REPEAT; COMMIT; END $$ DELIMITER ; -- ============================================ -- 5. 执行存储过程,插入 8000000 条记录 -- ============================================ CALL insert_emp(100001, 8000000);

查询未设置索引的列,用时 6.95 秒

查询构建索引的列,用时 10 毫秒以内。

可以明确观察到是否构建索引,对大数据量的查找造成了数量级的差距,如果再叠加并发场景,未设置索引基本不可用。

2. 除了索引,MySQL 还做了哪些努力?

索引能帮我们快速定位数据,但定位之后,数据终究要从磁盘读到内存。为了尽可能减少真正的磁盘 I/O,MySQL(InnoDB 引擎)在索引之外,还做了以下关键设计:

  1. InnoDB 以 16KB「页」为单位与磁盘交互

InnoDB 管理数据的最小存储单元叫做Page(页),默认大小为16KB

为什么不是一条一条读,而是整页读?因为数据在磁盘上通常是连续存储的(尤其是主键顺序插入时)。一次 I/O 读取 16KB,可能就把目标记录附近的相邻数据也加载进来了。这利用了空间局部性原理,用一次 I/O 换多次潜在的内存命中。

作为对比:操作系统文件系统的块(Block)通常默认是 4KB,但 InnoDB 的页大小是独立配置的(innodb_page_size),默认 16KB 是数据库场景下权衡后的结果。

  1. Buffer Pool —— 在内存中缓存数据页

磁盘 I/O 和内存操作的速度差了几个数量级。InnoDB 在启动时会向操作系统申请一大块内存,叫做Buffer Pool,用来缓存从磁盘读来的数据页

  • 读数据:先查 Buffer Pool,命中就直接返回(逻辑 I/O),未命中才去磁盘读取真实页(物理 I/O)。
  • 写数据:先修改 Buffer Pool 中的页,此时这个页就变成了脏页(Dirty Page)。脏页不会立刻写回磁盘,而是由后台线程(Page Cleaner)在合适的时机批量刷盘。

这种"延迟写"策略把大量随机磁盘写转化成了内存写 + 批量顺序刷盘,极大提升了吞吐量。


索引解决的是"如何快速找到数据在哪",而以上设计解决的是"找到之后如何尽可能少读磁盘、少写磁盘"。

两者结合,才构成了 MySQL 在海量数据下依然能高效运转的完整图景。

3. 理解索引:从页到 B+ 树

前面提到,InnoDB 以16KB 的页为单位管理数据。那么当表中有 800 万条记录、分布在成千上万个页中时,如何快速找到目标记录?

答案藏在两个层次:页内如何快速定位记录,以及页与页之间如何快速定位


3.1 页内:Page Directory(页目录)

先回到单个页。一页 16KB,可能存储几十到几百条记录。如果页内只能从头到尾扫描,就算确定了目标页,查找效率依然很低。

InnoDB 的解决方案是在每个数据页的尾部维护一个Page Directory(页目录),也叫槽(Slot)。它记录了页内部分记录的偏移位置,且这些记录是按主键有序的。

当需要在页内查找某条记录时,MySQL 会先在 Page Directory 中二分查找,快速定位到包含目标记录的槽,再在该槽对应的小范围内遍历。这样就把"页内全表扫描"变成了"页内二分查找"。

但这只能解决一个页内部的问题。当数据量增大,需要成千上万个页时,页与页之间又该如何组织?


3.2 页间:没有索引时,只能线性遍历

当多个页存储数据时,InnoDB 用双向链表将它们按主键顺序连接起来(页头中的FIL_PAGE_PREVFIL_PAGE_NEXT指针)。

此时如果没有任何索引,查询就只能从第一个页开始,沿着链表顺序遍历,直到找到目标记录。如果有 1000 万条数据分散在几万个页中,这种线性遍历的代价依然是不可接受的。

能不能像"书的目录"一样,给这些页也建立一个目录呢?


3.3 给页建立目录:B+ 树的雏形

答案是肯定的。MySQL 引入了一种分层目录的结构:

  • 最底层的页(叶子节点)存放真实的用户数据
  • 上层的页(非叶子节点)不存数据,只存两样东西:键值+指向子页的指针

具体规则是:每个非叶子节点中的目录项,存放的是其指向的子页中最小的键值(或键值范围)。当需要查找某条记录时,从根节点出发:

  1. 比较键值,确定目标记录应该落在哪个子页;
  2. 沿着指针进入子页;
  3. 如果子页还是非叶子节点,重复步骤 1~2;
  4. 直到进入叶子节点,在页内通过 Page Directory 定位具体记录。

这样,每一次比较都能排除掉一整棵子树的数据,而不是一条条比对。


3.4 最终形态:B+ 树

随着数据量增长,目录项也会变多。当根节点存不下时,就再往上抽一层,形成多级索引。最终,这棵树就演变成了B+ 树

B+ 树有几个至关重要的特征,决定了它为什么适合数据库索引:

  1. 非叶子节点不存数据,只存键值和指针

    • 这意味着一个 16KB 的页能存下成百上千个指针,而不是只能存几十条数据。树变得非常"矮胖"。
  2. 所有真实数据都存放在叶子节点

    • 保证了查询的稳定性:不管查哪条数据,I/O 次数都差不多(树的高度次)。
  3. 叶子节点之间用双向链表连接

    • 这让范围查询(BETWEEN><)极其高效:找到左边界后,直接顺着链表向后遍历,无需回退到上层节点。

3.5 一个直观的计算

假设一行数据约 500 字节,一个 16KB 的叶子页大约能存32 条记录。

非叶子节点只存主键(假设 INT 占 4 字节)和页指针(约 6 字节),一个 16KB 页大约能存1600 个目录项。

那么:

  • 2 层 B+ 树:1600 × 32 ≈5 万条记录
  • 3 层 B+ 树:1600 × 1600 × 32 ≈8000 万条记录

这意味着,对于 800 万数据的表,B+ 树的高度通常只有3 层。即使目标数据在磁盘上,最多也只需要3 次磁盘 I/O就能定位到!这和不建索引时的全表扫描(可能需要几万次 I/O)相比,差距是数量级的。

3.6 为什么别的数据结构不行

  1. 链表?链表线性遍历,无法实现跨越式的筛选,最终的时间效率仍然不可观
  2. 二叉搜索树?有退化问题,可能退化成线性结构
  3. AVL 和 红黑树?虽然是平衡或者近似平衡,但是毕竟是二叉结构,相比较多阶 B+,意味着树整体过高,大家都是自顶向下找,层高越低,意味着系统与硬盘更少的 IO Page 交互。虽然很优秀,但是有更加优秀的选择。
  4. Hash?官方的索引实现方式中, MySQL 是支持 HASH 的,Hash 跟其算法特征,决定了虽然有时候也很快 (O(1)),不过在面对范围查找就明显不行,另外还有其他差别,有兴趣可以查一下。

最值得比较的是 InnoDB 为何不用 B树 作为底层索引?

B树:

B+树:

  • B树节点,既有数据,又有 Page 指针,而 B+ 只有叶子节点有数据,其他目录页,只有键值和 Page 指针。
  • B+叶子节点,全部相连,而 B 树 没有。

为何选择 B+ 树呢?

两树的本质区别在于 “非叶子节点是否存储data”,如果非叶子节点不对 data 进行存储,空间就可以存储更多的目录页,从而使得树更矮,所以IO操作次数更少。

并且叶子节点相连,更便于进行范围查找。

综上所述使用 B+ 树实现更佳。


3.7 小结

  • 创建索引,本质上就是在构建一棵 B+ 树(针对 InnoDB 的主键索引而言)。
  • B+ 树通过"矮胖"的结构和页间目录,把"全表扫描"变成了"树的高度次 I/O"。
  • 索引解决的是"数据在哪个页"的问题,而 Page Directory 解决的是"数据在页内的哪个位置"的问题。两者配合,才完成了最终的快速定位。

4. 聚簇索引 VS 非聚簇索引

MyISAM 引擎同样使用 B+ 树作为索引结构,叶节点的 data 域存放的是数据记录的地址。

下图为 MyISAM 表的主索引, Col1 为主键。

其中, MyISAM 最大的特点是,将索引 Page 和数据 Page 分离,也就是叶子节点没有数据,只有对应数据的地址。

相较于 InnoDB 索引, InnoDB 是将索引和数据放在一起的。

InnoDB 这种用户数据与索引数据在一起索引方案,叫做聚簇索引。

5. 辅助索引与回表

MySQL 除了默认会建立主键索引外,我们用户也有可能建立按照其他列信息建立的索引,一般这种索引可以叫做辅助(普通)索引。

对于 MyISAM ,建立辅助(普通)索引和主键索引没有差别,无非就是主键不能重复,而非主键可重复。

下图就是基于 MyISAM 的 Col2 建立的索引,和主键索引没有差别:

同样, InnoDB 除了主键索引,用户也会建立辅助(普通)索引,我们以上表中的 Col3 建立对应的辅助索引如下图:

可以看到, InnoDB 的非主键索引中叶子节点并没有数据,而只有对应记录的 key 值。

所以通过辅助(普通)索引,找到目标记录,需要两次索引查找:首先检索辅助索引获得主键,然后用主键到主索引中检索获得记录。这种过程,就叫做回表查询

为何 InnoDB 针对这种辅助(普通)索引的场景,不给叶子节点也附上数据呢?原因就是太浪费空间了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询