☰
MySQL索引调优实战:吃透B+树、EXPLAIN与覆盖索引
2026/9/26 5:42:16 网站建设 项目流程

1. 调优前先弄懂索引的数据结构:B+树背后那点事

1.1 为什么MySQL选择B+树而不是哈希或者跳表

很多人在看MySQL索引调优的时候,第一步就卡在数据结构上。说实话,如果搞不懂为什么索引非要长成B+树这个样子,后面看执行计划、改SQL,基本上就是照着别人的文章抄,换个场景又不会了。

先给结论:InnoDB引擎下的索引底层结构是B+树,不是哈希表,也不是二叉树,更不是红黑树。

哈希表的查找时间复杂度是O(1),单点查询确实快得离谱,但它有两个致命问题:第一,哈希索引不支持范围查询,比如age > 20这种条件,哈希表只能全表扫;第二,哈希索引不支持排序,ORDER BY一旦出现,索引就废了。业务查询不可能永远只做等值匹配,所以哈希索引只能作为InnoDB的底层辅助结构,比如自适应哈希索引(Adaptive Hash Index),它是MySQL自己内部优化用的,不是我们能主动建的东西。

二叉树的问题就更明显了。如果插入的数据是有序的,二叉树就会退化成一条链表,查询复杂度直接变成O(n),这些索引结构在数据量大的时候根本撑不住。

B+树好在哪里?它把所有的数据都存放在叶子节点,非叶子节点只存储键值和指针。假设一个节点能存1000个键值,树的高度只有三层,就能存大约10亿条记录(1000×1000×1000),也就是说,哪怕一张表里有几千万数据,查询时也只需要三次磁盘I/O就能定位到目标叶子页。磁盘I/O是数据库性能最大的瓶颈,B+树这种矮胖结构就是冲着减少I/O次数去的。

1.2 聚簇索引和二级索引的本质区别

搞清楚B+树之后,紧接着就要分清InnoDB里索引的两种存在形式:聚簇索引(Clustered Index)和二级索引(Secondary Index)。

聚簇索引就是主键索引,表的每一行数据都直接挂在B+树的叶子节点上。如果没有显式定义主键,InnoDB会选择一个非空唯一索引作为聚簇索引;如果连唯一索引都没有,InnoDB会生成一个隐式的自增主键ROWID。所以你可以理解为:聚簇索引就是整张表本身。

二级索引则是我们自己创建的其他索引,CREATE INDEX idx_name ON t(col),叶子节点上存的不是完整行数据,而是索引列的值加上主键值。

这一点极其关键,因为它是理解"回表"这个概念的基础。举个例子:

CREATE TABLE user ( id INT PRIMARY KEY, name VARCHAR(50), age INT, city VARCHAR(50), KEY idx_city (city) ); SELECT * FROM user WHERE city = '北京';

这条SQL的执行流程是:先在二级索引idx_city的B+树中找到city = '北京'的所有记录,拿到这些记录对应的主键id,然后拿着id再去聚簇索引的B+树里查完整行数据。这个"拿着主键id再查一次聚簇索引"的动作,就是回表。

回表的意义在于:二级索引的树比聚簇索引小得多,查询时定位快,但拿到主键后必须二次查询才能拿全字段。如果查询的字段恰好都在二级索引里,那就不需要回表,这种索引就是覆盖索引(Covering Index)。

1.3 索引调优本质上是在调什么

上面这些内容看起来偏理论,但它直接决定了你的调优思路。索引调优的本质,是在IO成本和存储成本之间做博弈。

当数据量是几千条的时候,全表扫描也就几十毫秒,建不建索引无所谓。但当数据量到几百万条的时候,全表扫描可能要好几秒,而走索引可能只需要几十毫秒,差距是一百倍以上。

关键在于,全表扫描意味着要读聚簇索引的每一个叶子页。如果一行数据是200字节,一个叶子页默认16KB能放80行,一百万行就需要12500个数据页。MySQL按页读磁盘,全表扫描就是这12500次磁盘I/O。如果你的表还有很长的TEXT字段,那么同样的行数,页数可能翻倍,I/O次数也跟着翻倍。

