做MySQL查询性能优化这些年,我几乎每周都会遇到“为什么加了索引还是慢”这样的提问。一条SQL卡了十秒,开发和DBA互相排查半天,最后发现是执行计划根本没走对。MySQL 8作为长期支持版本,已经跑在大量线上业务里,但不少人还停留在5.7时代的优化思路,拿老办法处理新版本的问题,越优化越别扭。这篇文章我会从一条SQL在MySQL 8内部的完整执行链路说起,把成本模型、索引设计、执行计划分析、SQL改写、参数调优串成一条线,中间穿插我实际排查过的案例和踩过的坑,最后给出能直接抄作业的落地步骤。适合刚接触优化的开发同学,也适合想系统性补强底层原理的DBA朋友。
1. 先把底层执行链路吃透:一条SQL到底是怎么跑的
很多朋友上来就用EXPLAIN看执行计划,看到type字段是ALL就喊“没走索引”,看到NULL就蒙了。这种做法不是不对,而是跳过了最关键的一步:理解MySQL到底基于什么逻辑在决定“怎么跑”。不懂执行链路,你连EXPLAIN里的cost、rows这些数字为什么这么离谱都发现不了。
1.1 从连接器到存储引擎:SQL的完整旅程
一条SQL进入MySQL 8之后,至少要经过六个环节。
第一是连接管理。客户端连上MySQL,先经过连接器,这一层做身份认证和权限校验。权限校验的结果会被缓存,所以有时候你改了用户权限,已建立的连接还是要等重连才能生效。这也是生产环境里改权限后“怎么没反应”的常见原因。
第二是查询解析。解析器把SQL文本拆成词法单元,再根据语法规则生成语法树。语法不对,在这里就直接报错了。MySQL 8的解析器对复杂嵌套子查询的容忍度比5.7高不少,但语法合法不代表执行高效,真正决定命运的是后面的优化器。
第三是预处理阶段。预处理会检查表名、列名是否存在,解析表别名,还会做权限的二次校验。这里有一个容易忽略的点:如果SQL里使用了视图,预处理阶段会把视图定义展开成底层表,展开后的复杂度经常让人意想不到。
第四是查询优化。优化器是整条链路最核心的环节,它接收到预处理后的语法树,经过逻辑变换和成本估算,生成所谓的“执行计划”。你在EXPLAIN里看到的每一行,就是优化器认为的最优方案。
第五是执行器。执行器按照执行计划,不断调用存储引擎的接口,获取数据并做后续处理。注意,WHERE条件过滤、JOIN拼接、ORDER BY排序、LIMIT截断,这些动作大多发生在存储引擎返回记录之后,由执行器所在的Server层完成,而不是InnoDB层。
第六是返回结果。执行器把最终结果集返回给客户端,这一步还涉及结果集的协议编码和网络传输。我见过不少慢查询,慢的不是SQL本身,而是结果集太大,网络传输吃掉了几百毫秒甚至几秒。这类问题靠索引优化是解决不了的,得从减少返回列和分页入手。
1.2 优化器为什么能选出“烂”执行计划
理解了执行链路就会发现,优化器才是真正的“决策者”。但优化器做出决策的依据,说到底是对数据分布的猜测,而不是对数据的完全掌握。
MySQL优化器估算某个索引能过滤多少行时,依赖的是表的统计信息,比如基数、页数、平均行长度。如果统计信息和实际数据相差很远,执行计划就会跑偏。最典型的表现是:明明有选择性很好的索引,优化器非要用全表扫描,或者选了一个明显不是最优的驱动表。
为什么统计信息会不准?一是表长时间没有做ANALYZE TABLE,统计信息停留在旧版本;二是因为随机采样导致的偏差,对大表尤其明显。MySQL 8虽然会自动更新统计信息,但触发更新的条件是“超过一定比例的行数发生变化”,高频更新的大表依然可能出现统计滞后。
另外,优化器的成本模型里,I/O成本和CPU成本都有默认权重参数。默认情况下,MySQL认为一次随机读的代价远高于顺序读,这就导致优化器有时候会“偏爱”扫描大范围连续数据,而不是走随机I/O更频繁的索引。这种策略在机械硬盘时代是合理的,但在全闪存的服务器上,随机I/O并没那么慢,于是就会出现“优化器觉得全表扫更快,实际上索引查询快得多”的情况。
要验证优化器为什么这么选,最直接的方式是打开优化器跟踪。执行SET optimizer_trace='enabled=on',然后运行SQL,再去information_schema.OPTIMIZER_TRACE里看优化器的决策全过程。你能看到每一张表的估算行数、成本值,以及为什么在候选执行计划里挑中了最终那个。这一步,能让很多“玄学”瞬间变成“可以解释的工程问题”。
1.3 MySQL 8的版本特性带来的变化
MySQL 8.0相比5.7,在查询优化层面做了不少大的改动,最容易被感知的是这几个:
Hash Join。8.0.18版本开始,等值JOIN场景下优化器可以选用Hash Join,替代之前老版本的Block Nested-Loop。它尤其适合大表和小表做等值连接、且连接字段上没有索引的情况。但要注意,Hash Join不是万能药,它需要把驱动表的数据装进哈希表,内存不够会落盘,反而更慢。
不可见索引。你可以把某个索引标记为INVISIBLE,优化器不会走它,但索引本身还在维护。这用来验证“如果删掉这个索引,SQL是不是反而更快”特别方便,不用真的删了再重建。
函数索引。这个真的是从8.0.13开始好用起来的。以前对列做函数处理后索引必然失效,现在可以给表达式创建索引,比如CREATE INDEX idx_year_created ON orders ((YEAR(created_at)))。有了它,很多被迫改写SQL的场景都可以绕过去。
原子DDL。8.0里ALTER TABLE这类操作要么全部成功要么全部回滚,不会像5.7那样中途失败留下半成品。这对索引调整、表结构变更的安全性提升非常明显。
窗口函数和公共表表达式(CTE)也是8.0的明星特性。以前要写一堆子查询和自连接才能实现的排名、累计、同比环比,现在一个窗口函数就能搞定。但窗口函数用起来爽,优化起来也要小心,它通常需要在内存或临时表里保存全部分区数据,数据量大时容易触发磁盘临时表,执行时间立刻拉满。
这些新特性提醒我们:8.0不是5.7换个版本号,优化思路必须跟着更新。
2. 索引设计:最核心的提速手段
说句直白的话,80%以上的查询性能问题,最后都是靠索引解决的。但索引不是“建了就完事”,建得不对不仅浪费磁盘空间,还会拖慢写入。这节把索引的几个关键决策点讲透。
2.1 索引类型与数据结构的取舍
InnoDB的索引底层是B+树,不是哈希索引。虽然MySQL也支持哈希索引,但在InnoDB引擎里,哈希索引只存在于自适应哈希索引(Adaptive Hash Index)的内部优化中,无法由用户直接创建。所以你要牢记:InnoDB的索引默认就是B+树。
B+树的优势在于范围查询和排序。WHERE age > 20 AND age < 30或者ORDER BY created_at这种操作,B+树可以沿着叶子节点顺序扫描,效率远高于哈希结构。哈希索引擅长的是单值等值查询,但MySQL在InnoDB上给不了你这个选项,所以设计时不需要纠结。
另一个重要概念是聚簇索引和二级索引的区别。InnoDB的表数据本身就是按主键组织的B+树,这就是聚簇索引。二级索引的叶子节点存的是主键值,而不是行的物理地址。所以通过二级索引查询时,如果需要的列不在索引里,还得回表拿着主键再去聚簇索引查一次。这个“回表”动作,是很多慢查询的根本原因。
明白了这一点,你就能理解为什么“覆盖索引”这么重要。如果一个二级索引包含了查询需要的所有列,查询就完全不需要回表,直接扫描二级索引就出结果。建索引时把SELECT的列带进索引里,是一个性价比超高的优化手段。
2.2 联合索引的字段顺序与最左前缀原则
联合索引是面试高频题,但它不是背概念,而是要能在建索引时真正用对。联合索引(a, b, c)在B+树里先按a排序,a相同再按b排序,再按c排序。所以查询要能走这个索引,条件里必须包含最左前缀的a列。
最左前缀原则有两个容易忽略的延伸点。
第一点,字段顺序决定了“能命中多少个范围条件”。比如索引(user_id, status, created_at),查询WHERE user_id = 1 AND status = 'PAID' AND created_at > '2024-01-01'能将三列都用上,因为前两列是等值条件,第三列是范围条件。但如果换成WHERE user_id = 1 AND status > 'PAID' AND created_at > '2024-01-01',那么status是范围条件,它后面的created_at就用不上了。一次查询最多只能有一个字段的“范围条件”参与索引过滤,除非后续还有等值条件。
第二点,等值条件优先,范围条件放最后。建联合索引时,把等值比较的列放在前面,范围比较的列放在后面,这是基本准则。很多人一开始建索引是拿着查询条件照着写,结果是范围列挡在中间,后面的列全部失效,索引利用率极低。
从MySQL 8.0开始,还有一个只对8.0以上版本成立的特性:降序索引。5.7时代索引只能默认升序,ORDER BY created_at DESC经常导致filesort。8.0里你可以显式定义KEY idx_user_status_created (user_id, status, created_at DESC),让索引顺序和排序方向一致,直接消除filesort。我优化过不少分页查询,这个特性帮了大忙。
2.3 索引失效的典型场景与规避方法
索引失效的场景很多,归纳起来大概是这几类。
一是对索引列使用了函数或计算。WHERE YEAR(created_at) = 2024通常会让索引失效,因为B+树里存的是created_at本身,不是YEAR后的结果。MySQL 8的函数索引能解决部分问题,但能用好它的人不多,多数人还是老老实实把条件改成范围查询更简单。
二是隐式类型转换。WHERE user_id = '123456',如果user_id是BIGINT且列上有索引,这个查询有可能导致索引失效。因为MySQL要把列值转成字符串和参数比较,转换后索引就没法用了。反过来的情况,WHERE mobile = 13800138000,mobile是VARCHAR,这个数字常量会被转成字符串,不会引发索引失效,但为了保险,还是保持类型一致最省心。
三是左模糊匹配。WHERE name LIKE '%abc%'必然无法使用普通索引,因为B+树只能按前缀匹配。要用开头就是通配符的模糊搜索,就得考虑全文索引或者干脆外部搜索引擎。
四是OR连接的非索引列。WHERE id = 1 OR status = 'PAID',如果两个条件里有一个列上没有索引,优化器可能放弃索引改走全表扫描。用UNION拆分两个等值条件,往往比OR更友好。
五是排序字段和索引顺序不一致。ORDER BY想用索引避免filesort,排序字段必须和索引列的顺序、方向完全对齐。乱序或者混合升降序,都会让优化器放弃索引排序。
我见过很多开发一听到“索引失效”就紧张,其实大多数失效场景都能通过“理解条件表达式和B+树的匹配方式”来预判。你只要想清楚:B+树能不能顺着索引顺序快速定位到你想要的那批记录?能,就走索引;不能,就别指望索引。
3. 用EXPLAIN读懂执行计划
EXPLAIN是优化工具里的照妖镜。但照妖镜也得会用,否则只会看到一串字段,不知道哪些是关键信号。这一节把EXPLAIN的核心字段和推理方法讲明白。
3.1 重点字段解读:type、key、rows、filtered
EXPLAIN输出里最值得你盯住的字段,我按优先级给你排个序。
第一个是type,它描述了表访问方式。从好到差大致是:system>const>eq_ref>ref>range>index>ALL。你在实际优化中,大部分SQL走到ref和range就算健康,走到ALL就要警惕。const意味着主键或唯一索引等值查找,最多返回一行,这是最理想的情况。eq_ref常用于多表JOIN时,被驱动表用主键或唯一索引做等值关联。ref是普通二级索引的等值扫描。index看起来沾了“索引”两个字,其实是“索引全扫描”,和全表扫描一样要避免。
第二个是key。它显示优化器实际用到的索引。有时候你建的索引没被用上,关键可能不在索引本身,而是统计信息、列选择性或查询写法出了问题。用possible_keys能看到有哪些候选,对比key就能发现优化器为什么放弃了你预期的那个索引。
第三个是rows。这是优化器估算的需要扫描的行数,不是实际扫描行数。很多人在意这个数字太小还是太大,其实更重要的是用它来识别执行计划的质量。如果rows和你预估的数量级差得离谱,就可能需要ANALYZE TABLE更新统计信息。
第四个是filtered。这个字段表示表过滤后剩余行数的百分比。比如rows是10000,filtered是10,意味着优化器估算筛选后只剩1000行。filtered太低,要么是索引选择性太差,要么是统计信息不准,值得深挖。
还有一个要用起来的字段是Extra。它里面有几种值得注意的提示:Using index表示覆盖索引,好事;Using filesort表示额外排序,说明索引没顶上去;Using temporary表示使用临时表,常见于GROUP BY、DISTINCT和某些子查询;Using where表示存储引擎返回结果后又在Server层过滤,不一定坏,但要结合前面的type判断。
3.2 从执行计划到优化方案的完整推理过程
只看字段名没有用,关键是把执行计划当成一条“线索链”,顺着线索反推出问题根因。
举一个我自己实际处理过的简化案例。某订单列表接口的慢查询长这样:
SELECT order_id, total_amount FROM orders WHERE user_id = 123456 AND status IN ('PAID', 'REFUNDED') ORDER BY created_at DESC LIMIT 10 OFFSET 10000;EXPLAIN的结果大致是:
| id | select_type | table | type | key | rows | filtered | Extra |
|---|---|---|---|---|---|---|---|
| 1 | SIMPLE | orders | ref | idx_user_id | 20000 | 50 | Using where; Using filesort |
这里出现了两个信号:一是走了user_id索引,但rows估算有20000行,filtered只有50;二是出现了Using filesort。我的推理链是这样的:
首先,因为条件里只有user_id能走索引,status和created_at都不在索引上,所以数据库必须先把该用户的20000条订单全取出来,再在Server层过滤status,再排序,最后做深分页的OFFSET。整个过程变成了“取20000行、过滤一半、排序、丢弃前10000行、返回最后10行”,大部分代价都浪费了。
优化方案很直接:建立一个联合索引(user_id, status, created_at DESC)。这样WHERE里的等值条件user_id和status都能命中索引,created_at也已经按降序排好,ORDER BY不再需要filesort。更重要的是,LIMIT 10 OFFSET 10000可以通过索引的定位能力快速跳过前面的行,而不是把20000行全部取到内存里排序。
改完之后的EXPLAIN通常会变成:
| id | select_type | table | type | key | rows | filtered | Extra |
|---|---|---|---|---|---|---|---|
| 1 | SIMPLE | orders | range | idx_user_status_created | 100 | 100 | Using index condition; Using filesort (可能消失或保留) |
这个推理过程的关键不在于记住每个字段的值,而在于你看到一个痛点(Using filesort),能追溯到原因(索引没覆盖排序字段),再反推出修复动作(重建联合索引并声明DESC)。
3.3 EXPLAIN ANALYZE:MySQL 8的新利器
EXPLAIN给的是估算值,而MySQL 8.0.18以后新增的EXPLAIN ANALYZE给的是真实执行信息。它会把SQL真的跑一遍,输出每一步的实际耗时、实际扫描行数、循环次数。这对判断“优化器估算是不是太离谱”极其有用。
用法很简单,把EXPLAIN换成EXPLAIN ANALYZE,但注意它不支持INSERT等写语句,只适合SELECT、UPDATE和DELETE。
输出长这样:
-> Limit: 10 row(s) (actual time=3.51..3.52 rows=10 loops=1) -> Sort: orders.created_at DESC (actual time=3.49..3.50 rows=10010 loops=1) -> Filter: (orders.status in ('PAID','REFUNDED')) (actual time=0.31..2.24 rows=10010 loops=1) -> Index range scan on orders using idx_user_status_created (actual time=0.28..2.02 rows=20000 loops=1)这里能看到,实际扫描行数、实际时间一目了然。尤其当EXPLAIN的估算rows和实际rows差十倍以上时,就说明统计信息或成本估算出了问题。EXPLAIN ANALYZE不是每次排查都需要用,但它能在优化器行为像黑盒的时候,帮你打开一个真实的观察窗口。
4. 查询改写:从SQL写法上省出性能
索引和参数都优化到位后,SQL本身的写法反而成了瓶颈。相同的业务需求,不同写法在MySQL 8里的执行效率可能差出几个数量级。这一节专门讲改写技巧。
4.1 子查询与JOIN的取舍
只要聊到SQL优化,“子查询慢,改成JOIN”几乎是铁律。但在MySQL 8里,这句话要对半听。8.0对子查询做了很多优化,尤其是半连接变换和物化策略。例如WHERE id IN (SELECT order_id FROM order_items WHERE quantity > 5)这种形式,优化器有可能自动转成半连接,性能不比JOIN差。
但相关子查询依然要小心。例如每行都要执行一次子查询的写法:
SELECT o.id, (SELECT COUNT(*) FROM order_items i WHERE i.order_id = o.id) AS item_count FROM orders o WHERE o.created_at > '2024-01-01';这个子查询依赖外层o.id,相当于对满足条件的外层每一行都执行一次COUNT,如果外层行数很多,执行次数会爆炸。MySQL 8.0.16以后对这类相关子查询做了一些缓存优化,但缓存只对相同参数生效,数据分布复杂时依然很慢。更稳的写法是先GROUP BY聚合,再JOIN回去:
SELECT o.id, COALESCE(cnt.cnt, 0) AS item_count FROM orders o LEFT JOIN ( SELECT order_id, COUNT(*) AS cnt FROM order_items GROUP BY order_id ) cnt ON cnt.order_id = o.id WHERE o.created_at > '2024-01-01';总的来说,8.0里的IN转JOIN不是绝对的,但“把逐行子查询改成提前聚合”的思路永远不会过时。
4.2 深分页优化的三种思路
分页查询在业务里无处不在,LIMIT 1000000, 20是典型的深分页炸弹。MySQL从第100万行开始取20行,必须先把前100万行扫描完才能定位,然后丢弃它们。页数越深,消耗越大,而且这个消耗不会因为你加了索引而消失。
第一种优化思路是延迟连接。先只查主键,再做表连接:
SELECT o.id, o.order_no, o.total_amount FROM orders o JOIN ( SELECT id FROM orders WHERE user_id = 123456 AND status = 'PAID' ORDER BY created_at DESC LIMIT 20 OFFSET 100000 ) t ON o.id = t.id;子查询里只取主键,走覆盖索引可以快速定位20个主键,再回表取完整行。这样即便OFFSET很大,扫描的行数也控制在“定位+20行”的范围内。
第二种是书签分页。记住上一页最后一条记录的位置,用WHERE (created_at, id) < ('2024-05-01 10:00:00', 100000)这样的条件代替OFFSET。这种方案适合实时性要求高的场景,比如“下一页”按钮式的交互,因为它能利用索引直接跳到指定位置,完全避开全量扫描。
第三种是范围分页的预处理。如果分页场景总是围绕某个时间范围,可以提前根据created_at把数据分桶,查询时先定位到桶,再做小范围偏移。这个方案工程成本高,适合数据量极大、对分页响应时间异常敏感的业务。对大部分团队来说,延迟连接和书签分页已经够用了。
4.3 避免SELECT *与函数处理索引列
“SELECT *”是性能优化的头号大敌。表面上只是多写了个星号,实际上它强迫数据库读取整行、把全部列返回给客户端。如果表里有TEXT、BLOB这类大字段,SELECT *会把它们也捞出来,哪怕你根本用不到。这样会增大网络传输量、增大排序缓冲使用量,还可能让覆盖索引彻底失效。
正确做法是只SELECT需要的列。比如列表页只需要订单号和金额,就只查这两列,这样才有机会把这两个字段一起放进覆盖索引,直接避免回表。很多开发觉得少一两个字段无所谓,但在千万级数据量下,覆盖索引和回表的差别就是毫秒和秒的差别。
函数处理索引列的问题前面提过,这里再补充一个常见例子:WHERE DATE_FORMAT(created_at, '%Y-%m-%d') = '2024-05-01'。这种写法用不上索引,改成WHERE created_at >= '2024-05-01 00:00:00' AND created_at < '2024-05-02 00:00:00'就能走索引范围扫描。很多人写SQL时并没有意识到,一个看似微不足道的函数包裹,就让索引在幕后彻底失效了。
5. 参数调优:让InnoDB把硬件吃满
SQL和索引优化都做到位了,剩下的性能空间就要靠数据库参数来压榨。MySQL 8的参数比5.7更丰富,但默认值并不总能适应业务特点。这一节只讲影响力最大的几个参数。
5.1 innodb_buffer_pool_size等核心参数的设置
InnoDB的缓冲池是用来缓存数据页和索引页的内存区域。查询数据时,如果目标页在缓冲池里,就是内存读,速度快到微秒级;如果不在,就得去磁盘读,速度慢几个量级。所以innodb_buffer_pool_size基本决定了热数据的命中率。
经验法则是把这个参数设为服务器物理内存的60%到75%。比如一台32G内存的数据库专用机器,可以设成20G到24G。但要小心里面有个坑:MySQL 8的全局缓冲不止InnoDB buffer pool,还有performance_schema、information_schema、连接线程、排序缓冲等也会占用内存。设得太高,加上系统本身的开销,可能导致内存交换,性能反而下跌。
变更参数后要观察SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_reads'和Innodb_buffer_pool_read_requests。后者除以两者之和就是缓存命中率。命中率低于99.5%就要考虑调大缓冲池,或者优化查询减少需要访问的数据页数量。注意,命中率低不是只靠加内存解决的,如果你一查就是全表扫描,再大的缓冲池也会被不断涌进来的无用数据页挤爆。理想状态是“常用的小数据集常驻内存”,这要求索引和查询都得配合好。
另外,8.0默认的redo log容量也可以根据写入量调整。如果写入压力大,innodb_log_file_size或innodb_redo_log_capacity设得偏小,会频繁触发日志刷盘,影响写入吞吐。这个参数没有普适公式,可以结合SHOW ENGINE INNODB STATUS里看到每秒产生的日志量来推算。
5.2 慢查询日志与监控指标的联动使用
参数调优不能拍脑袋,得靠数据说话。慢查询日志是最基础的观测手段。MySQL 8里建议把slow_query_log=ON、long_query_time设成1秒甚至更低,再打开log_queries_not_using_indexes,把“没用索引的查询”也记录下来。
有了慢日志之后,我会用一个固定流程:先用pt-query-digest或自写脚本做慢日志聚合,按执行次数和总耗时排序,找出最值得优化的TOP SQL。然后把每一条TOP SQL放到EXPLAIN和EXPLAIN ANALYZE下面分析。这是最笨但也最有效的方法,比起凭空猜测“是不是参数不够大”要可靠得多。
再配合几个关键的STATUS指标:Innodb_rows_read能看出读了多少行,Select_scan和Select_full_join能看全表扫描和全连接的情况,Created_tmp_disk_tables能发现临时表落盘的问题。这些指标和慢日志联动起来,能帮你定位性能瓶颈到底在CPU计算、内存命中还是磁盘I/O。
5.3 连接数与线程池的平衡
max_connections设得太大,不代表服务更稳,反而可能引发雪崩。每个连接都要占用内存,8.0里每条连接默认的thread_stack和排序缓冲也会算进去。如果同时涌进来几百个慢查询连接,内存很快被吃光,后面所有连接包括健康查询都会被拖死。
更务实的做法是控制活跃查询数量。max_connections可以调得较大,但配合innodb_thread_concurrency限制InnoDB内部并发访问的线程数,让过多的请求在进入存储引擎之前排队。这样能避免大量线程同时争抢InnoDB的内部资源,反而降低了上下文切换的开销。
线上如果出现过“连接数堆积”的问题,还要看看事务隔离级别和锁等待。长时间持锁的事务会让其他会话一直等待,看起来像连接数被占满。这种时候不要急着调参,应该先找出来源:information_schema.INNODB_TRX和PERFORMANCE_SCHEMA.EVENTS_STATEMENTS_CURRENT里能看到还在跑的事务和正在执行的SQL。参数调优解决的是容量问题,锁和事务问题得靠代码和业务逻辑修复。
6. 实战案例复盘:从分析到落地的完整过程
光讲理论可能还觉得虚,我把一个完整优化过程的复盘写出来,从症状开始,到定位、到方案、再到最终效果,希望给你一个可直接参考的路径。
6.1 案例背景:一个订单查询接口的“由快变慢”
有一次我接手某业务库的优化请求,对方反馈:一个订单查询接口每天早上9点左右开始变慢,接口本来平均响应300毫秒,高峰期涨到3秒以上,还偶尔超时。数据库的CPU使用率不算高,磁盘I/O却很紧张。
这个接口是对外提供的订单列表查询,传参是用户ID、订单状态、时间范围,内部SQL大概是这样:
SELECT id, order_no, total_amount, created_at, status FROM orders WHERE user_id = ? AND status IN (?) AND created_at BETWEEN ? AND ? ORDER BY created_at DESC LIMIT 10;表已经有一个idx_user_id的单列索引,建表时间很早,业务量还没起来时跑得很顺。数据量到了几千万之后,问题集中爆发。
6.2 排查过程:从慢日志发现问题,到EXPLAIN定位根因
先从慢日志里把这条SQL捞出来,确认它在高峰期执行次数很高,平均扫描行数在几十万这个量级。接着用EXPLAIN看执行计划:
type: ref key: idx_user_id rows: 270000 filtered: 20 Extra: Using where; Using filesort问题很清楚:单列索引只用了user_id过滤,返回了27万行候选数据,再在Server层做状态、时间范围过滤,最后排序取10行。高峰期同一个用户反复查询,大量数据页被反复读取,磁盘I/O自然被拖垮。
我继续用EXPLAIN ANALYZE确认真实代价,结果发现实际读取行数和EXPLAIN估算基本一致,所以统计信息不是问题。根因就是索引没有覆盖到status、created_at这两个过滤字段,也没能覆盖排序字段。
6.3 方案落地:索引调整、SQL改写、参数配合后的效果对比
优化方案分三步走。
第一步,调整索引结构。把单列索引改造成联合索引,并把排序方向一起写进去:
ALTER TABLE orders ADD KEY idx_user_status_created (user_id, status, created_at DESC, id DESC);这里特意把主键id也放进索引末尾,是为了消除订单在created_at相同时的无限排序,让排序顺序完全确定,进一步避免filesort。
第二步,微调SQL。把IN (?)条件如果传入多个状态,改成等值条件分别查询后UNION ALL,避免IN列表的长度变化让优化器误判。同时把SELECT列表里不需要的字段剔除,比如业务方其实不需要status,那就别查。
第三步,调整参数。把innodb_buffer_pool_size从8G调到12G,让热数据有更多驻留空间。同时把long_query_time从默认值改小,便于后续持续监控。
上线后的效果很直接:高峰期接口最大响应时间从3秒多降到400毫秒左右,磁盘I/O使用率降到之前的四分之一。EXPLAIN的rows从27万降到了几百,Extra里的filesort也消失了。
这个案例的复盘给我们的启发是:排查性能问题别一上来就怀疑配置不够,先看执行计划,再从索引结构上下手,最后才考虑调参。顺序反了,很容易花大钱办小事。
7. 常见问题速查与避坑指南
最后这部分是把各种高频问题整理成速查表,再加上我踩过的一些坑,希望帮你减少无谓的试错成本。
7.1 七条高频性能问题的排查思路
| 现象 | 可能原因 | 优先排查方法 |
|---|---|---|
| 查询越来越慢,CPU不高 | 磁盘I/O被大量劣质查询打满 | 慢日志聚合,按扫描行数排序 |
| 加了索引还是全表扫描 | 统计信息过旧/索引失效 | ANALYZE TABLE后重新EXPLAIN |
| JOIN查询特别慢 | 连接字段无索引或驱动表过大 | 检查被驱动表连接字段索引 |
| 分页越翻越慢 | 深分页OFFSET太大 | 改延迟连接或书签分页 |
| 排序慢 | 索引顺序和ORDER BY不匹配 | 确认排序字段与索引顺序一致 |
| 临时表落盘 | GROUP BY/DISTINCT去重量过大 | 优化字段组合,增加内存临时表上限 |
| 高峰期连接堆积 | 长事务或大查询占连接太久 | 查INNODB_TRX,杀掉久IDLE事务 |
这张表里每一个“优先排查方法”,对应的都是前面章节讲过的具体动作。如果你按表做完还没解决,别急着扩展思路,先把EXPLAIN ANALYZE打开,把优化器每一步的真实代价看清楚,九成问题都会浮出水面。
7.2 那些年踩过的“优化陷阱”
第一个陷阱是盲目套用“索引越多越好”。我见过一张表上建了十几个索引,写入变得奇慢,磁盘空间也涨得飞快。索引不是免费的,每次插入和更新都要同步维护所有索引B+树。索引数量控制在业务实际查询模式覆盖范围内,去掉那些“可能以后会用”的索引。
第二个陷阱是只看EXPLAIN不看数据量。有时候EXPLAIN显示走了索引,rows很小,但接口还是慢。这种情况要看是不是SELECT了大字段、是否做了大量回表、是否是网络传输瓶颈。索引解决的是“怎么找数据”,但“怎么传数据”同样重要。
第三个陷阱是高估了MySQL 8自动优化的能力。8.0确实更强了,但优化器不会读心术,它不知道你表里的“status=PAID”只占1%,还是占99%。这类极度倾斜的数据分布,优化器很难准确判断,你需要通过直方图、统计信息和必要的索引设计帮它纠正。
第四个陷阱是上线前不压测。很多优化方案在测试库上看着快了十倍,一上生产反而更差。原因通常是生产数据分布和测试数据差太多。所以做索引调整前,最好在只读副本或者低峰期先试跑,对比真实数据的执行计划和响应时间,确认无副作用再切正式环境。
还有一个容易被忽略的小坑:ALTER TABLE建索引在高并发环境下可能引发主从延迟。MySQL 8虽然在线DDL已经比较优秀,但大表加索引还是会拷贝数据和更新统计信息。务必选择低峰期操作,或者用pt-online-schema-change这类工具平滑处理。
回到文章开头那个问题——为什么加了索引还是慢?答案往往不在于“有没有索引”,而在于“索引是不是和查询逻辑完全匹配”。索引字段的顺序、筛选字段的选择性、排序字段的方向、回表的次数,每个细节都在影响最终的查询效率。MySQL 8给了我们更多工具,但工具再多,分析思路才是根本。建议你下次接手慢查询时,别急着改SQL,先花十分钟把EXPLAIN的每一列看懂,再用EXPLAIN ANALYZE验证真实代价,最后再动索引和参数。这个习惯养成之后,很多“玄学慢查询”都会变成可解释、可复现、可解决的工程问题。