☰
MySQL索引与性能优化:从B+Tree到EXPLAIN的实战指南
2026/10/14 3:03:19 网站建设 项目流程

说实话,刚接触 MySQL 的时候,我对索引这东西的态度是“能用就行”。直到某次线上接口突然从 300 毫秒变成 8 秒,告警响个不停,我才发现一条很普通的查询语句,在数据量上来之后完全变了个样。后来只是补加了一个索引,响应时间又回到了几十毫秒。这件事给我留下很深的印象——索引不是加分项,而是基本功。这次整理的内容是 MySQL 基础教程第八章的主题:索引与性能优化基础,适合刚学完增删改查、准备深入理解数据库内部机制的同学,也适合已经写过不少 SQL 但没认真研究过执行计划的开发者。看完之后,你会知道索引为什么能加速查询,什么样的查询会走索引,哪些写法会让索引失效,以及当慢查询出现时,应该用什么思路去排查和优化。

1. 索引到底是什么:从全表扫描到快速定位

1.1 用生活里的例子理解索引机制

先回答一个基础问题:索引为什么能让查询变快?

假设你手里有一本 1000 页的技术书,想找到“事务隔离级别”这个知识点。没有目录的情况下,你只能从第 1 页开始翻,一页一页找,运气好可能十几页就碰到了,运气不好就要翻完整本。如果这本书有目录,你会先看目录找到这个知识点在第几章,然后直接翻到对应页码。目录就是这本书的“索引”。

MySQL 里的索引也是类似的东西。它把表里某个或某几个字段的值,按一定的数据结构组织起来,让数据库不用扫描整张表就能快速定位到目标行。这种“不用扫描整张表”的特性,在数据量小的时候看不出差别,但一旦表里有几百万、几千万行,全表扫描的代价是线性增长的,而索引查询的代价则可能维持在对数级别。

这么说可能有点抽象,换个角度。一个 1000 万行的表,如果做全表扫描,数据库要读 1000 万行数据才能找到目标。但如果有索引,数据库可以通过索引结构,先在索引里找到符合条件的行的位置,再直接去取那一行或者那几行。这个过程的差距,往往不是两倍三倍,而是几个数量级。

1.2 InnoDB 为什么默认用 B+Tree

索引在 MySQL 中最常见的数据结构是 B+Tree,这也是 InnoDB 存储引擎默认使用的索引结构。为什么不是哈希表,不是普通二叉树,偏偏是 B+Tree?这里有三个原因值得展开讲。

第一个原因是磁盘 IO 的特性。数据库的数据是存在磁盘上的,磁盘读写很慢,内存很快。如果索引是一棵普通的二叉树,树的高度会随着数据量增加而变得很高,意味着查询时要走很多次磁盘 IO。B+Tree 是多叉树,一个节点能存很多个子节点,同样的数据量下树的高度要低得多。以一个 3000 万行数据的表为例,如果主键是自增整型,每个叶子节点能存很多条记录,树的高度通常只有 3 到 4 层。也就是说,一次查询最多只需要几次磁盘 IO 就能定位到目标,这在工程上是非常可观的提升。

第二个原因是范围查询的支持。哈希表能在 O(1) 时间内找到单条等值匹配的记录,但对“大于某个值”“小于某个值”“在两个值之间”这类范围查询完全无能为力。B+Tree 的叶子节点是天然有序的,范围查询只需要找到起点,然后顺着叶子节点的链表依次往后遍历就行。业务里最常见的查询条件往往就是范围筛选,比如按时间范围查订单、按价格区间查商品,这些场景哈希索引根本接不住。

第三个原因是节点空间利用率。B+Tree 的每个节点可以存放多个键值,内部节点不存数据,只存键和指针,这样每个节点能覆盖更大的查找范围,进一步降低树的高度。而且 B+Tree 的叶子节点之间用指针连成了链表,排序和遍历都很方便,这对 MySQL 的 ORDER BY 和 GROUP BY 也有帮助。

1.3 主键索引、普通索引、唯一索引到底有什么区别

索引的类型听起来很多,但核心逻辑只有两条:这个索引是否允许重复值,这个索引是否承担着数据本身的存储。