索引就是为了把这个"读的页数"降下来。二级索引按索引列排序存储,定位到一两条记录,通常只需要读几个页。所以,判断一个索引好不好,核心指标很直接:它能不能让查询读更少的页、减少回表次数、避免文件排序(filesort)。后面所有调优操作,都是围绕这三件事展开的。

2. 索引失效的高频现场:六个最容易踩的坑

2.1 最左前缀法则:联合索引的"从左往右"铁律

联合索引是索引调优里最常见也最容易出问题的一种索引。很多人建联合索引的时候,字段顺序完全凭感觉,等到排查慢查询的时候才发现索引压根没被用上。

最左前缀法则说的是:对于一个(a, b, c)联合索引,查询条件里必须包含a,才能用到这个索引;只用b、c条件,或者只用b,都用不上这个索引(在MySQL 8.0之前基本如此,8.0引入了索引跳跃扫描(Index Skip Scan)优化,但那是特殊场景,不能依赖)。

这一点你得理解背后的逻辑。联合索引(a, b, c)在B+树里存储时,先按照a排序,a相同的情况下按b排序,b相同的情况下按c排序。它本质上是一个一维的有序序列,而不是三维的立方体。所以查询时如果跳过a直接查b,MySQL在B+树里根本不知道怎么去定位"b等于某值"的区间,因为它不知道这个b值对应哪些a的范围。

来看一个我在项目中遇到过的真实案例。有一张订单表,建了联合索引(seller_id, status, create_time),业务同学写的查询条件却是status = 'PAID' AND create_time > '2024-01-01',完全没带seller_id,结果这条SQL跑了3秒多。加上seller_id的条件后,直接降到50毫秒。

这里有个实操细节值得分享:联合索引的第一个字段,要选查询频率最高、区分度最高的字段。比如订单表里seller_id的区分度肯定高于status,所以把seller_id放最前面是合理的。如果把status放第一个,索引树里所有叶子节点按status排序,同一个status下的记录会非常多,定位效率大大降低。

还有一个容易被忽略的点:联合索引的字段顺序也影响排序场景。如果有ORDER BY b这样的需求,恰好联合索引是(a, b),且查询条件里已经限制了a = 某个值,那b就是有序的,MySQL可以直接用索引来排序,避免额外的filesort。如果联合索引是(b, a),那无论如何都用不上索引来排序。

2.2 隐式类型转换和函数运算:索引失效的隐形杀手

这是线上最容易出现的问题,而且出问题的人往往一脸茫然:明明走了索引,怎么查询还是慢?

SELECT * FROM user WHERE phone = 13800138000;

如果phone字段是VARCHAR类型,上面的SQL对索引列做了隐式的类型转换,MySQL会将字符串字段和数字比较时,把字段值转换为数字再比较,相当于对索引列用了CAST(phone AS SIGNED),这时索引就会失效,哪怕phone上有索引。在很多生产环境里,用户表几十万上百万数据,一条"1秒多"的查询会让接口响应变得非常慢。

同理,在索引列上做函数运算也一样:

SELECT * FROM order_info WHERE DATE(create_time) = '2024-06-01';

这里对create_time用了DATE()函数,索引直接失效。正确写法是:

SELECT * FROM order_info WHERE create_time >= '2024-06-01 00:00:00' AND create_time < '2024-06-02 00:00:00';

写成范围查询后,不仅能用上索引,逻辑也更严谨。这里多说一句,范围查询的右开区间写法< '2024-06-02'能准确覆盖到2024-06-01 23:59:59.999的数据,如果用<= '2024-06-01 23:59:59',就会漏掉毫秒级的记录。

怎么排查这类问题?用EXPLAIN看执行计划,如果type列是ALL,且Extra列里出现了Using where,同时possible_keys里明明有可用的索引,key却是NULL,那很大概率就是类型转换或函数运算把索引废掉了。

建议所有的关联字段、查询条件字段都要注意字段类型的一致性。项目初期就制定开发规范:数据库层的字段类型要严格匹配,程序传参时不要随意把数字传进VARCHAR字段。

2.3 LIKE、OR、范围查询的边界条件

