干后端这些年,被慢查询搞心态的次数不少。每次出问题,DBA丢过来的第一句话基本都是“这个SQL没走索引,加个索引就好了”。后来我自己负责业务库,才真正意识到,MySQL索引不是“加个字段就完事”那么简单:加错了不生效,加多了拖慢写入,最要命的是,明明有索引,执行计划却偏偏不用,线上慢查询照样把你压垮。
既然标题敢写“全网最详细”,这篇文章就尽量把 MySQL 索引从底层数据结构到日常调优、从单列索引到联合索引、从生效场景到失效案例一次讲透。内容不堆概念,重点放在“为什么这样设计”和“实操怎么用”上。无论你是刚学索引的新手,还是被线上慢查询困扰的开发者,这篇文章应该都能给你一些实际帮助。
1. 索引的本质与底层数据结构
1.1 索引是什么:一本书的目录逻辑
从最朴素的角度理解,索引就是数据表的“目录”。在没有索引的情况下,一条 SELECT * FROM user WHERE age = 25 要扫描全表,一行行判断,时间复杂度和数据总量成正比,几百万行就能把接口拖到超时。
而有了索引之后,MySQL 可以不再从第一行开始遍历,而是根据索引结构快速定位到目标行的位置。这个逻辑和查字典一样:你查“索引”这个字,不需要从第一页翻到最后一页,而是先去目录里找它的页码,再直接跳转过去。
但这里有个关键点:索引并不存储全量数据,它只是存储了“索引字段的值 + 对应行记录的物理位置(或主键值)”。这个“位置”,在 InnoDB 里通常就是主键值。理解这一点,后面讲回表、覆盖索引的时候就不会懵。
1.2 为什么选 B+ 树而不是二叉树或哈希表
MySQL 的 InnoDB 存储引擎默认使用 B+ 树作为索引结构。为什么不是二叉树、红黑树或者哈希表?这里面涉及到磁盘 IO 的特性。
我们先看磁盘存储的物理现实:MySQL 的数据最终还是落在磁盘上,而磁盘顺序读写快、随机读写慢。操作系统一次从磁盘读数据,是以“页”(默认 16KB)为单位加载到内存的。如果一个树形结构每一层只能存一个数据项,那么几百万行数据可能需要十几层的查找深度,每一层都面临一次磁盘 IO,性能不可接受。
二叉查找树在极端情况下会退化成链表,自平衡的 AVL 树 / 红黑树虽然能控制树高,但每个节点只能存一个元素,树的层数仍然很深。为了再加大每个节点的存储容量,B+ 树的核心设计就是:一个节点可以存储多个元素,并且叶子节点之间用指针串联。
B+ 树的几个关键特征:
- 内部节点只存索引键值,不存数据,因此一页可以容纳成百上千个键值,树的高度被压得很低。通常两三层的 B+ 树就能支撑千万级数据量。
- 叶子节点存储完整的主键值和该行对应的聚簇索引位置,并且叶子节点之间按顺序用双向指针连接。
- 数据存储是有序排列的,所以支持高效的区间查询(
BETWEEN、>、<等)和排序操作。
相对于哈希索引,B+ 树索引最大的优势是支持范围查询。哈希索引因为把键值散列成哈希码后是无序的,只能做精确匹配 = 或 IN,不支持排序,也不支持范围查找。所以即便哈希查找单条记录是 O(1) 复杂度,日常 OLTP 场景中用的依然还是 B+ 树,哈希索引在 InnoDB 中只作为自适应哈希索引存在,由引擎自动判断是否需要建立。
1.3 InnoDB 聚簇索引与二级索引的存储区别
说到 InnoDB 的索引,必须先区分聚簇索引和二级索引,这是我当年踩坑最多的概念之一。
InnoDB 规定:每张表必须且只能有一个聚簇索引。当我们定义了主键,主键索引就是聚簇索引;如果没有主键,MySQL 会找一个非空唯一索引作为聚簇索引;如果两者都没有,InnoDB 会隐式生成一个 6 字节的 row_id 作为聚簇索引。
聚簇索引的叶子节点直接保存了整行数据。也就是说,主键索引本身就是表数据本身,这也解释了为什么 InnoDB 表必须要有主键——没有主键,物理存储就无法组织。
除聚簇索引以外的索引统称为二级索引(也叫辅助索引或非聚簇索引)。二级索引的叶子节点存的是“索引列的值 + 主键值”,并不包含整行数据。所以,当我们通过二级索引查找一条数据时,会先走二级索引树查到主键值,然后再根据主键值去聚簇索引树里搜索完整行。这个二次查找的过程就叫“回表”。
举一个例子:
sql复制CREATE TABLE user (
id BIGINT PRIMARY KEY,
name VARCHAR(50),
age INT,
KEY idx_name (name)
);
如果执行 SELECT * FROM user WHERE name = '张三',MySQL 的步骤是:
- 在
idx_name这棵 B+ 树中,找到name = '张三'对应的叶子节点; - 从叶子节点拿到主键
id; - 再通过主键
id去聚簇索引树中查找完整行数据。
也就是说,这条 SQL 总共搜索了两棵 B+ 树。如果二级索引查询走了 3 层树高,回表又走了 3 层树高,一次查询就是 6 次逻辑 IO。数据量越大,回表性能损耗越明显。解决回表的办法就是后面会讲的覆盖索引。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 索引分类与创建语法
2.1 按功能分类:普通索引、唯一索引、主键索引、全文索引
MySQL 的索引可以按功能维度分类。日常开发中我们最常接触的是这几种:
| 索引类型 | 特点 | 常见用途 |
|---|---|---|
| 普通索引(INDEX/KEY) | 只加速查询,不限制重复值 | 高频查询字段 |
| 唯一索引(UNIQUE) | 加速查询 + 字段值唯一约束 | 手机号、邮箱、身份证号 |
| 主键索引(PRIMARY KEY) | 加速查询 + 非空约束 + 唯一约束 | 每张表必须有主键 |
| 全文索引(FULLTEXT) | 支持文本内容的模糊匹配 | 文章标题、正文的关键词检索 |
特别提一下唯一索引。很多同学会混淆唯一索引和唯一约束,实际上在 MySQL 中,建立唯一约束就是建立唯一索引,两者底层是同一个实现。唯一索引在查询优化器中天然更受欢迎,因为优化器知道同值记录最多只有一条,如果能命中唯一索引,扫描到第一条记录就可以直接停止,不需要继续扫描后续行,这对 LIMIT 1 场景特别友好。
全文索引在 InnoDB 中从 MySQL 5.6 开始才被支持,默认使用 ngram 分词插件来支持中文。但它和我们平时理解的搜索引擎分词不同,MySQL 全文索引在数据量大或者分词需求复杂时性能并不可观,很多团队宁可引入 Elasticsearch 也不是没道理的。常规单表模糊查询(LIKE '%keyword%')是走不了全文索引的,甚至普通索引也帮不上忙,这点后面讲失效场景时会详细说。
2.2 按字段数量分类:单列索引与联合索引
单列索引指索引只建立在单独一个字段上。联合索引(复合索引)则是建立在多个字段上,例如 KEY idx_user_age (name, age)。
很多人把联合索引理解为“分别给每个字段建索引”,这是完全错误的理解。联合索引底层依然是一棵 B+ 树,只不过这棵树每个节点的键值由多个字段共同组成,排序时按照定义顺序从左到右逐列比较:先按第一个字段排序,第一个字段相同再按第二个字段排序,以此类推。
这个多列排序规则直接决定了一条铁律:最左前缀原则。也就是说,使用联合索引时,查询条件必须从联合索引的最左侧字段开始连续匹配,否则索引无法生效。比如联合索引 (name, age, sex) 可以命中以下查询:
WHERE name = '张三'WHERE name = '张三' AND age = 25WHERE name = '张三' AND age = 25 AND sex = 1
而 WHERE age = 25 因为缺少左边第一列 name,这条 SQL 无法走联合索引;WHERE name = '张三' AND sex = 1 跳过了中间的 age,则只能用到联合索引的 name 部分,sex 这一列的条件只能在索引扫描后回表再过滤。
为什么要坚持最左匹配?回到 B+ 树的数据组织形式。联合索引 (name, age) 在逻辑上先按 name 排好序,当 name 相同时才按 age 排序。如果你跳过 name 直接拿 age 去查,那么扫描这棵 B+ 树时,根本没有一个稳定的起始位置可以定位——它就像一个“先按姓氏编排,同姓氏按名字编排”的电话本,你只知道要找“名字叫伟的人”,却不知道他姓什么,就只能整本翻一遍。
2.3 标准索引创建语法与演示
先说最基础的建索引语法:
sql复制-- 创建普通索引
CREATE INDEX idx_age ON user(age);
-- 创建唯一索引
CREATE UNIQUE INDEX uk_mobile ON user(mobile);
-- 建表时指定索引
CREATE TABLE user (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(50),
age INT,
mobile VARCHAR(20),
KEY idx_name_age (name, age),
UNIQUE KEY uk_mobile (mobile)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
给已有表加索引,推荐使用 ALTER TABLE 方式,语义更清晰:
sql复制ALTER TABLE user ADD INDEX idx_name_age (name, age);
ALTER TABLE user ADD UNIQUE INDEX uk_mobile (mobile);
ALTER TABLE user DROP INDEX idx_name_age;
如果你用的是 MySQL 8.0,还可以用 CREATE INDEX ... ALGORITHM=INPLACE, LOCK=NONE 的方式在线加索引,减少对线上写入的影响。不过即使在线 DDL 不阻塞写入,大表加索引的操作本身仍然需要重建表或构建索引结构,会消耗大量 IO 和 CPU,建议在业务低峰期执行。
这里有一个建索引时容易忽略的细节:字段顺序。假如业务查询条件是 WHERE name = ? AND age > ?,联合索引建议建 (name, age),因为 name 走等值匹配,age 走范围匹配;而 age 作为第二列在 B+ 树中仍然预留了排序位置,范围扫描会更高效。反过来建 (age, name) 的话,age 范围条件使用完第一列之后,name 的等值条件也无法继续利用索引排序,情况会差很多。
2.4 前缀索引:大字段索引的妥协方案
对于 VARCHAR(255) 甚至更长的字段,直接给整个字段建索引,会导致索引页能容纳的键值变少、B+ 树高度增加,同时索引占用空间也会飙升。解决办法是只取字段值的前 N 个字符建立前缀索引。
sql复制ALTER TABLE article ADD INDEX idx_title (title(20));
前缀索引能显著降低索引体积,但有两个代价:
- 无法使用覆盖索引,因为索引中只存了前缀而不是完整值;
- 排序操作(
ORDER BY title)无法完全利用索引,因为前缀部分相同后面不同; - 选择性可能降低。什么是选择性?就是
DISTINCT 前缀值数量 / DISTINCT 完整值数量,越接近 1,说明前缀区分度越高。
选择前缀长度时,可以这样对比:
sql复制SELECT COUNT(DISTINCT title) / COUNT(*) AS full_selectivity,
COUNT(DISTINCT LEFT(title, 10)) / COUNT(*) AS prefix_10,
COUNT(DISTINCT LEFT(title, 20)) / COUNT(*) AS prefix_20
FROM article;
连续调整 N 的值,找到选择性接近完整字段且长度尽量短的那个 N。
3. 联合索引设计:最左前缀的原理与应用
3.1 理解联合索引的键值排序规则
联合索引的底层原理值得拆开细究,因为它决定了我们设计索引时能不能真正发挥效果。
假设我们有联合索引 (name, age),它的逻辑存储结构长这样:
- 先按
name排序; name相同的情况下,再按age排序。
这就好比小区快递柜的存件逻辑:先按楼栋号(name)分组排序,同一个楼栋的件再按房间号(age)排序。当你明确知道要找 3 栋 502 时,可以直接走到 3 栋的格子再快速定位 502,效率极高。如果你只知道“要找 502”,不知道是哪一栋,那就只能在所有楼栋里挨个找 502 的房间号,效率自然低。
这个例子能帮我们理解一个更深的优化点:当联合索引的最左列是等值条件时,它后面的列会处于一种“局部有序”的状态。例如查询 WHERE name = '张三' AND age BETWEEN 20 AND 30,在 name = '张三' 的所有记录中,age 是严格有序排列的,所以 BETWEEN 范围查询可以直接命中一段连续的叶子节点,避免回表前的大范围扫描。这是我们在设计联合索引时最希望看到的场景。
3.2 联合索引字段排序的设计原则
创建联合索引时,字段顺序如何取舍?我总结了一套实操优先原则:
第一,等值条件放在前面,范围条件放在后面。 例如 WHERE status = 1 AND create_time > '2024-01-01',索引应设计为 (status, create_time)。因为 status 等值匹配条件下,create_time 在索引内保持连续有序,范围扫描效率最高。反过来把 create_time 放前面,status 的等值匹配就没法在索引树上完全过滤。
第二,区分度高的字段优先。 区分度低意味着同一个索引键值对应了大量行,比如性别字段只有“男/女”,如果把它放在联合索引第一位,索引的过滤效果会很差。假设索引 (sex, age),执行 WHERE sex = '男' 时,会扫出全表一半的数据,这跟全表扫描区别不大了。而 (age, sex) 虽然同样利用不了 sex 条件,但 age 一个等值就能过滤掉绝大多数行,实际扫描量少得多。
第三,高频查询的字段放在前面。 如果系统里 90% 的查询都只带 user_id,只有 10% 会带着 user_id + type 查询,那么把 user_id 放在第一位的收益远高于把 type 放在第一位。
设计阶段花几分钟理清这四个原则,比上线后半夜爬起来加索引要划算得多。我见到过太多开发者建索引时只看字段高低频,完全不管等值和范围的分布,结果索引是建了,慢查询却一个没少。
3.3 联合索引如何同时优化 ORDER BY 和 GROUP BY
联合索引不只是加速 WHERE 过滤,它还能直接优化排序和分组,避免 MySQL 生成临时表和文件排序(filesort)。为什么能用索引排序?因为 B+ 树叶子节点只存储有序数据,所以如果查询的 ORDER BY 字段顺序和联合索引的前缀顺序完全一致,MySQL 直接遍历索引叶子节点就能得到有序结果,连排序操作都省了。
看这个经典场景:
sql复制-- 联合索引 (user_id, create_time)
SELECT * FROM order_table
WHERE user_id = 1001
ORDER BY create_time DESC;
由于 create_time 在联合索引中处于第二列且 user_id 是等值匹配,所以 user_id = 1001 对应的所有 create_time 自然有序,查询可以直接按索引倒序遍历,根本不需要 filesort。
但是如果写成了:
sql复制SELECT * FROM order_table
ORDER BY create_time DESC;
联合索引最左列 user_id 没有出现在条件中,整个索引无法用于排序,MySQL 只能把结果集拉到内存或磁盘做排序操作。对于大的结果集,filesort 的代价很高。
结论很简单:如果你发现 SQL 中有高频的排序或分组需求,把这些排序字段放在联合索引的合适位置,经常能一箭双雕——既过滤又排序。
4. 索引失效场景:我踩过的那些坑
4.1 经典失效场景速查表
很多开发者觉得“建了索引 SQL 就一定走”,这是最大的误解。MySQL 的查询优化器会结合成本模型、统计信息、表数据量综合决定是否使用索引。一个索引建在那里,并不代表任何 SQL 都能用到它。下面是我整理的最常见失效场景:
| 场景 | 示例 | 失效原因 |
|---|---|---|
| 对索引列使用函数 | WHERE DATE(create_time) = '2024-01-01' |
索引列被函数处理后,无法与索引树中的原始键值比较,MySQL 只能全表扫描 |
| 隐式类型转换 | WHERE mobile = 13800138000(mobile 为 varchar) |
字符串列与数字比较时,MySQL 会先把列转成数字,相当于对列使用了 CAST 函数 |
| 前置模糊查询 | WHERE name LIKE '%张三%' |
B+ 树只能按前缀高效匹配,前导通配符让扫描起点无法确定 |
| 联合索引违反最左原则 | WHERE age = 25(联合索引为 name, age) |
缺少最左列,索引结构物理上无法定位 |
| OR 条件包含非索引列 | WHERE name = '张三' OR status = 1 |
优化器无法用一个索引同时完成两个列的查询,大概率选择全表扫描 |
| 索引列参与计算 | WHERE age + 5 = 30 |
改变了索引列的原始值,树形结构无法命中 |
IS NOT NULL 不等于语义 |
WHERE age IS NOT NULL |
优化器可能认为扫描全表比索引回表更划算 |
| 数据区分度过低 | WHERE sex = '男' |
优化器估计扫描行占比过高,放弃索引 |
4.2 隐式类型转换:最容易被忽视的元凶
隐式类型转换是我在代码 review 中看到频次最高的问题。最常见的就是手机号字段存的是 VARCHAR,但查询条件直接写了数字:
sql复制SELECT * FROM user WHERE mobile = 13800138000;
MySQL 的规则是:当字符串列与数字比较时,字段会被隐式转换成数字,然后再做匹配。这个转换相当于对每个索引列都调用了 CAST(mobile AS SIGNED),索引列无法再和索引树中的原始字符串键值直接对照,于是索引失效。
修改方式很直接,把查询条件改成字符串即可:
sql复制SELECT * FROM user WHERE mobile = '13800138000';
这里还有一个反向案例:如果索引列本身就是数字类型,而查询条件传了字符串 '123',MySQL 会把字符串转数字,索引仍然可以用。所以问题往往发生在“字符串列被转成数字”的场景。看执行计划时,如果发现某个索引列出现隐式转换,观察 EXPLAIN 的 type 从 ref 变成 ALL,基本就能确认。
4.3 函数操作导致索引失效的正确解法
WHERE DATE(create_time) = '2024-01-01' 无法使用 create_time 索引,是因为函数处理后的结果和索引树里的字符串键值不是同一个语义。要解决这个问题,不需要绕弯子改写成复杂的表达式,直接把条件改成范围查询就行:
sql复制-- 改写前
SELECT * FROM order_table WHERE DATE(create_time) = '2024-01-01';
-- 改写后
SELECT * FROM order_table
WHERE create_time >= '2024-01-01 00:00:00'
AND create_time < '2024-01-02 00:00:00';
改写后的查询可以利用 create_time 索引做高效的范围扫描。这背后的逻辑就是 B+ 树天然支持有序区间定位,只要你给的是一个连续区间,它就能直接定位到起点叶子节点然后顺序遍历。这是所有索引优化中“性价比”最高的一类改写,效果立竿见影。
4.4 优化器选择全表扫描不代表索引彻底没用
还有一种情况让我最初也很困惑:明明给字段加了索引,执行计划却还是显示 ALL(全表扫描)。排查后发现,查询条件 WHERE status = 1 而 status 字段只有 1 和 2 两个值,并且 1 占数据的 80%。优化器会估算:用二级索引需要扫描 80% 的二级索引节点,再逐条回表找主键数据,这个成本比直接全表扫描聚簇索引(也就是整张表)还高,所以它宁可全表扫描。
注意:这不是索引失效,而是 MySQL 通过代价估算主动放弃索引。如果确实需要这类查询,通常的解法是考虑数据分布调整、减少非必要扫描行数,或者在 SQL 中增加其它过滤条件把结果集降下来。
理解决策过程很重要,它能避免我们误判“索引失效”的本质——很多时候不是结构问题,是优化器认为不划算。
5. 索引下推与覆盖索引:两个能救命但常被忽略的特性
5.1 覆盖索引:避免回表带来的性能损耗
我之前提到,二级索引叶子节点不包含完整行数据,只包含索引列和主键。如果一条 SQL 要查找的字段已经全部包含在二级索引中,InnoDB 就不需要回表查询聚簇索引了——这就是覆盖索引。
看一个判断标准:执行计划 Extra 字段如果出现 Using index,说明当前查询用到了覆盖索引。这里的“Using index”不是“正在使用索引”这么简单,它特指查询所需的数据都可以从索引树获取,不需要回表。
覆盖索引带来两个好处:一是减少回表产生的随机 IO;二是二级索引通常比聚簇索引体积小许多,同样加载一页索引数据能覆盖更多行,扫描效率更高。
如何设计覆盖索引?核心就是把需要高频查询的字段塞进联合索引中。比如有一条语句:
sql复制SELECT name, age FROM user WHERE name = '张三';
虽然 name 字段本身有索引,但 age 字段没有,查询流程会变成:先通过 name 索引找到 name = '张三' 对应的主键,再逐条回表读取 age。如果把索引改成 (name, age),那么 name 和 age 都在索引树里,查询到主键后可以直接返回,连回表都省了。这在 count 类高频统计场景下尤其好用:
sql复制SELECT COUNT(*) FROM order_table WHERE status = 'PAID';
如果有一个覆盖索引 (status, id),只需要扫描较小的索引树完成统计,不用读完整行数据,性能差距非常明显。
5.2 索引下推(ICP)是什么:把过滤推向存储引擎层
索引下推(Index Condition Pushdown,ICP)是 MySQL 5.6 引入的优化手段。要理解它,先要理解没有 ICP 时 MySQL 是怎么执行的。
假设有联合索引 (name, age),执行:
sql复制SELECT * FROM user WHERE name LIKE '张%' AND age = 25;
LIKE '张%' 能走索引,但 age = 25 没法在索引树的 B+ 树查找阶段直接参与定位,因为 age 是第二列,前面第一列是范围条件而非等值条件。没有 ICP 时,InnoDB 会利用 name LIKE '张%' 扫描索引,把所有满足“以张开头”的叶子节点对应的主键捞出来,每条都回表,回到聚簇索引后拿到完整行再判断 age = 25,不满足就丢弃。这样会产生大量无效回表。
有了 ICP 之后,MySQL 会把 age = 25 这个条件下推到存储引擎层。存储引擎扫描索引叶子节点时,就不再急着回表,而是先在索引层面判断当前这条二级索引记录的 age 是否等于 25,不满足直接跳过,只有 age 满足的记录才回表。这样回表次数大幅减少。
实践中有两个关键点需要补充:
- ICP 默认是开启的,由参数
optimizer_switch控制,运行SET optimizer_switch = 'index_condition_pushdown=on'可以开启。 - 执行计划中
Extra字段如果显示Using index condition,说明当前查询使用了索引下推优化。
需要提醒的是:ICP 适用于二级索引,不适用于聚簇索引,因为聚簇索引的叶子节点就是整行数据,不存在回表问题,所以无需下推。
6. EXPLAIN 解读:判断索引是否生效的唯一标准
6.1 核心字段怎么看
讲了这么多规则,落到实操层面,判断一条 SQL 有没有走索引、走得是否高效,标准动作就是看执行计划。MySQL 中通过 EXPLAIN 关键字获取执行计划:
sql复制EXPLAIN SELECT * FROM user WHERE name = '张三'\G
重点关注以下几列:
| 列名 | 含义 | 期望值 |
|---|---|---|
| type | 访问类型 | 从好到差依次为 system > const > eq_ref > ref > range > index > ALL |
| key | 实际使用的索引 | 不为 NULL |
| key_len | 使用索引的长度 | 值越大,说明索引利用程度越高 |
| ref | 与索引比较的常量或列 | const、列名等 |
| rows | 预估扫描行数 | 越小越好 |
| filtered | 过滤后剩余百分比 | 越高越好 |
| Extra | 额外信息 | 如果出现 Using index 最佳;出现 Using filesort / Using temporary 则需警惕 |
type 是最直观的指标。const 表示使用主键或唯一索引的等值查询,通常是最理想的情况;ref 表示普通二级索引的等值匹配;range 表示索引范围扫描;index 表示全索引扫描,比全表扫描好一点但仍需要警惕;ALL 就是全表扫描,重点排查对象。
key_len 也很有用,常用于判断联合索引用到了几列。比如 idx_name_age 的 name 是 VARCHAR(50) utf8mb4,age 是 INT,那么 key_len 的值可以推算出实际用到了哪几列,如果只看到 name 那一段的长度(201,50*4+1),说明只有 name 列生效;如果看到 206(再加上 4 字节的 INT 和 1 字节可空标记),说明两列都用上了。
6.2 通过 EXPLAIN 分析一次慢查询
分享一个最近的线上案例。某业务订单表的查询语句:
sql复制SELECT order_no, amount, status
FROM order_table
WHERE create_time BETWEEN '2024-06-01' AND '2024-06-30'
ORDER BY amount DESC
LIMIT 20;
表里已经有 create_time 单列索引,EXPLAIN 结果:
- type: range
- key: idx_create_time
- Extra: Using filesort
虽然走了索引,但 Extra 里出现了 Using filesort,意思是 ORDER BY amount DESC 没有利用索引顺序,MySQL 把 create_time 范围内所有数据都查出来后再做排序。订单表一个月数据几十万行,每次请求都把这些行拉到排序缓冲区,接口性能可想而知。
优化方式是新建联合索引 (create_time, amount)。这样在 create_time 等值或范围条件下,amount 在索引内是有序的,ORDER BY amount DESC 就能直接使用索引的顺序,不再 filesort。修改后 EXPLAIN 的 Extra 不再出现 Using filesort,接口延迟从 1.8 秒直接降到 30 毫秒。
这类优化在搜索型业务里特别常见。所以排查慢 SQL 时不要只盯着 key 是不是不为空,更要看 Extra 里有没有隐藏的性能杀手。
7. 索引维护与常见问题排查
7.1 冗余索引与重复索引的清理
索引不是越多越好。每个索引都是一棵独立的 B+ 树,写入数据时需要对每棵索引树做更新,插入、删除、修改的代价都会随之上升。过多索引不仅浪费磁盘空间,还会拖慢写入速度。
我见过一张 8 个字段的表建了 7 个索引,其中两个索引明显重复:一个 KEY idx_name (name),一个 KEY idx_name_age (name, age)。由于联合索引 (name, age) 本身就能覆盖 (name) 前缀的查询,所以 idx_name 完全是冗余的,可以安全删除。
怎么找冗余索引?执行 SHOW INDEX FROM table_name,然后人工或使用工具对比前缀列表。把每个索引的字段前缀提取出来后,如果发现某个索引的第一个字段和另一个联合索引的前缀完全重叠,且它没有承载唯一约束,基本可以判断为冗余。
7.2 为什么索引会突然失效:统计信息过期与碎片问题
有一种情况经常被忽视:SQL 没有变化,执行计划却在某次版本迭代后突然从走索引变成全表扫描。这通常和统计信息过期有关。InnoDB 通过采样估算索引的区分度,如果表中数据大幅度增删,统计信息没有及时更新,优化器会基于旧数据做出错误的成本判断。
解决办法包括执行 ANALYZE TABLE table_name 更新统计信息,或者通过 OPTIMIZE TABLE table_name 重建表消除索引碎片。OPTIMIZE TABLE 在 InnoDB 中本质上是 ALTER TABLE ... FORCE,会重建整张表和所有索引,大表执行前务必确认磁盘空间充足,并且安排在低峰期。
另一个常见现象是页分裂。当 B+ 树的叶子节点写满后,新插入的数据会触发页分裂,产生大量碎片页。碎片太多会让一次范围扫描物理上读取的页数量远超理论值。对于频繁插入和删除的表,建议定期重建索引或整表优化。
7.3 强制走索引的兜底手段与风险
有时我们会发现优化器的选择确实不理想,但经过分析确认索引本身是好用的,此时有强制手段:
sql复制SELECT * FROM user FORCE INDEX (idx_name_age) WHERE name = '张三';
FORCE INDEX 会告诉优化器优先使用指定索引。但这个方法我不建议在业务代码里频繁使用。原因有三个:
- 数据库版本升级、数据分布变化、统计信息更新后,优化器模型也在变化,原本强制指定可能变成次优方案;
- 代码中硬编码索引名,导致后续 DBA 调整索引结构时,业务代码也要跟着改;
- 掩盖了真实问题(比如 SQL 本身可以改写得更合理),容易形成技术债。
更好的做法是使用 optimizer_switch 或优化 SQL 逻辑本身。
7.4 通过慢查询日志发现潜在索引问题
最后分享一个实用的日常巡检手段。开启慢查询日志前,先确认当前配置:
sql复制SHOW VARIABLES LIKE 'slow_query_log';
SHOW VARIABLES LIKE 'long_query_time';
如果没开,在 MySQL 8.0 中可以动态开启:
sql复制SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;
SET GLOBAL log_queries_not_using_indexes = 'ON';
long_query_time 建议设成 1 秒,log_queries_not_using_indexes 可以记录所有没走索引的 SQL,是发现潜在索引问题的金矿。我每次接手新业务库,第一件事就是打开慢查询日志跑上一周,把 top N 慢 SQL 一条条拿出来用 EXPLAIN 分析,找出共同点后统一优化。
有一个经验值得分享:慢查询日志中很多慢 SQL 每次只慢 0.5 秒左右,算不上灾难性事故,也没人主动报障,但累积起来会持续消耗数据库 CPU 和 IO。定期观察这些“亚健康 SQL”并优化,比等业务响应变慢再去救火更从容。这也是为什么我建议不仅关注 long_query_time,还要打开 log_queries_not_using_indexes,尽早发现“看似正常但实际全表扫描”的查询。
8. 总结我日常的索引调优流程
在实际工作中,我处理 MySQL 索引问题基本遵循一个固定流程:
第一步,拿到慢 SQL 后先看表结构和数据量,理解查询的业务语义。
第二步,用 EXPLAIN 看执行计划的 type、key、rows、Extra,判断当前是走索引还是全表扫描,扫描行数是否过大。
第三步,结合 WHERE 条件和字段区分度判断索引设计是否合理:是否有合适的联合索引、字段顺序是否符合最左前缀原则、排序字段有没有被索引利用、能否用覆盖索引消除回表。
第四步,对确实需要优化的 SQL 先做低成本改写:范围查询改写来替代函数操作,分离 OR 条件,拆分大查询为多个小查询。
第五步,如果 SQL 无法改写,再考虑新增或调整索引。索引调整遵循“先加后删”原则,新索引上线跑一段观察期,确认无问题后再删除旧索引,避免一步操作造成不可回退的风险。
过去几年处理过的数据库问题里,大约七成以上性能问题都能通过合理索引解决,真正的硬件瓶颈或架构问题只占少数。把索引的理解从“建几个 KEY”提升到“理解 B+ 树如何存储、扫描、回表和排序”,写出来的 SQL 和对索引的运用自然会上一个台阶。索引优化不是一个一劳永逸的工作,它需要随着数据量和业务查询模式的变化持续调整。你在加每一个索引之前,先问自己三个问题:这个索引能不能减少回表?能不能覆盖排序字段?能不能用最少的字段数支撑住这条 SQL?想清楚这三个问题再动手,基本不会建出太差的索引。