主键索引是特殊的唯一索引。在 InnoDB 中,表本身就是按主键构建的 B+Tree 组织的,这棵树的叶子节点存的是整行数据。换句话说,主键索引就是这张表的“本体”。MySQL 表要求有主键,原因就在这里:没有主键,InnoDB 还得找一个隐藏列来充当内部使用的聚簇索引,这既浪费空间,也影响性能。

普通索引和唯一索引的区别在于:普通索引允许字段值重复,唯一索引不允许。唯一索引除了加速查询之外,还能在数据库层面保证数据的唯一性。比如用户表的手机号字段,如果业务上要求每个手机号只能注册一次,那直接建唯一索引,既保证了正确性,又拿到了查询加速的好处,一举两得。

联合索引则是把多个字段合成一个索引。举个例子,订单表经常用“用户 ID + 下单时间”查询,那可以建一个 (user_id, create_time) 的联合索引。注意,联合索引有“最左前缀”的讲究,这个在后面的实操部分会单独细说。

1.4 聚集索引和非聚集索引的回表问题

InnoDB 的索引策略和 MyISAM 有本质区别。MyISAM 里所有索引都是独立的树,叶子节点存的是数据行的地址,查数据的时候先通过索引找到地址,再根据地址去取数据,这个操作叫回表。InnoDB 则不同,它的主键索引(聚簇索引)的叶子节点就是整行数据,所以通过主键查询不需要回表。

那 InnoDB 的非主键索引(二级索引)呢?它的叶子节点存的是主键值。也就是说,如果你通过一个普通索引查询数据,执行流程是先在这个二级索引的树里找到对应的主键值,再去主键索引的树里查一次整行数据,这也是回表。一切设计都有取舍:如果二级索引叶子节点直接放整行数据,那每一份索引都会复制全表数据,空间开销太夸张,更新时的维护成本也高。所以 InnoDB 选择了“二级索引存主键值”的方式,用一次额外的回表查询来换取空间和可维护性。

这里就有一个很实用的优化点:如果查询需要的所有字段都包含在索引本身中,就不用回表,这种索引叫做覆盖索引。比如一个索引是 (user_id, create_time, order_status),你只查 user_id、create_time、order_status 这三个字段,MySQL 直接从这个索引里拿数据即可,根本不会回主键索引。这就是很多 DBA 常说的“尽量用覆盖索引查”的原因。

2. 索引设计与选型:不是所有字段都适合建索引

2.1 索引的优势与代价

说到“索引能加速查询”,新手很容易走极端:把所有字段都加上索引,总没有坏处吧?大错特错。

索引是一棵额外的 B+Tree,它需要空间来存放。每次往表里插入一行数据,MySQL 不仅要写主键索引,还要更新该表上每一个二级索引。每多一个索引,写入时就要多维护一棵树。在大量 INSERT、UPDATE 的业务中,索引越多,写入成本越高,甚至会导致插入变慢、锁冲突加剧。

另一个容易被忽略的问题是存储空间。一张 1000 万行的大表,如果给一个很长的 VARCHAR 字段建索引,这个索引树本身的体积可能就撑起好几个 GB,这在磁盘和内存里都是实打实的压力。

所以,索引设计的第一步不是“给哪些字段加索引”,而是“要不要给这个字段加索引”。判断标准大概有三条:这个字段是不是经常出现在 WHERE 条件、JOIN 关联条件或 ORDER BY 中;这个字段的区分度高不高;这个字段是不是 UPDATE 非常频繁。如果某个字段几乎不参与查询,或者虽然参与查询但区分度极低,那建索引的收益就非常有限,甚至不如不建。

2.2 区分度:索引的“筛选力”

“区分度”这个词值得重点讲。区分度指的是字段中不同值的比例。比如性别字段,只有“男”“女”两个值,区分度极低。就算给性别建了索引,查询时也可能返回一半的数据行,MySQL 优化器大概率会认为走索引还不如直接全表扫描来得快。

反过来看订单状态、用户 ID 这类字段,每个值的占比都很小,那索引的筛选效果就非常明显。通常我们用 SELECT COUNT(DISTINCT column) / COUNT(*) 来估算区分度,如果这个比值接近 1,说明每个值基本只对应少数几行,索引效果会很好;如果比值低于 10%,就要慎重考虑。