模糊查询是索引失效的重灾区。LIKE 'abc%'能走索引,因为MySQL可以把前缀匹配转成范围扫描;但LIKE '%abc'和LIKE '%abc%'基本不走索引,因为无法从B+树索引中直接定位。

如果业务必须要做后缀模糊匹配,通常的思路是改数据模型。比如搜索引擎(ES)就是这个场景的典型替代方案。如果数据量不大,用全文索引也可以。最不推荐的做法就是拿着几百万行的表做%xxx%的LIKE扫描。

OR的情况也要注意:

SELECT * FROM user WHERE id = 100 OR phone = '13800138000';

假设id上有主键索引,phone上有唯一索引,MySQL在早期版本里会对OR条件做一次"索引合并"(Index Merge),但这并不是什么时候都高效。更稳的做法是把OR拆成UNION ALL,或者用UNION去重:

SELECT * FROM user WHERE id = 100 UNION SELECT * FROM user WHERE phone = '13800138000';

虽然语句变长了,但两个子查询都能各自走索引,实测下来往往比OR更快更稳定。

范围查询(>、<、BETWEEN)本身能走索引,但如果一个联合索引中多个字段都有范围条件,比如WHERE a > 100 AND b > 200,那么只有a能用到索引定位,b的条件只能在a定位出来的结果集里做过滤。这是因为B+树的叶子节点是按第一个字段排序的,第一个字段筛出来之后,第二个字段在这个子集里仍然是无序的,就无法继续用索引来定位区间了。这是范围查询条件下索引的"后一个字段失效"问题,设计联合索引时要把等值条件放在范围条件前面。

3. 用EXPLAIN给索引做体检:核心列逐项逐字解读

3.1 type列的访问级别:从ALL到ref意味着几十倍性能差距

一旦SQL性能出现问题,第一个动作就是EXPLAIN。你要把EXPLAIN SELECT ...的输出当成体检报告来读。type列是报告里最重要的指标,它表示MySQL找到目标记录所用的访问方式,按性能从高到低排列:

type含义典型场景
system表中只有一条记录系统表
const主键或唯一索引等值查询WHERE id = 100
eq_ref关联查询时,被驱动表通过主键/唯一索引查找JOIN的ON条件
ref非唯一索引等值匹配WHERE city = '北京'
range索引范围扫描WHERE age BETWEEN 20 AND 30
index扫描整个索引树比全表扫描略好,但仍很慢
ALL全表扫描最差,几百万行会卡到秒级

最直观的体会:const和ref查询通常在几十毫秒内返回,ALL在百万级数据下普遍在1秒以上。把一条SQL从ALL优化到ref,效果比提升服务器配置、扩内存都明显。

eq_ref在JOIN场景中很关键。比如:

SELECT * FROM order_info o LEFT JOIN user u ON o.user_id = u.id WHERE o.create_time > '2024-01-01';

如果u.id是主键,MySQL在驱动表(order_info)取出一条记录后,用user_id去user表的主键索引里精确查找,这个过程就是eq_ref。但如果u.id是二级索引或者没有索引,你会发现type变成ALL,而且Extra里可能出现Using join buffer,性能一下就下来了。

3.2 key_len列的计算方法:核对索引实际用到了几列

key_len列的含义是本次查询实际用到的索引字节数。很多时候你以为索引生效了,但实际可能只用了联合索引的前半段,key_len就是用来揭穿这个假象的。

举一个例子:

CREATE TABLE user ( id INT PRIMARY KEY, name VARCHAR(50) NOT NULL, age INT NOT NULL, status TINYINT NOT NULL, KEY idx_name_age (name, age) ); EXPLAIN SELECT id, name FROM user WHERE name = '张三' AND age > 20;

按字段类型算:name是VARCHAR(50),字符集是utf8mb4的话,每个字符最多4字节,50×4=200,加上2字节的变长长度标识,所以name占用202字节;age是INT,4字节。key_len的期望值应该是206字节。

如果EXPLAIN显示key_len = 202,就说明本次查询只用了联合索引的name列,age列因为范围条件的关系没被用于索引定位。这个信息很重要,它直接告诉你联合索引的利用率。

