先把结论放在前面:绝大多数 MySQL 性能问题,根子都出在索引设计上。这不是夸张,从我接手过的线上案例来看,十次慢查询里至少有七八次,explain 一出来,要么是全表扫描,要么是索引建了但没用上。这篇文章不打算讲那些特别虚的“理论大全”,而是以我实际排查和优化过的经验为主线,把索引优化从“该怎么想”到“具体怎么干”完整梳理一遍。不管你是刚接触数据库的新人,还是被线上慢查询折磨过的老手,这篇文章应该都能给你一些可以直接落地的思路。
文章会围绕这样一条主线展开:先搞明白索引到底在解决什么问题,再学会判断哪些场景真正需要优化,接着是实际动手建索引、调 SQL 时那些容易踩的坑,最后是问题排查时怎么一步步定位到根因。每一部分都会尽量说清楚“为什么这么做”,而不是只丢给你一堆结论。
1. 索引优化的本质:先搞清楚索引到底在解决什么问题
1.1 索引不是“加速器”,而是一种查找结构
很多人对索引的理解,就是“加了索引查询就变快”。这句话方向对,但容易让人忽略一个关键点:索引不是玄学,它本质上是一种数据结构,目的是减少数据库扫描的数据量。
MySQL 里最常见的 InnoDB 引擎,索引用的是 B+ 树。为什么要用 B+ 树?你可以把数据库表想象成一本书,没有索引的时候,你想找某个词,就只能从第一页翻到最后一页,这叫全表扫描。有索引就相当于书最后附了一个“关键词+页码”的目录,你查目录定位到页码,直接翻过去就行。
B+ 树的厉害之处在于,它的每个节点可以存储很多个键值,树的高度通常只有 2 到 4 层。也就是说,哪怕表里有几千万行数据,你通过索引查找一条记录,往往只需要做 3 到 4 次磁盘 I/O。而全表扫描,可能需要读几千个数据页。这个差距,在小数据量时感觉不明显,一旦数据量上了百万级,就是几十毫秒和几十秒的差别。
但这里有一个常见的误解:索引并不是“建得越多越好”。每建一个索引,写入数据时就要多维护一份 B+ 树结构,插入、更新、删除的性能都会受影响。所以索引优化的核心,不是“把所有字段都加上索引”,而是“在合适的查询场景下,用最少的索引覆盖最多的查询需求”。
1.2 什么时候需要优化索引:慢查询的三种典型迹象
在实际工作中,判断要不要做索引优化,我一般看三个信号。
第一个信号是慢查询日志里出现了执行时间超过阈值(比如 1 秒)的 SQL。这种情况最直接,说明这条 SQL 在当前数据量下已经跑不动了。先用 EXPLAIN 看一下执行计划,如果 type 列是 ALL(全表扫描),或者 rows 列估算扫描行数特别大,那基本就是索引的问题。
第二个信号是数据库 CPU 使用率居高不下,但并发量其实并不高。这时候有可能是某些 SQL 在做大量磁盘 I/O,导致 CPU 大部分时间在等待 I/O 完成。这种“CPU 高但业务量不大”的诡异现象,往往也是索引缺失导致的。
第三个信号比较隐蔽,是随着数据量增长,接口响应时间出现“阶梯式”上升。比如数据量在 50 万时接口只要 50ms,到了 100 万时突然变成 500ms,这种非线性增长通常意味着数据量已经突破了某个索引结构的承载阈值,比如联合索引的区分度下降、或者原本的索引选择已经不再适合当前的查询模式。
1.3 索引优化的边界:不是所有 SQL 都靠索引解决
有一点必须提前说明,索引不是万能的。如果你的 SQL 里写了 LIKE '%关键词%' 这种前置模糊匹配,或者对字段做了函数运算(比如 WHERE YEAR(create_time) = 2024),那就算建了索引,MySQL 也会因为无法对计算结果直接匹配索引,而放弃使用索引。这种情况下,更合理的做法是调整查询写法,或者引入额外的搜索方案(比如 ES),而不是死磕索引。
另外,索引优化解决的是“查询”问题,不解决“写”问题。如果你的系统瓶颈在于写入冲突、锁等待,那就算索引建得再好,也无济于事。所以做优化之前,先定位好瓶颈到底在哪一层。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 索引设计的核心细节:建索引前必须想清楚的几件事
2.1 区分度:索引列的选择标准
建索引之前,你要先问自己一个问题:这个字段的值,能不能有效区分出不同的数据行?这个指标在 MySQL 里叫“区分度”(Cardinality),意思是索引列中不重复值的数量。
为什么区分度这么重要?因为索引查找的本质,是通过 B+ 树快速缩小范围。如果一列的值只有几种(比如 status 字段只有“待支付”“已支付”“已取消”三种值),那即使建了索引,MySQL 通过索引定位到的仍然是一大片数据,最终还是得一个一个回表去看。这种情况下,优化器可能会觉得“走索引还不如直接全表扫描快”,于是干脆不用索引。
那怎么判断区分度够不够?我的经验是,对于单列索引,如果这列的不重复值数量除以总行数,比值低于 20% 左右,就要谨慎考虑是否值得建索引。当然这个比例不是绝对的,比如一些枚举字段虽然区分度低,但如果配合其他条件组合查询,仍然可能有价值。
用一个实际例子来说明,假设有一个用户订单表,里面有 user_id、status、create_time 三个字段。如果查询是“查某个用户的所有待支付订单”,那 user_id 的区分度很高(每个用户对应的订单数相对较少),而 status 的区分度很低。这时候建联合索引 (user_id, status),就能先用 user_id 精确定位到某用户的订单,再在结果内筛选 status,效率会好很多。
2.2 联合索引的最左前缀原则
联合索引可能是索引优化里最实用、也最容易被用错的知识点。MySQL 的联合索引遵循“最左前缀原则”:一个联合索引 (a, b, c),实际上相当于建了 (a)、(a, b)、(a, b, c) 三个索引。
这意味着,如果查询条件里只包含 b 和 c,不包含 a,那这个联合索引是用不上的。反过来,如果你查的是“只带 a 条件”或者“带 a 和 b 条件”,那就都能命中索引。
这个特性在索引设计时非常关键。你要根据业务查询的实际情况,把最常作为查询条件的字段放在最左边,这样才能最大化利用联合索引。举个例子,一个订单表的常用查询有四种:
- 按
user_id查 - 按
user_id加status查 - 按
user_id加create_time查 - 按
create_time查
在这种场景下,建 (user_id, create_time) 联合索引,比分别建 user_id 单独索引和 create_time 单独索引更合理。因为 (user_id, create_time) 既能覆盖前三种查询,又因为 user_id 在左边而不会失效。而单独的 create_time 索引,只有在第四种查询时才能用上,覆盖场景太窄。
2.3 回表与覆盖索引:索引优化里的“性价比之王”
关于索引,很多人忽略了一个概念——回表。当你通过索引找到了符合条件的记录后,如果索引里没有包含查询需要的所有字段,MySQL 就必须拿着得到的主键值,再到主键索引(聚簇索引)里查找完整的数据行,这个过程就是回表。回表意味着额外的磁盘 I/O。
那有没有办法避免回表?答案就是覆盖索引。如果查询需要的所有字段都包含在索引本身中,那 MySQL 在索引的 B+ 树上就能直接拿到全部数据,不用再回表了。
这里我特别想强调一个真实案例:之前帮一个电商客户优化订单查询,原始 SQL 是这样的:
sql复制SELECT order_id, order_amount, status
FROM orders
WHERE user_id = 12345
ORDER BY create_time DESC
LIMIT 20;
这个表原本建了 (user_id) 单列索引。通过 EXPLAIN 看到 Extra 列里有 Using filesort,说明排序没有走索引,而是在内存中额外做了一次排序,数据量大时非常慢。
后来我把索引改成 (user_id, create_time, order_id, order_amount, status),也就一个联合索引,把查询用到的条件和返回字段全塞进去了。改完之后,Extra 列变成了 Using index,排序也走了索引,查询从原来的 800ms 降到了 30ms 以内。
这个案例的关键,不只是覆盖索引的威力,更重要的是搞清楚了一个底层机制:索引的叶子节点本身是有序的。在 B+ 树中,索引数据是按照键值有序排列的,如果你的 ORDER BY 字段恰好是联合索引的一部分,而且排序方向与索引顺序一致,那 MySQL 直接顺着索引顺序遍历就行,完全不需要额外排序。这是一种“白嫖”索引有序性的优化方式,成本几乎为零,收益却巨大。
2.4 索引下推:MySQL 5.6 之后的隐藏优化
还有一个容易被人忽略的优化,叫索引下推(Index Condition Pushdown,ICP)。这个特性在 MySQL 5.6 之后默认开启,它的作用是:当使用联合索引查询时,如果索引中包含的字段能在索引层直接做条件过滤,MySQL 就不需要把每条记录都回表去判断了。
举个例子,索引 (user_id, status),查询条件是 user_id = 123 AND status = 'paid'。在没有 ICP 的时候,MySQL 会用 user_id 定位到该用户的所有记录,然后逐条回表检查 status。有了 ICP,MySQL 会在索引遍历时直接检查 status 字段,不匹配的直接跳过,回表次数大幅减少。
这个优化不需要你做任何配置,属于“白拿”的性能提升。但前提是,查询条件里的过滤字段必须在联合索引里,这就是为什么设计索引时要把常用来过滤的字段尽量放进同一个联合索引中。
3. EXPLAIN 与慢查询排查:索引优化的实操工具箱
3.1 学会正确阅读 EXPLAIN,比会写 SQL 更重要
在我带过的团队里,我发现不少开发同事会用 EXPLAIN,但经常抓不住重点。这里我分享一下我的查看顺序,避免新手一头雾水。
拿到 EXPLAIN 的结果后,我通常按四个维度去判断:
type:这是最重要的字段,它表示 MySQL 使用了哪种查找方式。从好到差大致是system>const>eq_ref>ref>range>index>ALL。看到一个ALL,基本就是全表扫描,必须警觉。index虽然看着还行,但实际上是“遍历了整个索引”,不等于高效,也要结合rows来判断。key:实际用到的索引。如果这里显示NULL,说明没走索引。rows:MySQL 估计需要扫描的行数。这个值当然是越少越好。Extra:这里经常藏着重要的性能线索。比如Using filesort表示额外排序,Using temporary表示用了临时表,这两个都要尽量避免。Using index是好事,表示覆盖索引生效了。
还有一个小技巧:EXPLAIN 除了可以查看普通查询,也可以用来查看 UPDATE 和 DELETE 语句的执行计划(在 MySQL 5.6+ 中支持),方法是在语句前面加上 EXPLAIN。这有助于定位写操作慢的问题,因为写操作如果条件没走索引,也可能导致锁范围的扩大。
3.2 慢查询日志配置:把问题暴露在阳光下
很多时候,你根本不知道线上哪些 SQL 是慢的,直到用户投诉。为了避免这种被动局面,建议提前开启慢查询日志。
慢查询日志的配置有两种方式。一种是临时开启,适合排查问题:
sql复制SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;
SET GLOBAL log_queries_not_using_indexes = 'ON';
这样设置了之后,所有执行时间超过 1 秒、或者没走索引的 SQL 都会被记录到日志文件里。log_queries_not_using_indexes 这个开关特别有用,哪怕一条 SQL 只跑了 0.01 秒,只要它没走索引,也会被记录下来,方便你提前发现潜在的性能隐患。
另一种是永久配置,修改 MySQL 配置文件(如 my.cnf 或 my.ini)中的相关参数,保存后重启 MySQL 生效。我建议在生产环境把 long_query_time 设置为 1 到 2 秒,同时开启 log_queries_not_using_indexes,这样既能抓到明显的慢 SQL,又不会因为日志太多而影响磁盘空间。
拿到慢查询日志后,可以用 mysqldumpslow 工具来做汇总分析,它会按执行次数、耗时等维度帮你把日志里的 SQL 洗牌归类,快速定位最需要优化的那几条。
3.3 一次真实的索引优化排查实录
前几天一个朋友找我帮忙,说他们系统有个接口越来越慢,从最初的 100ms 涨到了现在的 3 秒。我让他把慢查询日志拉出来,发现是这么一条 SQL:
sql复制SELECT *
FROM payment_records
WHERE merchant_id = 10086
AND pay_status = 1
AND create_time >= '2024-01-01'
ORDER BY create_time DESC
LIMIT 50;
通过 EXPLAIN 查看,type 列是 ALL,也就是说,这张已经超过 500 万行的表,每次查询都在做全表扫描。表上的索引只有主键和 merchant_id 单独索引。
问题在哪?理论上 merchant_id 有索引,为什么没走?原因在于 SELECT * 加上了 ORDER BY create_time DESC,而这个排序字段也不在索引里。MySQL 的优化器权衡后认为,走 merchant_id 索引拿到结果后还要进行文件排序,不如直接全表扫再加排序,于是选择了全表扫描。
解决思路很清晰,建一个联合索引 (merchant_id, pay_status, create_time)。这个索引的作用:
merchant_id用于等值过滤pay_status用于等值过滤,放在第二列不会破坏最左前缀create_time用于范围查询和排序
改完之后,EXPLAIN 的 type 变成了 ref,rows 从 500 万变成了几百,查询时间降到了 20ms 左右,效果立竿见影。
这个案例特别典型,因为它同时涉及了等值条件、范围条件、排序三个维度。很多人在设计索引时,只关注 WHERE 条件,却忘了 ORDER BY 也是可以走索引的。这是一个非常常见的疏漏。
3.4 安装与版本选择:优化效果从源头开始
在聊索引优化的时候,还有个很现实的维度值得提一句——MySQL 的版本选择。不同版本的优化器行为有差异,数据统计信息的准确性、索引选择的合理性都会影响最终效果。如果你还在用 MySQL 5.5 或者更老的版本,建议至少升级到 5.7。5.7 的优化器在索引选择方面比 5.5 要聪明不少,而且 EXPLAIN 的 type 中多了 index_subquery 等更精细的类型展示,排查问题时能看到更多有用信息。
如果是新项目,直接上 MySQL 8.0 是更省心的选择。8.0 对索引的优化支持更好,比如支持了 INVISIBLE INDEX(隐藏索引),这个功能可以用来测试“删除某个索引后性能会不会受影响”,而不用真的去删索引,在索引优化的时候非常实用。隐藏索引可以让你在不影响业务的前提下,安全验证索引的价值,这是老版本做不到的。
Windows 和 Linux 上安装 MySQL 的步骤差异不大,但如果你用的是 Linux 服务器,建议用包管理器安装而不是源码编译,省事且不容易出问题。安装完成后,第一时间检查 sql_mode 和字符集配置,这两个东西虽然和索引没有直接关系,但会影响数据的存储和比较方式,间接影响索引的命中率。
4. 索引不生效的典型场景与避坑指南
4.1 隐式类型转换:索引失效的头号杀手
这是一个非常隐蔽但极其常见的问题。当你的表里某个字段是 varchar 类型,但查询时传入的是一个整数,MySQL 会“好心”地帮你做类型转换。问题是,一旦对索引列做了函数或隐式转换,索引就会失效。
举个例子,表里有 phone 字段,类型是 varchar(20),你写了:
sql复制SELECT * FROM users WHERE phone = 13800138000;
这里的 13800138000 是整数,MySQL 会将 phone 字段转换成数字再比较,相当于对索引列做了函数运算,索引就废了。正确写法是:
sql复制SELECT * FROM users WHERE phone = '13800138000';
这类问题最坑的地方在于,数据量小的时候根本感觉不到差异,查询照样能出结果,只是慢一点。等数据量涨起来,问题就集中爆发了。我在排查时,遇到索引莫名失效的情况,第一反应就是去检查字段类型和传入参数的类型是否一致。
4.2 函数运算与表达式:索引的天然克星
除了隐式转换,在索引列上做任何函数运算或表达式计算,都会让索引失效。比如:
WHERE YEAR(create_time) = 2024,这种写法无法使用create_time上的索引。正确做法是改写为范围查询:WHERE create_time >= '2024-01-01' AND create_time < '2025-01-01'。WHERE amount + 10 > 100,这种对索引列做算术运算的写法也会导致索引失效。正确做法是把表达式移到常量一侧:WHERE amount > 90。
这类问题的核心逻辑在于:B+ 树索引存储的是原始值,不是计算后的值。MySQL 无法在索引中直接查找一个“经过函数运算后的结果”,所以只能放弃索引。理解了这一点,你就不会再写出这类自毁索引的 SQL 了。
4.3 前模糊匹配与 NOT 条件的处理
LIKE '%keyword' 这种前模糊匹配,因为无法确定匹配的起点,MySQL 只能放弃索引。道理和字典类似:你知道要查的词以“X”开头,就能快速定位到那一页;但如果你只知道词里包含“X”,那就只能整本书翻了。
如果业务确实需要这类模糊搜索,有几个替代方案:
- 尝试改成后缀固定前缀模糊:
LIKE 'keyword%',这种写法是可以用索引的。 - 如果必须全文模糊,考虑引入全文索引或专门的搜索引擎。
- 尽量不用
NOT IN、NOT LIKE、!=这类否定条件,因为优化器通常很难利用索引来求“补集”,结果往往是全表扫描。如果数据量大,可以考虑改写成反向查询,或者拆成多次查询。
4.4 数据与索引统计信息过期的问题
还有一个比较少见、但遇到就很头疼的情况:索引明明存在,查询条件也完全匹配,但 MySQL 就是不用索引。原因可能是表的索引统计信息过期了,优化器基于错误的统计信息做出了错误的决策。
解决办法很简单,执行 ANALYZE TABLE 表名; 重新统计索引信息即可。MySQL 的优化器在决定是否使用某个索引时,依赖的是统计信息而不是实时数据,如果数据变动频繁,统计信息就会逐渐失真。这就好比导航软件用的是过期地图,你明明知道一条近路,它非要给你导航到绕远的那条路上。
在 MySQL 8.0 中,ANALYZE TABLE 的性能已经有所提升,但在大表上执行时仍会短暂占用资源,建议在业务低峰期操作。
以下是一个常用的索引失效场景汇总,方便日常排查时快速对照:
| 场景 | 示例 | 是否走索引 | 优化建议 |
|---|---|---|---|
| 隐式类型转换 | WHERE phone = 13800138000 |
否 | 传入匹配类型 |
| 对索引列做函数运算 | WHERE YEAR(create_time) = 2024 |
否 | 改写为范围查询 |
| 前模糊匹配 | LIKE '%keyword' |
否 | 改为 LIKE 'keyword%' 或引入全文搜索 |
| 索引列参与算术运算 | WHERE amount + 10 > 100 |
否 | 改写为 amount > 90 |
| 使用 OR 连接非索引列 | WHERE id = 1 OR name = '张三' |
否 | 改写为 UNION,或为 name 建索引 |
| 统计信息过期 | 索引存在但未使用 | 否 | 执行 ANALYZE TABLE |
5. 联合索引与排序、分组、去重的联动优化
5.1 索引如何加速 ORDER BY:让排序“白嫖”索引的有序性
B+ 树索引的叶子节点是按索引列有序排列的。所以,如果你的 ORDER BY 条件正好和索引的列顺序一致,MySQL 就可以直接按照索引顺序读取数据,完全不需要额外的文件排序。
这里的关键是“顺序一致”。ORDER BY create_time DESC 和索引 (create_time) 的顺序是一致的(因为逆序读索引也是可行的),所以可以走索引。但 ORDER BY user_id, create_time 如果索引是 (create_time, user_id),那就无法直接利用索引排序了,因为索引先按 create_time 排,再按 user_id 排,而查询要求的排序主次正好相反。
关于排序走索引,有一点经常被忽视:ORDER BY 中字段的排序方向也必须一致才行。如果你写 ORDER BY column1 ASC, column2 DESC,而索引是按照 (column1 ASC, column2 ASC) 组织的,那 MySQL 没法直接用索引完成这种混合方向的排序,因为 B+ 树中的叶节点只能按一致的方向遍历。但在 MySQL 8.0 中,你可以创建“降序索引”来解决这个问题,即 INDEX idx_name (column1 ASC, column2 DESC),这样就能匹配混合方向的排序需求了。
5.2 索引如何加速 GROUP BY:避免临时表
GROUP BY 的本质是先分组再聚合。如果没有索引可用,MySQL 会先创建一个临时表,把数据按照分组字段排列,再做聚合计算,这会带来额外的磁盘和内存开销。
如果 GROUP BY 的字段上有合适的索引,MySQL 就可以顺着索引顺序扫描,遇到相同值的数据自然就归为一组,完全不需要临时表和文件排序。这就是为什么在表设计时,如果你知道某个字段经常用于分组统计,就应该把它纳入联合索引。
另外,配合 MAX()、MIN() 这类聚合函数也有讲究。比如 SELECT MAX(price) FROM products WHERE category_id = 10,如果 (category_id, price) 有联合索引,MySQL 可以直接定位到 category_id = 10 的最后一行的 price 值,因为索引是按 price 排序的,最后一个值自然就是最大值,效率极高。
5.3 索引在去重与优化 JOIN 中的特殊作用
关于 DISTINCT 去重,有个容易混淆的点:DISTINCT 本身并不会导致索引失效,但如果没有合适的索引,MySQL 需要扫描全部数据然后去重。如果 DISTINCT 的字段上有索引,MySQL 可以直接按照索引顺序扫描,由于 B+ 树天然去重(相同值在索引中相邻),所以去重操作就变得极为高效。这里关键不是“去重会不会走索引”,而是“有没有索引可以让去重时避免临时表”。
JOIN 操作也有类似逻辑。驱动表(外层表)被驱动表(内层表)做关联时,被驱动表的关联字段必须有索引,否则每关联一行就全表扫描一次,性能会爆炸性下降。比如:
sql复制SELECT * FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.create_time >= '2024-01-01';
这里 orders 是驱动表,users 表是每次关联都要被查的表。如果 users.id 没有主键索引(显然不可能),但再比如关联字段是 u.phone 且没有索引,那性能问题就严重了。所以在设计 JOIN 时,务必检查被驱动表的关联字段是否有索引。
5.4 用索引优化“分页深翻”问题的思路
分页查询是日常开发里最常见也最容易踩坑的场景。偏移量小时问题不大,比如 LIMIT 20 OFFSET 0,直接走索引扫 20 行就完事。但如果你写的是 LIMIT 20 OFFSET 1000000,MySQL 仍然需要把前 100 万行都扫出来,然后丢弃,只返回最后 20 行。这就是“深翻页”问题。
索引优化可以帮上忙的思路是:先用子查询只查主键,再通过主键关联回表拿完整数据。
sql复制SELECT * FROM orders
WHERE id IN (
SELECT id FROM orders
WHERE create_time >= '2024-01-01'
ORDER BY create_time
LIMIT 20 OFFSET 1000000
);
子查询可以完全走覆盖索引,只扫描索引而不回表,比直接 SELECT * 扫 100 万行要快得多。当然,如果数据量真的超大,更彻底的做法是改成“基于游标”的分页,也就是记录上一页最后一条记录的 id,下一页直接 WHERE id > 上一页最大id LIMIT 20,这种方式在大数据量下是性能最优的。
6. 实战复盘:三种常见业务场景的索引设计全过程
6.1 场景一:电商订单列表查询
这是一个非常标准的业务场景:订单表通常有几十个字段,会随着业务扩张持续增长。
需求分析:用户端要支持“查看我的订单列表”,通常按状态筛选、按时间倒序排序、分页显示。后台要支持“按订单号精确查询”。
考虑到两种典型查询:
sql复制-- 用户端
SELECT id, order_no, amount, status, create_time
FROM orders
WHERE user_id = ?
AND status = ?
ORDER BY create_time DESC
LIMIT 20;
sql复制-- 后台
SELECT *
FROM orders
WHERE order_no = ? ;
索引方案:
- 联合索引
(user_id, status, create_time):覆盖了“用户+状态+时间排序”的场景,create_time放在最后用于排序,同时这个索引也能覆盖住单查user_id的场景(最左前缀原则)。 order_no建唯一索引:后台精确查询,唯一索引可以额外保证业务上订单号不重复。
这里我特别想提醒的是:不要在 user_id、status、create_time 三个字段上分别建三个单列索引。很多人以为单列索引建得多,查询时 MySQL 会把它们合并起来用,其实 MySQL 8.0 之前没有完善的索引合并策略(8.0 之后虽有,但场景受限),而且一个查询通常只会用一个索引。单列索引不仅浪费空间,还会拖慢写入性能。
6.2 场景二:内容系统的分类与标签查询
内容系统是另一个很常见的索引优化场景。假设有一个 articles 表,包含 category_id、tag_id、publish_time、title、content 等字段。
典型查询包括:
sql复制SELECT id, title FROM articles
WHERE category_id = 3
AND publish_time >= '2024-06-01'
ORDER BY publish_time DESC;
索引方案:联合索引 (category_id, publish_time)。这个索引能精确匹配分类,然后在分类内按时间排序,典型的范围查询场景。
这里要注意的坑是:如果你再加一个 tag_id 条件,比如 WHERE category_id = 3 AND tag_id = 5 AND publish_time >= ...,那原来的 (category_id, publish_time) 联合索引就帮不上多少忙了。你需要在执行计划里确认这一点,然后根据实际业务调整索引,比如改成 (category_id, tag_id, publish_time)。设计联合索引时,字段的顺序一定要根据实际的查询条件组合来定,而不是想当然。
还有一个细节:title 字段如果需要模糊搜索,直接在 title 上建普通索引是没用的,因为 LIKE '%关键词%' 走不了索引。如果有强烈的搜索需求,要么用全文索引,要么上搜索引擎。
6.3 场景三:登录鉴权场景的账号查询
登录场景的核心查询是“根据账号找到用户记录”。假设有 users 表,字段 username 是 varchar 类型(或手机号),加上密码、状态等。这类查询的特点是:数据量很大、请求频率极高、单次查询必须足够快。
典型的 SQL:
sql复制SELECT id, username, password_hash, status
FROM users
WHERE username = ? ;
索引方案:给 username 加唯一索引。由于登录场景是精确匹配,而且账号必须唯一,唯一索引既能保证业务约束,又能实现 O(log n) 级别的查找速度。加上这条 SQL 只查了几个字段,如果把这些字段都放入索引中,还能进一步利用覆盖索引避免回表,做到极致优化。
这里有一个必须强调的细节:用户名或手机号一定要用 varchar 类型存储,并且 SQL 参数要传字符串,否则就会掉进前面说的隐式类型转换的坑里。
7. 常见问题速查:索引优化面试与实操高频场景
7.1 面试题与自测:你能正确回答几个?
在面试或者自查的时候,有几个问题很能检验你对索引的理解深度,这里整理出来供大家参考:
问题一:MySQL 为什么选择 B+ 树作为索引结构?
可以分几点回答:一是 B+ 树的非叶子节点不存储实际数据,每个节点能存储更多键值,因此树高度更低,磁盘 I/O 次数更少;二是叶子节点通过指针串联成链表,非常适合范围查询;三是所有数据都在叶子节点,查询性能相对稳定。
问题二:联合索引 (a, b, c) 能命中哪些查询?
这是一个高频考题。能命中的查询类型包括:a 单独条件、a 和 b 组合、a、b、c 三个条件全部出现。但是要注意:如果条件只包含 a 和 c,b 缺失,那只有 a 能用到索引,c 的过滤效果就会大打折扣,属于“索引部分失效”。
问题三:什么是覆盖索引?为什么它查询快?
覆盖索引是指查询涉及的字段全部在索引中,MySQL 无需回表就能获取全部数据。它减少了回表造成的随机 I/O,所以查询速度更快。如果能把查询字段也放入索引中,慢查询的优化效果会非常显著。
问题四:EXPLAIN 结果中,Using filesort 是什么意思?
Using filesort 表示 MySQL 需要对结果进行额外的排序操作,如果没有走索引顺序,通常会先取出数据放入内存(或磁盘临时文件)再排序。看到这个字段,基本可以判断这条 SQL 有排序优化的空间。
问题五:一个 1000 万行的表,没有索引和走唯一索引查询,性能差多少?
这是一个理解性的问题。全表扫描最坏情况下要扫描 1000 万行,而使用唯一索引时,B+ 树的高度大约 3 层,也就是 3 到 4 次磁盘 I/O 就能定位到数据。在传统机械硬盘上,这可能是几十倍甚至上百倍的差距。即便在现代 SSD 上,差距依然很明显。
7.2 索引优化相关的工具与辅助手段
工欲善其事,必先利其器。这里分享几个我工作中常用的工具和方法:
- MySQL 官方自带的
mysqldumpslow:快速汇总慢查询日志。 pt-query-digest(Percona Toolkit 的一部分):功能更强大,可以分析慢查询日志、通用日志、二进制日志等,输出非常详细的报告,是排查 SQL 性能问题的利器。sys.schema_unused_indexes视图(MySQL 5.7+):可以查看哪些索引从未被使用,这些就是可以安全考虑删除的“冗余索引”。performance_schema:结合 MySQL 8.0 的sys.statement_analysis视图,可以看到所有语句的统计信息,比如总执行时间、平均执行时间、扫描行数等,非常适合做整体性能体检。
另外,pt-duplicate-key-checker 和 pt-index-usage 这两个 Percona 工具也能帮助快速定位重复索引和未使用索引。在索引优化项目中,先找出冗余索引并清理,往往是性价比最高的一步,因为把所有索引都保留不动,反而会影响写入性能并占用额外的磁盘空间。
7.3 从索引优化到数据库运维的延伸
聊到这里,我想再说一个容易被忽视的点。索引优化不能只停留在“找出慢 SQL、加上索引”这一步。随着业务演进,查询模式会变化,数据分布也会变化,因此索引需要持续维护。我见过不少团队,上线时索引设计得很合理,后来业务发了新功能,查询条件变了,旧索引就开始闲置,新查询却缺少索引,性能问题随之而来。
所以,合理的做法是把“索引优化”纳入例行的数据库巡检流程,每季度或每半年做一次全面审查。重点看几个方向:是否有未使用索引可以删除,是否有新出现的慢 SQL 需要补索引,是否有索引因为统计信息过期而需要重新 ANALYZE。
这里补充说明一下索引下推、索引合并等特性在 MySQL 优化器中的实际作用,可以帮助大家更好地理解。索引下推在前文已经详细解释过,它能让联合索引中的部分字段在索引层先做过滤,减少回表次数。索引合并在特定条件下(比如 WHERE a = 1 OR b = 2 且 a、b 分别有索引)可能被优化器启用,但实际使用场景有限,强烈建议在设计阶段避免依赖它,而是通过合理的联合索引来解决问题。
8. 一些我在实践中踩过的坑与心得
最后这部分,我想分享几个不那么“技术理论化”、但在实际工作中非常影响效率的经验。
第一个坑,是盲目根据“网上推荐”加索引。不同的业务场景,数据分布千差万别,一套索引方案不可能适配所有系统。我见过有人把整张表的每个字段都加上索引,结果写入性能严重下降,查询也没快多少。正确的做法是先收集慢查询日志,确认哪些 SQL 是真正需要优化的,再针对性地设计索引。没有数据支撑的索引优化,基本都是无效劳动。
第二个坑,是忽略了查询中的 SELECT *。很多慢查询的优化空间不在索引,而在返回字段。SELECT * 会迫使 MySQL 回表拿所有字段,即使索引本身已经覆盖了核心字段,也没法避免回表。把 SELECT * 改成只返回需要的字段,再配合覆盖索引,往往能带来立竿见影的性能提升。这个改动比调索引更简单,很多人却忽略了。
第三个坑,是前文提到的“排序字段没有进索引”。ORDER BY 是索引优化中比较容易遗漏的部分。我做过统计,来找我排查慢查询的案例里,至少有三成是 WHERE 条件都走索引了,但 ORDER BY 字段不在索引中,导致额外的文件排序。记住一个核心原则:联合索引的字段顺序,要同时考虑 WHERE 条件、ORDER BY 和 GROUP BY,而不是只盯着过滤条件。
第四个经验,是关于统计信息更新的。很多人在表数据快速增长后,发现查询性能突然恶化,第一反应是加索引或者改 SQL,但有时候只需要执行一次 ANALYZE TABLE 就能解决。这个操作虽然简单,却经常被遗漏。所以在排查性能问题时,别把运维操作想得太神秘,先去排查统计信息和执行计划,再做大动作。
第五个经验,是关于索引命中的验证。我养成了一个习惯:任何索引改动上线前,都必须用 EXPLAIN 验证一遍,确认 key 列确实用上了预期索引,rows 列符合预期扫描行数。这个习惯帮我避免了很多“建了索引但没走”的尴尬情况。尤其是通过隐藏索引先进行测试,确认索引有效之后再正式创建,这是 MySQL 8.0 时代非常推荐的安全操作方式。
再分享一个小技巧:有时候同一条 SQL,在不同数据量下执行计划可能完全不同。这是因为 MySQL 优化器会基于统计信息选择路径。所以,测试索引效果时,尽量在和生产环境数据量差不多的库上进行验证,不要在只有几万行数据的测试库上做判断,否则上线后可能被实际数据量打脸。
索引优化这件事,说到底是“理解数据分布 + 理解查询模式 + 理解 MySQL 执行逻辑”的综合应用。它不像写业务代码那样有标准答案,更多的时候是权衡和取舍。你既要学会用 EXPLAIN 去观察,也要敢于根据业务形态调整索引结构。希望这篇文章能帮你少走一些弯路,把索引优化从“拍脑袋”变成“有章法”。踩过的坑越多,你就越能体会到,数据库性能优化这门手艺,核心不在于背多少理论,而在于能不能准确判断那条 SQL 到底是慢在 I/O、慢在排序、还是慢在回表,然后对症下药。
