开篇介绍:
hello 大家,那么对于数据库而言,最重要的就是查询数据了,而查询数据最重要的又是速度,换句话说就是效率,而当数据库中有大量数据的时候,诶,不可避免的是,我们查询指定数据的速度肯定也会变慢,本质还是因为等待系统从磁盘、硬盘读取数据的时间消耗,这一点我们在IO那里也有讲到过,那么对于数据库而言,我们肯定要想办法解决这个在大量数据中查询指定数据效率低的问题,所以,索引,就诞生了,本篇博客,我们就将一起去学习索引!
在数据库开发中,索引是提升查询性能的核心手段,但多数开发者仅停留在 “创建索引” 的表层使用,对其底层原理、语法细节、优化技巧知之甚少,导致频繁出现 “索引失效”“过度索引”“查询缓慢” 等问题。本文将从 “无索引的痛点” 出发,用 “图书馆找书”“文件柜收纳” 等生活化类比拆解底层原理,结合 “逐行语法解析 + 海量实战案例 + 错误场景复现”,把 MySQL 索引的 “底层逻辑 – 语法规则 – 实战优化” 讲透 —— 不仅让你 “会用”,更让你 “懂原理、知语法、能优化”。
一、无索引的痛:海量数据查询的 “噩梦”
在探讨索引语法前,我们先通过实战验证 “无索引” 的低效,同时从底层 IO 角度解释 “为什么慢”,为后续索引原理铺垫。
1.1 基础语法:创建测试表 + 批量插入数据
1.1.1 语法准备:存储过程与函数创建语法
MySQL 中批量插入海量数据需借助存储过程(Procedure) 和自定义函数(Function),核心原因是:单次INSERT语句插入 1 条数据会触发 1 次磁盘 IO,而存储过程可批量封装操作,关闭自动提交后仅触发 1 次 IO,效率提升 100 倍以上。
1. 自定义函数:生成随机字符串
DELIMITER $$ — 临时修改语句结束符为$$(默认;,避免函数体中;与结束符冲突,底层是防止MySQL提前解析函数体)
CREATE FUNCTION rand_string(n INT)
RETURNS VARCHAR(255)
DETERMINISTIC — 输入相同参数返回相同结果,MySQL会缓存函数结果,减少重复计算(底层优化点)
BEGIN
— 声明变量:chars_str(字符集)、return_str(结果)、i(计数器)
DECLARE chars_str VARCHAR(100) DEFAULT 'abcdefghijklmnopqrstuvwxyzABCDEFJHIJKLMNOPQRSTUVWXYZ';
DECLARE return_str VARCHAR(255) DEFAULT '';
DECLARE i INT DEFAULT 0;
— WHILE循环:比FOR循环更适配MySQL,底层是逐行执行,无预编译优化
WHILE i < n DO
— CONCAT:字符串拼接,底层是内存缓冲区拼接,避免多次磁盘写入
— SUBSTRING+RAND:随机截取字符,RAND()底层是基于系统时间的伪随机数生成
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 ; — 恢复默认结束符,底层是告诉MySQL“函数定义结束,可执行后续语句”
2. 自定义函数:生成随机数字
DELIMITER $$
CREATE FUNCTION rand_num()
RETURNS INT(5)
DETERMINISTIC
BEGIN
DECLARE i INT DEFAULT 0;
— FLOOR(10 + RAND()*500):生成10~510的随机数,底层是将0~1的小数映射到目标区间
— 优化点:避免生成0值(部门编号通常从1开始,符合业务逻辑)
SET i = FLOOR(10 + RAND()*500);
RETURN i;
END $$
DELIMITER ;
3. 存储过程:批量插入数据
DELIMITER $$
CREATE PROCEDURE insert_emp(in start INT(10), in max_num INT(10))
BEGIN
DECLARE i INT DEFAULT 0;
SET autocommit = 0; — 关闭自动提交:底层是暂停InnoDB的事务日志(redo log)刷盘,批量插入仅写1次日志
— REPEAT循环:先执行后判断,比WHILE更适合“至少插入1条数据”的场景
REPEAT
SET i = i + 1;
INSERT INTO EMP VALUES (
(start+i), — 员工编号:自增逻辑,避免主键冲突(底层是InnoDB的主键唯一性校验)
rand_string(6), — 6位姓名:平衡“区分度”和“存储空间”
'SALESMAN',
0001,
CURDATE(), — 底层是调用MySQL的日期函数,返回当前系统日期(无需手动输入)
2000,
400,
rand_num()
);
UNTIL i = max_num
END REPEAT;
COMMIT; — 批量提交:底层是将redo log刷盘,同时更新数据页(Page),仅1次IO操作
END $$
DELIMITER ;
1.1.2 核心语法:创建 EMP 表(无索引)
CREATE TABLE EMP (
empno INT(10) NOT NULL, — 非空约束:底层是InnoDB在数据页中标记该字段“不允许NULL”,节省NULL标识位
ename VARCHAR(6) NOT NULL,
job VARCHAR(10) NOT NULL,
mgr INT(10), — 可空:底层是预留1个字节的NULL标识位
hiredate DATE NOT NULL,
sal INT(10) NOT NULL,
comm INT(10),
deptno INT(5) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
— 关键底层:
— 1. InnoDB引擎:数据存储在聚簇索引的叶子节点,无索引时数据无序存储
— 2. utf8字符集:每个汉字占3字节,姓名字段VARCHAR(6)最多存2个汉字,符合业务场景
— 3. INT(10):10是显示宽度(仅展示用),底层存储仍为4字节(INT固定4字节)
1.1.3 执行存储过程:插入 800 万条数据
CALL insert_emp(100001, 8000000); — 插入800万条,本地单机(8核16G)耗时约3分钟
— 补充:监控插入过程的底层状态(语法)
SHOW PROCESSLIST; — 查看存储过程执行状态,State列显示“copy to tmp table”表示数据在内存临时表整理
SHOW ENGINE INNODB STATUS; — 查看InnoDB底层状态,重点看“INSERT BUFFER”(插入缓冲)使用情况
1.2 无索引查询:
1.2.1 基础查询语法(无索引)
SELECT * FROM EMP WHERE empno=998877;
1.2.2 执行结果与底层原因
- 耗时:本地单机环境约 4.93 秒;
- 底层原因:
- 无索引时,MySQL 相当于 “图书馆没有书目索引,要找《998877 号员工档案》,只能从第 1 本书翻到最后 1 本”;
- 磁盘 IO 层面:800 万条数据存储在约 5000 个 InnoDB Page(16KB / 页)中,MySQL 需逐页读取(5000 次磁盘 IO),每次 IO 耗时约 1ms,总耗时≈5000*1ms=5 秒(与实测 4.93 秒吻合);
- 内存层面:读取的 Page 会缓存到 Buffer Pool,但首次查询无缓存,必须读磁盘;
- CPU 层面:逐行校验empno=998877,800 万次比较操作,占用 CPU 资源。
1.2.3 索引救场:创建索引后语法验证(补充底层变化)
ALTER TABLE EMP ADD INDEX idx_empno(empno);
— 底层变化:
— 1. MySQL为empno字段构建B+树索引,存储在独立的索引页中;
— 2. B+树高度约3层(800万数据的B+树高度计算:第1层1个节点,第2层约1000个节点,第3层约8000个节点,覆盖800万数据);
— 3. 构建索引耗时约10秒(需遍历所有数据页,排序后写入索引页)。
SELECT * FROM EMP WHERE empno=123456; — 耗时0.01秒以内
1.2.4 优化结果底层解析
- 耗时:0.01 秒以内;
- 底层原因:
- B + 树索引相当于 “图书馆书目索引”,通过 empno 直接定位到目标 Page(仅 3 次磁盘 IO:根节点→子节点→叶子节点);
- 3 次 IO 耗时≈3ms,加上内存缓存,总耗时 < 10ms;
- CPU 层面:仅需校验 1 条数据,资源占用可忽略。
1.3 核心语法总结
| 创建自定义函数 | DELIMITER 函数名参数类型函数体 DELIMITER ; | 需临时修改结束符;必须指定返回类型;DETERMINISTIC 可选(提升效率) | 函数结果缓存,减少重复计算;避免单条 INSERT 的多次 IO |
| 创建存储过程 | DELIMITER 过程名参数过程体 DELIMITER ; | 支持批量操作;关闭 autocommit 提升插入效率;COMMIT 统一提交 | 关闭自动提交减少 redo log 刷盘次数;批量提交仅 1 次 IO |
| 创建表 | CREATE TABLE 表名 (字段 类型 [约束]) ENGINE = 引擎 DEFAULT CHARSET = 字符集; | InnoDB 为默认引擎;NOT NULL 约束确保字段非空;INT (10) 中 10 是显示宽度,非存储长度 | InnoDB 数据存储在聚簇索引页;NOT NULL 节省 NULL 标识位;字符集决定字节占用 |
| 创建普通索引 | ALTER TABLE 表名 ADD INDEX 索引名 (字段名); 或 CREATE INDEX 索引名 ON 表名 (字段名); | 索引名建议遵循 “idx_字段名” 规范;单个字段索引可省略索引名 | 构建 B + 树索引,高度≈log (1000, 数据量);索引页独立存储,减少查询 IO |
| 查询数据 | SELECT 字段列表 FROM 表名 WHERE 条件; | * 表示查询所有字段;WHERE 后接筛选条件,无索引时条件字段会触发全表扫描 | 无索引:逐页读取 + 逐行校验;有索引:B + 树定位 + 仅校验目标数据 |
二、底层基础:理解磁盘存储与 MySQL IO 交互
要彻底懂索引语法,需先明白 “索引为何能提升效率”—— 核心是减少磁盘 IO 次数(磁盘 IO 是数据库最慢的操作,比内存操作慢 10 万倍)。
2.1 磁盘物理结构与数据定位
2.1.1 磁盘的三层存储结构
其实关于磁盘的解析呢,我们之前也有一篇博客去进行了专门的解析,相信大家应该还记得。
| 扇区(Sector) | 512 字节 | 文件柜的 “抽屉格子”(最小存储单位) | 无直接对应 | 磁盘的最小读写单位,操作系统必须整扇区读写 |
| 数据块(Block) | 4KB(8 个扇区) | 文件柜的 “抽屉”(操作系统 IO 单位) | 无直接对应 | 操作系统缓存的最小单位,减少扇区级别的 IO 次数 |
| 页(Page) | 16KB(InnoDB) | 文件柜的 “文件夹”(MySQL IO 单位) | InnoDB Page | MySQL 的最小读写单位,1 个 Page 包含多个数据行,减少数据块级别的 IO 次数 |
2.1.2 语法验证:查看 InnoDB Page 大小
SHOW GLOBAL STATUS LIKE 'innodb_page_size';
— 执行结果:Innodb_page_size = 16384(16KB)
— 底层意义:
— 1. 16KB是InnoDB经过实测的“最优IO单位”:太小(如4KB)会导致Page数量过多,IO次数增加;太大(如32KB)会导致单次IO耗时过长;
— 2. 1个16KB Page可存储约100行EMP表数据(每行约150字节),800万数据≈80000个Page(1.25GB);
— 3. 修改Page大小:需在MySQL初始化时设置(my.cnf中innodb_page_size=16384),运行中无法修改(底层是数据文件格式固定)。
2.2 MySQL 内存缓存:Buffer Pool
Buffer Pool 是 InnoDB 的 “内存缓冲区”,相当于 “把文件柜里常用的文件夹拿到桌面上”,避免每次都开抽屉。
2.2.1 查看 Buffer Pool 配置
— 查看Buffer Pool大小(默认128MB)
SHOW GLOBAL VARIABLES LIKE 'innodb_buffer_pool_size';
— 执行结果:innodb_buffer_pool_size = 134217728(128MB = 128*1024*1024)
— 底层解读:
— 1. 128MB Buffer Pool可缓存约8192个Page(128MB/16KB),占800万数据的10%;
— 2. 生产环境建议设置为物理内存的50%~70%(如16G内存设置为10G),最大化缓存Page。
— 查看Buffer Pool实例数
SHOW GLOBAL VARIABLES LIKE 'innodb_buffer_pool_instances';
— 执行结果:innodb_buffer_pool_instances = 1
— 底层解读:
— 1. 多实例(如8个)可减少并发竞争(每个实例有独立锁),适合8核以上CPU;
— 2. 实例数建议等于CPU核心数(如8核设置8个实例)。
2.2.2 修改 Buffer Pool 大小
— 修改为512MB(需管理员权限,重启MySQL生效)
SET GLOBAL innodb_buffer_pool_size = 536870912; — 512*1024*1024=536870912
— 验证修改(重启后)
SHOW GLOBAL VARIABLES LIKE 'innodb_buffer_pool_size';
— 底层注意事项:
— 1. 修改后需重启MySQL,因为Buffer Pool是初始化时分配的内存区域;
— 2. 过大的Buffer Pool会导致MySQL启动缓慢(需初始化内存结构);
— 3. 过小的Buffer Pool会导致“缓存命中率低”,频繁读磁盘(可通过SHOW ENGINE INNODB STATUS查看命中率)。
2.2.3 Buffer Pool 的工作流程(用 “图书馆桌面” 类比)
2.3 核心共识:索引语法的设计目标
所有索引语法的核心目标,都是让 MySQL 能用最少的 IO 次数找到目标 Page:
- 创建索引时,语法指定 “字段名”→ 底层是 “以该字段为键值构建 B + 树”,B + 树的每个节点对应 1 个 Page,通过键值快速定位目标 Page;
- 查询时,WHERE 条件匹配索引字段→ 底层是 “遍历 B + 树,从根节点到叶子节点,仅需 3~4 次 IO”;
- 索引失效时→ 底层是 “无法通过 B + 树定位 Page,只能全表扫描(N 次 IO)”。
底层原理:
首先要明确一个核心前提:MySQL 里的所有数据,最终都是存在硬盘上的,硬盘的特点是 “存得多但找得慢”—— 就像图书馆的仓库,书放得再多,没有目录,找一本特定的书也要翻遍整个仓库。而索引的本质,就是给硬盘里的海量数据,建一套 “图书馆式的多级目录”,让数据库找数据不用翻遍硬盘,而是按目录一步步定位。
但数据库建目录不是随便建的,因为硬盘和图书馆仓库还有个区别:硬盘只能按 “固定大小的小块” 读写数据,就像图书馆只能按 “16 页的小薄书” 为单位借书、还书,哪怕你只看 1 页,也要把整本 16 页的薄书拿出来。这个 “固定大小的小块”,就是 MySQL 里的页(Page),也是索引设计的最小单位—— 所有的数据、所有的目录,都必须装在这个 “小块” 里,索引的所有设计,都是围绕这个 “小块” 展开的。
接下来,咱们从 “页是什么” 开始,一步步讲透索引的整个设计逻辑,全程大白话,细节拉满,保证让你看到最后彻底明白 “索引到底是怎么设计出来的”。
一、先搞懂索引设计的最小单位:页(Page)—— 硬盘的 “固定大小小薄书”
MySQL 给页定了一个固定大小:16KB(16384 字节,这个大小几乎不会改,就像图书馆统一把小薄书做成 16 页)。不管是实际的业务数据(比如用户 id、姓名、年龄),还是给数据建的目录,都必须装在这个 16KB 的页里,硬盘和 MySQL 交互,也只能 “一页一页” 来 —— 读数据就是读整页,写数据也是写整页,没有 “读半页、写半页” 的说法。
这是索引设计的第一原则:一切皆页,所有操作围绕页展开。
1.1 页的两个核心类型:数据页(存正文的薄书)+ 目录页(存指引的目录本)
就像图书馆的薄书分 “存正文的故事书” 和 “存指引的目录本”,MySQL 的页也分两种核心类型,各司其职,绝不混装 —— 这是索引设计的第二原则,也是目录能高效指引的关键。
- 数据页:专门存实际的业务数据,比如用户表的 id=1、姓名 = 张三、年龄 = 25,订单表的订单号 = O123、金额 = 99,这些实实在在的内容,都存在数据页里。数据页就像图书馆里写满正文的 16 页小薄书,是数据的最终载体。
- 目录页:专门存数据的指引信息,不存任何实际业务数据,只记 “某段数据存在哪个数据页里”“这个数据页里的数范围是多少”。目录页就像图书馆里只写目录的小本子,本子里只有 “《张三的记录》在 3 排 5 号薄书里”“id=1-16 的记录在页号 100 的薄书里” 这种指引,没有正文。
除此之外,每个页还有一个唯一的身份证号 —— 页号,就像图书馆给每本薄书、每个目录本编一个唯一的编号(比如页号 100、页号 200),数据库只要知道页号,就能直接在硬盘上找到这个页,不用再翻找 —— 这是索引能快速定位的核心基础,所有目录页里的指引,最终都是指向某个页的页号。
1.2 页的两个基础设计:有序排列 + 双向链表,为 “建目录” 打基础
不管是数据页还是目录页,数据库都会给它们做两个基础设计,这两个设计看起来简单,却是后续所有目录设计的前提,就像图书馆会把所有薄书按编号排好,还在每本薄书的封面上标上 “上一本编号”“下一本编号”:
到这里,你先记住一个结论:MySQL 里的所有数据,都被拆成了一个个 16KB 的 Data 页,按主键有序排好,用双向链表连起来,每个 Data 页有唯一的页号;而索引,就是给这些 Data 页建一套 “目录本(目录页)”,让数据库能按目录找 Data 页,不用翻遍所有 Data 页。
二、数据页的详细设计:存实际数据的 “薄书”,内部也有 “小目录”
咱们先把 “存数据的载体 —— 数据页” 讲透,因为目录页最终都是指向数据页的,数据页怎么设计,决定了目录页该怎么建。
还是用类比:数据页是16 页的薄书(16KB),这本薄书的内部排版是固定的,就像所有故事书都有 “封面→目录→正文→封底”,数据页也有固定的结构:页头(封面)→页体(目录 + 正文)→页尾(封底),总大小刚好 16KB,一点不多一点不少。
2.1 页头:数据页的 “封面”,存基础信息,一眼就能看懂
数据页的开头是页头(File Header),占 38 个字节(相当于薄书的封面,就几行字),里面只存这本 “薄书” 的基础信息,数据库拿到一个数据页,先看页头,就知道这本薄书是干嘛的、编号是多少、和谁挨着。页头里的核心信息就 4 个,都是为了 “快速定位、快速关联”:
除此之外,页头里还有一些校验信息,不用管,核心就是上面 4 个 —— 设计页头的唯一目的,就是让数据库能快速识别这个数据页的基本信息,不用读正文,就能知道它的编号、邻居、存了多少数据。
2.2 页体:数据页的 “核心内容区”,有 “小目录”+“正文”,找数据不用逐行翻
页体是数据页的核心,占了差不多 16KB-100 字节的空间(薄书的正文 + 内部小目录),也是数据页设计最精妙的地方 —— 它被分成了两部分:槽位目录(页内小目录)+ 数据记录区(正文),而且数据记录区的记录,必须按主键从小到大有序排列。
咱们先讲数据记录区:这部分就是存实际业务数据的地方,比如用户表的 id=1 姓名 = 张三 年龄 = 25、id=2 姓名 = 李四 年龄 = 30,一条条记录按主键 id 的顺序排好,就像薄书里的正文按章节顺序排好,绝不乱序。这里有个关键设计:1 个数据页能存多少条记录,取决于单条记录的大小—— 如果单条记录 1KB,那 16KB 的数据页就能存 16 条;如果单条记录 2KB,就只能存 8 条,反正总大小不能超过 16KB。
然后是槽位目录(Slot Directory):这是数据页的 “灵魂设计”,也是为了解决 “数据页里存了 16 条记录,找 id=10 的记录,难道要逐行翻 16 条吗?” 的问题 —— 槽位目录就是数据页内部的小目录,就像薄书里的 “章节目录”,标着 “第 1 章在第 1 页,第 10 章在第 10 页”,让你不用逐页翻就能找到目标内容。
槽位目录的设计特别简单:它是一个有序的小数组,数组里的每个元素,只存 “某条记录在数据页里的具体位置(偏移量)”—— 比如数组的第 1 个元素,存 id=1 的记录在数据页里的位置,第 10 个元素,存 id=10 的记录在数据页里的位置。因为数据记录区是按主键有序的,所以槽位目录也是按主键有序的,找记录时,数据库会先二分查找槽位目录(比如找 id=10,先在槽位目录里找到第 10 个元素),再根据元素里的位置,直接跳转到目标记录,不用逐行翻。
举个例子:数据页里存了 id=1-16 的 16 条记录,要找 id=10 的记录,数据库不会从 id=1 开始数,而是先查槽位目录,找到第 10 个元素,这个元素标着 “id=10 的记录在数据页的第 800 字节位置”,数据库直接跳到 800 字节,就能拿到 id=10 的记录,一步到位。
设计槽位目录的核心目的:把数据页内的查找效率,从 “逐行翻的 O (N)” 提升到 “二分查找的 O (logN)”,哪怕数据页里存满了记录,找一条也只要几步。
2.3 页尾:数据页的 “封底”,存校验信息,保证数据没坏
数据页的最后是页尾(File Trailer),占 8 个字节(薄书的封底),里面只存两个校验信息:校验和 + LSN,作用就像薄书封底的防伪标,保证这本数据页的内容在硬盘上没被损坏、没被篡改。数据库读取数据页时,会先算一遍数据页的校验和,和页尾里的校验和对比,如果不一样,就说明数据页坏了,直接放弃读取;如果一样,就说明数据是完整的,可以正常用。
页尾的设计很简单,核心就是保证数据页的完整性,没有其他复杂的逻辑。
2.4 数据页的核心设计总结:有序 + 内部小目录,为 “建外部大目录” 打基础
咱们把数据页的设计总结一下,核心就三个点,这三个点都是后续建目录页的基础:
到这里,你可以想象一下:你的数据库里有 100 万条用户记录,每条记录 1KB,这些记录被分成了62500 个数据页(100 万 / 16),每个数据页存 16 条,按 id=1-16、17-32…… 的顺序排好,每本数据页的封面都标了上一本和下一本的编号,内部还有小目录 —— 如果没有索引,数据库找 id=50000 的记录,就要从第 1 个数据页开始,逐页翻到第 3125 个数据页(50000/16),翻 3000 多本薄书,慢得离谱;而有了索引,就是给这 62500 个数据页,建一套 “目录本(目录页)”,让数据库按目录本直接找到第 3125 个数据页,不用翻遍所有。
接下来,咱们就讲这套 “目录本(目录页)” 是怎么设计的。
三、目录页的详细设计:存指引的 “小本子”,只记 “数据页的范围 + 页号”
目录页的设计,比数据页简单,因为它不用存复杂的业务数据,只存数据页的指引信息—— 核心设计逻辑是:轻量化、有序、只存关键指引,目的是让一个目录页能存尽可能多的指引信息,减少目录的层级。
还是用类比:目录页是16 页的小本子(16KB),这个小本子里没有任何正文,只有一行行的 “指引项”,每一行指引项都只记三个信息:对应数据页的最小主键值 + 最大主键值 + 数据页号,比如 “1-16,页号 100”“17-32,页号 101”“33-48,页号 102”—— 说白了,目录页就是 “数据页的清单”,记着每本数据页存了什么范围的数据、编号是多少。
和数据页一样,目录页也有固定的结构:页头→页体→页尾,页头和页尾的设计和数据页几乎一模一样(页头存页号、上 / 下一页号、页类型;页尾存校验信息),唯一的区别在页体—— 目录页的页体只存目录项数组,没有槽位目录(因为目录项本身就是有序的,直接二分查找就行)。
咱们重点讲目录页的页体和目录项,这是目录页的核心:
3.1 目录项:目录页的 “核心指引单元”,极简设计,只存三个关键信息
目录项是目录页里的最小指引单元,就像目录本里的一行记录,设计得极致极简,只存三个信息,没有任何冗余 —— 因为越简单,一个目录页能存的目录项就越多,目录的层级就越少,找数据就越快。这三个信息是:
为什么要存 min_key 和 max_key?因为数据库找数据时,会先根据要找的主键值,在目录页里找 “主键值落在哪个 min_key 和 max_key 之间” 的目录项,再拿到对应的 page_no,直接定位到数据页。
比如要找 id=20 的记录,数据库会在目录页里找 “min_key≤20≤max_key” 的目录项,找到 “17-32,页号 101” 这个项,拿到页号 101,直接找到数据页 101,不用再看其他目录项。
3.2 目录项的大小:极致压缩,让一个目录页能存上千个指引
目录项的设计核心是 “压缩”,因为目录项越小,一个 16KB 的目录页能存的目录项就越多。咱们算一笔账,看看一个目录页能存多少个目录项:
- 主键如果是bigint 类型(8 个字节,最常用的主键类型),min_key 和 max_key 各占 8 字节,合计 16 字节;
- 页号是6 个字节(InnoDB 的固定设计);
- 一个目录项的总大小:16+6=22 字节(实际会有一点点对齐,约 24 字节);
- 一个 16KB 的目录页,扣掉页头和页尾的约 100 字节,剩下约 16284 字节;
- 一个目录页能存的目录项数量:16284/24 ≈ 678 个(如果主键是 int 类型,8 字节,能存约 1170 个)。
这是什么概念?一个目录页能存约 1000 个数据页的指引信息,如果一个数据页存 16 条记录,那一个目录页就能指引16000 条记录—— 找 16000 条记录以内的数据,只需要查一个目录页,就能找到对应的页。
3.3 目录页的核心设计规则:有序 + 轻量化 + 唯一指引,保证目录高效
目录页的设计有三个硬性规则,这三个规则是保证目录能快速指引的关键,缺一不可:
3.4 单级目录页的查询流程:找数据 = 查目录页→找数据页→查数据页
到这里,咱们有了数据页和单级目录页,可以看一下最简单的索引查询流程了,用一个例子讲透:假设:
- 主键是 int 类型,一个目录页能存 1170 个目录项,一个数据页存 16 条记录;
- 有 10000 条记录,被分成 625 个数据页(10000/16),用 1 个目录页就能指引所有数据页(625<1170);
- 要找id=5000的记录。
查询流程就三步,全程只有3 次硬盘读写(3 次 IO):
我的天啊,这实在是太方便啦,不过屏幕前的各位,有没有想起来之前linux中所讲的虚拟内存呢?
而如果没有目录页(没有索引),数据库需要逐页翻 625 个数据页(625 次 IO),才能找到 id=5000 的记录 —— 这就是索引的威力,把硬盘读写次数从 “成千上万次” 降到 “个位数”。
四、多级目录的设计:单级目录不够用,就建 “总目录→分目录→数据页”
单级目录页能解决 “几万、几十万条记录” 的指引问题,但如果数据量到了千万、亿级,单级目录页就不够用了 —— 比如有2200 万条记录,一个数据页存 16 条,需要137.5 万个数据页,一个目录页最多存 1170 个目录项,需要1175 个目录页才能指引所有数据页(1375000/1170≈1175)。
这时候,单级目录页就变成了 “新的数据页”,需要给这些目录页再建一层更高级的目录页—— 这就是多级目录的设计逻辑,就像图书馆的书太多,一个目录本记不下,就建总目录本→分区目录本→正文薄书的层级:
- 总目录本:记着每个分区目录本的范围和位置;
- 分区目录本:记着每个正文薄书的范围和位置;
- 正文薄书:存实际的书内容。
对应到 MySQL 的索引设计,就是顶级目录页→二级目录页→数据页,如果数据量再大(比如百亿级),还能加三级目录页,但实际业务中,三级目录页已经能覆盖所有场景,MySQL 的索引层级几乎不会超过 4 层 —— 因为 4 层目录就能指引1170×1170×1170×16≈260 亿条记录,足够用了。
4.1 多级目录的设计逻辑:目录页也能被 “目录化”,层级复用同一套设计
多级目录的设计逻辑特别简单,因为所有层级的目录页,设计规则都是一样的—— 不管是顶级、二级、三级,目录页的结构、目录项的设计、有序排列的规则,全都一模一样,只是指引的对象不同:
- 顶级目录页:指引的是二级目录页,目录项里存的是 “二级目录页的主键范围 + 二级目录页的页号”;
- 二级目录页:指引的是数据页,目录项里存的是 “数据页的主键范围 + 数据页的页号”;
- 如果有三级目录页:顶级指引二级,二级指引三级,三级指引数据页,以此类推。
简单说:除了最底层的目录页指向数据页,其他所有层级的目录页,都指向下一层的目录页,所有层级的目录页都用同一套设计规则—— 这是 MySQL 索引设计的精妙之处,不用为不同层级设计不同的结构,复用即可,简单高效。
4.2 多级目录的核心数据:3 层目录能指引 2200 万条记录,4 层能指引 260 亿条
咱们再算一笔账,直观感受一下多级目录的威力,还是按int 主键、1 个目录页存 1170 个目录项、1 个数据页存 16 条记录来算:
- 1 层目录(无目录,直接数据页):能存 16 条记录;
- 2 层目录(顶级目录页→数据页):1170×16=18720 条;
- 3 层目录(顶级→二级→数据页):1170×1170×16=2200 万条;
- 4 层目录(顶级→二级→三级→数据页):1170×1170×1170×16=260 亿条。
这就是为什么 MySQL 的索引查询,不管数据量多大,硬盘读写次数(IO)都只有 3-4 次—— 因为索引层级最多 4 层,读顶级目录页(1 次 IO)→读二级目录页(2 次 IO)→读三级目录页(3 次 IO)→读数据页(4 次 IO),全程只有 4 次,和数据量无关。
4.3 多级目录的查询流程:三步变四步,逻辑完全复用
多级目录的查询流程,只是在单级目录的基础上,多了一步 “查上一层目录页”,逻辑完全复用,还是用3 层目录找 id=150000 的记录举例:
全程还是 3 次 IO,和单级目录的查询逻辑完全一样,只是多了一步查顶级目录页 —— 这就是多级目录的设计优势:层级增加,但查询逻辑不变,IO 次数只增加 1 次,效率始终保持最高。
4.4 多级目录的核心设计总结:复用 + 层级可控,亿级数据也能 3 次 IO 定位
多级目录的设计总结就两个点,这两个点让 MySQL 的索引能支撑海量数据:
到这里,咱们讲透了数据页、目录页、多级目录,这是 MySQL 索引设计的基础框架,接下来的聚簇索引、辅助索引、页分裂 / 合并,都是在这个基础框架上的具体落地和动态维护。
五、聚簇索引的设计:MySQL 的 “主索引”,数据页就是索引,索引就是数据页
聚簇索引是 InnoDB 引擎默认的、唯一的主索引,也是最核心的索引,它的设计核心是:聚簇索引的 “数据页” 就是表的实际数据,表的实际数据就是聚簇索引的 “数据页”,聚簇索引不是 “附加到表上的东西”,而是表的物理存储方式。
简单说:聚簇索引和表是同一个东西,建表的时候,聚簇索引就已经建好了,表的所有数据,都按聚簇索引的规则存在数据页里。
聚簇索引的设计,完全基于咱们前面讲的 “数据页 + 多级目录页” 框架,只是有几个硬性的设计规则,这些规则决定了聚簇索引的高效性。
5.1 聚簇索引的 4 个核心设计规则,硬性要求,缺一不可
聚簇索引是 InnoDB 的 “灵魂”,有 4 个硬性的设计规则,所有 InnoDB 表都必须遵守,这是聚簇索引高效的基础:
规则 1:必须以 “主键” 作为聚簇索引的关键字,数据页按主键有序排列
聚簇索引的所有数据页,都必须按主键从小到大有序排列,目录页的所有目录项,也必须按主键范围有序排列 —— 主键是聚簇索引的 “唯一关键字”,没有主键,就无法建聚簇索引,因为数据页没有统一的排序依据。这就是为什么 InnoDB 表必须有主键—— 不是 MySQL 要求的,而是聚簇索引的设计要求,没有主键,数据页就乱堆,目录页就没法建。
规则 2:聚簇索引的目录页,直接指向数据页,没有中间冗余
聚簇索引的多级目录页,最终都是直接指向存实际数据的数据页,目录项里存的是 “数据页的主键范围 + 数据页号”,没有任何中间环节 —— 这是聚簇索引查询最快的原因,因为从顶级目录页到数据页,只有 3-4 次 IO,直接拿到实际数据,不用再做其他操作。
规则 3:聚簇索引是 “唯一的”,一个 InnoDB 表只能有一个聚簇索引
因为聚簇索引就是表的物理存储方式,一个表的物理数据只能按一种顺序存在硬盘上 —— 比如按 id 排序,就不能再按年龄排序,因为数据页的物理位置是固定的,不能既在 id=1-16 的位置,又在年龄 = 25-30 的位置。所以一个 InnoDB 表只能有一个聚簇索引,就是以主键为关键字的那个。
规则 4:如果表没有显式定义主键,InnoDB 会自动生成 “隐藏主键”
如果我们建表时没写主键(比如CREATE TABLE user (name VARCHAR(10), age INT);),InnoDB 不会报错,而是会自动生成一个隐藏的 6 字节自增主键(row_id),作为聚簇索引的关键字,数据页按这个隐藏的 row_id 有序排列。这个隐藏主键对用户不可见,也不能查询,但它的存在,是为了满足聚簇索引的设计要求 —— 保证数据页有统一的排序依据,能建目录页。
5.2 聚簇索引的核心优势:查询最快,没有回表,是所有索引的基础
聚簇索引的设计,决定了它是MySQL 里查询最快的索引,核心优势就一个:目录页直接指向数据页,查询时拿到数据页就能拿到实际数据,没有任何额外操作,这叫 “无回表”。
而且,所有其他索引(辅助索引),最终都要依赖聚簇索引—— 辅助索引的设计,都是基于聚簇索引的主键,这一点咱们后面讲辅助索引时会详细说。
5.3 聚簇索引的主键设计建议:优先用 “自增主键”,别用 uuid
聚簇索引的主键设计,直接影响索引的性能,核心建议是:优先用自增 int/bigint 主键,别用 uuid、随机字符串作为主键—— 原因很简单,就是为了减少数据页的 “页分裂”(后面会讲页分裂)。自增主键的特点是按顺序增长,比如 id=1、2、3、4……,插入数据时,只会往最后一个数据页里插,就算最后一个数据页满了,也只需要分裂最后一个数据页,成本极低;而 uuid 是随机的字符串,插入数据时,uuid 可能落在任意数据页的中间,比如当前数据页存 uuid=A-F,插入一个 uuid=C 的记录,就要把这个数据页拆成两个,成本极高。
这是聚簇索引设计的一个重要细节,记住就行:自增主键是聚簇索引的最优选择。
5.4 聚簇索引的总结:表就是索引,索引就是表,主键是核心
聚簇索引的设计可以用一句话总结:**InnoDB 的表,就是以主键为关键字的聚簇索引,聚簇索引的数据页就是表的物理数据,二者合二为一,主键是聚簇索引的核心,决定了数据的存储顺序和目录的构建规则。
六、辅助索引的设计:MySQL 的 “副索引”,目录页指向 “主键”,需要回表
除了聚簇索引,我们手动创建的所有索引(比如INDEX idx_age (age)、UNIQUE INDEX idx_name (name))都叫辅助索引,也叫二级索引。辅助索引是 “附加到表上的索引”,不是表的物理存储方式,它的设计核心是:有独立的多级目录页和数据页,但数据页里不存实际业务数据,只存 “辅助索引关键字 + 聚簇索引主键”,查询时需要通过主键回查聚簇索引,才能拿到完整数据。
简单说:辅助索引是 “目录的目录”,它的目录最终指向的不是实际数据,而是聚簇索引的主键,必须再通过主键查聚簇索引,才能拿到完整数据 —— 这个 “通过主键查聚簇索引” 的步骤,叫回表。
6.1 辅助索引的核心设计规则:独立目录 + 只存主键,依赖聚簇索引
辅助索引的设计完全基于 “数据页 + 多级目录页” 框架,但有 3 个核心规则,和聚簇索引差异明显:
规则 1:有独立的多级目录页和数据页,与聚簇索引互不干扰
辅助索引和聚簇索引是两套完全独立的 “目录页 + 数据页” 体系 —— 比如 age 索引有自己的顶级目录页、二级目录页、数据页,name 索引也有自己的一套,它们和聚簇索引的目录页、数据页互不影响,增删改数据时,需要同时维护聚簇索引和所有辅助索引的目录页、数据页。
规则 2:辅助索引的数据页,只存 “辅助关键字 + 主键”,不存实际业务数据
这是辅助索引和聚簇索引最核心的区别:聚簇索引的数据页存完整业务数据,而辅助索引的数据页只存两个东西 ——① 辅助索引关键字(比如 age、name);② 聚簇索引的主键(比如 id),而且数据页按 “辅助关键字” 有序排列,目录页的目录项也按 “辅助关键字范围” 排列。
举例:age 辅助索引的数据页里,存的是 “age=25,id=100”“age=25,id=101”“age=26,id=102”……,不是 “age=25,name = 张三,地址 = xxx” 这种完整数据 —— 这样设计的目的是 “轻量化辅助索引”,让辅助索引的数据页、目录页能存更多内容,减少层级。
规则 3:辅助索引必须依赖聚簇索引,查询需回表(覆盖索引除外)
因为辅助索引的数据页不存完整数据,所以用辅助索引查询时,必须走 “查辅助索引→拿主键→查聚簇索引→拿完整数据” 的流程,也就是回表 —— 这是辅助索引比聚簇索引查询慢的原因,IO 次数翻倍(聚簇索引 3 次 IO,辅助索引 6 次 IO)。
6.2 辅助索引的查询流程:比聚簇索引多一步回表,IO 次数翻倍
咱们用 “age 辅助索引查 age=25 的完整记录” 举例,假设是 3 层目录,流程分 5 步,共 6 次 IO:
如果要查多条记录(比如 age=25 有 10 条),回表步骤会重复 10 次,但因为聚簇索引的目录页、数据页可能被缓存(后面讲 Buffer Pool),实际 IO 次数会比 6 次少,但总体还是比聚簇索引慢。
6.3 覆盖索引:辅助索引的 “优化设计”,避免回表,IO 次数减半
覆盖索引是辅助索引的 “最优形态”,设计核心是:让查询需求刚好能通过辅助索引的数据页满足(只需要辅助关键字 + 主键),不用回表查聚簇索引,意思就是说把要查找的字段和筛选条件字段放在同一个辅助索引中,是这样的哦—— 这时候辅助索引的查询 IO 次数,和聚簇索引一样,都是 3 次。
举例:
- 查询需求:SELECT id FROM user WHERE age=25;(只查主键 id,不查其他字段);
- 辅助索引数据页:存的是 “age+id”,刚好满足查询需求;
- 查询流程:读 age 辅助索引的顶级→二级→数据页(3 次 IO),直接拿到 id,不用回表;
- 识别方式:用 EXPLAIN 查询时,Extra 列会显示 “Using index”,表示用到了覆盖索引。
覆盖索引的设计技巧:把查询需要的字段,都加到辅助索引里,比如要查SELECT id, name FROM user WHERE age=25;,可以创建INDEX idx_age_name (age, name),辅助索引的数据页存 “age+name+id”,刚好覆盖查询字段,避免回表。
6.4 复合辅助索引:多字段组合的辅助索引,遵循 “最左匹配”
复合辅助索引是多个字段组合的辅助索引(比如INDEX idx_dept_age (dept, age)),它的设计核心是:数据页按 “最左字段优先” 有序排列,目录页的目录项也按最左字段范围排列—— 这就是 “最左匹配原则” 的底层原因,不是语法要求,是数据页的排序规则决定的。
复合辅助索引的数据页排序规则:
先按第一个字段(dept)有序排列,dept 相同的情况下,再按第二个字段(age)有序排列,比如:“dept=10,age=25,id=100”→“dept=10,age=26,id=101”→“dept=20,age=24,id=102”→“dept=20,age=25,id=103”……
最左匹配原则的底层逻辑:
因为数据页是按 “最左字段” 排序的,所以:
- 查dept=10 AND age=25:能匹配索引,先按 dept 找范围,再按 age 找,高效;
- 查dept=10:能匹配索引,直接按 dept 找范围,高效;
- 查age=25:不能匹配索引,因为数据页里 age 是按 dept 分组排序的,整体无序(dept=10、20、30 都有 age=25),数据库只能全表扫描。
复合辅助索引的设计建议:把查询频率高、区分度高的字段放在最左边,比如 dept 查询频率比 age 高,就把 dept 放前面,保证最左字段能匹配更多查询场景。
6.5 前缀索引:长字符串辅助索引的 “轻量化设计”
如果辅助索引的关键字是长字符串(比如 name 字段,长度 50),直接建索引会有问题:
- 辅助索引的数据页里,每个关键字占 50 字节,能存的记录数大幅减少(比如从 1000 条降到 200 条);
- 目录页的目录项也变大,能存的指引数减少,目录层级增加,IO 次数变多。
前缀索引的设计优化:只取长字符串的前 N 个字符作为辅助关键字,比如INDEX idx_name_prefix (name(20)),只存 name 的前 20 个字符 + 主键,核心是 “在保证区分度的前提下,缩小关键字大小”。
前缀索引的设计要点:
6.6 辅助索引的总结:独立目录 + 依赖聚簇索引,覆盖索引是最优解
辅助索引的设计可以总结为:
- 有独立的多级目录页和数据页,数据页只存 “辅助关键字 + 主键”,轻量化设计;
- 依赖聚簇索引,查询需回表,IO 次数比聚簇索引多一倍;
- 覆盖索引能避免回表,是辅助索引的最优形态;
- 复合索引遵循最左匹配,前缀索引适合长字符串,都是辅助索引的优化手段。
七、索引的动态维护:页分裂与页合并,保证目录和数据页的有序性
前面讲的都是 “静态索引”—— 数据不变时,数据页和目录页的结构。但实际业务中,数据会频繁增删改,这时候索引需要动态调整,核心就是页分裂(插入数据时)和页合并(删除数据时),目的是保证数据页始终有序、目录页的指引始终准确。
7.1 页分裂:数据页满了,拆成两个页,同步更新目录页
当往数据页里插入数据,数据页已经存满(16KB 用完),无法再插入新记录时,就会触发页分裂—— 把当前数据页拆成两个新数据页,重新分配记录,再同步更新目录页的指引,避免数据页无序。
页分裂的完整流程(以聚簇索引、自增主键为例):
假设:数据页 100 存 id=1-16(满了),要插入 id=17 的记录,触发页分裂:
页分裂的核心影响:
- 优点:保证数据页始终有序,目录页指引准确,不影响查询效率;
- 缺点:增加 IO 成本(拆页、移数据、更新目录),还会产生 “页碎片”(数据页里有空闲空间);
- 关键差异:自增主键只会在最后一个数据页触发页分裂,成本低;非自增主键可能在任意数据页触发分裂,成本极高。
7.2 页合并:数据页空闲多了,合并成一个页,同步更新目录页
当数据页里的记录被删除,空闲空间达到阈值(InnoDB 默认约 50%),且相邻的数据页也有空闲空间时,会触发页合并—— 把两个相邻的数据页合并成一个,释放空页,减少碎片,同步更新目录页。
页合并的完整流程:
假设:数据页 100(id=1-8,空闲空间 60%)和数据页 300(id=9-17,空闲空间 55%),都有大量空闲空间,触发页合并:
页合并的核心影响:
- 优点:减少页碎片,让数据页更紧凑,提升查询 IO 效率;
- 缺点:增加删除数据后的 IO 成本(移数据、更新目录),但比页分裂的成本低;
- 触发条件:空闲空间达到阈值 + 相邻页有空闲,不是删除一条记录就触发,避免频繁合并。
7.3 索引碎片:页分裂与合并的 “副作用”,需要定期清理
索引碎片的本质是:页分裂 / 合并后,数据页出现大量空闲空间,目录页仍指向这些不紧凑的数据页,导致查询时需要读取更多的页,IO 效率降低。
索引碎片的两种类型:
索引碎片的清理方法:
7.4 动态维护的总结:页分裂保有序,页合并减碎片,自增主键降成本
索引动态维护的核心逻辑:
- 插入数据触发页分裂,保证数据页始终有序,目录指引准确;
- 删除数据触发页合并,减少碎片,提升 IO 效率;
- 自增主键能大幅降低页分裂的频率和成本,是索引动态维护的 “最优配合”。
八、索引设计的隐形支撑:Buffer Pool 缓存,把磁盘 IO 变成内存 IO
前面讲的所有流程,都默认 “每次读页都要读硬盘”,但实际 MySQL 里有一个 “隐形优化器”——Buffer Pool(缓冲池),它是内存中的一块区域,专门缓存常用的目录页和数据页,把频繁访问的页从硬盘读到内存,后续查询直接读内存,不用再读硬盘,把 “慢磁盘 IO” 变成 “快内存 IO”。
8.1 Buffer Pool 的核心设计:按页缓存,优先缓存目录页
Buffer Pool 的设计很简单,核心就是 “按页缓存”,有三个关键设计:
8.2 Buffer Pool 对索引性能的影响:IO 次数大幅降低
Buffer Pool 是索引性能的 “隐形提升器”,举个例子:
- 第一次查 id=100 的记录:读顶级目录页(硬盘 IO)、二级目录页(硬盘 IO)、数据页(硬盘 IO),共 3 次硬盘 IO,同时把这三个页缓存到 Buffer Pool;
- 第二次查 id=100 的记录:直接从 Buffer Pool 读这三个页,0 次硬盘 IO,纯内存读取,速度提升 10 万倍以上;
- 查 id=101 的记录(和 id=100 在同一个数据页):数据页、目录页都在缓存里,也是 0 次硬盘 IO。
生产环境中,Buffer Pool 的大小通常会调得很大(比如 8G、16G),能缓存大部分高频访问的目录页和数据页,让绝大多数查询都不用读硬盘,只读内存 —— 这是 MySQL 索引能支撑高并发查询的核心原因之一。
8.3 Buffer Pool 的总结:缓存高频页,把磁盘 IO 变成内存 IO,提升并发性能
Buffer Pool 的设计核心:通过缓存高频访问的目录页和数据页,减少硬盘 IO 次数,把索引查询的速度从 “毫秒级” 提升到 “微秒级”,支撑高并发查询。
九、总结:MySQL 索引设计的核心逻辑
把整个索引设计的逻辑串起来,核心就四句话,也是咱们全程讲的重点:
整个索引设计的所有细节,不管是数据页的槽位目录、目录页的极简设计,还是页分裂、覆盖索引,最终目的都只有一个:减少硬盘 IO 次数,让数据库找数据不用翻遍硬盘,而是按目录快速定位,把慢查询变成快查询。
三、索引的本质:B + 树的 “目录” 设计
索引的底层数据结构是B + 树,MySQL 的索引语法本质是 “构建 B + 树”“维护 B + 树”“使用 B + 树查询” 的接口。我们先拆解 B + 树的结构,再关联语法。
3.1 B + 树的结构
3.1.1 B + 树的四层结构(从根到叶)
| 第 1 层 | 根节点 | 子节点的 “键值范围 + 指针” | 书目总目录(“A-F 类在 1 楼,G-Z 类在 2 楼”) | 索引根页 | 1 个 |
| 第 2 层 | 非叶子节点 | 叶子节点的 “键值范围 + 指针” | 楼层目录(“A 类在 1 楼 1 区,B 类在 1 楼 2 区”) | 索引中间页 | 约 1000 个 |
| 第 3 层 | 叶子节点 | 索引字段值 + 数据 Page 指针(MyISAM)/ 主键值(InnoDB) | 书架目录(“A001 号书在 1 楼 1 区 1 架 1 层”) | 索引叶子页 | 约 8000 个 |
| 数据层 | 数据页 | 完整数据记录 | 实际的书籍内容 | 数据页 | 约 80000 个 |
3.1.2 B + 树的核心特性(语法关联)
— 语法验证:主键索引有序
CREATE TABLE user (id INT PRIMARY KEY, name VARCHAR(16));
INSERT INTO user VALUES(3, '张三'), (1, '李四'), (2, '王五');
SELECT * FROM user; — 结果按id升序:1(李四)、2(王五)、3(张三)
— 底层原因:B+树的叶子节点按id有序排列,查询时直接遍历叶子节点,无需排序。
— 语法验证:范围查询高效
SELECT * FROM user WHERE id BETWEEN 1 AND 3; — 仅需遍历叶子节点链表,无需回查上层节点
— 底层原因:叶子节点链表可直接从id=1遍历到id=3,无需重复查找根节点/非叶子节点。
- 对比 B 树:B 树的非叶子节点存数据,导致单个节点能存的键值少→树高更高→IO 次数更多(B 树查 800 万数据需 5 次 IO,B + 树仅 3 次)。
3.1.3 主键索引的 B + 树构建(语法 + 底层拆解)
CREATE TABLE user (
id INT PRIMARY KEY, — 主键字段,自动创建聚簇索引
age INT NOT NULL,
name VARCHAR(16) NOT NULL
);
底层构建过程(逐步拆解):
3.2 为什么选择 B + 树?
其他数据结构因不适合 MySQL 的 IO 场景,未被主流存储引擎采用,对应语法支持情况:
| 链表 | 无专门语法支持 | 线性遍历,查询效率 O (n),无法应对海量数据 | 需 800 万次 IO |
| 二叉搜索树 | 无专门语法支持 | 易退化,有序插入时退化为链表,语法无法保证平衡 | 最坏需 800 万次 IO |
| AVL 树 / 红黑树 | 无专门语法支持 | 树高过高(二叉结构,800 万数据树高≈23),语法无法减少 IO 次数 | 需 23 次 IO |
| Hash 表 | MEMORY 引擎支持(CREATE INDEX 索引名 ON 表名 (字段名) USING HASH) | 不支持范围查询(如 WHERE id BETWEEN 1 AND 100),语法无法优化范围查询效率 | 等值查询 1 次 IO,范围查询 800 万次 IO |
| B 树 | 无专门语法支持(InnoDB/MyISAM 默认 B + 树) | 节点存储数据,单个节点键值少(树高≈4),语法无法降低 IO 次数 | 需 4 次 IO |
| B + 树 | 所有引擎默认支持(CREATE INDEX 语法) | 无明显缺陷,适配磁盘 IO 特性 | 需 3 次 IO |
语法验证:MEMORY 引擎的 Hash 索引(补充失效场景)
CREATE TABLE hash_test (
id INT,
name VARCHAR(20),
INDEX idx_id (id) USING HASH — 明确指定Hash索引
) ENGINE=MEMORY;
INSERT INTO hash_test VALUES(1, '张三'), (2, '李四'), (3, '王五');
— 支持等值查询(Hash索引高效)
EXPLAIN SELECT * FROM hash_test WHERE id=2;
— 执行计划:type=ref,rows=1(仅扫描1行),Extra=Using index(使用Hash索引)
— 不支持范围查询(Hash索引失效)
EXPLAIN SELECT * FROM hash_test WHERE id BETWEEN 1 AND 3;
— 执行计划:type=ALL,rows=3(全表扫描),Extra=(无索引标识)
— 底层原因:Hash索引是通过id计算Hash值(如id=2→Hash=100),无法通过Hash值判断范围,只能全表扫描。
3.3 核心语法总结(B + 树关联)
| 创建聚簇索引 | CREATE TABLE 表名 (字段 INT PRIMARY KEY); 或 ALTER TABLE 表名 ADD PRIMARY KEY (字段); | 主键字段为 B + 树键值,叶子节点存储完整数据;树高≈3 层,查询需 3 次 IO | IO 次数:3 次 |
| 创建辅助索引 | CREATE INDEX 索引名 ON 表名 (字段); 或 ALTER TABLE 表名 ADD INDEX 索引名 (字段); | 字段为 B + 树键值,叶子节点存储主键值;查询需先查辅助索引(3 次 IO),再查聚簇索引(3 次 IO) | IO 次数:6 次 |
| Hash 索引创建 | CREATE INDEX 索引名 ON 表名 (字段) USING HASH; | 仅 MEMORY 引擎支持,B + 树结构替换为 Hash 表;等值查询 1 次 IO,范围查询全表扫描 | IO 次数:1 次(等值)/N 次(范围) |
四、MySQL 索引的分类与语法详解
MySQL 索引按功能分为主键索引、唯一索引、普通索引、全文索引,按存储结构分为聚簇索引、非聚簇索引。
4.1 主键索引(Primary Key)—— 效率最高的索引
主键索引是 MySQL 自动为 “主键字段” 创建的聚簇索引,语法核心是 “PRIMARY KEY” 约束。
4.1.1 语法结构(三种创建方式 + 底层细节)
方式 1:创建表时直接指定主键(最常用)
CREATE TABLE user1 (
id INT(11) NOT NULL PRIMARY KEY, — 字段后直接加PRIMARY KEY
name VARCHAR(30) NOT NULL
);
底层细节:
- INT(11):显示宽度 11 位(仅用于展示,底层存储 4 字节),主键字段建议用 INT(占用空间小,排序快);
- NOT NULL:PRIMARY KEY 隐含 NOT NULL 约束(即使省略,MySQL 也会自动添加);
- 索引名固定为 “PRIMARY”(无法自定义,可通过SHOW KEYS FROM user1验证);
- 聚簇索引的叶子节点存储完整数据,查询时无需回表(IO 次数最少)。
方式 2:创建表时在末尾指定主键(适合复合主键)
CREATE TABLE user2 (
id INT(11) NOT NULL,
dept_id INT(11) NOT NULL,
name VARCHAR(30) NOT NULL,
PRIMARY KEY(id, dept_id) — 复合主键:id+dept_id联合作为主键
);
底层细节:
- 复合主键的 B + 树键值是 “id+dept_id”(拼接后排序),如 (1,10)、(1,20)、(2,10);
- 唯一性校验:仅当 id 和 dept_id 都相同时,才判定为重复(如 (1,10) 和 (1,10) 重复,(1,10) 和 (1,20) 不重复);
- 排序规则:先按 id 升序,id 相同时按 dept_id 升序(语法上体现为SELECT * FROM user2结果按此顺序)。
方式 3:创建表后添加主键(表已存在时使用)
CREATE TABLE user3 (
id INT(11) NOT NULL,
name VARCHAR(30) NOT NULL
);
ALTER TABLE user3 ADD PRIMARY KEY(id);
底层注意事项:
- 前提:表中 id 字段必须 “非空且唯一”(否则报错),MySQL 会先全表扫描校验;
- 性能:若表中已有海量数据(如 800 万),添加主键耗时约 1 分钟(需构建聚簇索引 B + 树);
- 数据重排:InnoDB 会按 id 字段重新排序数据页(聚簇索引的特性),可能导致 “页分裂”,建议创建表时直接指定主键(避免后期重排)。
4.1.2 主键索引的核心特性
INSERT INTO user1 VALUES(1, '张三');
INSERT INTO user1 VALUES(1, '李四'); — 报错:Duplicate entry '1' for key 'PRIMARY'
— 底层原因:插入时遍历B+树,发现id=1已存在,触发唯一性约束,终止插入。
INSERT INTO user1 VALUES(NULL, '王五'); — 报错:Column 'id' cannot be null
ALTER TABLE user1 ADD PRIMARY KEY(name); — 报错:Multiple primary key defined
CREATE TABLE user4 (
id INT(11) NOT NULL AUTO_INCREMENT PRIMARY KEY, — 自增主键
name VARCHAR(30) NOT NULL
);
底层优化点:
- 自增主键按顺序插入,避免 B + 树的 “页分裂”(无序插入会导致页分裂,增加 IO);
- 自增主键的下一个值存储在 InnoDB 的系统表中,插入时直接获取,无需计算;
- 语法上可省略 id 字段插入:INSERT INTO user4(name) VALUES('张三'),id 自动为 1。
4.1.3 常见错误与解决方案
| Duplicate entry 'xxx' for key 'PRIMARY' | 插入重复的主键值 | B + 树键值唯一性校验失败 | 确保主键值唯一;使用自增主键(AUTO_INCREMENT);删除重复数据:DELETE FROM 表名 WHERE id=xxx; |
| Column 'xxx' cannot be null | 主键字段插入 NULL 值 | B + 树无法存储 NULL 键值(NULL 无排序意义) | 为主键字段添加 NOT NULL 约束;插入时传入非空值;若需空值,用 0 / 空字符串替代 |
| Multiple primary key defined | 重复创建主键索引 | 聚簇索引只能有一个(数据只能按一种顺序存储) | 删除已存在的主键:ALTER TABLE 表名 DROP PRIMARY KEY; 再创建新主键 |
| Incorrect table definition; there can be only one auto column | 多个字段设置 AUTO_INCREMENT | 自增属性依赖主键,一个表只能有一个自增序列(存储在系统表中) | 仅为主键字段设置 AUTO_INCREMENT;若需多个自增字段,用触发器实现 |
4.2 唯一索引(Unique Index)—— 保证字段唯一性
唯一索引用于保证字段值唯一(允许 NULL 值,且可多个 NULL),语法核心是 “UNIQUE” 关键字,其实我们在之前也有讲到过,不就是唯一键吗。
4.2.1 语法结构(三种创建方式 + 底层细节)
方式 1:创建表时直接指定唯一索引
CREATE TABLE user4 (
id INT(11) NOT NULL AUTO_INCREMENT PRIMARY KEY,
phone VARCHAR(11) NOT NULL UNIQUE, — 手机号:非空+唯一
email VARCHAR(30) UNIQUE — 邮箱:允许NULL+唯一
);
底层细节:
- 唯一索引的 B + 树结构与主键索引类似,但叶子节点存储 “唯一字段值 + 主键值”(辅助索引特性);
- phone 字段非空 + 唯一:功能接近主键,但一个表可多个(如同时给 phone 和 email 加唯一索引);
- email 字段允许 NULL:NULL 不参与唯一性校验(多个 NULL 不冲突),底层是 B + 树将 NULL 视为 “最小键值”,存储在叶子节点最左侧。
方式 2:创建表时在末尾指定唯一索引(复合唯一索引)
CREATE TABLE user5 (
id INT(11) NOT NULL AUTO_INCREMENT PRIMARY KEY,
dept_id INT(11) NOT NULL,
emp_no VARCHAR(20) NOT NULL,
UNIQUE idx_dept_emp(dept_id, emp_no) — 复合唯一索引
);
底层验证:
INSERT INTO user5(dept_id, emp_no) VALUES(1, '001');
INSERT INTO user5(dept_id, emp_no) VALUES(1, '001'); — 报错:Duplicate entry '1-001' for key 'idx_dept_emp'
— 底层原因:复合唯一索引的键值是“dept_id+emp_no”,拼接后(1,001)已存在,触发唯一性校验。
方式 3:创建表后添加唯一索引(两种语法对比)
— 语法1:ALTER TABLE(修改表结构)
CREATE TABLE user6 (
id INT(11) NOT NULL AUTO_INCREMENT PRIMARY KEY,
id_card VARCHAR(18)
);
ALTER TABLE user6 ADD UNIQUE idx_id_card(id_card);
— 语法2:CREATE UNIQUE INDEX(直接创建索引)
CREATE UNIQUE INDEX idx_id_card ON user6(id_card);
底层区别:
- ALTER TABLE:会触发表锁(MyISAM)或元数据锁(InnoDB),锁表期间无法写操作;
- CREATE UNIQUE INDEX:InnoDB 中是 “在线 DDL”(MySQL 5.6+),锁表时间极短(仅修改元数据);
- 功能完全一致,建议优先用CREATE UNIQUE INDEX(减少锁表时间)。
4.2.2 唯一索引的核心特性
| 数量限制 | 一个表可多个 | 一个表只能一个 | 唯一索引是辅助索引(多个不影响数据存储顺序);主键索引是聚簇索引(数据只能按一种顺序存储) |
| NULL 值允许 | 允许(多个 NULL) | 不允许 | 唯一索引的 B + 树将 NULL 视为 “最小键值”;主键索引的 B + 树无法存储 NULL |
| 索引类型 | 辅助索引(非聚簇) | 聚簇索引 | 唯一索引叶子节点存主键值;主键索引叶子节点存完整数据 |
| 自增支持 | 不支持 | 支持 | 自增属性依赖聚簇索引,辅助索引无数据存储控制权 |
4.2.3 常见错误与解决方案
| Duplicate entry 'xxx' for key 'xxx' | 插入重复的唯一索引值 | 唯一索引 B + 树键值唯一性校验失败 | 1. 查找重复数据:SELECT 字段,COUNT () FROM 表名 GROUP BY 字段 HAVING COUNT () > 1; 2. 删除重复数据:DELETE FROM 表名 WHERE id NOT IN (SELECT MIN (id) FROM 表名 GROUP BY 字段); 3. 重新创建索引 |
| Can't write; duplicate key in table 'xxx' | 创建唯一索引时,表中已存在重复数据 | 构建唯一索引 B + 树时,发现重复键值,终止构建 | 先清理重复数据,再创建索引;若需保留重复数据,改用普通索引 |
| Index name 'xxx' already exists | 索引名已存在 | MySQL 的索引名在表内唯一,重复创建会触发元数据校验失败 | 1. 查看已有索引:SHOW KEYS FROM 表名;2. 删除旧索引:DROP INDEX 索引名 ON 表名;3. 创建新索引 |
4.3 普通索引(Index)—— 最常用的索引
普通索引是无唯一性约束的辅助索引,适用于频繁作为查询条件的字段(如姓名、部门编号),语法核心是 “INDEX” 关键字,这个是我们要学习的重点所在。
4.3.1 语法结构
方式 1:创建表时指定普通索引
CREATE TABLE user8 (
id INT(11) NOT NULL AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(20) NOT NULL,
email VARCHAR(30) NOT NULL,
age INT(3) NOT NULL,
INDEX idx_name(name), — 单字段普通索引
INDEX idx_email_age(email, age) — 复合普通索引
);
底层细节:
- 普通索引的 B + 树无唯一性校验,键值可重复(如 name=' 张三 ' 可出现多次);
- 复合索引的键值是 “email+age”,排序规则:先按 email 升序,email 相同时按 age 升序;
- 索引名省略时,MySQL 默认命名规则:单字段→字段名,复合字段→字段 1_字段 2(如 email_age)。
方式 2:创建表后添加普通索引(ALTER TABLE)
CREATE TABLE user9 (
id INT(11) NOT NULL AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(20) NOT NULL,
email VARCHAR(30) NOT NULL,
age INT(3) NOT NULL
);
ALTER TABLE user9 ADD INDEX idx_name(name); — 单字段
ALTER TABLE user9 ADD INDEX idx_email_age(email, age); — 复合字段
方式 3:创建表后添加普通索引(CREATE INDEX)
格式其实也很简单,无非就是 create index 索引名 on 表名(字段名)
CREATE INDEX idx_name ON user9(name); — 单字段
CREATE INDEX idx_email_age ON user9(email, age); — 复合字段
语法对比:
| ALTER TABLE | 在线 DDL(短时间锁表) | 同时修改表结构(如添加字段 + 创建索引) | 一次元数据修改,效率高 |
| CREATE INDEX | 在线 DDL(几乎无锁) | 仅创建索引 | 专注索引构建,锁表时间更短,建议优先使用 |
4.3.2 复合普通索引的 “最左匹配原则”
复合普通索引的查询效率取决于 “查询条件是否匹配索引的最左前缀”,这是普通索引语法中最重要的优化点。
语法示例:创建复合索引
CREATE INDEX idx_name_age ON user8(name, age); — 字段顺序:name在前,age在后
最左匹配原则的底层逻辑(用 “字典” 类比)
复合索引idx_name_age的 B + 树键值是 “name+age”,相当于 “字典先按姓名排序,姓名相同再按年龄排序”:
- 查 “name = 张三”→ 相当于 “查字典中所有姓张的字”,能直接定位(用索引);
- 查 “name = 张三 AND age=18”→ 相当于 “查字典中姓张且页码为 18 的字”,能精准定位(用索引);
- 查 “age=18”→ 相当于 “查字典中所有页码为 18 的字”,无法通过姓名目录定位(不用索引)。
语法验证:哪些查询能用到索引?
| SELECT * FROM user8 WHERE name=' 张三 '; | 是(全索引) | type=ref,key=idx_name_age,rows=10 | 3 次 |
| SELECT * FROM user8 WHERE name=' 张三 ' AND age=18; | 是(全索引) | type=ref,key=idx_name_age,rows=1 | 3 次 |
| SELECT * FROM user8 WHERE age=18; | 否(全表扫描) | type=ALL,key=NULL,rows=8000000 | 8000 次 |
| SELECT * FROM user8 WHERE name=' 张三 ' AND age>18; | 是(部分索引) | type=range,key=idx_name_age,rows=5 | 3 次 |
| SELECT * FROM user8 WHERE name LIKE ' 张 %'; | 是(部分索引) | type=range,key=idx_name_age,rows=100 | 3 次 |
| SELECT * FROM user8 WHERE name LIKE '% 三 '; | 否(全表扫描) | type=ALL,key=NULL,rows=8000000 | 8000 次 |
语法优化建议
- 查询频率高的字段放最左(如 deptno 比 gender 查询频繁,deptno 放前);
- 区分度高的字段放最左(如 name 区分度 90%,age 区分度 10%,name 放前);
- 示例:idx_deptno_gender(deptno 在前,gender 在后)。
- 仅查 age 时,不要依赖idx_name_age,单独创建idx_age:
CREATE INDEX idx_age ON user8(age);
SELECT * FROM user8 WHERE age=18; — IO次数从8000次→3次
- 仅支持 “% 在末尾”(如name LIKE '张%'),避免 “% 在开头”(如name LIKE '%三');
- 若需后缀模糊查询,可反向存储字段(如 name=' 张三 '→reverse_name=' 三张 '),再创建索引:
ALTER TABLE user8 ADD COLUMN reverse_name VARCHAR(20) AFTER name;
UPDATE user8 SET reverse_name = REVERSE(name);
CREATE INDEX idx_reverse_name ON user8(reverse_name);
SELECT * FROM user8 WHERE reverse_name LIKE '三%'; — 用到索引
4.3.3 常见错误与解决方案
| Index column size too large | 索引字段长度过大(如 VARCHAR (2000)) | 索引页存储的键值过长,单个 Page 能存的键值少→树高增加→IO 次数增多 | 前缀索引:CREATE INDEX idx_name ON 表名 (name (20));(索引前 20 个字符,减少键值长度) |
| Too many keys specified; max 64 keys allowed | 表中索引数量超过 64 个 | MySQL 限制单表索引数≤64(元数据存储限制) | 删除无用索引:DROP INDEX 索引名 ON 表名;合并复合索引(如 idx_name+idx_age→idx_name_age) |
| Column 'xxx' not found | 创建索引时字段不存在 | 元数据校验失败(字段名拼写错误 / 字段已删除) | 1. 查看表结构:DESC 表名;2. 修正字段名;3. 重新创建索引 |
4.4 全文索引(Fulltext Index)—— 大文本关键词检索
全文索引用于对大文本字段(如文章内容、评论)进行关键词检索,语法核心是 “FULLTEXT” 关键字,需注意存储引擎限制。
4.4.1 语法结构(创建 + 查询 + 底层细节)
方式 1:创建表时指定全文索引
CREATE TABLE articles (
id INT UNSIGNED AUTO_INCREMENT NOT NULL PRIMARY KEY,
title VARCHAR(200) NOT NULL,
body TEXT NOT NULL,
FULLTEXT idx_title_body(title, body) — 全文索引
) ENGINE=MyISAM DEFAULT CHARSET=utf8;
底层细节:
- 支持字段类型:CHAR、VARCHAR、TEXT(仅文本类型),INT/DATE 等不支持;
- MyISAM vs InnoDB:
- MyISAM:支持全文索引,支持中文(需配置分词),效率高;
- InnoDB(5.6+):支持全文索引,但不支持中文分词(需第三方插件如 CoreSeek);
- 全文索引的底层结构:不是 B + 树,而是 “倒排索引”(关键词→文档 ID 列表)。
方式 2:创建表后添加全文索引
CREATE TABLE articles2 (
id INT UNSIGNED AUTO_INCREMENT NOT NULL PRIMARY KEY,
title VARCHAR(200) NOT NULL,
body TEXT NOT NULL
) ENGINE=MyISAM;
ALTER TABLE articles2 ADD FULLTEXT idx_title_body(title, body);
全文索引查询语法(核心:MATCH () AGAINST ())
— 示例1:自然语言模式(默认,无运算符)
SELECT * FROM articles WHERE MATCH(title, body) AGAINST('database');
— 底层:计算关键词与文档的“相关性得分”,按得分排序(得分越高,匹配度越高)。
— 示例2:布尔模式(支持运算符)
SELECT * FROM articles WHERE MATCH(title, body) AGAINST('+database -mysql' IN BOOLEAN MODE);
— 运算符解析:
— +database:必须包含database;
— -mysql:必须排除mysql;
— >database:提升database的相关性得分;
— <mysql:降低mysql的相关性得分;
— database*:匹配database、databases等前缀。
4.4.2 全文索引的核心特性(语法 + 底层限制)
— 查看MyISAM关键词长度限制
SHOW GLOBAL VARIABLES LIKE 'ft_min_word_len'; — 默认4(忽略<4的关键词,如a、an)
— 查看InnoDB关键词长度限制
SHOW GLOBAL VARIABLES LIKE 'innodb_ft_min_token_size'; — 默认3
修改方法:
- 编辑 my.cnf:ft_min_word_len=2(MyISAM)、innodb_ft_min_token_size=2(InnoDB);
- 重启 MySQL;
- 重建全文索引(修改配置后需重新构建):
ALTER TABLE articles DROP INDEX idx_title_body;
ALTER TABLE articles ADD FULLTEXT idx_title_body(title, body);
- MySQL 内置停止词列表(如 the、a、is、的、了等无意义词汇),检索时自动忽略;
- 自定义停止词列表:
- 创建停止词文件:/usr/local/mysql/stopwords.txt(每行一个停止词);
- 编辑 my.cnf:ft_stopword_file=/usr/local/mysql/stopwords.txt;
- 重启 MySQL,重建索引。
- MyISAM+CoreSeek(中文分词插件):
- 安装 CoreSeek;
- 配置分词规则;
- 使用MATCH() AGAINST()查询中文关键词。
4.4.3 实战对比:全文索引 vs like 查询
| 全文索引 | 10 万篇 | 0.05 秒 | 10 次 | 5% | 倒排索引直接定位关键词对应的文档 ID,无需扫描全文 |
| like 查询 | 10 万篇 | 5 秒 | 1000 次 | 80% | 全表扫描,逐行匹配 % database%,需读取所有文本内容 |
4.4.4 常见错误与解决方案
| Fulltext index is not supported on table | 存储引擎不支持(如 InnoDB 5.5 及以下) | 元数据校验失败(存储引擎不兼容) | 1. 升级 MySQL 到 5.6+;2. 更换为 MyISAM 引擎;3. 使用第三方全文检索工具(如 Elasticsearch) |
| Column 'xxx' cannot be part of a FULLTEXT index | 字段类型不支持(如 INT) | 全文索引仅支持文本类型,INT 无分词意义 | 转换字段类型:ALTER TABLE 表名 MODIFY 字段名 VARCHAR (20); |
| Can't find FULLTEXT index matching the column list | MATCH () 字段与索引字段不一致 | 倒排索引的关键词列表与查询字段不匹配 | 确保 MATCH () 中的字段与创建索引时的字段完全一致(如 MATCH (title, body) 对应 idx_title_body) |
| 中文检索无结果 | 未配置中文分词 | MySQL 默认按空格 / 标点分词,中文无分隔符→无法识别关键词 | 安装 CoreSeek 插件;改用 Elasticsearch;手动添加中文分词符(如 “数据库 索引”) |
4.5 索引查询与删除语法
创建索引后,需掌握 “查询索引信息” 和 “删除无用索引” 的语法,这是索引维护的关键。
4.5.1 查询索引信息
语法 1:SHOW KEYS FROM 表名 \\G;
SHOW KEYS FROM user8 \\G;
核心字段解读(底层意义):
| Table | user8 | 索引所属表名 |
| Non_unique | 1 | 0 = 唯一索引(主键 / 唯一索引),1 = 非唯一索引(普通 / 全文索引) |
| Key_name | idx_name_age | 索引名(主键索引固定为 PRIMARY) |
| Seq_in_index | 1 | 字段在复合索引中的位置(1 = 第一个字段,2 = 第二个字段) |
| Column_name | name | 索引对应的字段名 |
| Collation | A | 排序方式(A = 升序,NULL = 无排序) |
| Cardinality | 8000000 | 索引基数(估算的唯一值数量),基数≈数据量→索引效率高,基数小→索引效率低 |
| Sub_part | NULL | 前缀索引的长度(如 name (20)→20) |
| Packed | NULL | 是否压缩索引键值(NULL = 未压缩,COMPRESSED = 压缩) |
| Null | 字段是否允许 NULL(空 = 不允许,YES = 允许) | |
| Index_type | BTREE | 索引类型(BTREE=B + 树,HASH = 哈希,FULLTEXT = 全文索引) |
| Comment | 索引注释(可通过 COMMENT 添加:CREATE INDEX idx_name ON 表名 (name) COMMENT ' 姓名索引 ';) |
语法 2:SHOW INDEX FROM 表名;(与 SHOW KEYS 等价)
SHOW INDEX FROM user8; — 横向显示,适合批量查看多个索引
语法 3:DESC 表名;(最简略)
DESC user8;
Key 列解读:
- PRI:主键索引;
- UNI:唯一索引;
- MUL:普通 / 全文索引(非唯一);
- 空:无索引。
4.5.2 删除索引语法
语法 1:删除主键索引
ALTER TABLE user3 DROP PRIMARY KEY;
底层注意事项:
- 主键索引删除后,InnoDB 会创建隐藏的rowid字段作为聚簇索引(rowid是 6 字节自增整数);
- 若主键字段有AUTO_INCREMENT,需先取消自增:
ALTER TABLE user4 MODIFY id INT(11) NOT NULL; — 取消自增
ALTER TABLE user4 DROP PRIMARY KEY; — 删除主键 - 主键删除后,数据会按rowid重新排序(触发页分裂,耗时较长)。
语法 2:删除唯一索引 / 普通索引 / 全文索引
格式也比较简单,我个人是比较推荐使用drop index 索引改名字 on 表名;
— 语法1:ALTER TABLE
ALTER TABLE user6 DROP INDEX idx_id_card;
— 语法2:DROP INDEX(推荐)
DROP INDEX idx_name ON user8;
底层注意事项:
- 删除索引会释放索引页的存储空间(可通过SHOW TABLE STATUS LIKE 'user8'查看数据 / 索引大小);
- 在线 DDL 删除索引:InnoDB 5.6 + 支持 “在线删除”,锁表时间极短(仅修改元数据);
- 复合索引删除:只需指定索引名,无需指定字段(如DROP INDEX idx_email_age ON user8)。
4.5.3 常见错误与解决方案
| Can't drop index 'PRIMARY': needed in a foreign key constraint | 主键被外键引用 | 外键约束依赖主键,删除主键会导致外键失效 | 1. 查看外键:SELECT * FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE WHERE REFERENCED_TABLE_NAME=' 表名 '; 2. 删除外键:ALTER TABLE 子表名 DROP FOREIGN KEY 外键名;3. 删除主键 |
| Unknown key name 'xxx' | 索引名不存在 | 元数据中无该索引名(拼写错误 / 已删除) | 1. 查看所有索引:SHOW KEYS FROM 表名;2. 修正索引名;3. 重新删除 |
| Error on rename of './dbname/table.frm' to './dbname/#sql2-xxx.frm' | 表被锁定 | 其他会话持有表的写锁 / 元数据锁,无法修改表结构 | 1. 查看锁:SHOW PROCESSLIST; 2. 杀死锁定进程:KILL 进程 ID; 3. 重新删除索引 |
五、聚簇索引与非聚簇索引
聚簇索引和非聚簇索引的核心区别是 “索引与数据的存储位置关系”,语法上通过 “存储引擎” 和 “索引类型” 决定。
5.1 聚簇索引(InnoDB 主键索引)—— 数据即索引
5.1.1 语法关联:InnoDB 表的主键索引
CREATE TABLE itest (
id INT PRIMARY KEY,
name VARCHAR(11) NOT NULL
) ENGINE=InnoDB;
底层存储结构(可视化拆解):
- 根节点 / 非叶子节点:存储 “主键值 + 子节点指针”;
- 叶子节点:存储完整数据行(id+name),且按主键有序排列;
5.1.2 聚簇索引的查询流程(用 “文件柜” 类比)
SELECT * FROM itest WHERE id=1;
查询流程:
5.2 非聚簇索引(MyISAM 所有索引 + InnoDB 辅助索引)
5.2.1 语法关联:MyISAM 表的索引
CREATE TABLE mtest (
id INT PRIMARY KEY,
name VARCHAR(11) NOT NULL
) ENGINE=MyISAM;
底层存储结构:
- mtest.frm:表结构文件(元数据);
- mtest.MYD:数据文件(存储无序的完整数据);
- mtest.MYI:索引文件(存储所有索引的 B + 树);
- 叶子节点存储 “主键值 + 数据文件中的物理地址”(如 id=1→地址 0x123456);
- 查询时需先查索引(3 次 IO),再根据地址读数据文件(1 次 IO),总 IO 次数:4 次。
5.2.2 语法关联:InnoDB 的辅助索引(非聚簇)
CREATE INDEX idx_name ON itest(name); — 普通索引(辅助索引)
底层结构 + 查询流程:
- 叶子节点存储 “name 值 + 主键 id”(如 name=' 张三 '→id=1);
- 非叶子节点存储 “name 值 + 子节点指针”;
SELECT * FROM itest WHERE name='张三';
- 步骤 1:遍历辅助索引 B + 树,找到 name=' 张三 ' 对应的主键 id=1(3 次 IO);
- 步骤 2:遍历聚簇索引 B + 树,找到 id=1 对应的完整数据(3 次 IO);
- 总 IO 次数:6 次(比聚簇索引多 3 次);
EXPLAIN SELECT * FROM itest WHERE name='张三';
— Extra列:Using index condition(表示触发回表)
5.3 核心区别
| 语法触发 | InnoDB 表 + PRIMARY KEY 约束 | MyISAM 表 + 任何索引类型;InnoDB 表 + 非主键索引 |
| 存储文件 | 索引和数据在同一文件(.ibd) | MyISAM:.MYI+.MYD;InnoDB 辅助索引:.ibd 中独立 B + 树 |
| 叶子节点存储 | 完整数据记录 | MyISAM:数据物理地址;InnoDB 辅助索引:主键值 |
| 查询 IO 次数 | 3 次(无需回表) | MyISAM:4 次;InnoDB 辅助索引:6 次(需回表) |
| 数据有序性 | 数据按主键有序存储 | 数据无序存储,索引有序 |
| 页分裂影响 | 数据页分裂,影响性能 | 索引页分裂,不影响数据页 |
| 语法限制 | 一个表只能有一个 | 一个表可以有多个 |
5.4 语法优化:减少回表
回表是 InnoDB 辅助索引的性能瓶颈,可通过 “覆盖索引” 避免:
— 需求:查询name='张三'的id和name(无需其他字段)
— 步骤1:创建覆盖索引(包含查询所需的所有字段)
CREATE INDEX idx_name_cover ON itest(name, id);
— 步骤2:查询(仅需扫描索引,无需回表)
SELECT id, name FROM itest WHERE name='张三';
— 执行计划:Extra=Using index(覆盖索引,无回表)
— 底层IO次数:3次(与聚簇索引相同)
六、索引创建原则与语法优化
掌握索引语法后,需遵循 “合理创建、避免滥用” 的原则,否则会导致写操作效率下降。
6.1 索引创建原则
原则 1:频繁作为查询条件的字段 —— 创建索引
— 场景:员工表频繁按deptno查询
ALTER TABLE EMP ADD INDEX idx_deptno(deptno);
— 底层验证:
EXPLAIN SELECT * FROM EMP WHERE deptno=10;
— 执行计划:type=ref,key=idx_deptno,rows=1000(IO次数从8000次→3次)
原则 2:唯一性太差的字段 —— 不适合单独创建索引
— 场景:gender字段(仅男/女,唯一性差)
— 不推荐:单独创建索引(索引基数小,IO收益<开销)
ALTER TABLE EMP ADD INDEX idx_gender(gender); — 索引基数=2,查询仍需扫描50%数据
— 推荐:复合索引后缀
ALTER TABLE EMP ADD INDEX idx_deptno_gender(deptno, gender);
— 底层验证:
EXPLAIN SELECT * FROM EMP WHERE deptno=10 AND gender='男';
— 执行计划:type=ref,key=idx_deptno_gender,rows=500(IO次数=3次)
原则 3:更新频繁的字段 —— 不适合创建索
— 场景:sal字段(频繁调薪)
— 不推荐:创建索引(更新时需维护B+树,增加IO)
ALTER TABLE EMP ADD INDEX idx_sal(sal);
— 底层代价:
— 1. 更新sal时,需修改数据页+索引页(2次IO);
— 2. 若sal在复合索引中,需修改所有包含sal的索引页(多次IO)。
— 替代方案:
— 1. 无索引,查询时用分区表(按sal范围分区);
— 2. 离线更新索引(低峰期创建,高峰期删除)。
原则 4:不会出现在 WHERE 子句的字段 —— 不创建索引
— 场景:remark字段(仅展示,不查询)
— 不推荐:创建索引(浪费存储空间,无查询收益)
ALTER TABLE EMP ADD INDEX idx_remark(remark);
— 底层代价:
— 1. 索引占用存储空间(remark字段VARCHAR(200),索引页占用大);
— 2. 插入/更新remark时,需维护索引(增加IO)。
原则 5:复合索引字段顺序 ——“查询频繁的在前,区分度高的在前”
— 场景:频繁查询deptno+name(deptno查询频率80%,name区分度90%)
— 推荐:idx_deptno_name(deptno在前,name在后)
CREATE INDEX idx_deptno_name ON EMP(deptno, name);
— 底层验证:
EXPLAIN SELECT * FROM EMP WHERE deptno=10; — 用到索引(最左匹配)
EXPLAIN SELECT * FROM EMP WHERE deptno=10 AND name='张三'; — 用到索引(全匹配)
— 不推荐:idx_name_deptno(name在前,deptno在后)
CREATE INDEX idx_name_deptno ON EMP(name, deptno);
— 底层验证:
EXPLAIN SELECT * FROM EMP WHERE deptno=10; — 未用到索引(违反最左匹配)
6.2 索引语法优化技巧
技巧 1:前缀索引 —— 优化长字符串字段
— 场景:email字段(VARCHAR(50),前20个字符已能区分99%的值)
CREATE INDEX idx_email_prefix ON user(email(20));
— 底层优化:
— 1. 索引键值长度从50字节→20字节,单个Page能存的键值数从200→500;
— 2. B+树高度从4层→3层,IO次数从4次→3次;
— 3. 存储空间从100MB→40MB,减少60%。
— 验证前缀索引有效性:
SELECT COUNT(DISTINCT email) / COUNT(*) AS full_distinct,
COUNT(DISTINCT LEFT(email,20)) / COUNT(*) AS prefix_distinct
FROM user;
— 若prefix_distinct≈full_distinct(如>99%),则前缀索引有效。
技巧 2:覆盖索引 —— 避免回表查询
— 场景:查询deptno=10的员工姓名和部门编号
— 步骤1:创建覆盖索引(包含deptno+name)
CREATE INDEX idx_deptno_name ON EMP(deptno, name);
— 步骤2:查询(仅扫描索引,无需回表)
SELECT name, deptno FROM EMP WHERE deptno=10;
— 执行计划:Extra=Using index(覆盖索引)
— 底层IO次数:3次(vs 回表的6次)。
技巧 3:避免索引失效的语法写法
| SELECT * FROM user WHERE YEAR(birthday)=1990; | 索引字段做函数操作,破坏 B + 树有序性 | SELECT * FROM user WHERE birthday BETWEEN '1990-01-01' AND '1990-12-31'; | 8000 次→3 次 |
| SELECT * FROM user WHERE deptno!=10; | 不等于操作无法利用 B + 树的范围查找 | SELECT * FROM user WHERE deptno<10 UNION ALL SELECT * FROM user WHERE deptno>10; | 8000 次→6 次 |
| SELECT * FROM user WHERE name LIKE '% 三 '; | 后缀模糊匹配,无法利用 B + 树的前缀排序 | 反向存储 + 前缀索引:SELECT * FROM user WHERE reverse_name LIKE ' 三 %'; | 8000 次→3 次 |
| SELECT * FROM user WHERE name=123; | 字符串字段与数字比较,触发类型转换 | SELECT * FROM user WHERE name='123'; | 8000 次→3 次 |
| SELECT * FROM user WHERE deptno IN (10,20,30); | IN 操作在数据量大时失效(MySQL 优化器选择全表扫描) | 拆分多个 OR:SELECT * FROM user WHERE dept |
技巧 4:使用 FORCE INDEX 强制走索引(优化器误判时)
MySQL 的查询优化器会根据 “索引基数、数据量、IO 成本” 自动选择是否走索引,但部分场景下优化器会误判(如小表全表扫描更快,但大表仍选择全表扫描),此时可通过FORCE INDEX强制走索引。
— 场景:EMP表800万数据,deptno=10的记录有10万条,优化器误判为全表扫描
— 错误执行计划:type=ALL,key=NULL(全表扫描,8000次IO)
EXPLAIN SELECT * FROM EMP WHERE deptno=10;
— 正确语法:FORCE INDEX强制走idx_deptno索引
EXPLAIN SELECT * FROM EMP FORCE INDEX (idx_deptno) WHERE deptno=10;
— 执行计划:type=ref,key=idx_deptno(3次IO)
— 底层原理:强制优化器使用指定索引,跳过成本计算,直接通过B+树定位数据
注意事项:
- 仅在优化器明确误判时使用,不要滥用(优化器大部分场景下的选择是最优的);
- 若频繁需要 FORCE INDEX,说明索引基数统计失效,需更新统计信息:
sql
ANALYZE TABLE EMP; — 更新表的索引基数统计,让优化器重新计算成本
技巧 5:批量操作优化(减少索引维护 IO)
批量插入 / 更新 / 删除数据时,频繁的索引维护会导致大量 IO(每次操作都要调整 B + 树),可通过临时删除索引→批量操作→重建索引优化。
— 场景:批量插入100万条员工数据到EMP表,表中已有idx_deptno/idx_ename等5个索引
— 步骤1:临时删除所有非主键索引(主键索引无法删除,聚簇索引特性)
DROP INDEX idx_deptno ON EMP;
DROP INDEX idx_ename ON EMP;
— 步骤2:批量插入数据(无索引维护,IO次数减少90%)
CALL insert_emp(9000001, 1000000);
— 步骤3:重建索引(一次性构建B+树,比逐条维护效率高10倍)
CREATE INDEX idx_deptno ON EMP(deptno);
CREATE INDEX idx_ename ON EMP(ename);
底层优化逻辑:
- 逐条插入带索引的表:每次插入需调整 B + 树 + 页分裂,100 万次插入对应 100 万次索引维护;
- 先删后建索引:重建索引时一次性遍历数据 + 排序 + 构建 B + 树,仅 1 次索引维护,大幅减少 IO。
技巧 6:NULL 值处理优化(避免索引失效)
索引字段允许 NULL 时,MySQL 会将 NULL 视为最小键值存储在 B + 树最左侧,但部分查询条件会因 NULL 值导致索引失效,需统一处理。
— 场景:user表的email字段允许NULL,创建了idx_email索引,查询非空email时索引失效
— 错误写法:SELECT * FROM user WHERE email IS NOT NULL;(部分MySQL版本会全表扫描)
— 底层原因:IS NOT NULL的范围查询在NULL值较多时,优化器判定成本过高
— 优化方案1:字段默认值替代NULL(推荐,从源头避免)
ALTER TABLE user MODIFY email VARCHAR(50) NOT NULL DEFAULT ''; — 空字符串替代NULL
CREATE INDEX idx_email ON user(email);
SELECT * FROM user WHERE email!=''; — 用到索引,3次IO
— 优化方案2:使用函数过滤(仅兼容)
SELECT * FROM user WHERE IFNULL(email, '')!=''; — 用到索引,3次IO
七、索引失效的避坑指南
7.1 语法写法类失效(占比 80%,最易避免)
场景 1:索引字段做函数 / 运算操作
— 复现语法
CREATE INDEX idx_sal ON EMP(sal);
SELECT * FROM EMP WHERE sal*1.1 > 5000; — 索引失效,全表扫描
— 底层原因:对sal做乘法运算,破坏了B+树的键值有序性,MySQL无法通过索引定位
— 解决方案:将运算移到条件右侧(不修改索引字段)
SELECT * FROM EMP WHERE sal > 5000/1.1; — 用到索引,3次IO
场景 2:隐式类型转换
— 复现语法
CREATE INDEX idx_ename ON EMP(ename); — ename是VARCHAR类型
SELECT * FROM EMP WHERE ename=123; — 索引失效,全表扫描
— 底层原因:数字123与字符串ename做比较,MySQL自动执行CAST(ename AS INT),对索引字段做转换
— 解决方案:保持类型一致,传入字符串条件
SELECT * FROM EMP WHERE ename='123'; — 用到索引,3次IO
场景 3:模糊查询 % 在开头
— 复现语法
CREATE INDEX idx_ename ON EMP(ename);
SELECT * FROM EMP WHERE ename LIKE '%张'; — 索引失效,全表扫描
— 底层原因:%在开头表示“任意前缀+张”,无法匹配B+树的前缀有序键值,无法定位节点
— 解决方案:反向存储字段+前缀索引(详见6.2技巧3)
ALTER TABLE EMP ADD reverse_ename VARCHAR(6) AFTER ename;
UPDATE EMP SET reverse_ename = REVERSE(ename);
CREATE INDEX idx_rev_ename ON EMP(reverse_ename);
SELECT * FROM EMP WHERE reverse_ename LIKE '张%'; — 用到索引,3次IO
场景 4:AND/OR 混合使用无优先级
— 复现语法
CREATE INDEX idx_deptno_age ON EMP(deptno, age);
SELECT * FROM EMP WHERE deptno=10 OR age>20; — 索引失效,全表扫描
— 底层原因:OR两侧的条件,age>20无法匹配索引的最左前缀,优化器直接放弃索引
— 解决方案1:加括号拆分,保证一侧匹配索引
SELECT * FROM EMP WHERE (deptno=10 OR age>20) AND deptno IS NOT NULL; — 用到索引
— 解决方案2:拆分查询,UNION ALL合并结果
SELECT * FROM EMP WHERE deptno=10 UNION ALL SELECT * FROM EMP WHERE age>20 AND deptno!=10;
场景 5:使用 NOT IN/NOT EXISTS(大数据量)
— 复现语法
CREATE INDEX idx_deptno ON EMP(deptno);
SELECT * FROM EMP WHERE deptno NOT IN (10,20,30); — 数据量大时索引失效
— 底层原因:NOT IN的范围过大时,MySQL优化器判定全表扫描的IO成本更低
— 解决方案:用LEFT JOIN替代NOT IN
SELECT e.* FROM EMP e LEFT JOIN (SELECT 10 AS deptno UNION ALL SELECT 20 UNION ALL SELECT 30) t ON e.deptno=t.deptno WHERE t.deptno IS NULL;
7.2 索引设计类失效
场景 1:复合索引违反最左匹配原则
— 复现语法
CREATE INDEX idx_name_age_deptno ON EMP(name, age, deptno);
SELECT * FROM EMP WHERE age=18; — 索引失效,全表扫描
— 底层原因:复合索引的最左前缀是name,查询条件未包含name,无法匹配索引节点
— 解决方案1:调整查询条件,包含最左前缀name
SELECT * FROM EMP WHERE name='张三' AND age=18; — 用到索引
— 解决方案2:单独为age创建索引
CREATE INDEX idx_age ON EMP(age); — 用到索引,3次IO
场景 2:索引字段唯一性太差
— 复现语法
CREATE INDEX idx_gender ON EMP(gender); — gender只有男/女,基数=2
SELECT * FROM EMP WHERE gender='男'; — 索引失效,全表扫描
— 底层原因:索引基数太小,扫描索引的IO成本(3次)与扫描全表的IO成本(5次)接近,优化器选择全表扫描
— 解决方案:将该字段作为复合索引的后缀,不单独创建
CREATE INDEX idx_deptno_gender ON EMP(deptno, gender);
SELECT * FROM EMP WHERE deptno=10 AND gender='男'; — 用到索引,3次IO
场景 3:过度索引导致优化器选择困难
— 复现语法
CREATE INDEX idx_deptno ON EMP(deptno);
CREATE INDEX idx_age ON EMP(age);
CREATE INDEX idx_deptno_age ON EMP(deptno, age);
CREATE INDEX idx_age_deptno ON EMP(age, deptno);
SELECT * FROM EMP WHERE deptno=10 AND age>20; — 优化器误判,选择单字段索引
— 底层原因:表中索引过多,优化器的成本计算耗时过长,最终选择低效的单字段索引
— 解决方案:删除无用索引,仅保留最优的复合索引
DROP INDEX idx_deptno ON EMP;
DROP INDEX idx_age ON EMP;
DROP INDEX idx_age_deptno ON EMP;
— 仅保留idx_deptno_age,优化器直接选择该索引,3次IO
八、索引实战:企业级场景落地
8.1 场景 1:电商订单表
业务特性
- 表名:order_info,数据量:5000 万条;
- 高频查询条件:order_id(等值)、user_id(等值)、create_time(范围)、pay_status(等值);
- 高频更新字段:pay_status(支付状态)、update_time(更新时间);
- 核心查询:
- 根据订单号查询订单详情:SELECT * FROM order_info WHERE order_id='O20240101001';
- 根据用户 ID 查询近 3 个月订单:SELECT * FROM order_info WHERE user_id=1001 AND create_time >= '2024-01-01';
- 根据支付状态查询未支付订单:SELECT * FROM order_info WHERE pay_status=0;
索引设计方案(语法 + 底层)
CREATE TABLE order_info (
order_id VARCHAR(32) NOT NULL PRIMARY KEY, — 主键索引(聚簇)
user_id BIGINT NOT NULL,
goods_id BIGINT NOT NULL,
amount DECIMAL(10,2) NOT NULL,
pay_status TINYINT NOT NULL DEFAULT 0, — 0-未支付,1-已支付,2-退款
create_time DATETIME NOT NULL,
update_time DATETIME NOT NULL,
INDEX idx_user_create (user_id, create_time), — 复合索引:用户ID+创建时间
INDEX idx_pay_create (pay_status, create_time) — 复合索引:支付状态+创建时间
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
性能优化点
- 避免更新索引字段:pay_status作为复合索引的前缀,但更新频率低(仅支付时修改),索引维护 IO 可接受;
- 覆盖索引优化:查询订单列表时,仅返回核心字段,创建覆盖索引避免回表;
— 需求:查询用户1001的订单号、商品ID、金额(无需全字段)
CREATE INDEX idx_user_cover ON order_info(user_id, create_time, order_id, goods_id, amount); — 覆盖索引
SELECT order_id, goods_id, amount FROM order_info WHERE user_id=1001 AND create_time >= '2024-01-01';
— Extra=Using index,无回表,IO次数3次 - 分区表结合索引:按create_time做范围分区,减少单表数据量,索引仅在分区内生效;
— 创建按月份分区的订单表
CREATE TABLE order_info (
— 字段同前
) ENGINE=InnoDB DEFAULT CHARSET=utf8
PARTITION BY RANGE (TO_DAYS(create_time)) (
PARTITION p202401 VALUES LESS THAN (TO_DAYS('2024-02-01')),
PARTITION p202402 VALUES LESS THAN (TO_DAYS('2024-03-01')),
PARTITION p202403 VALUES LESS THAN (TO_DAYS('2024-04-01'))
);
— 查询2024-01的订单,仅扫描p202401分区,数据量减少为1/12
8.2 场景 2:用户信息表
业务特性
- 表名:user_info,数据量:1000 万条;
- 高频查询条件:user_id(等值)、phone(等值)、nickname(模糊);
- 低频更新:仅用户修改资料时更新,无批量操作;
- 核心查询:
- 根据用户 ID 查询用户信息:SELECT * FROM user_info WHERE user_id=1001;
- 根据手机号查询用户:SELECT * FROM user_info WHERE phone='13800138000';
- 根据昵称模糊查询:SELECT * FROM user_info WHERE nickname LIKE '张%';
索引设计方案
CREATE TABLE user_info (
user_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, — 自增主键(聚簇索引)
phone VARCHAR(11) NOT NULL UNIQUE, — 唯一索引,手机号唯一
nickname VARCHAR(30) NOT NULL,
age TINYINT,
gender TINYINT,
address VARCHAR(100),
create_time DATETIME NOT NULL,
INDEX idx_nickname (nickname(20)) — 前缀索引:昵称前20个字符,减少索引大小
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
性能优化点
- 唯一索引替代主键:若业务中用手机号作为唯一标识,可将phone设为主键,但建议用自增主键(INT 比 VARCHAR 存储效率高);
- 昵称模糊查询优化:仅支持%在末尾,若需全模糊查询,结合 Elasticsearch 做全文检索,不依赖 MySQL 索引;
- 敏感字段隔离:将用户密码、身份证号等敏感字段拆分到user_secure表,仅创建必要索引,减少敏感数据泄露风险。
8.3 场景 3:系统日志表
业务特性
- 表名:sys_log,数据量:1 亿条 +(日增 100 万条);
- 高频操作:插入日志(99%),查询日志(1%);
- 查询条件:create_time(范围)、log_type(等值)、user_id(等值);
- 核心查询:根据时间范围 + 日志类型查询日志:SELECT * FROM sys_log WHERE log_type=1 AND create_time BETWEEN '2024-01-01' AND '2024-01-02';
索引设计方案(语法 + 底层)
CREATE TABLE sys_log (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, — 自增主键
log_type TINYINT NOT NULL, — 1-操作日志,2-错误日志,3-访问日志
user_id BIGINT,
content TEXT NOT NULL,
create_time DATETIME NOT NULL,
INDEX idx_type_create (log_type, create_time) — 复合索引:日志类型+创建时间
) ENGINE=InnoDB DEFAULT CHARSET=utf8
PARTITION BY RANGE (TO_DAYS(create_time)) (
PARTITION p202401 VALUES LESS THAN (TO_DAYS('2024-02-01')),
PARTITION p202402 VALUES LESS THAN (TO_DAYS('2024-03-01')),
— 按月分区,保留6个月数据,过期分区直接删除
);
性能优化点
- 禁止创建多余索引:日志表插入频率极高,每增加 1 个索引,插入效率下降 20%,仅保留必要的查询索引;
- 定期清理过期数据:通过ALTER TABLE sys_log DROP PARTITION p202310;直接删除过期分区,无需逐条 DELETE;
- 使用批量插入:日志插入用INSERT INTO sys_log VALUES(…), (…), (…);,减少 IO 次数,提升插入效率。
九、索引性能监控与调优
9.1 索引使用情况监控(语法 + 解读)
MySQL 提供了sys库和information_schema库,可通过 SQL 语法查询索引的使用次数、未使用索引、失效索引,精准定位无用索引。
9.1.1 查询表的索引使用次数
— 语法:查询指定表的所有索引使用情况(需开启performance_schema)
SELECT
INDEX_NAME, — 索引名
COUNT_USAGE — 使用次数
FROM sys.schema_unused_indexes
WHERE TABLE_SCHEMA='your_db' AND TABLE_NAME='EMP';
— 解读:
— COUNT_USAGE=0 → 索引从未使用,可删除;
— COUNT_USAGE<100 → 索引使用频率极低,评估后可删除;
— 开启performance_schema:SET GLOBAL performance_schema = ON;(需重启MySQL)
9.1.2 查询数据库中所有未使用的索引
— 语法:查询整个数据库的未使用索引
SELECT
TABLE_SCHEMA AS 数据库名,
TABLE_NAME AS 表名,
INDEX_NAME AS 未使用索引名
FROM sys.schema_unused_indexes;
— 实战建议:将未使用索引整理后,在低峰期逐一删除(先备份,再删除)
9.1.3 查询索引的具体使用统计
— 语法:查询索引的扫描次数、更新次数等详细统计
SELECT
TABLE_NAME,
INDEX_NAME,
SCANS AS 索引扫描次数,
UPDATES AS 索引更新次数
FROM information_schema.INDEX_STATISTICS
WHERE TABLE_SCHEMA='your_db';
— 解读:
— 扫描次数/更新次数比值越高 → 索引性价比越高;
— 扫描次数=0 → 索引未使用,可删除;
— 更新次数远大于扫描次数 → 索引维护成本过高,评估后可删除。
9.2 索引碎片清理(语法 + 底层)
InnoDB 的索引在频繁插入 / 删除 / 更新后,会产生索引碎片(B + 树中的空节点、不连续的页),导致索引页数量增加,IO 次数上升,查询效率下降。需定期清理碎片,优化索引结构。
9.2.1 查看索引碎片率
— 语法:查询指定表的碎片率(InnoDB)
SELECT
TABLE_NAME,
DATA_FREE AS 空闲空间(字节),
(DATA_FREE/(DATA_LENGTH+INDEX_LENGTH))*100 AS 碎片率(%)
FROM information_schema.TABLES
WHERE TABLE_SCHEMA='your_db' AND TABLE_NAME='EMP';
— 解读:
— 碎片率<5% → 无需清理;
— 5%≤碎片率≤30% → 执行OPTIMIZE TABLE;
— 碎片率>30% → 先删除索引,再重建索引(效率更高)。
9.2.2 清理索引碎片(语法 + 底层)
方式 1:OPTIMIZE TABLE(适合碎片率 5%-30%)
— 语法:清理表的索引碎片(InnoDB/MyISAM)
OPTIMIZE TABLE your_db.EMP;
— 底层原理:
— 1. InnoDB:重建表的聚簇索引和辅助索引,重新排序数据页和索引页,释放空闲空间;
— 2. MyISAM:整理数据文件和索引文件,减少碎片;
— 注意事项:执行时会锁表(InnoDB 5.6+为在线DDL,锁表时间短),需在低峰期执行。
方式 2:删除重建索引(适合碎片率 > 30%)
— 语法:先删除索引,再重建索引(效率比OPTIMIZE TABLE高50%)
— 1. 删除索引
DROP INDEX idx_deptno ON EMP;
DROP INDEX idx_ename ON EMP;
— 2. 重建索引
CREATE INDEX idx_deptno ON EMP(deptno);
CREATE INDEX idx_ename ON EMP(ename);
— 底层原理:一次性重建B+树,避免逐页整理碎片,直接生成无碎片的索引结构,效率更高。
十、终极总结:MySQL 索引核心知识图谱
10.1 索引底层原理核心
索引的本质是基于 B + 树的磁盘数据目录,通过空间换时间,将全表扫描的 N 次 IO 减少为 B + 树高度的 3-4 次 IO,核心设计目标是减少磁盘 IO 次数(数据库性能的核心瓶颈是磁盘 IO)。
10.2 索引语法核心图谱
| 主键索引 | CREATE TABLE 表名 (字段 PRIMARY KEY);ALTER TABLE 表名 ADD PRIMARY KEY (字段); | SELECT * FROM 表名 WHERE 主键字段 = 值; | ALTER TABLE 表名 DROP PRIMARY KEY; | 唯一标识记录(如 user_id) | 唯一、非空;一个表仅 1 个;InnoDB 聚簇索引 |
| 唯一索引 | CREATE UNIQUE INDEX 索引名 ON 表名 (字段);ALTER TABLE 表名 ADD UNIQUE (字段); | SELECT * FROM 表名 WHERE 唯一字段 = 值; | DROP INDEX 索引名 ON 表名; | 唯一字段(如 phone/email) | 允许 NULL;一个表可多个;辅助索引 |
| 普通索引 | CREATE INDEX 索引名 ON 表名 (字段 / 复合字段);ALTER TABLE 表名 ADD INDEX (字段); | SELECT * FROM 表名 WHERE 索引字段 = 值 / 范围; | DROP INDEX 索引名 ON 表名; | 高频查询字段(如 deptno/age) | 复合索引遵循最左匹配;不单独创建低基数索引 |
| 前缀索引 | CREATE INDEX 索引名 ON 表名 (字段 (前缀长度)); | SELECT * FROM 表名 WHERE 字段 LIKE ' 前缀 %'; | DROP INDEX 索引名 ON 表名; | 长字符串字段(如 nickname/email) | 前缀长度需保证区分度 > 99%;减少索引大小 |
| 覆盖索引 | CREATE INDEX 索引名 ON 表名 (查询条件字段,查询结果字段); | SELECT 查询结果字段 FROM 表名 WHERE 查询条件字段 = 值; | DROP INDEX 索引名 ON 表名; | 高频查询且无需全字段 | 包含所有查询所需字段;避免回表;IO 次数最少 |
| 全文索引 | CREATE FULLTEXT INDEX 索引名 ON 表名 (字段) ENGINE=MyISAM; | SELECT * FROM 表名 WHERE MATCH (字段) AGAINST (' 关键词 '); | DROP INDEX 索引名 ON 表名; | 大文本关键词检索(如文章 / 评论) | 仅支持文本类型;MyISAM 优先;中文需分词插件 |
10.3 索引设计黄金原则
结语
敲完最后一行索引调优的 SQL 语句,回头再看我们从无索引时 800 万条数据查询耗时 4.93 秒的痛点出发,走过的这一路,从磁盘 IO 的底层本质到 Page 与目录的设计细节,从 B + 树的结构原理到聚簇、非聚簇索引的语法拆解,从索引失效的避坑指南到电商订单、用户信息、系统日志三大企业级场景的落地设计,再到索引的性能监控与碎片清理,我们终于把 MySQL 索引从 “表层的创建语句” 挖到了 “底层的设计逻辑”,也终于明白,索引从来都不是一句简单的CREATE INDEX就能驾驭的技能,而是融合了磁盘存储、数据结构、业务场景、性能调优的综合能力。
很多开发者在接触索引时,都会陷入一个误区:以为记住 “创建索引的语法”“索引失效的口诀” 就够了,可实际工作中,依然会遇到 “建了索引查得更慢”“复合索引怎么排字段都不对”“海量数据下索引维护拖垮写性能” 的问题。究其根本,是因为只看到了索引的 “形”,却没理解索引的 “神”—— 我们在文中反复强调,数据库性能的核心瓶颈是磁盘 IO,而索引的所有设计、所有语法、所有优化,最终的目标只有一个:减少磁盘 IO 的次数。从 Page 的 16KB 固定大小设计,到目录页的多级指引,从 B + 树的 3 层结构控制 IO 次数在 3-4 次,到覆盖索引避免回表减少一半 IO,从自增主键减少页分裂降低 IO 成本,再到分区表结合索引减少扫描的数据量,所有的细节都在为这个核心目标服务。理解了这一点,就抓住了索引的根本,那些看似零散的语法规则、优化技巧,都会变成有迹可循的逻辑推导,而不是死记硬背的条条框框。比如,为什么复合索引要遵循最左匹配?因为 B + 树的键值是按最左字段有序排列的,打破这个顺序就无法通过目录快速定位,只能全表扫描增加 IO;为什么低基数字段不能单独建索引?因为扫描索引的 IO 成本和全表扫描相差无几,建索引反而会增加维护的 IO 开销;为什么 uuid 不能作为主键?因为无序插入会引发频繁的页分裂,每次分裂都要做数据移动、目录更新,带来大量的 IO 操作。当我们把这些问题的答案回归到 “减少 IO” 这个核心,所有的困惑都会迎刃而解。
当然,索引的学习,从来都不是 “纸上谈兵”,而是 “知行合一”。文中我们设计了 800 万条数据的实战测试,拆解了电商、用户、日志三大企业级核心场景的索引方案,这些场景都是开发者日常工作中一定会遇到的:高频查询与高频更新并存的订单表,需要平衡查询效率和索引维护成本;长字符串、模糊查询居多的用户表,需要用前缀索引、唯一索引兼顾效率和业务唯一性;高频插入、低频查询的日志表,需要 “少索引 + 分区表” 最大化插入性能。这些实战方案告诉我们,没有 “万能的索引设计”,只有 “适合业务的索引设计”。索引的创建不是越多越好,单表 5 个索引的上限不仅是 MySQL 的物理限制,更是业务的逻辑限制 —— 每多一个索引,插入、更新、删除操作就要多维护一棵 B + 树,带来额外的 IO 开销;索引的设计也不能脱离业务,脱离了查询频率、更新频率、数据量的索引,再完美的语法也只是空中楼阁。比如,订单表的idx_user_create复合索引,是因为业务中高频按 “用户 ID + 时间范围” 查询;日志表只建一个idx_type_create索引,是因为业务中只有 “日志类型 + 时间范围” 这一种查询方式;用户表的昵称用前缀索引,是因为业务中只有前缀模糊查询的需求。所有的索引设计,都要从业务出发,先梳理清楚 “哪些字段是高频查询条件”“哪些字段是高频更新字段”“查询的方式是等值还是范围”“数据量有多大、增长速度有多快”,再结合索引的底层原理做设计,这样的索引,才是能真正提升性能的有效索引。
而索引的能力,也不仅是 “设计和创建”,还有 “持续的监控与维护”。很多开发者建完索引就置之不理,却不知道随着数据的增删改,索引会产生碎片,会出现 “未使用的索引”“维护成本远大于查询收益的索引”,这些 “僵尸索引” 会慢慢拖垮数据库的性能。文中我们介绍了用sys库、information_schema库监控索引使用情况的方法,教大家如何识别未使用的索引、如何计算索引的碎片率、如何用OPTIMIZE TABLE或删除重建的方式清理碎片,这些都是日常工作中不可或缺的技能。索引就像数据库的 “精密仪器”,需要定期的 “体检” 和 “保养”,只有及时清理无用索引、优化碎片索引、更新统计信息,才能让索引始终保持高效的状态。从创建到使用,从监控到优化,形成一个完整的闭环,才是真正的索引调优能力。
学习索引的过程,可能会有些枯燥:那些 Page、目录页、B + 树的细节,那些 IO 次数的计算,那些语法背后的底层逻辑,需要我们沉下心来一点点琢磨。但当你真正理解了这些细节,当你在工作中用文中的方法解决了慢查询问题,当你看到自己设计的索引让 800 万条数据的查询从 4.93 秒降到 0.01 秒,当你在海量数据下通过分区表 + 复合索引平衡了查询和写性能,你会发现,这份枯燥的背后,是技术带来的实实在在的成就感。而这份对底层原理的探索精神,更是作为开发者最珍贵的品质。在数据库的世界里,性能优化的道路没有尽头,索引只是其中的重要一环,但学会了用 “底层原理” 推导 “实际应用”,用 “业务场景” 指导 “技术设计”,这份思维方式,会让我们在后续学习 InnoDB 存储引擎、事务、锁、分库分表时,都能事半功倍。
作为开发者,我们每天都在和数据打交道,而高效的查询,是系统性能的基础。MySQL 索引,作为提升查询性能的核心手段,是每个数据库开发者都必须掌握的硬本领。从只会写SELECT *的新手,到能设计高效索引的工程师,再到能结合业务做性能调优的架构师,这中间的差距,不在于记住了多少语法,而在于对底层原理的理解,在于对业务场景的把握,在于知行合一的实践。
希望这篇文章,能成为你 MySQL 索引学习路上的一个台阶,让你不仅学会 “怎么建索引”,更懂得 “为什么这么建”“怎么建才更好”。也希望你能把文中的知识落地到实际工作中,从分析一条慢查询日志开始,从设计一张表的索引开始,从监控一次索引的使用情况开始,一点点积累,一点点实践。当你能从容应对各种业务场景的索引设计,能快速定位并解决索引失效问题,能在海量数据下保持数据库的高效性能时,你会发现,自己已经在数据库性能优化的道路上,迈出了坚实的一步。
技术的道路,道阻且长,行则将至。索引的学习告一段落,但数据库的探索永无止境。愿你保持对技术的热爱,保持对底层原理的好奇,在数据的世界里,不断探索,不断成长,让自己的技术能力,成为支撑业务发展的坚实力量。也愿你的每一次查询,都能因合理的索引而高效,你的每一个系统,都能因优秀的设计而稳定。
网硕互联帮助中心




评论前必须登录!
注册