一条原本几十毫秒就能跑完的SQL,在数据量涨到几百万行之后突然变成两秒多,应用层开始超时报警。遇到这种情况,我的第一反应不是去调代码、加缓存,而是先把这条SQL的执行计划拿过来看。
MySQL执行计划就是优化器在真正执行SQL之前做的一份“操作方案”:它打算先访问哪张表、用哪个索引、大概扫描多少行、以什么顺序把多张表的数据拼接起来。方案选对了,一条SQL可以快上几个数量级;方案选歪了,再好的硬件也扛不住。很多同学在慢SQL优化上卡壳,不是因为不会写SQL,而是因为看不懂执行计划里那些字段——type、rows、Extra明明都认识,但拼在一起并不知道在说什么。
这篇文章我把执行计划从产生原理到实战排查串起来讲一遍,包括优化器内部是怎么算账的、EXPLAIN输出的每一列该怎么读、type访问路径背后对应多少真实工作量,以及几个从线上慢SQL一路改到执行计划变好的完整案例。不管是刚接触MySQL的新手,还是已经在做SQL优化的开发者,都可以照着这套思路去定位问题。
1. 优化器是怎么算出一份执行计划的
1.1 MySQL的优化器拿到一条SQL后,真正做了什么
先想一个问题:SQL语句本身描述的是“我想要什么结果”,而不是“你应该怎么取数据”。
比如 SELECT * FROM t_order WHERE user_id = 100 AND amount > 100,这句话只表达了三个约束条件:查订单表、用户ID是100、金额大于100。但至于先按user_id过滤还是先按amount过滤、用哪个索引、是走二级索引回表还是直接扫主键,SQL语句里一个字都没提。这些决定全部由MySQL的优化器来做。
整个流程大概是这样的:SQL进来后,第一步是词法分析和语法解析,把字符串变成一棵语法树,这个过程会检查SQL写得对不对;第二步是预处理,校验表名、字段名是否存在,做权限检查之类的语义校验;第三步才进入真正的优化阶段,生成多个候选执行计划,估算每个计划的成本,选一个成本最低的;最后执行器按照选定的计划去存储引擎里取数据。
这里要建立一个基本认知:执行计划不是SQL执行完之后才产生的,而是执行之前优化器给出的预案。就像你开车去一个陌生地点,导航软件在出发前会计算几条路线,预估每条路的耗时,然后推荐一条。MySQL的优化器干的就是导航的工作,但它计算的不是红绿灯和堵车时间,而是扫描多少行数据、读多少个数据页。
1.2 成本模型:优化器这笔账是怎么“算”出来的
MySQL优化器的核心决策依据是一个成本模型,它给数据库里的各种操作都定义了代价。简化来看,成本主要由两部分构成:一个是IO成本,读一个数据页算作一次IO成本;一个是CPU成本,每比较、每计算一行记录都算作一次CPU成本。
例如一条查询如果用主键定位一行数据,成本就非常低,因为InnoDB的聚簇索引叶子节点就是完整的数据行,通过主键可以直接定位到那一页;但如果做一个全表扫描,优化器会估算表里一共有多少行、这些数据分布在多少个数据页上,然后把这些页的IO成本全部累加。
为了做这个估算,优化器需要依赖一些基础统计信息,比如表的行数、索引的基数(cardinality)、索引的选择性等。这些信息来源于MySQL维护的统计字典,有些来自 SHOW TABLE STATUS,有些来自 information_schema.STATISTICS 里的 CARDINALITY。
问题来了:统计信息终究是“抽样估算”,不是精确值。如果表数据频繁增删改,而统计信息没有及时更新,优化器拿到的就可能是一个过期的行数,算出来的成本自然就不准。这就是为什么后面我会专门讲统计信息失真对执行计划的影响——很多执行计划看起来“不聪明”,问题恰恰出在它拿到的基础数据就是错的。
1.3 为什么执行计划也有可能与实际不符
优化器在计算成本的时候,面对的数据不是真实数据,而是统计信息。统计信息不够准确时,优化器就会“猜错”。举几个很典型的例子:
- 表实际有500万行,但优化器统计信息还停留在上一次ANALYZE时的80万行;
- 某列的索引基数明明很高,但统计信息里显示的数据分布已经过时;
- 查询条件里写了函数或者发生了隐式类型转换,优化器无法准确判断条件能过滤掉多少行,只好给一个比较悲观的估算。
执行计划只是基于估算的最优解,不是实测的最优解。这也是为什么业界一直强调“慢SQL优化要以真实执行计划而非直觉为准”,但同时也别忘了,估算型执行计划有天然误差。后面我会单独讲如何用 EXPLAIN ANALYZE 拿到真实执行数据,把误差暴露出来。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. EXPLAIN每一列都要看得懂:从select_type到Extra怎么读
2.1 EXPLAIN的三种输出格式与基本用法
MySQL查看执行计划的入口是EXPLAIN语句,基本用法就是在你的SELECT前加上EXPLAIN关键字。比如:
sql复制EXPLAIN SELECT * FROM t_order WHERE user_id = 100;
EXPLAIN不会真正执行这条查询,它只是在优化器完成决策后,把执行计划以表格形式输出给你。线上排查的时候,这条语句几乎不会对数据库产生额外压力,可以放心用。
MySQL支持三种输出格式,适用场景不太一样:
- 传统表格格式:一行对应一个表访问步骤,最直观,日常排查主力;
FORMAT=JSON:输出结构化的JSON,里面带cost成本信息,适合需要看优化器决策细节的场景;FORMAT=TREE:输出树状格式,层级关系比表格更清楚,适合理解多表join的执行顺序。
我实际最常用的还是传统格式,因为一眼扫过去就能定位type和key列。需要钻取成本细节时才用JSON或者TREE格式。
2.2 输出列逐项翻译:哪些是决策信号,哪些只需要扫一眼
拿一个实际例子看输出。假设有一张订单表:
sql复制CREATE TABLE `t_order` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`user_id` BIGINT NOT NULL,
`order_no` VARCHAR(64) NOT NULL,
`amount` DECIMAL(10,2) NOT NULL DEFAULT 0,
`status` TINYINT NOT NULL DEFAULT 0,
`create_time` DATETIME NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB;
执行下面这条EXPLAIN:
sql复制EXPLAIN SELECT * FROM t_order WHERE user_id = 100;
输出大致如下:
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | SIMPLE | t_order | NULL | ref | idx_user_id | idx_user_id | 8 | const | 56 | 100.00 | NULL |
这一大堆列里,按重要程度排个序:type、key、rows、Extra,这四个是日常诊断的核心。其他列的意义是帮助你理解访问对象和方式。
id:不是一个序号代表一个id值,而是这一行对应第几步操作。一个join查询会出现多行,如果id不同,数字大的先执行。select_type:表示查询类型。SIMPLE是最简单的单表查询;PRIMARY是外层主查询;SUBQUERY是子查询;DERIVED是from子句里的派生表;UNION/UNION RESULT出现在union查询中。table:当前这一步要访问哪张表。partitions:命中了哪个分区;没有分区则为NULL。type:访问路径类型,这个是执行计划里最重要的信号之一,下面一节重点讲。possible_keys:优化器认为可能用到的索引列表,只是“候选名单”。key:优化器最终选定的索引。如果这里为NULL,说明这个表没用上任何索引。key_len:选定的索引键最大字节长度。它能帮你判断联合索引实际用了哪几列,越短说明条件约束越少。ref:走索引时,是用什么值与索引进行比较。如果是常量,就是const;如果是另一张表的列,会显示列名。rows:优化器估算的需要扫描的行数。记住,这只是“估算”,不是实际值。filtered:通过查询条件过滤后,剩下行数占扫描行数的百分比估算值。真正要处理的估算行数约等于rows * filtered / 100。Extra:额外的执行信息,既有锦上添花的好消息,也有需要警惕的坏消息。
2.3 Extra字段:很多慢SQL的“罪证”都藏在这里
Extra里经常出现一串英文短语,对新手来说这是整个执行计划里最劝退的部分。实际上,把它当作几个固定信号来记就行,看到哪个就想到什么含义。
| Extra内容 | 含义 | 好/坏 |
|---|---|---|
| Using index | 覆盖索引扫描,直接读索引就拿到全部需要的数据,不需要回表 | 好 |
| Using where | 存储引擎返回数据后,Server层再对行做条件过滤 | 中性,但要看是否有索引可用 |
| Using index condition | 发生了索引条件下推(ICP),部分WHERE条件下推到存储引擎过滤,减少回表次数 | 偏中性偏好 |
| Using filesort | 结果需要进行文件排序,数据量大时可能很慢 | 重点关注 |
| Using temporary | 使用了临时表来去重或排序,常见于GROUP BY、DISTINCT | 重点关注 |
| Using join buffer | join时驱动表无法使用索引,用了join缓冲区做连接 | 重点关注 |
| Range checked for each record | 关联查询中,内层表逐行做索引范围检查,效率较低 | 重点关注 |
举个例子,一条慢SQL执行计划里如果出现Using filesort,大概率说明排序字段和索引顺序不匹配,导致MySQL不得不把数据先捞出来额外排一遍。再有Using temporary时,说明GROUP BY或DISTINCT导致MySQL创建了内部临时表,这种场景一旦数据量大,基本跑不快。
3. type列的“阶梯”:从全表扫到主键命中,工作量差几个数量级
3.1 type阶梯的几个典型档位
EXPLAIN结果里的type列是最值得先读的字段。它表示的是从表中查找行的方式,按性能从高到低大致是这样的阶梯:
system:表里只有一行数据,是const的特例;const:通过主键或唯一索引定位到最多一行数据,比如WHERE id = 100;eq_ref:join时,内层表通过主键或唯一索引进行等值匹配,每一行驱动表最多匹配到一行;ref:通过普通二级索引等值匹配,可能返回多行;range:通过索引做范围扫描,比如BETWEEN、>、<、IN、前缀匹配等;index:全索引扫描,也就是遍历整棵索引树,而不是直接读表数据;ALL:全表扫描,从聚簇索引第一个叶子节点开始扫到最后一个。
理解这个阶梯,核心是想明白一个问题:每条SQL扫描的数据量级差多少。const类型只读一页左右的数据,ref类型只需要通过辅助索引精确定位到几条rows;而ALL类型意味着要从表的第一行扫到最后一页,扫描量通常是几十万、几百万的级别。量级差了好几个万,执行时间自然不是一个数量级。
3.2 B+树索引下的回表和覆盖索引,为什么直接影响type
InnoDB默认的聚簇索引结构是B+树。表中每一行完整数据都存在主键索引的叶子节点里,所以根据主键查找天然高效。二级索引的叶子节点则只存索引列和主键值,如果用二级索引查询但select需要索引列以外的字段,就必须根据叶子节点上的主键值,再到聚簇索引里取整行,这个过程叫回表。
回表是一次随机IO,回表次数一旦多起来,数据库反而可能不如直接全表扫描来得快。这就是为什么同样走一个二级索引,查询性能可能天差地别——回表比例是核心变量。
如果查询涉及的字段全都包含在二级索引里,那就不需要回表了。这种叫覆盖索引,Extra里会出现Using index,通常情况下这是非常理想的状态。比如有联合索引(user_id, create_time),执行:
sql复制SELECT user_id, create_time FROM t_order WHERE user_id = 100;
这个查询要的两列都在联合索引里,直接扫索引树就能返回,不需要回表。
另一个常见的优化叫索引条件下推(Index Condition Pushdown,ICP),Extra里的表现是Using index condition。它的原理是把部分WHERE条件下推到存储引擎,让引擎在二级索引扫描时就过滤,而不是把所有命中的索引记录都回表之后再由Server层过滤。简单说,就是尽可能让过滤发生在“便宜的索引树上”,而不是“昂贵的回表和数据行上”。
3.3 别把type等级当作唯一标准
初学者很容易背下一个刻板结论:ALL一定差,const一定好。但type等级不是绝对标准,要看具体量大不大。
举个例子,ALL并不天然等于慢。如果一个表总行数只有几十条,做全表扫描毫无压力,比绕来绕去走索引更省事。反过来,range索引扫描虽然type比ALL高,但如果这个范围覆盖了表里50%的数据,每一行都要回表,性能可能比全表扫描还差。
还有index类型。很多人看到type=index以为走了索引就稳了,其实它可能是扫描整个二级索引树,并不比全表扫描好多少,只是扫的树更小一些。如果二级索引比聚簇索引小很多,而查询又可以用上覆盖索引时,index全扫反而可能好过回表严重的range。
所以正确的读法是:type给出的是访问路径的“形状”,rows和Extra才会告诉你这个形状下要处理多少数据、有没有额外操作。执行计划必须组合起来看,单看一个字段容易误判。
4. 估算不等于真实:统计信息失真与EXPLAIN ANALYZE
4.1 rows和filtered:优化器也是“估计着干”
回到EXPLAIN输出里的rows列。它真的只是一个估算值,InnoDB并不会为优化器提供精确的行数,而是根据统计信息和一些启发式规则来估计。
最常见的统计信息来源是索引的基数cardinality,你可以用这条命令查看:
sql复制SHOW INDEX FROM t_order;
输出里会有一个Cardinality字段。它表示索引中不同值的数量估算值。假如idx_user_id的Cardinality很小,比如只有几十,说明这个字段上重复值很多,即使走了索引也要扫出大量行。如果idx_user_id的Cardinality接近表行数,说明这个字段区分度很高,走索引的过滤效果会很好。
filtered列也是一个估算百分比,表示从扫描到的行里,经过多少个查询条件后还能剩下的比例。比如前面示例里rows=56,filtered=100,意思是预计扫56行,全部留下。如果一条SQL有多个过滤条件,优化器往往很难精确估计这些条件的联合分布,尤其当条件之间有隐藏相关性时,误差会成倍放大。
4.2 统计信息刷新与直方图的正确用法
当你发现执行计划的rows明显和真实数据量对不上时,第一时间要做的是刷新统计信息:
sql复制ANALYZE TABLE t_order;
这条命令会让InnoDB重新采样并更新表统计信息,之后再看执行计划,rows通常会准确很多。
但ANALYZE TABLE只能刷新基础的基数和行数估算,它解决不了数据分布不均的问题。比如说某个字段80%的值都是同一个值,剩下20%分散在几百个值里,优化器如果只知道“一共多少个不同值”,还是会算不准。
针对这种情况,MySQL 8.0引入了直方图(Histogram),它把字段值的分布情况按桶记录。比如可以给订单状态字段建一个分布直方图:
sql复制ANALYZE TABLE t_order UPDATE HISTOGRAM ON status WITH 100 BUCKETS;
建完之后,优化器再估算WHERE status = 1能过滤多少行时,就有了真实的分布参考,而不再用一个笼统的选择性百分比去猜。
需要注意,直方图主要服务于那些没有索引也想要统计信息帮助估算的字段,或者索引存在但数据分布极度倾斜的场景。不是每个字段都需要建,滥用会让ANALYZE本身也成为开销。
4.3 EXPLAIN ANALYZE能告诉你真实执行情况
8.0.18以后的MySQL还提供了一个非常实用的工具:EXPLAIN ANALYZE。它和EXPLAIN最大的区别是:EXPLAIN只输出优化器的估算计划,而EXPLAIN ANALYZE会真正执行SQL,把每一步耗时和真实扫描行数打出来。
用法非常简单,还是在SQL前加上关键词:
sql复制EXPLAIN ANALYZE SELECT * FROM t_order WHERE user_id = 100;
输出结果的格式是树状的,里面会包含actual time(实际耗时)、actual rows(实际行数)这样的字段。比如你看到优化器估算rows=56,但actual rows=230000,那就是统计信息严重滞后或者条件估算直接失灵。这种证据面前,任何争议都没有意义。
这里有一个必须强调的警告:EXPLAIN ANALYZE会真实执行查询,DML语句甚至有副作用风险。线上大查询、生产库峰值时期、大批量更新语句,千万不要随手拿EXPLAIN ANALYZE去试。先返回结果集小、逻辑简单的SELECT上用,或者干脆在压测环境里跑。
5. 三个真实慢查询的诊断过程:从执行计划到SQL重生
5.1 隐式类型转换让索引一夜失效
之前处理过一个订单查询变慢的工单,最初SQL长这样:
sql复制SELECT * FROM t_order WHERE order_no = 1001001001;
order_no这个字段建表时定义的是VARCHAR(64),而且有唯一索引。但开发同事在传参时,这个值被ORM或其他代码层处理成了数值类型,最终拼接到SQL里就没有加引号。
看起来只是一个引号问题,影响却是致命的。MySQL的规则是:如果比较操作的一边是字符串列、另一边是数值,它会把字符串列隐式地转换为数值再比较。转换发生在索引列上,相当于对order_no做了函数操作,索引就失效了,type从原来的const瞬间掉到ALL。
当时的执行计划里能明显看到:type=ALL,尽管possible_keys还列着那个唯一索引,key=NULL。这等于把优化器的纠结过程都暴露了——它知道有索引,但成本模型认为在索引列上做完类型转换再查找,已经没法走常规索引查找路径了。
修复办法很简单,改成字符串常量:
sql复制SELECT * FROM t_order WHERE order_no = '1001001001';
修完再看执行计划,type又回到了const。这类问题要养成一个习惯:varchar列和数值比较,永远显式加引号,不要指望MySQL自动帮你处理好。
5.2 排序变成文件排序,一个复合索引解决问题
另一个典型场景是列表查询。需求是按某个用户查订单,并按创建时间倒序展示最近N条。
sql复制SELECT * FROM t_order
WHERE user_id = 100
ORDER BY create_time DESC
LIMIT 10;
表上之前有两个单列索引,分别是idx_user_id(user_id)和idx_create_time(create_time)。EXPLAIN结果长这样:type=ref,key=idx_user_id,看着挺正常,但Extra里赫然写着Using filesort。
问题出在哪?idx_user_id确实能快速把user_id=100的行找出来,但在这个索引内部,数据是按user_id排序的,user_id相同时,索引里并不保证按create_time排序。MySQL只好把找到的这批行都取出来,临时放到排序缓冲区里按create_time重新排。如果这个用户有几万条订单,排序的开销会非常明显。
解决方案是建一个联合索引,把过滤和排序统一到同一棵索引树里:
sql复制ALTER TABLE t_order ADD KEY idx_user_create (user_id, create_time);
建完之后再EXPLAIN,key变成了idx_user_create,Extra里的Using filesort消失。为什么?因为在(user_id, create_time)这棵二级索引里,同一个user_id的叶子节点天然按照create_time递增排列,InnoDB顺着索引扫到该用户的最后一条,往前倒序取10行就结束了。
这是一个非常经典的“索引即排序”案例。记住,如果ORDER BY字段和WHERE字段能组合成联合索引的前缀关系,排序问题往往就迎刃而解了,不用像以前那样傻乎乎地做文件排序。
5.3 join驱动表的正确姿势
多表join的执行计划里,优化器要决定谁先执行、谁后执行,先执行的那张表通常叫驱动表(outer table),后执行的那张表被驱动。
看一个不够合理的真实案例:
sql复制SELECT u.nickname, o.order_no, o.amount
FROM t_user u
JOIN t_order o ON o.user_id = u.id
WHERE u.level = 3;
这类查询慢,多数原因不是SQL语法问题,而是缺少合适的索引。当时EXPLAIN的结果显示t_order表上没有走idx_user_id索引,而是直接对每一行驱动记录做了全表扫描,Extra里还出现了Using join buffer。这就意味着MySQL是先把t_user过滤出level=3的用户,然后用这些用户ID逐一去全表匹配t_order。
原因在于idx_user_id在旧表结构里确实不存在,后来补上了索引之后,EPPLAIN结果立刻变化:t_order的访问类型从ALL变成了ref。
但同样要提醒一点,不是所有join都要无脑给被驱动表加索引。“小表驱动大表”这个原则里的“小表”,指的是过滤后参与join的数据集大小,不是表的物理行数。如果过滤条件能把大表压成很小的数据量,那张大表也可以作为驱动表。优化器自己会按成本选,手工写join时可以刻意把过滤效果好的表放前面,但最终的判断还是要以执行计划为准。
6. 优化器为什么不选我的索引?用optimizer_trace查“案底”
6.1 optimizer_trace怎么开,怎么看
有时候会遇到一个特别气人的场景:你明明建了索引,甚至几个索引都齐全,EXPLAIN也显示possible_keys里有你的索引,但优化器就是不选它,偏偏选了个全表扫描。问它为什么,EXPLAIN回答不了。
这时候要开optimizer_trace,看优化器做决策的过程。它是MySQL优化器留下的“案底”,记录了每一步评估的细节。开启方式有三种会话参数,一般在测试会话里执行:
sql复制SET optimizer_trace = 'enabled=on', end_markers_in_json=on;
然后执行你想要排查的那条SQL:
sql复制SELECT * FROM t_order WHERE create_time >= '2025-01-01' AND create_time < '2025-02-01';
接着查询优化器留下的trace信息:
sql复制SELECT * FROM information_schema.OPTIMIZER_TRACE\G
最后记得关掉trace,避免影响会话后续性能:
sql复制SET optimizer_trace = 'enabled=off';
optimizer_trace是会话级的,不影响其他连接。
6.2 打开trace后,重点分析什么
OPTIMIZER_TRACE输出的JSON内容非常长,新手很容易被吓退。不要尝试逐行读完,按这几个节点重点翻就行。
第一个重点是rows_estimation,它记录了对每张表各种访问路径的行数估算和成本估算,能看到全表扫描的成本、每个潜在索引的成本分别是多少。这里能回答“为什么不用索引”的第一个原因:可能是索引成本真的更高,也可能因为统计信息不准把成本算高了。
第二个重点是considered_execution_plans,它列出了优化器最终候选的几个执行计划,每个计划都会标记是否被选择,以及被选择或放弃的原因。比如你期望优化器用idx_create_time做范围扫描,但它经过计算发现,这个范围覆盖的行数太多、回表成本已经超过全表扫描,那么它会在trace里给出理由。
我实际遇到过一种情况:表里某个范围条件占全表数据量40%以上,而查询还要回表取大量字段。优化器一算,走二级索引需要几千次随机IO回表,全表顺序扫描才几百块IO,当然选择不用索引。这不是优化器糊涂,而是我们没给它一个足够有吸引力的索引方案——如果select字段能覆盖到索引里,成本可能就会反转,索引就愿意被用了。
6.3 别急着FORCE INDEX
很多人遇到“优化器不选索引”的第一反应是给SQL加FORCE INDEX强制指定。我不建议一上来就这么干,原因有两个。
第一,FORCE INDEX是绕过优化器的成本评估,直接把某条路封死。如果你指定的索引本身就不是最优方案,反而会让SQL变得更慢;而且等到数据量、数据分布变化后,这个强制指令不会自动调整,最后变成一个隐形的定时炸弹。
第二,真正的问题往往不是“索引没用”,而是“索引方案不对”或“统计信息不对”,强制指定只是掩盖症状。更稳妥的排查顺序是:先ANALYZE TABLE刷新统计信息,再看数据分布是否需要直方图,然后检查能不能改成覆盖索引或者联合索引,最后才考虑SQL重写。
只有一种场景我会接受FORCE INDEX:你已经确认是因为已知版本优化器的bug或极端数据分布问题,导致成本评估长期失准,又没有其他等价改写方案时,加一个带上注释说明的强制索引,并在代码里记TODO,等后续优化器版本修复后再摘除。而且要记住,FORCE INDEX如果写在线上SQL里,一定要经受住数据增长后的回归验证。
排查SQL性能问题,最大的误区是凭感觉猜。MySQL执行计划的意义就在于,把优化器那个黑盒决策摊开给你看。每次写完一条稍微有点复杂的SQL,都花几秒跑一下EXPLAIN,看看type是不是掉到了ALL,rows是不是大得离谱,Extra里有没有不该出现的Using filesort、Using temporary。这个习惯长期坚持下来,你对索引、统计信息、成本模型的理解会越来越有体感,再也不需要遇到慢SQL就到处问人。