再补充一个计算规则细节:VARCHAR字段允许NULL的话,会在变长长度标识里额外增加1字节;CHAR同样按字符集计算字节数但不需要加变长标识。

3.3 Extra列里隐蔽的信息:Using filesort和Using temporary

Extra列里的信息价值极高,但也最容易被忽略。看到下面这两个词,基本就意味着性能隐患。

Using filesort代表MySQL需要对结果集进行额外的排序,而不是直接按索引顺序取数据。filesort并不产生磁盘文件,而是在内存或磁盘临时文件里做排序操作,数据量大时非常耗时。如果查询里有ORDER BY,且这个排序字段没有被索引覆盖,就会触发filesort。

Using temporary代表MySQL创建了临时表,这通常出现在GROUP BY或DISTINCT高成本场景中。临时表会带来额外内存分配和可能的磁盘写入,性能下降非常明显。

除了这两个负面标志,还有几个正面的:

  • Using index:这表示覆盖索引生效,查询不需要回表,这是最理想的情况。
  • Using index condition:表示使用了索引下推(Index Condition Pushdown,ICP)。InnoDB在8.0之前的版本就有这个优化,它允许MySQL把部分WHERE条件的判断下推到存储引擎层,提前在索引扫描阶段过滤记录,减少回表次数。
  • Backward index scan:表示索引反向扫描,MySQL 8.0针对ORDER BY DESC的优化,性能比filesort好得多。

3.4 possible_keys和key不一致时怎么办

possible_keys列出可能用到的索引,key是实际选用的索引。两者不一致的情况非常常见:MySQL的优化器认为某个候选索引成本更低,但选择的可能不是你认为最优的那个。

此时最需要养成的好习惯是ANALYZE TABLE。因为优化器的成本估算依赖表的统计信息,如果统计信息过期,优化器会错误地选错索引。ANALYZE TABLE可以重新收集索引的基数(Cardinality)和分布情况,能让优化器作出更准确的判断。

但key不理想的时候,先别急着用FORCE INDEX强制指定。因为数据分布一变,强制索引可能会变得更糟。应该先看看:

  1. 表统计信息是不是过期了,跑一次ANALYZE TABLE试试;
  2. 是不是SQL写法导致索引条件匹配率太低;
  3. 如果确实需要强制索引,加注释说明原因并定期复核。

4. 一次线上索引调优的完整流程:从慢SQL到SQL改写

4.1 定位慢SQL:开启慢查询日志并找到候选索引

调优的第一步是找问题,而不是上来就加索引。开启慢查询日志是标配动作:

slow_query_log = ON slow_query_log_file = /var/log/mysql/mysql-slow.log long_query_time = 0.5 log_queries_not_using_indexes = ON

long_query_time设成0.5秒,低于这个阈值的SQL都要看一遍。这个阈值可以根据业务实际情况调整,但刚起步时建议设得严格一些,多收集问题,再从里面筛。

拿到慢SQL后,用EXPLAIN看执行计划。此时要关注的重点是:这条SQL有没有可用的索引?如果没有,加什么索引?如果有但没走,是SQL写法问题还是优化器问题?

举个例子,我处理过的一条真实慢SQL:

SELECT order_id, user_id, status, amount FROM order_info WHERE user_id = 12345 ORDER BY create_time DESC LIMIT 20;

EXPLAIN显示type = ALL,rows = 220万。当时表上只有主键索引,user_id完全没有索引。对于这样的SQL,最简单的优化就是加一个(user_id, create_time)联合索引,用户过滤掉大部分行,排序也走索引避免filesort。

4.2 覆盖索引:把小表变大索引,直接消灭回表

上面的查询还有一个优化空间:查询字段是order_id, user_id, status, amount,联合索引是(user_id, create_time),那么在索引树里能找到user_id和create_time,但status和amount还是需要回表才能拿到。

如果这个接口被高频调用,回表成本就不能忽视。解决方案是建立覆盖索引:

ALTER TABLE order_info ADD INDEX idx_user_cover (user_id, create_time, status, amount);

