【MySQL】深入浅出MySQL索引特性:从磁盘I/O底层数据结构到实战调优

🔥个人主页:Cx330🌸

❄️个人专栏:《C语言》《LeetCode刷题集》《数据结构-初阶》《C++知识分享》

《优选算法指南-必刷经典100题》《Linux操作系统》:从入门到入魔

《Git深度解析》:版本管理实战全解 《Qt 极境架构》MySQL 核心技术与实战

🌟心向往之行必能


🎥Cx330🌸的简介:


目录

前言

[一. 为什么需要索引?(海量数据痛点)](#一. 为什么需要索引?(海量数据痛点))

索引的核心定义

常见索引分类

[二. 实战演练:海量数据检索测试](#二. 实战演练:海量数据检索测试)

[2.1 创建测试表](#2.1 创建测试表)

[2.2 编写存储过程构建差异化数据](#2.2 编写存储过程构建差异化数据)

[2.3 无索引查询测试](#2.3 无索引查询测试)

[2.4 创建索引并再次测试](#2.4 创建索引并再次测试)

[三. 认识磁盘](#三. 认识磁盘)

[3.1 MySQL与存储](#3.1 MySQL与存储)

[3.2 磁盘随机访问 (Random Access) 与连续访问 (Sequential Access)](#3.2 磁盘随机访问 (Random Access) 与连续访问 (Sequential Access))

[四. 深入底层:从磁盘 I/O 到 B+ 树](#四. 深入底层:从磁盘 I/O 到 B+ 树)

[4.1 磁盘与 MySQL 的交互单位(Page)](#4.1 磁盘与 MySQL 的交互单位(Page))

[3.2 为什么不用其他数据结构?](#3.2 为什么不用其他数据结构?)

[3.3 B+ 树的终极对决](#3.3 B+ 树的终极对决)

[五. 存储引擎索引实现:InnoDB vs MyISAM](#五. 存储引擎索引实现:InnoDB vs MyISAM)

[5.1 InnoDB 中的聚簇索引(Clustered Index)](#5.1 InnoDB 中的聚簇索引(Clustered Index))

[5.2 MyISAM 中的非聚簇索引(Non-clustered Index)](#5.2 MyISAM 中的非聚簇索引(Non-clustered Index))

[5.3 聚簇索引(Clustered Index)VS 非聚簇索引(Non-clustered Index)](#5.3 聚簇索引(Clustered Index)VS 非聚簇索引(Non-clustered Index))

[六. MySQL 索引管理实战](#六. MySQL 索引管理实战)

[6.1 创建索引](#6.1 创建索引)

[6.2 查询索引](#6.2 查询索引)

[6.3 删除索引](#6.3 删除索引)

[6.4 玩转全文索引(Fulltext Index)](#6.4 玩转全文索引(Fulltext Index))

[七. 索引设计的黄金法则](#七. 索引设计的黄金法则)

结语


前言

在 C/C++ 后端高并发系统开发中,数据库往往是整个系统的性能瓶颈。要编写出高性能的后端服务,仅仅精通 C++ 语法和网络模型是远远不够的,还必须掌握数据库的底层优化。

MySQL 索引作为"物美价廉"的性能优化神器,不用我们加内存、改程序、调物理拓扑,只需一条正确的 CREATE INDEX,就能将查询速度提升成百上千倍。但"天下没有免费的午餐",索引的引入势必会带来磁盘空间占用以及写操作(插入、更新、删除)时的 I/O 负担。本文将结合底层原理,带你彻彻底底搞懂 MySQL 索引的"前世今生"。


一. 为什么需要索引?(海量数据痛点)

在没有索引的情况下,当我们在海量数据表中查询一条记录时,数据库只能进行全表扫描(Table Scan)。这意味着需要将磁盘上的整张表数据,逐行加载到内存中进行比对,其时间复杂度为线性阶 O(N)。在面对数百万、甚至千万级数据量时,这种查询方式会导致严重的磁盘 I/O 阻塞,响应时间往往达到数秒甚至数分钟,这在现代后端服务中是完全不可接受的。

为了解决这一痛点,MySQL 引入了索引(Index)机制。

索引的核心定义

索引是帮助 MySQL 高效获取数据排好序的数据结构。它的本质是通过空间换时间,将无序的数据通过特定的结构组织起来,使查询的时间复杂度降低到对数阶 O(log N)。

常见索引分类

  • 主键索引(Primary Key):表中唯一标识一行数据的索引,不允许重复且不能为 NULL。

  • 唯一索引(Unique Index):限制列值必须唯一,但允许为 NULL。

  • 普通索引(Normal Index):最基本的索引,没有任何限制。

  • 全文索引(Fulltext Index):用于解决中子文或大文本的模糊匹配检索问题。

二. 实战演练:海量数据检索测试

为了直观感受索引的威力,我们模拟构建一个包含 8,000,000(八百万) 条记录的海量数据表,对比有无索引的查询性能。

2.1 创建测试表

复制代码
CREATE DATABASE IF NOT EXISTS index_demo;
USE index_demo;

-- 创建海量数据表
CREATE TABLE EMP (
    empno INT UNSIGNED DEFAULT 0,
    ename VARCHAR(20) DEFAULT '',
    job VARCHAR(9) DEFAULT '',
    mgr INT UNSIGNED DEFAULT 0,
    hiredate DATE NOT NULL,
    sal DECIMAL(7,2) DEFAULT 0.00,
    comm DECIMAL(7,2) DEFAULT 0.00,
    deptno MEDIUMINT UNSIGNED DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

2.2 编写存储过程构建差异化数据

由于海量数据必须具备差异性,我们利用存储过程和随机生成函数来进行数据填充。

复制代码
-- 1. 产生随机字符串函数
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 ;

-- 2. 产生随机数字函数
DELIMITER $$
CREATE FUNCTION rand_num() RETURNS INT(5)
BEGIN
    DECLARE i INT DEFAULT 0;
    SET i = FLOOR(10 + RAND() * 500);
    RETURN i;
END $$
DELIMITER ;

-- 3. 核心数据插入存储过程
DELIMITER $$
CREATE PROCEDURE insert_emp(IN start_num 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_num + i), 
            rand_string(6), 
            'SALESMAN', 
            0001, 
            CURDATE(), 
            2000.00, 
            400.00, 
            rand_num()
        );
        UNTIL i = max_num
    END REPEAT;
    COMMIT; -- 统一提交
END $$
DELIMITER ;

调用存储过程插入 800 万条数据(该过程需要运行几分钟):

复制代码
-- 插入 8,000,000 条记录
CALL insert_emp(100001, 8000000);

2.3 无索引查询测试

在未加任何索引的情况下,查询员工编号为 998877的员工信息:

复制代码
SELECT * FROM EMP WHERE empno = 998877;
  • 执行耗时 :约 6秒 ~ 7.0 秒

  • 原因分析 :没有索引,MySQL 执行了全表扫描(type: ALL),逐行读取磁盘数据进行比对,I/O 开销极大。

2.4 创建索引并再次测试

我们在 empno列上建立一个普通索引:

复制代码
ALTER TABLE EMP ADD INDEX (empno);

再次执行相同的查询:

复制代码
SELECT * FROM EMP WHERE empno = 4500123;
  • 执行耗时 :约 0.00 秒(极其微秒级)

  • 原因分析:此时 MySQL 直接通过索引树进行二分查找,仅进行了几次磁盘 I/O 交互便定位到了目标行。


三. 认识磁盘

3.1 MySQL****与存储

MySQL 给用户提供存储服务,而存储的都是数据,数据在磁盘这个外设当中。磁盘是计算机中的一个机 械设备,相比于计算机其他电子元件,磁盘效率是比较低的,在加上IO 本身的特征,可以知道,如何提交效率,是 MySQL 的一个重要话题。
先来研究一下磁盘:

扇区

数据库文件,本质其实就是保存在磁盘的盘片当中。也就是上面的一个个小格子中,就是我们经常所说的扇区。当然,数据库文件很大,也很多,一定需要占据多个扇区。

题外话

  • 从上图可以看出来,在半径方向上,距离圆心越近,扇区越小,距离圆心越远,扇区越大
  • 那么,所有扇区都是默认 512 字节吗?目前是的,我们也这样认为。因为保证一个扇区多大,是由比特位密度决定的。
  • 不过最新的磁盘技术,已经慢慢的让扇区大小不同了,不过我们现在暂时不考虑。

我们在使用 Linux,所看到的大部分目录或者文件,其实就是保存在硬盘当中的。(当然,有一些内存文件系统,如:procsys之类,我们不考虑)
所以,最基本的,找到一个文件的全部,本质,就是在磁盘找到所有保存文件的扇区。
而我们能够定位任何一个扇区,那么便能找到所有扇区,因为查找方式是一样的。

  • 柱面 (磁道):多盘磁盘,每盘都是双面,大小完全相等。那么同半径的磁道,整体上便构成了一个柱面
  • 每个盘面都有一个磁头,那么磁头和盘面的对应关系便是 1 对 1 的
  • 所以,我们只需要知道,磁头 (Heads)、柱面 (Cylinder)(等价于磁道)、扇区 (Sector) 对应的编号。即可在磁盘上定位所要访问的扇区。这种磁盘数据定位方式叫做 CHS。不过实际系统软件使用的并不是 CHS(但是硬件是),而是 LBA,一种线性地址,可以想象成虚拟地址与物理地址。系统将 LBA 地址最后会转化成为 CHS,交给磁盘去进行数据读取。不过,我们现在不关心转化细节,知道这个东西,让我们逻辑自洽起来即可。

结论

我们现在已经能够在硬件层面定位,任何一个基本数据块了 (扇区)。那么在系统软件上,就直接按照扇区 (512 字节,部分 4096 字节),进行 IO 交互吗?不是

  • 如果操作系统直接使用硬件提供的数据大小进行交互,那么系统的 IO 代码,就和硬件强相关,换言之,如果硬件发生变化,系统必须跟着变化
  • 从目前来看,单次 IO 512 字节,还是太小了。IO 单位小,意味着读取同样的数据内容,需要进行多次磁盘访问,会带来效率的降低。
  • 之前学习文件系统,就是在磁盘的基本结构下建立的,文件系统读取基本单位,就不是扇区,而是数据块。

故,系统读取磁盘,是以块为单位的,基本单位是 4KB。

3.2 磁盘随机访问 (Random Access) 与连续访问 (Sequential Access)

随机访问:本次 IO 所给出的扇区地址和上次 IO 给出扇区地址不连续,这样的话磁头在两次 IO 操作之间需要作比较大的移动动作才能重新开始读 / 写数据。 连续访问:如果当次 IO 给出的扇区地址与上次 IO 结束的扇区地址是连续的,那磁头就能很快的开始这次 IO 操作,这样的多个 IO 操作称为连续访问。

因此尽管相邻的两次 IO 操作在同一时刻发出,但如果它们的请求的扇区地址相差很大的话也只能称为随机访问,而非连续访问。

磁盘是通过机械运动进行寻址的,连续访问不需要过多的定位,故效率比较高


四. 深入底层:从磁盘 I/O 到 B+ 树

作为 C++ 后端程序员,我们不能只停留在表面的 SQL 语句,必须探究底层的硬件交互与数据结构。

4.1 磁盘与 MySQL 的交互单位(Page)

MySQL 作为关系型数据库,其数据最终是持久化在磁盘上的。然而,系统从磁盘读取数据的最小单位并非"字节",而是以"块/页"为单位。

  • 操作系统的页:通常为 4KB。

  • MySQL InnoDB 存储引擎的页(Page) :默认大小为 16KB

    +-------------------------------------------------------------+
    | InnoDB Page |
    | +------------------+ +-----------------+ +------------+ |
    | | File Header | | Page Header | | Infimum | |
    | +------------------+ +-----------------+ +------------+ |
    | | User Records | |
    | | Row 1 (Directory) -> Row 2 -> Row 3 -> Row 4 ... | |
    | +-------------------------------------------------------+ |
    | | Page Directory | | File Trailer | | |
    | +------------------+ +-----------------+ | |
    +-------------------------------------------------------------+

MySQL 的 I/O 交互都是以 16KB 的 Page 为基本单位的。即使你只修改或查询 1 字节的数据,MySQL 也会将整页(16KB)的数据加载到内存 Buffer Pool 中。

3.2 为什么不用其他数据结构?

在设计索引时,为什么不选择常见的二叉树、红黑树、Hash 表或 B 树?

数据结构 缺点与局限性
Hash 表 虽然等值查询时间复杂度为 O(1),但不支持范围查询 (如 ><between)以及排序,会退化为全表扫描。
二叉搜索树 (BST) 容易发生倾斜,在顺序插入时会退化成单链表,查询时间复杂度退化为 O(N),树的高度极高。
红黑树 (RBT) 虽然能自动平衡,但在海量数据下,树的高度(Height)依然不可控。每一次向下搜索父子节点都可能是一次磁盘 I/O。
B 树 (B-Tree) 每个节点既存储索引,又存储实际的 Data 记录。这导致单个 Page(16KB)能容纳的索引数量大幅减少。为了容纳相同数量的数据,树的高度会变得很高,增加 I/O 次数。

3.3 B+ 树的终极对决

MySQL 最终选择了 B+ 树(B+ Tree) 作为其核心索引结构。它在 B 树的基础上做出了极具艺术性的改良:

复制代码
                    +-------------------+
                    |   Page (Level 1)  |   <-- 非叶子节点:只存索引和指针
                    |   [ 10 | 20 | 30 ]|
                    +-----+----+----+---+
                          |    |    |
             +------------+    |    +------------+
             v                 v                 v
     +---------------+ +---------------+ +---------------+
     | Page (Level 2)| | Page (Level 2)| | Page (Level 2)|  <-- 叶子节点:存储完整的数据行
     | [10] [12] [15]| | [20] [22] [25]| | [30] [32] [35]|
     +---------------+ +---------------+ +---------------+
     (Double Linked List:  <---------->  <----------> )
  1. 非叶子节点只存储键值(Key)和页面指针,不存储实际行数据(Data)。因此,一个 16KB 的非叶子节点 Page 可以存放上千个索引项。

  2. 所有实际的数据记录全部存储在叶子节点(Leaf Node)。这确保了整棵树的高度极矮(通常 800 万数据也只需要 3~4 层),即最多进行 3~4 次磁盘 I/O 即可定位数据。

  3. 叶子节点之间通过双向链表相连,这使得范围查询、排序和分组操作变得异常高效。只需要定位到边界点,即可通过链表进行顺序双向遍历。


五. 存储引擎索引实现:InnoDB vs MyISAM

MySQL 常见的存储引擎有 InnoDB 和 MyISAM,它们对 B+ 树索引的底层实现有着本质的区别。

复制代码
                     [ 查询键值: 18 ]
                            |
             +--------------+--------------+
             |                             |
             v                             v
     【InnoDB (聚簇索引)】           【MyISAM (非聚簇索引)】
     +---------------------+       +---------------------+
     |   Index + Data      |       |   Index + Address   |
     | (索引与数据合二为一) |       | (索引与数据文件分离) |
     +---------------------+       +---------------------+
     | Key: 18             |       | Key: 18             |
     | Data: {18, Jack...} |       | Addr: 0x7fff1234 --+
     +---------------------+       +---------------------+    |
                                                              v
                                                   +----------------------+
                                                   |     MYD Data File    |
                                                   |  Row: {18, Jack...}  |
                                                   +----------------------+

5.1 InnoDB 中的聚簇索引(Clustered Index)

  • 特点 :索引和数据是紧密结合的,B+ 树的叶子节点直接存放了整行数据的完整记录

  • 主键索引即聚簇索引 :如果表定义了主键,则主键索引就是聚簇索引;若无主键,则会选择第一个非空唯一索引;若依然没有,InnoDB 会自动生成一个隐式的 row_id

  • 辅助索引(Secondary Index / 非聚簇索引) :辅助索引的叶子节点并不存放完整行数据,而是存放主键的值

    • :通过辅助索引查询时,首先在辅助索引树中找到主键,再拿着主键去主键索引树上查找完整数据。这个过程叫做回表(Table Lookup)

5.2 MyISAM 中的非聚簇索引(Non-clustered Index)

  • 特点:索引和数据完全分离。

  • 文件构成

    • .MYI 文件:存储索引树。

    • .MYD 文件:存储实际的行数据。

  • 工作原理 :无论是主键索引还是普通索引,MyISAM 的 B+ 树叶子节点中存放的都不是完整数据,也不是主键值,而是数据行在 .MYD 文件中的物理磁盘地址(Address 指针)

5.3 聚簇索引(Clustered Index)VS 非聚簇索引(Non-clustered Index)


六. MySQL 索引管理实战

在开发中,我们需要对索引进行灵活的创建、查询和删除。以下是完整的操作指令总结:

6.1 创建索引

复制代码
-- 方式 1:创建表时指定索引
CREATE TABLE user10 (
    id INT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(20),
    email VARCHAR(50),
    UNIQUE KEY idx_email (email),    -- 唯一索引
    INDEX idx_name (name)            -- 普通索引
);

-- 方式 2:在已有表上追加索引
ALTER TABLE user10 ADD INDEX idx_name_email (name, email); -- 复合索引

-- 方式 3:直接创建索引
CREATE INDEX idx_name ON user10 (name);

6.2 查询索引

查询表中已存在的索引信息:

复制代码
-- 方法 1:最常用、最详尽
SHOW KEYS FROM user10\G

-- 方法 2:作用相同
SHOW INDEX FROM user10;

-- 方法 3:查看表结构(信息较为简略)
DESC user10;

6.3 删除索引

复制代码
-- 方法 1:删除主键索引
ALTER TABLE user10 DROP PRIMARY KEY;

-- 方法 2:删除普通索引/唯一索引/复合索引
ALTER TABLE user10 DROP INDEX idx_name;

-- 方法 3:直接 DROP INDEX 语法
DROP INDEX idx_email ON user10;

6.4 玩转全文索引(Fulltext Index)

普通的 LIKE '%database%' 查询会导致索引失效而进行全表扫描。在海量文本检索时,必须使用全文索引。

复制代码
-- 创建包含全文索引的表
CREATE TABLE articles (
    id INT UNSIGNED AUTO_INCREMENT NOT NULL PRIMARY KEY,
    title VARCHAR(200),
    body TEXT,
    FULLTEXT (title, body) -- 建立全文索引
) ENGINE=InnoDB;

-- 模拟插入测试数据
INSERT INTO articles (title, body) VALUES
('MySQL Tutorial', 'DBMS stands for Database Management System...'),
('How To Use MySQL Well', 'After you apply fulltext index, you can query...'),
('Optimizing Databases', 'Active your query speed using index structures...');

-- 使用 MATCH ... AGAINST 语法进行全文检索
SELECT * FROM articles 
WHERE MATCH (title, body) AGAINST ('database');

注意

  1. 全文索引在使用时需要用特定的 MATCH (列名) AGAINST ('关键字') 语法。

  2. 使用 EXPLAIN分析上述语句,可以清晰地看到 key: title(代表成功命中了全文索引),而不再是全表扫描。


七. 索引设计的黄金法则

索引虽好,但不可贪多。以下是我们在后端架构设计中必须遵循的索引建立原则:

  1. 高选择性列优先 :选择性**(COUNT(DISTINCT column) / COUNT(*))**越接近 1 的列越适合建索引。例如:身份证号、手机号、邮箱适合建索引;而性别、状态字段(只有0和1)由于重复率极高,绝对不适合单独建索引。

  2. 最左前缀法则(Most Left Prefix):对于复合索引(多列联合索引),MySQL 从左到右进行匹配。查询条件中必须包含复合索引的最左侧列,否则索引将会失效。

    • 例如:建立复合索引 (a, b, c),查询 WHERE a = 1 AND b = 2 可以用索引;而直接查询 WHERE b = 2 AND c = 3 则无法使用索引。
  3. 覆盖索引优化(Covering Index):尽量让查询的列只包含在索引树的节点中,这样可以避免"回表"操作,极大地提升查询效率。

  4. 避免在索引列上做运算 :如 WHERE YEAR(add_time) < 2026 会导致 add_time上的索引完全失效。应该改写为 WHERE add_time < '2026-01-01'


结语

在 C++ 编写的高性能后端服务中,数据库的高效吞吐是第一要义。透彻理解 MySQL 索引在磁盘层面的 16KB Page 交互,掌握 B+ 树的底层逻辑,以及合理使用 InnoDB 聚簇索引与覆盖索引,能让我们在面对海量数据和高并发场景时游刃有余。

如果你觉得本文对你有所启发,请不吝点赞、收藏、关注!我们下期再见!