有人会问:既然区分度低就别建索引,那为什么联合索引 (status, create_time) 在业务中还是常见?因为哪怕 status 本身区分度不高,但 create_time 的加入把整体筛选率拉低了。联合索引的效果要看所有字段组合起来的总区分度,而不是单看一个字段。这里隐含的另一个事实是:区分度低的字段放在联合索引的前列往往意义不大,除非它能用来减少后续字段的比较范围。

2.3 联合索引的字段顺序选择

联合索引最重要的规则就是最左前缀:一个查询要利用 (a, b, c) 这个联合索引,必须从 a 开始匹配,跳过最左边的列去用 b 或 c 是不行的。

那字段顺序怎么排?我的思路是先看等值查询,再看范围查询,最后看排序需求。举个例子:业务里最常见的查询是“按用户查订单,再按时间排序”,那 (user_id, create_time) 比 (create_time, user_id) 合理。因为 user_id 是等值条件,create_time 是排序条件。如果把 create_time 放前面,索引能定位到所有符合条件的记录,但是排序字段 user_id 的顺序完全随机,MySQL 还得额外做一次 filesort;而 (user_id, create_time) 这个索引天然就是“同一个 user 的数据按 create_time 有序排列”,查询时直接顺序读就行。

这里还有一个很隐蔽的优化点:如果联合索引正好覆盖了查询中的所有字段,那么这个联合索引既是查询加速器,又是覆盖索引,可以避免回表。比如 (user_id, create_time) 这个索引,当你只需要查 user_id 和 create_time 时,根本不用回表拿其他字段。在写 SELECT 时,尽量只取需要的字段,不要无脑 SELECT *,因为字段越多,覆盖索引能覆盖到的概率就越低。

2.4 全文索引和其他特殊索引

全文索引和普通索引不是同一个思路。普通索引解决的是“某个字段的值等于什么”或者“在什么范围”的问题,全文索引解决的是“某个文本字段包含什么关键词”的问题。MySQL 的全文索引默认对中文支持不算特别友好,如果业务主要靠搜索功能,通常更推荐用专门的外部搜索引擎,而不是数据库的全文索引。如果在 MySQL 内必须做低频的文本搜索,可以试试全文索引,但要记得它的分词、停用词和打分机制都有限制。

还有一些特殊的索引场景,比如前缀索引——给很长的字符串字段(比如 URL)建索引时,可以只取前 N 个字符作为索引内容。前缀索引的优点是省空间,缺点是会影响区分度,而且这种索引无法用来做覆盖索引。我在实际项目里遇到过一个 URL 字段,全字段建索引占了非常大的空间,改成前 50 个字符做前缀索引后,空间大幅度下降,查询性能因为返回的候选集本来已经很小,实际影响也不大。

3. 实操:用 EXPLAIN 和慢查询日志定位性能瓶颈

3.1 EXPLAIN 执行计划,每个字段都不能放过

优化 SQL 的第一步不是“凭感觉写”,而是让 MySQL 告诉你它打算怎么执行。EXPLAIN 就是干这个的。你只需要在一条 SELECT 语句前面加上 EXPLAIN 关键字,MySQL 就会输出这条查询的执行计划,包括访问类型、使用到的索引、预估扫描的行数等关键信息。

EXPLAIN 的输出字段很多,我挑几个最常看也最有用的讲。

type 字段是访问类型,它从好到差大概有 system -> const -> eq_ref -> ref -> range -> index -> ALL 这样的顺序。system 和 const 表示能直接通过主键或者唯一索引定位到一行,是最高效的。ref 和 range 表示通过索引做了范围或等值匹配,属于比较正常的范围。index 表示虽然用了索引,但是这个索引覆盖了所有需要读取的行,本质上还是在扫描索引树。ALL 就是全表扫描,这是最需要警惕的。

key 字段表示实际用到的索引名。有时候你以为 MySQL 会走索引,但实际没走,问题往往出在这里。

rows 字段是预估需要扫描的行数。注意,它是预估,不是准确值,但对评估 SQL 好坏的参考意义非常大。如果明明过滤条件很严格,rows 却显示几十万,那大概率是索引选择出了问题。