这样索引叶子里已经包含了查询所需的全部字段,EXPLAIN里会出现Using index,回表动作被完全消除。当然,覆盖索引不是免费的,每个额外字段都会增加索引的存储空间和维护成本,所以只建议针对高频SQL精准设计,别什么字段都往里塞。

有个细节要提醒:覆盖索引中的字段顺序同样有讲究。过滤字段(user_id)放最前,排序字段(create_time)紧随其后,最后才是查询返回的字段(status, amount),也就是"过滤、排序、回表兜底"这样的顺序。

4.3 分页深翻页的优化:延迟关联是常规武器的升级版

深分页问题几乎每个项目都会遇到。前端要查第10000页,每页20条:

SELECT * FROM order_info WHERE user_id = 12345 ORDER BY create_time DESC LIMIT 200000, 20;

这条SQL的"200000, 20"意味着MySQL要先找到排序后的前200020条记录,然后丢弃前200000条,只返回最后20条。虽然走了索引,代价依然很大。

处理方式有很多,我推荐两种也最常用的。

第一种,延迟关联:

SELECT o.* FROM order_info o INNER JOIN ( SELECT id FROM order_info WHERE user_id = 12345 ORDER BY create_time DESC LIMIT 200000, 20 ) t ON o.id = t.id;

子查询只查询二级索引(id和user_id、create_time都在索引里),不读全行数据,所以LIMIT的代价大幅降低。拿到那20个id之后,再回表取整行数据。实测下来,4百万行的表,这个写法比原始写法快一个数量级。

第二种,基于上一页最大create_time的"游标式"翻页:

SELECT * FROM order_info WHERE user_id = 12345 AND create_time < '2024-05-20 10:00:00' ORDER BY create_time DESC LIMIT 20;

这种方式完全跳过了偏移量,只要create_time有索引,性能就是恒定的。前提是业务要能接受"没有跳页功能,只能依次往下翻"的限制。

4.4 排序分组场景:怎么设计索引让filesort消失

GROUP BY在MySQL中的实现原理跟ORDER BY很相似——先排序,再分组。如果分组字段没有索引支撑,一样会触发filesort。比如:

SELECT city, COUNT(*) FROM user GROUP BY city;

如果city上有索引,MySQL直接扫描索引树,按顺序依次统计,不需要额外的排序和临时表。如果没有索引,就会在临时表中累积数据,然后排序统计,数据量大时性能非常差。

项目里常见的写法是:

SELECT user_id, COUNT(*) as cnt FROM order_info WHERE create_time >= '2024-01-01' GROUP BY user_id ORDER BY cnt DESC LIMIT 10;

这类"销售排行"类SQL,最优的索引结构是(create_time, user_id)。因为WHERE限定create_time的范围,联合索引可以从create_time入手缩小范围,然后在索引的就地有序性中完成GROUP BY user_id的分组。

只要记住一个原则就好:WHERE的等值字段、范围字段优先,GROUP BY和ORDER BY字段紧跟,SELECT返回字段兜底。

5. 索引调优的经验清单:设计原则、维护技巧与常见误区

5.1 索引列怎么选:区分度、长度和频率的平衡

选索引列时,有一个很实用的量化指标叫区分度(Selectivity),等于COUNT(DISTINCT col) / COUNT(*)。区分度越高,索引过滤效果越好。例如性别的区分度只有0.5左右,一个索引下来几乎把一半数据都捞出来,意义就很小。

计算公式可以这样验证:

SELECT COUNT(DISTINCT user_id) / COUNT(*) AS selectivity FROM order_info;

如果某列的区分度超过0.8,而且查询频率又高,就很适合做索引;低于0.2的基本可以考虑放弃(除非这个列的意义是过滤大量数据而不是精确定位,比如状态字段提高覆盖索引可能性,某种情况下也是可行的)。

前缀索引也是一个常用手段,特别是对VARCHAR超长字段(如URL、描述文本)。与其对整个字段建索引,不如只取前几个字符作为索引。比如:

ALTER TABLE article ADD INDEX idx_title (title(20));

