浅析 MySQL 索引

MySQL 索引

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

在使用 MySQL 的场景下,数据库往往存储着海量数据(几万条、几百万条,甚至上千万条)。

如果不对数据做任何处理,查询时就只能从头到尾逐行遍历(全表扫描),时间效率极低。

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

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


1. 没有索引会怎样?

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

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

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

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

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

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

SQL 复制代码
-- ============================================
-- 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 针对这种辅助(普通)索引的场景,不给叶子节点也附上数据呢?原因就是太浪费空间了。

相关推荐
沈浩(种子思维作者)6 小时前
心理的研究,我从何来?
数据库·人工智能·量子计算
糖果店的幽灵6 小时前
【langgraph 从入门到精通graphApi 篇】节点与边 —— 图的骨架
java·数据库·人工智能·redis·langgraph
91刘仁德6 小时前
MySQL 数据类型详解
android·笔记·mysql·adb
z落落7 小时前
SQL 表的基本操作 数据库增删改查(CRUD)+SQL 进阶语法详解(标识列插入、模糊查询、Union、变量、运算、别名)
数据库·sql
编程圈子8 小时前
操作系统学习14 IDT + PIC + 键盘中断
android·学习·计算机外设
代码的小搬运工10 小时前
网络请求(NSURL、NSURLRequest、NSURLSessionDataTask、协议回调与 JSON 数据的基本流向)
网络·数据库·json
齊家治國平天下15 小时前
Android 14 Vehicle HAL (VHAL) 设计与实现深度解析
android·aaos·carservice·车载开发·android 14·vhal·vehicle hal
Cx330❀15 小时前
【MySQL基础】一文吃透“表的约束”:从 Null/Default 到主外键的终极安全法则
linux·服务器·数据库·c++·mysql·安全
c2385616 小时前
第二篇:《测试指挥官:可视化单题自测框架(含 assert 实操)》
java·数据库·c++·算法·安全性测试