Extra 字段里经常会冒出来几类重要信息。Using index 表示这是一个覆盖索引查询,不用回表,这是好现象。Using filesort 表示 MySQL 不得不额外做一次排序,如果出现在大数据量查询里,通常意味着排序字段没有合适的索引。Using temporary 表示用了临时表,常见于 GROUP BY 或者 DISTINCT,需要警惕。

3.2 慢查询日志配置

慢查询日志是发现性能问题的重要入口。MySQL 可以配置一个阈值,执行时间超过这个阈值的 SQL 会被记录到日志文件中。比如设置 long_query_time = 1,表示大于 1 秒的查询都会记下来。

我以前在某项目排查线上问题时,第一件事就是看慢查询日志。日志里往往直接暴露了当前数据库里最拖后腿的那批 SQL,顺着这些 SQL 一条一条过 EXPLAIN,基本就能找到问题源头。开启慢查询日志的操作很简单,主要是调整 MySQL 配置文件里的几个参数,比如 slow_query_log、slow_query_log_file、long_query_time。不管是用云数据库还是自建库,这个功能都建议第一时间打开,它不会立刻改变你的业务,但会有提前预警的价值。

3.3 通过 EXPLAIN 优化一条真实 SQL

拿一个具体例子来过一遍流程。某业务有一张订单流水表,里面有 500 万条数据。业务查询需求是:查某个用户在最近 30 天内的订单记录,并按下单时间排序,取出最近 20 条。最初的 SQL 大概是这样的:

SELECT order_id, order_status, create_time, total_amount FROM orders WHERE user_id = 123456 AND create_time >= '2024-01-01' ORDER BY create_time DESC LIMIT 20;

第一次用 EXPLAIN 看的时候,type 还是 ALL,rows 预估 500 万。问题很明显:表上没有合适的索引,MySQL 只能全表扫描,然后对满足条件的结果排序,再取 20 条。500 万行数据的全表扫描加排序,慢是必然的。

加一个 (user_id, create_time) 的联合索引,再跑 EXPLAIN,情况就变了。type 变成了 range,rows 降到几千,Extra 里已经不再提示 filesort,因为索引遍历的顺序天然满足 create_time 的排序。实际响应时间从原来的两秒多降到了几十毫秒。这就是一个索引带来的质变。

3.4 关注 EXPLAIN 中的 type 变化对比

把优化前后放在一张表里看,重点信息一目了然:

EXPLAIN 字段优化前优化后
typeALLrange
keyNULLidx_user_time
rows500万3000
ExtraUsing where; Using filesortUsing index condition

type 从 ALL 到 range,说明查询从全表扫描变成了索引范围扫描。rows 从 500 万降到 3000,代表扫描的数据量直接缩了几个数量级。Extra 里 filesort 消失,也省掉了一次额外的排序操作。三个变化叠加起来,效果就是查询耗时从秒级降到了毫秒级,在线上的感受就是页面从“转圈”变成了“秒开”。

4. 常见索引失效场景与避坑指南

4.1 函数操作和隐式类型转换

很多开发者在 WHERE 条件里对字段做函数处理是一种极常见的索引失效场景。比如:

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

这段 SQL 的意图是查出某一天创建的用户,但 DATE(create_time) 这个操作,相当于让 MySQL 对 create_time 字段的每个值都先做一次函数运算,然后再比较。这就导致索引里保存的是原始的时间值,无法直接在索引上进行范围匹配,优化器只能放弃索引。

解决方案很简单:换一种写法。把条件改成范围比较即可:

SELECT * FROM users WHERE create_time >= '2024-01-01' AND create_time < '2024-01-02';

这样 create_time 保持原始值不变,索引就完全能派上用场。

隐式类型转换同样容易踩坑。假设手机号字段是用 VARCHAR 类型保存的,你在查询时却写成了数字 13812345678,MySQL 会对字段做类型转换后再比较,索引自然就失效了。这个问题的排查方式也很简单:在执行计划里看到 type 变成 ALL 或者 rows 很大时,先检查参数类型和字段类型是否一致。

4.2 LIKE 和通配符的摆放