注意,前缀索引也有一个明显的缺点:不能作为覆盖索引使用,因为索引里存储的只有前20个字符,查询SELECT字段,仍然需要回表。所以对于字段长度大且查询频率一般的场景,前缀索引能省不少空间。

5.2 索引的维护:冗余索引、失效索引与周期性检查

对上线一段时间的表来说,索引既是帮手也可能是累赘。写多读少的表,过多的索引会严重拖慢写入和更新,因为每次DML操作都要同步维护所有索引树。

常见的冗余场景是:

KEY idx_user (user_id), KEY idx_user_status (user_id, status)

idx_user就是冗余索引,因为idx_user_status已经覆盖了user_id作为最左前缀的所有应用场景,保留idx_user完全多余。这种索引过多的表,很多开发同学意识不到:一个三索引的表,写入成本是单索引表的三倍以上。

周期性体检可以用sys.schema_unused_indexes视图(MySQL 5.7+中提供):

SELECT * FROM sys.schema_unused_indexes;

这个视图记录了长期未使用的索引,定期查看并删除,是提升写入性能最直接的手段。

5.3 常见误区与面试高频题:如何把调优经验讲清楚

梳理几个反复被人问到的索引经典面试题,如果把原理吃透了,这些题就一点都不难。

先说说为什么主键推荐用自增ID而不是UUID。这涉及聚簇索引的结构:自增ID是有序的,新记录插入时总是追加到B+树的最右侧,避免了频繁的页分裂和随机写;而UUID无序,会产生大量随机插入,导致页分裂、页碎片化,索引维护的成本急剧上升。这也是InnoDB最底层的物理存储特性决定的。

再比如"索引越多越好吗"这类问题,答案很明确不是。写入要同步维护索引树,索引数据占磁盘空间,优化器选索引也会因为索引太多而决策变慢。生产经验是:单张表二级索引数量建议控制在5个以内,高频写表控制在3个以内。

还有人问,为什么数据量小的表全表扫描比索引更快。这就要回到第一章的结论:一次索引查询至少要读索引页和数据页;如果表只有几千行,全表扫描只需要读十几个页,比索引查询的I/O次数还少,优化器当然选全表扫描。这解释了为什么很多小表上创建的索引用不上——不是索引无效,是没必要。

5.4 一个完整的调优自检清单

最后分享我每次做索引设计或SQL评审时都会过一遍的自检清单。这套检查习惯能帮你规避绝大多数索引使用问题:

  1. 查询条件里的列是否都出现在某个索引中?如果是联合索引,是否满足最左前缀?
  2. 是否有隐式类型转换或函数运算作用在索引列上?
  3. 排序和分组字段能否通过索引完成(避免filesort/temporary)?
  4. 高频查询能否通过覆盖索引消除回表?
  5. 过滤字段的区分度是否足够高(至少大于0.2)?
  6. 分页深度超过100页时,有没有考虑过延迟关联或游标方案?
  7. 是否存在冗余索引可删除?
  8. 索引列的字符集和排序规则,与关联表列是否一致?
  9. 调用ANALYZE TABLE刷新统计信息后,优化器选索引是否恢复正常?
  10. 这类场景如果数据条数超过亿级,是否已经评估过其他架构方案?

这套清单里的每一项,几乎都对应着我们前面讨论过的底层原理。能完整回答这些问题的同学,基本上就是建立了一套完整索引优化的系统能力,而不只是背了一堆零散知识。

我个人在实际操作中的体会是:索引调优最怕的不是不知道原理,而是遇到问题就急着加索引——改一个索引往往牵一发而动全身,尤其是生产环境宽表上多个SQL共享一个联合索引时,调整字段顺序可能让一条SQL变快的同时让另一条变慢。规范做法是先花时间把慢SQL和它的执行计划完整记录下来,把条件逐个拆开,确定瓶颈到底是回表、filesort还是扫描行数过大,再针对性动手。在最后修改之前,也一定要找一张结构类似的只读备份表先测试一遍,确认执行计划和查询耗时都有改善,再放到生产环境去执行。这样整个流程走下来,才能真正做到每一步优化都有理有据,而不是靠直觉碰运气。

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

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

立即咨询