资讯动态

MySQL Explain执行计划详解:慢查询排查与索引优化实战

发布时间:2026/9/18 20:59:09 来源:尧图企业网站定制
凌晨两点被电话叫起来一条订单查询接口的 P99 从 80ms 直接冲到 4.2s业务方在群里刷屏。我做的第一件事不是翻代码而是连上库把那条 SQL 原封不动复制出来前面加上 EXPLAIN 敲回车。两秒钟后我看到 typeALL、rows230万、Extra 里光秃秃只有 Using where心里就有数了。MySQL 执行计划 Explain 这个东西说它是 DBA 和後端工程师的听诊器一点不夸张——它不会直接告诉你加个索引就好了但它会把优化器打算怎么读表、打算读多少行、用哪个索引、在哪一步排序全都摊在你面前。这篇内容我打算把 Explain 从头到尾讲一遍每一列到底在说什么、哪些数字会骗人、为什么同一个 SQL 换个参数写法执行计划就完全变了、以及我在真实排障里遇到的四类典型劣化现场。适合正在被慢查询折磨的後端同学、刚接手线上库的运维、以及准备面试但只会背type 要至少 range的朋友。不需要你懂优化器源码但需要你愿意动手在自己的库上敲几遍。1. 慢查询排查第一刀先把 EXPLAIN 敲出来1.1 EXPLAIN 到底在回答什么问题很多人对 Explain 的理解停留在看看有没有走索引这个认知太窄了。它实际上在回答三个层次的问题。第一层是访问路径这张表是全表扫、走索引范围扫、还是走唯一索引精确命中。第二层是代价估算优化器预计要读多少行、过滤后剩多少行、每个候选索引的代价是多少。第三层是执行细节需不需要额外排序、需不需要建临时表、索引条件下推能不能生效、连接时用哪种算法。这三层信息是逐层递进的。你看到 typeALL 只是症状真正的原因可能藏在 possible_keys 为空压根没可用索引、也可能是 key 有值但 rows 估算巨大优化器觉得走索引不划算还可能是 Extra 里的 Using filesort 才是真正的耗时大头。只盯 type 一个字段去调优很容易把一条本来没问题的 SQL 改得更慢。我自己的习惯是拿到一条慢 SQL先看 Extra 和 rows再看 type 和 key最后回头核对 key_len 有没有被截断。这个顺序和很多教程相反但实测下来最能快速定位大头——因为线上大部分慢查询不是扫了全表这么极端而是走了索引但排序在磁盘上做了几十万行。还有一个必须提前说清楚的点Explain 输出的 rows 是估算值不是真实值。它来自 InnoDB 的统计信息索引基数 cardinality 采样页统计信息过期、数据倾斜严重、或者用了非索引列做条件估算都可能差出几个数量级。所以看到 rows1 千万别高兴太早看到 rows1000000 也别急着否定真正的对账手段在后面的 EXPLAIN ANALYZE。1.2 优化器是怎么挑执行计划的要读懂 Explain得先知道优化器在纠结什么。MySQL 的优化器8.0 之后是基于代价的 CBO并且引入了 Hypergraph 优化器处理多表连接会枚举若干候选执行计划给每个计划算一个代价值挑最小的。代价主要来自两块IO 代价读多少页和CPU 代价比较多少行、排序多少行。这里有个关键概念叫回表代价。假设表上有二级索引 idx_name你要查 name 和 age而 age 不在索引里。走 idx_name 找到主键再回主键索引取 age这叫回表。如果 name张三 匹配了 10 万行优化器就会算一笔账走索引要 10 万次随机回表还不如直接全表扫一遍按顺序读。于是你看到的现象就是——明明有索引Explain 里 key 却是 NULL。这不是 MySQL 抽风是它算过觉得不划算。理解这一点之后很多玄学现象就解释得通了。为什么加了 LIMIT 10 之后突然走索引了因为优化器知道只需要 10 行回表 10 次很划算。为什么换成 SELECT * 就不走索引了因为需要回表的列变多了覆盖索引的优势没了。为什么同样是范围查询值域宽的走全表、值域窄的走索引也是这笔账。提示与其反复猜优化器的心思不如学会看它的账本——optimizer_trace会把每个候选计划的代价列出来这在第 4 节会详细讲。2. 输出列逐项拆解哪些列必须看哪些列容易骗人2.1 id、select_type、table先弄清谁在查谁id 这一列的本质是执行顺序的编号不是第几个查询。规则很简单id 相同的行从上往下依次执行id 越大优先级越高越先执行id 为 NULL 的行通常是 UNION RESULT表示最后合并结果。很多人在复杂 SQL 里看到 id 出现 1、1、2、3 就懵了其实只是说明子查询id2、3先跑外层主查询id1后跑。select_type 描述的是这一行在整体结构里扮演的角色常见取值我整理成下面这张表配合实例看会更清楚select_type 取值含义典型场景SIMPLE简单查询没有子查询和 UNION普通单表/多表 JOINPRIMARY最外层查询含子查询时的外层语句SUBQUERY不在 FROM 中的、不相关的子查询where id in (select ...) 且不依赖外层DEPENDENT SUBQUERY相关子查询依赖外层字段where exists (select ... where a.xouter.y)DERIVEDFROM 子句里的派生表from (select ...) tMATERIALIZED被物化的子查询8.0 中 IN 子查询物化后UNIONUNION 中的第二个及之后的 SELECTunion all 的後半段UNION RESULTUNION 结果合并合并临时表看到 DEPENDENT SUBQUERY 就要警惕通常意味着子查询会被外层每一行驱动执行一次也就是常说的相关子查询陷阱。2000 万行的外层表 × 每次子查询 1ms那就是 20000 秒。这类 SQL 在业务代码里经常因为 ORM 自动生成而悄悄出现我在排查时如果看到这个值第一反应就是把它改写成 JOIN。table 列则是参与这一行执行的表名或别名派生表会显示成derived2这种形式数字对应 id。当你在 Explain 里看到derived2并且 typeALL、rows 很大基本可以判断派生表被物化成了临时表且没走索引——这是个非常常见的性能黑洞。2.2 type从 system 到 ALL 的十一级台阶type 是所有人最容易记住、也最容易误读的一列。它描述的是访问类型从好到坏的常见排序是type 值含义大致量级system表只有一行系统表极少见const通过主键或唯一索引等值命中1 行eq_refJOIN 中被驱动表用唯一索引命中每行 1 条ref非唯一索引等值匹配几十到几千行fulltext全文索引视匹配度ref_or_null类似 ref额外包含 NULL一般index_merge多个索引合并使用视情况range索引范围扫描视范围index扫整棵索引树通常很大ALL全表扫描最大这里有几个反直觉的点必须说清楚。第一typeindex 不一定比 range 快它只是说明扫的是索引而不是表如果你的索引是 (id, name) 这种宽索引扫整棵索引树的代价可能比扫表还高。第二typeALL 不一定就是灾难如果表只有 200 行全表扫的代价远小于走索引再回表优化器选 ALL 是正确的决定这种情况改它反而变慢。第三工程上最实用的判断标准不是 type 的绝对档次而是type 与 rows 的组合。我一般这样分type 在 ref/eq_ref/const 且 rows 在千级以内基本健康typerange 且 rows 在万级以内可接受但要关注 filteredtypeindex 或 ALL 且 rows 超过十万就必须动手了。这个经验阈值不是教科书结论是我在几套不同规模业务库上总结出来的你在自己库里跑一段时间后也会形成类似的直觉。注意type 只是访问方式的标签真正决定耗时的是这个访问方式要读多少页、这些页在不在内存里。缓冲池命中率高的时候ALL 扫一百万行可能只要几百毫秒冷数据下走索引回表几十万次反而更慢。所以永远要结合线上实际情况判断。2.3 key、key_len、possible_keys索引到底用没用possible_keys 是优化器认为可以用的索引候选集key 是最终实际用了的索引。两者不一致时才是有信息量的时候如果 possible_keys 有值而 key 是 NULL说明优化器评估后主动放弃了索引通常是回表代价太高或者统计信息不准如果 possible_keys 本身就是 NULL那就是索引缺失或者条件写法让索引失效了这是纯粹的 SQL 改写问题。真正的高手会盯的是key_len因为这列能反推出联合索引用到了几个字段。它的计算规则是按字段的实际存储长度累加MySQL 8.0 下常见字段的参考值如下字段类型索引长度字节可空时额外 1TINYINT12INT45BIGINT89DATETIME(0)56TIMESTAMP(0)45CHAR(n) utf8mb44n4n1VARCHAR(n) utf8mb44n24n3假设有联合索引idx_a_b_c (a int not null, b varchar(50) not null, c int not null)如果 Explain 里 key_len 显示 4说明只用到 a显示 4202206说明用到 a 和 b显示 210 才是三个字段全部用上。这个推导在排查为什么联合索引只走了一半的时候极其有用——你不需要去读 SQL光看 key_len 就知道断在第几个字段上。我遇到过一个典型案例SQL 是where a1 and bxxx and c3看着三个条件都命中了联合索引但 key_len 只有 206。原因出在 b 字段上代码传进来的参数类型是数字虽然值能对上但 MySQL 在做类型比较时做了转换判断导致第三列 c 无法继续用索引下推。这类问题不打印 key_len 根本发现不了。2.4 rows 与 filtered最容易被误读的两个数字rows 是优化器估算的需要扫描的行数注意是扫描不是返回。filtered 是一个百分比表示经过 where 条件过滤后还剩多少比例的行预计会进入下一步。这两个数字的正确用法是相乘rows × filtered% 才是这一行最终向上层输出的估算行数。举个实际的例子。假设 Explain 有一行 rows10000、filtered1.00那么实际输出大约 100 行另一行 rows500、filtered100.00实际输出 500 行。单看 rows后者好像更快但结合 filtered 一看前者从 10000 行里过滤出 100 行过滤成本是 9900 次比较而后者没有过滤成本。在多表 JOIN 里这个乘积直接决定了下一层被驱动表要被驱动多少次。还有个历史坑必须提醒MySQL 5.6 及更早版本filtered 在没有条件下推时会被写成 100.00这个 100 不代表真的没有过滤而是根本没有估算。虽然现在主流已经是 8.0但如果你在维护老库别被这个数字误导。8.0 之后 filtered 的估算准确度好了不少但仍然依赖统计信息质量。rows 估算失真的头号原因是统计信息过期。InnoDB 的统计信息默认不会自动频繁刷新尤其是大批量导入、批量删除之后。这时候执行ANALYZE TABLE 表名手动刷新一下往往能让执行计划立刻回到正常轨道。8.0 还支持直方图统计对于取值分布极度不均的列比如状态字段、城市字段建个直方图比建索引还管用ANALYZE TABLE orders UPDATE HISTOGRAM ON status WITH 64 BUCKETS;64 是桶数一般用默认或 64 就够。这个技巧我在处理某个状态值占了 95% 的订单表时用得最多效果比硬加索引好。2.5 Extra信息量最大的一栏如果只能看 Explain 的一列我选 Extra。它包含的信息比前面所有列加起来都多也是最容易出现看懂了但没重视的地方。常见的值我按加分项和减分项分一下加分项说明优化到位Using index覆盖索引查询所需列全在索引里不需要回表。这是最理想的状态。Using index condition索引条件下推ICP把 where 里能用索引判断的条件下推到存储引擎层过滤减少回表次数。Using MRR用了 Multi-Range Read把随机回表转成近似顺序读。Using index for group-by松散索引扫描GROUP BY 直接利用索引有序性不用建临时表。Using index for skip scan8.0.13 之后的新特性最左前缀缺失时也能部分利用索引。Select tables optimized away优化器直接用索引统计信息得出结果比如select min(id) from t走主键。减分项需要重点排查Extra 值问题常见对策Using filesort需要额外排序调整索引顺序与 ORDER BY 对齐或减少结果集Using temporary需要建临时表GROUP BY / DISTINCT 列建立合适索引Using join buffer被驱动表没走索引给关联列加索引Using where存储引擎返回后再过滤属正常现象但配合大 rows 就要警惕Range checked for each record每行都要重新评估用哪个索引通常是关联列缺索引重点说说Using filesort。很多人一看这个词就以为排序落磁盘了其实它既包括内存排序也包括磁盘排序名字有误导性。真正判断是否落盘要看Sort_merge_passes状态变量和sort_buffer_size。小结果集几千行以内的内存排序非常快完全不用管但如果 rows 是几十万还带 filesort那就必须处理。处理思路只有一个让排序字段的顺序与某个索引的字段顺序完全一致这样 MySQL 可以直接按索引顺序读出来filesort 自然消失。Using temporary也是同理。它常见于 GROUP BY 的字段没有索引、或者 DISTINCT 与 ORDER BY 用了不同字段、或者 UNION 去重。临时表如果在内存里internal_tmp_mem_storage_engine相关配置还好一旦超过tmp_table_size和max_heap_table_size就落磁盘性能会断崖式下跌。我在线上看到这个值时处理优先级比 filesort 还高。3. 动手实操四类高频劣化现场3.1 现场一最左前缀被破坏建张演示表模拟订单场景CREATE TABLE t_order ( id bigint NOT NULL AUTO_INCREMENT, user_id bigint NOT NULL, status tinyint NOT NULL, city varchar(32) NOT NULL, amount decimal(10,2) NOT NULL, create_time datetime NOT NULL, PRIMARY KEY (id), KEY idx_user_status_city (user_id,status,city) ) ENGINEInnoDB;写入 200 万行数据后跑这条 SQLEXPLAIN SELECT id, amount FROM t_order WHERE status 1 AND city 杭州;预期会看到 typeALL、possible_keysNULL、keyNULL。原因很直白联合索引的顺序是 user_id → status → city跳过第一个字段直接用后面的B 树没法定位。这里有个例外要说清楚8.0.13 之后优化器在某些情况下会启用 skip scan把 user_id 的每个不同值枚举一遍再走后续字段但它的启用条件很苛刻——第一个字段的基数必须很低而且优化器算了账觉得划算才行。指望它来救场是不现实的正确的做法还是补一个(status, city)的索引或者把 user_id 加上。修完之后再 explain看到 key_len 等于 status 的 1 字节加 city 的 4×322130 字节合计 131status 是 NOT NULL tinyint1 字节city 不可空 varchar(32) utf8mb4130 字节。但注意因为索引里没有 amount 和 id 之外的覆盖能力……实际上 id 是主键InnoDB 二级索引叶子上自带主键值所以这条 SQL 其实是覆盖索引扫描Extra 里应该出现 Using index。这就是为什么我建索引时习惯把常用查询的返回列考虑进去——能覆盖就覆盖。提示最左前缀的判定不只看书写顺序还要看等值条件和范围条件的分界。一旦某个字段用了范围、、between、like x%它后面的字段就无法再用于索引定位只能用于索引下推过滤。这一点在 key_len 上体现得非常直接。3.2 现场二隐式类型转换与函数包裹这两类问题是 SQL 改写里性价比最高的优化因为通常改一行代码就能让性能提升十倍以上而且不需要动表结构。先看隐式转换-- user_id 是 bigint参数传成了字符串 10086 EXPLAIN SELECT * FROM t_order WHERE user_id 10086;字段是数字、常量是字符串时MySQL 会把字符串转成数字再比较索引依然有效。但反过来如果字段是 varchar常量传的是数字问题就大了-- city 是 varchar参数传成了数字 EXPLAIN SELECT * FROM t_order WHERE city 0;这时 MySQL 会把每一行的 city 值转成数字再和 0 比较索引直接失效type 退化成 ALL。这个坑在 Java 代码里特别隐蔽因为 MyBatis 的#{}传参类型如果和方法参数声明不一致就可能出现。我排查过的一个案例里订单号是 varchar 存储的前端传参时被某层框架自动转成了 Long结果一条本来走唯一索引的查询变成了全表扫 800 万行。函数包裹索引列是另一类经典问题-- 错误写法索引列被函数包住 EXPLAIN SELECT * FROM t_order WHERE DATE(create_time) 2025-01-15; -- 正确写法改成范围查询索引可用 EXPLAIN SELECT * FROM t_order WHERE create_time 2025-01-15 00:00:00 AND create_time 2025-01-16 00:00:00;原理很简单B 树是按 create_time 的原始值排序的一旦你套上 DATE()每一行都要先算函数值才能比较索引的有序性就没用了。等价改写的套路就是把函数操作搬到常量那一侧。同理substr(order_no,1,4)2025可以改成order_no like 2025%后者能走索引范围扫。这里补一个 8.0 之后的小福利函数索引。如果你的业务确实需要按 DATE(create_time) 查询可以直接建一个函数索引ALTER TABLE t_order ADD INDEX idx_create_date ((DATE(create_time)));这个索引会按函数结果排序原 SQL 不用改写就能命中。代价是索引维护成本插入更新时都要多算一次函数值所以只在你确实高频使用该函数查询时才考虑。3.3 现场三排序分组引发的 filesort 与 temporary排序问题在 Explain 里的表现就是 Extra 出现 Using filesort但真正要判断的是这个排序能不能被索引消掉。规则可以总结成三条。第一条ORDER BY 的字段顺序必须和索引字段顺序一致。索引是 (user_id, create_time)ORDER BY user_id, create_time可以直接利用索引ORDER BY create_time, user_id就做不到只能 filesort。很多人以为字段一样就行其实顺序错了结果完全不同。第二条排序方向要一致或者表上有降序索引。ORDER BY a ASC, b DESC这种混合方向在 MySQL 8.0 之前是无法用索引的。8.0 支持真正的降序索引可以这样建ALTER TABLE t_order ADD INDEX idx_user_time (user_id ASC, create_time DESC);建完之后ORDER BY user_id ASC, create_time DESC就能直接走索引。这个特性在实际业务里很实用因为按用户分组、按时间倒序是极常见的列表页需求。第三条WHERE 等值条件与 ORDER BY 的字段顺序要构成连续前缀。索引是 (user_id, status, create_time)SQL 是WHERE user_id1 ORDER BY create_time这里 status 是断点排序无法直接利用索引。这也是为什么我在设计联合索引时总是把等值条件字段放前面、范围或排序字段放后面。再看 GROUP BY。它的原理是先把数据按分组字段排序再聚合。所以如果分组字段上有索引MySQL 可以直接顺序读没有索引就必然产生临时表加排序也就是 Using temporary; Using filesort 同时出现。有一次我优化一个报表 SQL只把GROUP BY city改成走 (city) 索引同时把SELECT *收窄成实际需要的四个字段查询时间从 3.8 秒降到 210 毫秒。收窄 SELECT 列的收益经常被低估——它可能让原本要回表的查询变成覆盖索引。3.4 现场四JOIN 顺序与派生表物化多表 JOIN 的执行计划里表出现的顺序就是执行顺序第一张是驱动表后面是被驱动表。MySQL 8.0 用的是嵌套循环连接Nested Loop Join被驱动表会被驱动表的每一行调用一次。所以核心原则是小表驱动大表被驱动表的关联列必须有索引。Explain 里怎么看出问题看两张表的 rows如果第一张表 rows5第二张 rows800000 且 keyNULL那就意味着 800000 行的表会被完整扫描 5 次总共 400 万行。而如果第二张表关联列上有索引Explain 里它会显示 typeref、key对应索引每次只读几行。优化器一般会自动选小表做驱动表但在以下情况会选错统计信息不准、表上有 WHERE 条件导致估算偏差、或者你用了 STRAIGHT_JOIN 强制顺序。我遇到过一次优化器选了估算 rows1 但实际 30 万行的表做驱动原因是那个字段的统计信息是几天前的。ANALYZE TABLE 之后优化器就自己纠正了。派生表的问题更隐蔽EXPLAIN SELECT t.city, COUNT(*) FROM (SELECT city, amount FROM t_order WHERE status1) t GROUP BY t.city;如果这个派生表被物化Explain 里会看到 id 较大的一行 tablederived2typeALL意味着先跑子查询把结果存进临时表无索引外层再从临时表里扫。8.0 有derived_merge优化能把这个子查询直接合并进外层避免物化但合并有条件子查询里有聚合、DISTINCT、LIMIT、UNION 时就不会合并。想确认是否合并可以查看 optimizer_switchSELECT optimizer_switch\G看到derived_mergeon表示优化开关是开的。如果确实无法合并退而求其次的办法是把派生表改成 JOIN 或者给子查询加上 LIMIT 减少物化行数。我的经验是能写成 JOIN 的尽量写 JOIN派生表在 8.0 下虽然比 5.7 聪明很多但仍然是最容易踩坑的写法之一。4. 进阶玩法EXPLAIN 之外的三件套4.1 FORMATJSON / TREE 看代价普通 EXPLAIN 是表格输出方便但信息有限。加一个 FORMAT 参数就能看到优化器内部的代价账本EXPLAIN FORMATJSON SELECT id, amount FROM t_order WHERE user_id10086 AND status1\GJSON 输出里有两个字段特别值钱。一个是query_cost这是优化器为整个查询估算的总代价单位是随机读页的抽象值。另一个是每个步骤的read_cost和eval_cost以及prefix_cost累计到当前步骤的代价。当你纠结到底是走这个索引还是那个索引时把两种写法的 JSON 拉出来对比 query_cost比凭直觉猜靠谱得多。还有一个更直观的FORMATTREE它以树形展示执行流程嵌套关系一目了然EXPLAIN FORMATTREE SELECT o.id, u.name FROM t_order o JOIN t_user u ON o.user_idu.id WHERE o.status1;输出会是类似- Nested loop inner join - Filter ... - Index lookup on u using PRIMARY (ido.user_id)这样的结构缩进层级直接告诉你哪个操作在里层。排查多表 JOIN 时这个格式比表格清楚太多。我现在的习惯是单表看表格多表看 TREE。4.2 EXPLAIN ANALYZE 对账估算与实际8.0.18 引入的EXPLAIN ANALYZE是个分水岭功能它会真正执行这条 SQL然后输出每个节点的估算行数、实际行数、实际耗时。前面说过 rows 是估算的这里终于有了对账手段EXPLAIN ANALYZE SELECT city, COUNT(*) FROM t_order WHERE status1 GROUP BY city;输出里你会看到类似(actual time0.35..1240 rows8 loops1)和(cost... rows100000)前者是实际值后者是估算值。如果两者相差十倍以上基本可以断定统计信息有问题直接ANALYZE TABLE往往就能修好。但这里有个巨大的安全警告必须放在最前面EXPLAIN ANALYZE 会真实执行语句包括写操作。对 UPDATE、DELETE 使用它数据是真会被改的。我的做法是两种要么先把 DML 改写成等价的 SELECT 来看执行计划要么把整个语句包在事务里执行完之后 ROLLBACK且必须确认当前隔离级别和业务无冲突。BEGIN; EXPLAIN ANALYZE UPDATE t_order SET amount0 WHERE user_id10086; ROLLBACK;另外EXPLAIN ANALYZE 因为要真正跑完对一条耗时 30 秒的 SQL 就得等 30 秒所以只适合在测试库或者低峰期用别在生产高峰随手敲。4.3 optimizer_trace 追问优化器的心思有些场景你就是想不通明明有索引、条件也写得对为什么优化器偏偏不用这时候optimizer_trace是终极武器它会把优化器考虑过的所有候选方案、代价计算过程、最终选择理由全部记录下来。用法分三步SET optimizer_trace enabledon; SET optimizer_trace_max_mem_size 1048576; SELECT id, amount FROM t_order WHERE user_id10086 AND status1; SELECT * FROM information_schema.OPTIMIZER_TRACE\G SET optimizer_trace enabledoff;输出是一个巨大的 JSON重点看rows_estimation和considered_execution_plans两段。前者列出每个索引的估算行数和代价后者列出所有被比较的计划及最终选择。我印象最深的一次排查一条 SQL 死活不走 idx_user_statustrace 里显示该索引的估算代价是 4200而全表扫是 3800只差 400 就选了全表扫。在这种情况下把索引改成覆盖索引加上查询需要的列之后代价直接降到 900优化器立刻改选索引。这个工具的代价是输出噪音大、不容易读所以别指望看完就懂建议先从简单的单表 SQL 练手。另外optimizer_trace_max_mem_size如果设得太小trace 会被截断你看到的就是不完整信息我第一次用就栽在这上面排查了半天才发现是内存限制。5. 排查清单与常见问题速查5.1 高频疑问速查表我把这几年前后被问得最多的 Explain 问题整理成一张表方便你直接对照现象可能原因处理方向possible_keys 有值但 key 为 NULL回表代价过高或统计信息过期ANALYZE TABLE考虑覆盖索引减少 SELECT 列typeALL 且无可用索引条件列缺索引或索引失效补索引检查函数包裹与隐式转换key_len 比预期短联合索引只用到了前几个字段核对条件字段顺序补齐中间字段rows 估算与实际相差悬殊统计信息陈旧或数据倾斜ANALYZE TABLE建直方图Extra 出现 Using filesort排序字段与索引顺序不匹配调整索引字段顺序或方向Extra 出现 Using temporaryGROUP BY / DISTINCT 无合适索引为分组字段建索引避免多字段混用加了索引反而变慢小表全扫比走索引更快判断表规模小表可不加索引同一 SQL 不同参数计划不同参数驱动的代价变化属正常现象用直方图或强制索引稳定计划8.0 里 IN 子查询变慢子查询物化策略变化改写成 JOIN或检查物化临时表大小JOIN 中某表重复全扫被驱动表关联列无索引给关联列加索引注意字段类型要一致关于加了索引反而变慢我再补一句。曾经有一个只有 300 行的配置表同事给某个字段加了索引结果查询反而从 0.2ms 变成 0.5ms。原因就是优化器在走索引回表 300 次和顺序扫 300 行之间选了前者白白多了一次索引遍历。小表加索引不是必须的评估标准是表规模和维护成本。5.2 我自己的排查顺序和几条硬经验最后把我实际的排查流程写下来。拿到一条慢 SQL我按这个顺序走第一步跑 EXPLAIN 看 Extra 和 type先判断是大头问题还是小毛病第二步看 key 和 key_len确认索引使用情况是否与预期一致第三步看 rows 和 filtered 相乘估算各步骤输出行数找出放大最严重的那一步第四步如果还看不懂上 EXPLAIN ANALYZE 对账估算与实际第五步仍然无解就开 optimizer_trace 看候选计划。几条踩坑换来的经验也一并分享。第一不要在业务代码里依赖索引名做强制索引USE INDEX和FORCE INDEX在表结构变化后可能直接报错线上事故我见过两次。第二上线前一定在接近生产数据量的环境验证执行计划小数据量下所有 SQL 都很快全是 const 和 ref根本看不出问题。第三统计信息刷新要纳入运维流程大批量数据变更后手动 ANALYZE比事后救火便宜得多。第四索引不是越多越好每个索引都会拖慢写入我在一个高写入订单表上删掉了四个低效索引写入 TPS 提升了 18%查询几乎没有变化。Explain 这东西看一百篇文章不如在自己的库上敲一百次。找一张百万级的表把各种条件写法都跑一遍对比 Extra 和 key_len 的变化那种原来是这样的感觉比背任何结论都管用。我现在遇到新的慢查询基本上看一眼 Extra 加 rows 就能猜个八九不离十这个直觉没有捷径全是从一次又一次的 EXPLAIN 里练出来的。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价