LIKE 查询是另一个经典的索引失效场景。需要说明的是,并不是所有 LIKE 都不能用索引。LIKE 'prefix%'这种前缀匹配是可以用索引的,因为匹配的起点是确定的。而%前缀或者%中间%这种写法,MySQL 无法确定从哪里开始扫描,索引就派不上用场了。

规避办法是看业务到底是不是真的需要“模糊查询”。如果可以,优先用范围查询或者前缀匹配顶上去。真的需要全文级别的搜索,那就老老实实考虑全文索引或者外部的搜索组件,不要在 SQL 的 LIKE 里硬撑。

4.3 OR 条件导致的全表扫描

当一个 SQL 语句里存在多个 OR 条件时,如果其中一个条件的字段没有索引,整个查询就可能退化成全表扫描。比如:

SELECT * FROM orders WHERE order_id = 10086 OR user_name = 'abc';

order_id 有索引,但 user_name 没有索引。MySQL 很难同时用“索引找 order_id”和“全表扫 user_name”这两套结果去合并,干脆选择全表扫描。遇到这种写法,更合理的做法是拆成两个查询,再用 UNION 合并结果。虽然 SQL 看起来长了,但执行效率往往反而更高。

4.4 为什么加了索引优化器却不走索引

这不是索引失效,而是优化器的成本判断。MySQL 优化器在决定是否走索引时,会估算两种方式的成本。如果表的体量很小,总共才几百行,全表扫描的开销非常低,优化器不会选择索引。如果字段值的分布非常不均匀,比如查询只有“是/否”两个值,等值查询会把表里一半以上的数据都捞出来,优化器也倾向于直接全表扫。

所以,看到 EXPLAIN 里 type 是 ALL,不一定每次都是坏事。尤其是小表查询,全表扫描可能比走索引回表更快。真正要警惕的是:大表、过滤条件请求精准、却还在做全表扫描。这种情况下,优先怀疑索引设计的问题,再考虑是不是统计信息过旧导致的优化器误判。

4.5 数据分布统计信息的重要性

MySQL 的优化器决定是否走索引,很大程度上依赖统计信息。如果一张大表经常大批量更新,而统计信息没有及时更新,优化器手里的数据就是过时的。它可能高估了索引的成本,也可能低估了目标数据的数量,最终做出了全表扫描的错误选择。

遇到这种情况,可以用 ANALYZE TABLE 来更新统计信息。我在某次线上排查中遇到过类似问题:一张核心表的几百万条记录被批量更新过,之后出现了个别大查询选择错误索引的情况,跑了 ANALYZE TABLE 之后,执行计划才恢复到预期状态。

4.6 避免 SELECT * 对覆盖索引的影响

还有一个隐藏的坑是 SELECT *。当查询使用了覆盖索引时,如果 SELECT 后面只列索引里包含的字段,MySQL 可以直接从索引树拿数据。但一旦写入 SELECT *,MySQL 发现你要的字段远不止索引里的那几列,就必须回表补全剩余字段,覆盖索引的优势就不复存在了。

在实际项目中,我发现很多慢查询并不是 SQL 逻辑本身有问题,而是 SELECT * 把回表成本放大了。每次只是需要几个字段,却把整行几十列全部取出来,传输的数据量、回表的次数、内存的占用全部上升。改写成只取需要的字段之后,查询速度有明显提升。这也是“字段过多不建索引”场景里容易被低估的优化技巧之一。

5. 实战:一次慢查询的完整排查流程

5.1 背景与现象

某项目有一个后台订单管理页面,运营人员按日期范围查订单列表,翻到后面的页时接口响应特别慢。起初很多人以为是前端渲染的问题,后来看到后端日志里出现了大量超过 3 秒的 SQL,才确定瓶颈在数据库。

这个订单表当时的数据量是 300 多万行,索引情况是有一个主键索引,还有一个 (order_status) 的普通索引,但是查询条件是“下单时间范围 + 订单状态 + 门店编号”。表里的查询条件字段非常多,导致原有的索引根本覆盖不到这个场景。

5.2 排查步骤一:找到慢查询对应的 SQL

先打开慢查询日志,把时间阈值调到 1 秒,很快在日志里翻到了那条问题 SQL。它的大致写法是:

SELECT order_id, order_status, store_id, create_time, total_amount FROM orders WHERE create_time BETWEEN '2024-01-01' AND '2024-02-01' AND order_status = 'paid' AND store_id = 1001 ORDER BY create_time DESC LIMIT 10 OFFSET 5000;

一眼看上去条件不多,但问题在于 OFFSET 5000。MySQL 实现 LIMIT OFFSET 的方式是扫描 5010 条结果,然后丢弃前 5000 条。当列表页很深时,OFFSET 越来越大,查询自然越来越慢。

5.3 排查步骤二:用 EXPLAIN 看执行计划

把这条 SQL 丢进 EXPLAIN 跑了一遍:type 是 ALL,rows 预估三百万,Extra 里出现 Using filesort。这说明 MySQL 已经在拿全部数据做排序,再逐行丢掉前面 5000 条,整个过程完全没有任何索引可以依靠。即便这是后台页面,这种写法也扛不住业务量增长。

5.4 排查步骤三:设计合适的联合索引

基于业务查询模式,把 WHERE 里的字段和 ORDER BY 的字段一起设计索引。首选方案是 (store_id, create_time),因为 store_id 是一个等值条件,放在索引前面更合适,create_time 作为第二个字段既用于范围过滤,也天然满足排序需求。

建完索引后 EXPLAIN 的结果:type 变成了 range,key 显示 idx_store_time,rows 降到 1000 左右,Using filesort 也消失了。这已经解决了大部分性能问题。

但 OFFSET 的问题依然存在。深分页时,即使索引合适,MySQL 还是要扫描并丢弃前面大量符合条件的行。更好的做法是改用“游标式”分页:在查询结果里带上上一页最后一条数据的某个唯一字段(比如 id),下一页查询时通过 WHERE 条件把起点限制在上一页之后,然后 LIMIT 固定数量。

SELECT relevant_fields FROM orders WHERE store_id = 1001 AND create_time BETWEEN '2024-01-01' AND '2024-02-01' AND id > last_seen_id ORDER BY create_time DESC LIMIT 10;

这种方式的扫描行数大幅减少,因为每页只需要从游标位置继续往后取 10 条,而不是每次从头扫描几千行再丢弃。

5.5 排查步骤四:验证结果并复盘

优化后,同样的页面接口从 4 秒多降到了 200 毫秒以内,业务方反馈体验明显改善。复盘这次排查,真正改变结果的只有两件事:一是联合索引设计合理,二是分页写法优化。全程没有改表结构,没有增加缓存层,成本不高,收益很直接。

除了这次案例,日常我还会定期查一下慢查询日志,把那些频繁出现的 SQL 拿出来逐个看执行计划。很多问题在最开始只是几百毫秒,如果不处理,数据量增长后就会变成几秒甚至几十秒。性能优化的基本功,说到底就是“早发现、早处理、尽量减少不必要的扫描数据”。

5.6 索引维护:别建完就撒手不管

索引不是建完就一劳永逸的。随着业务变化,有些索引长期没被使用,白白占用空间、拖慢写入。有一些 SQL 之前走索引,但数据分布变化后执行计划突变,也需要重新评估索引设计。

常见的运维手段有三个:用慢查询日志定位实际未使用的索引;定期对数据变化大的表执行 ANALYZE TABLE 更新统计信息;在维护窗口对大表进行索引整理或重建。删除索引之前要先确认线上没有正在使用的场景,绝不要在业务高峰期直接删索引。

我个人在实际操作中还有一个习惯:每次业务上线新功能,尤其是涉及新查询条件时,把相关 SQL 先放到测试环境用 EXPLAIN 过一遍,确认执行计划是预期的再发布。这比起上线后发现问题再补救,省心得多。

最后再分享一点:性能优化要抱着“先定位,再优化”的心态。不要一上来就堆索引,也不要凭感觉改 SQL。先把慢查询日志、EXPLAIN 执行计划这些基本功用熟,大多数问题都会自己浮出水面。MySQL 的索引和优化体系非常成熟,只要你遵循了这些基础原则,就足以应对绝大多数场景。

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

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

立即咨询