ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

MySQL慢查询不慌,EXPLAIN执行计划详解与SQL优化实战

MySQL慢查询不慌,EXPLAIN执行计划详解与SQL优化实战 调MySQL慢查询第一件事不是看索引、不是改配置而是先跑一条EXPLAIN把SQL的“执行计划”摊开看。这东西在面试里是高频考点在性能排查里是最基础的debug手段。EXPLAIN能告诉你一个查询走了哪个索引、扫描了多少行、有没有文件排序、有没有临时表甚至能看出你写的SQL是不是在“硬扫全表”。本文就围绕EXPLAIN的完整输出结合实际SQL例子和优化场景把每个列、每个关键字讲透顺便把EXPLAIN ANALYZE、FORMATJSON这些进阶用法也一并整理。1. EXPLAIN是什么把SQL执行计划摊开来看1.1 为什么要先看执行计划很多朋友在SQL变慢之后的第一反应是“加索引”这思路没错但加索引之前必须搞清楚一件事当前SQL到底卡在哪。有些慢是因为没走索引有些慢是走了索引但索引选错了还有些慢是因为排序、分组、关联搞出了临时表和文件排序。这些情况光看SQL本身很难判断但执行计划会把优化器的决策过程全部暴露出来。EXPLAIN就是MySQL用来查看“优化器如何执行这条SQL”的命令。它不会真的去跑数据而是基于表结构、索引、统计信息估算出一条SQL的成本并告诉你它打算怎么查先读哪张表、用哪个索引、预估扫多少行、是否要额外排序。这就好比出门前看地图先定路线再上路而不是开出去堵死在半路再掉头。1.2 最基本的用法在任意一条SELECT前面加上EXPLAIN即可EXPLAIN SELECT order_no, status FROM orders WHERE user_id 123;MySQL 5.7及之前EXPLAIN会返回一列简化信息8.0里默认输出的列更完整还支持FORMATJSON、FORMATTREE以及EXPLAIN ANALYZE。日常调试最常用的就是默认的表格形式。它的输出每一行代表一个查询步骤。复杂SQL可能有多行比如关联查询、子查询、UNION都会拆成多个步骤每一行告诉你这一步怎么执行。读的顺序通常是从上往下但遇到关联查询时要结合id列一起看。下面先把每个列逐个说清楚这是理解执行计划的基础。2. EXPLAIN输出列全解读从id到Extra2.1 核心输出列速查表默认输出包含大约12列根据MySQL版本不同略有差异。把它们一次性记住有点难但可以分三组来看第一组是“执行顺序与语句类型”id、select_type、table、partitions第二组是“索引使用情况”type、possible_keys、key、key_len、ref第三组是“代价评估与额外信息”rows、filtered、Extra。列名含义典型的坑id查询步骤编号id越大越先执行id相同则从上往下执行关联查询中id相同顺序不代表优先级select_type查询类型是简单查询还是子查询、联合查询等DEPENDENT SUBQUERY往往说明子查询在“逐行执行”table当前步骤访问的表名也可能是派生表别名看到derivedN说明中间有派生表partitions命中的分区分区表才显示优化时希望它越少越好type访问类型从system到ALL好坏一眼看出来ALL是性能杀手必须重点排查possible_keys可能用到的索引注意只是“可能”列出多个索引不代表都用得上key优化器最终选用的索引NULL表示没走索引key_len使用的索引字节长度可以反推SQL使用了联合索引的哪几列ref使用索引等值匹配时参考的列或常量和key配合判断匹配方式rows优化器预估需要扫描的行数预估不是实际值但和实际偏差过大说明统计信息过期filtered存储引擎返回后经过WHERE过滤后剩余行的百分比关联查询中过滤比例低会导致驱动表膨胀Extra附加信息包含排序、临时表、覆盖索引等关键标记Using filesort / Using temporary 出现了要警惕2.2 读懂id和select_type先说id。每遇到一个SELECT关键字MySQL就会给它分配一个id。规则有点反直觉id数字越大越先执行。比如SELECT * FROM a WHERE id IN (SELECT id FROM b)子查询的id是2外层是1实际执行时先执行id2的部分。而普通的JOIN查询两张表的id相同比如都是1这时候从上往下读第一行是驱动表第二行是被驱动表。select_type里最需要留意的是DEPENDENT SUBQUERY。普通的SUBQUERY只会被子查询执行一次和主查询结果无关但DEPENDENT SUBQUERY表示子查询依赖外层查询的值MySQL优化的不好时相当于外层每扫一行就执行一次子查询代价极高。EXPLAIN SELECT * FROM user u WHERE EXISTS (SELECT 1 FROM order o WHERE o.user_id u.id);这条SQL里子查询的select_type会显示为DEPENDENT SUBQUERY如果o.user_id没有索引那就成了经典的N1问题慢到怀疑人生。看到这个标记第一反应就是检查关联字段有没有索引或者干脆改成JOIN。2.3 type列决定SQL“吃饭”的方式type列是判断查询质量的第一指标它描述了MySQL找到所需数据行的方式从好到坏大致是system const eq_ref ref range index ALL只要看到ALL就意味着全表扫描。如果表比较大这个SQL十有八九就是慢查询元凶。这几种类型的具体含义在下一节展开这里想先强调type是面试和排查时最常被问到的点你得能背下来并且能举例子。2.4 Extra列里的信号Extra列是信息量最大的一个很多优化点都藏在这里。最值得优先关注的是两个Using filesort文件排序和Using temporary使用临时表。这两个东西都意味着MySQL额外做了内存或磁盘层面的操作数据量一大就非常容易拖慢查询。它们经常出现在ORDER BY、GROUP BY、DISTINCT这些操作上。另一个好消息是Using index它表示“覆盖索引”也就是查询要的列已经全部在索引里不需要回表。这个标记出现得越多说明索引设计越贴合业务查询。同样的SQL从Using filesort优化成Using index性能可能差一个数量级。3. type访问类型详解从system到ALL3.1 每种类型实际长什么样type的排序已经告诉了你质量的优劣但光记住顺序没用关键是要在实际SQL里认出每种类型。我用一套简单的表结构来演示CREATE TABLE user_info ( id INT PRIMARY KEY AUTO_INCREMENT, user_name VARCHAR(32) NOT NULL, age TINYINT NOT NULL, email VARCHAR(64), UNIQUE KEY uk_email (email), KEY idx_user_name (user_name), KEY idx_age (age) ) ENGINEInnoDB;system表中只有一行数据系统表或者查询恰好命中一张只有一行数据的表。平时业务SQL基本见不到只有SELECT * FROM (SELECT 1) t这种或者系统字典表才可能出现。const用主键或唯一索引等值匹配最多返回一行。比如EXPLAIN SELECT * FROM user_info WHERE id 100; EXPLAIN SELECT * FROM user_info WHERE email tomexample.com;对id 100或唯一索引email等值查询MySQL能直接定位到那一行所以type是const。这是效率最高的访问之一你写的SQL应该尽可能做到这种级别。eq_ref出现在关联查询中被驱动表通过主键或唯一索引等值匹配。含义是“我知道关联列上最多只有一行匹配”每次驱动表来一个值被驱动表查一次就能确定命中的那行。EXPLAIN SELECT * FROM user_info u JOIN order_info o ON u.id o.user_id;如果o.user_id是主键或唯一索引被驱动表的type就是eq_ref。这个类型也很快常见于JOIN的“一边一条”关联。ref普通二级索引等值匹配或者使用了联合索引的最左前缀。和const的区别在于它可能匹配到多行。比如EXPLAIN SELECT * FROM user_info WHERE user_name Tom;user_name上有普通索引idx_user_name所以这里 type ref。它效率很高但可能返回多行实际代价要看匹配的行数。range索引范围扫描。比如WHERE id 100、WHERE age BETWEEN 20 AND 30、WHERE user_name LIKE Tom%都能通过索引快速定位范围内数据。这个类型表示“用到了索引但不是一个值而是一个范围”。index全索引扫描也就是遍历整棵索引树。它比全表扫描好一点因为它不需要回表但要读的索引数据量仍然很大。出现这个类型时往往是因为查询需要覆盖索引但索引范围太大或者WHERE条件没法走索引前缀。EXPLAIN SELECT age FROM user_info;如果age有索引这条查询可能走index因为索引比表小而且age列就在索引里。ALL全表扫描从头到尾把表读一遍。只要条件列没索引、或者用了函数包裹条件列、或者查询条件本身无法使用索引就会变成ALL。对一张大表做全表扫描那基本就是灾难现场。3.2 为什么“能走索引”也可能走成ALL这里有个很常见的误区以为给字段建了索引查询就一定能用上。实际上下面这些情况索引会失效type直接从range掉成ALL对索引列使用了函数WHERE DATE(created_at) 2025-01-01除非建了函数索引8.0.13支持否则索引报废隐式类型转换WHERE mobile 13800138000mobile是varchar数字和字符串比较触发类型转换索引失效前导模糊匹配WHERE user_name LIKE %Tom%无法使用索引。只有Tom%这种后缀模糊能走rangeOR条件连接非索引列WHERE id 1 OR status 0如果status没有索引整条查询可能退化成ALL联合索引不满足最左前缀索引是(user_id, created_at)但你只写了created_at条件用不上。排查慢SQL时如果type是ALL先按这个清单过一遍基本能找出索引失效的原因。我在实际排查中发现隐式类型转换是最隐蔽的表面上条件写得很正常但字段类型对不上索引就悄悄没了。4. Extra列深度解析这些关键字决定了查询的下限4.1 Using where 和 Using index 的区别很多初学者会把这两个搞混。简单来说Using where表示存储引擎返回了数据后MySQL还要在Server层再过滤一遍。最常见的情况是索引只帮你定位了一部分行剩下条件需要回表后逐行判断。比如联合索引(user_id, created_at)查询条件是user_id 123 AND status 1status不在索引里MySQL用索引定位user_id之后还要对每行status再做过滤Extra就会出现Using where。Using index表示所有需要的数据都能从索引里取得不需要回表。比如索引是(user_id, created_at)查询SELECT created_at FROM orders WHERE user_id 123那created_at和user_id都在索引里直接用索引就够了。最理想的情况是Using index不仅查询快连回表都省了。这就是覆盖索引的威力。4.2 Using filesort 和 Using temporary 是性能黑洞Using filesort不是真的用磁盘“文件”排序而是表示MySQL没法直接用索引的排列顺序返回数据需要额外做一次排序。排序可能在内存也可能在磁盘但都是额外开销。出现它的典型场景是ORDER BY的列和WHERE条件用的索引对不上。举个例子EXPLAIN SELECT order_no, status FROM orders WHERE user_id 123 ORDER BY created_at DESC;如果只有idx_user_id索引MySQL会先用user_id定位到数据再对created_at做一次文件排序。数据量小时没什么感觉几万行以上排序开销就明显了。优化方案是建联合索引(user_id, created_at)让索引天然按user_id和created_at排好MySQL按顺序读出来就是ORDER BY的结果Using filesort就会消失。Using temporary更是重量级它表示MySQL为了完成查询创建了临时表。常见于GROUP BY、DISTINCT、UNION和某些子查询。比如EXPLAIN SELECT status, COUNT(*) FROM orders GROUP BY status;如果status没有索引MySQL可能需要创建临时表来做分组统计。分组列加索引往往能消除临时表。注意一点临时表分为内存临时表和磁盘临时表如果临时表太大MySQL会自动转成磁盘临时表性能会断崖式下跌。4.3 其他值得留意的Extra标记Extra标记含义应对思路Using index condition使用了索引条件下推ICP部分过滤条件下推到存储引擎一般是好事表示索引利用率高Using join buffer关联查询没有用索引需要把驱动表结果放入join buffer去匹配被驱动表检查关联条件的索引Impossible WHEREWHERE条件恒为假MySQL直接说“查不出来”说明条件写错了比如10No tables used没有涉及表比如SELECT 1正常现象Select tables optimized away查询被优化到不需要访问任何表比如只查COUNT或MIN性能极佳不用管Distinct正在去重检查是否可以用索引消除去重5. rows、key_len、select_type从预估到印证5.1 rows 是估算值但有参考意义rows是优化器预估“为了找到目标行需要读多少行”。它来源于表的统计信息不是精确值。数据更新频繁时如果统计信息不准rows会和实际情况偏差很大。但即便不准它也是判断执行计划好坏的重要参考如果typeALLrows接近全表总行数那这条SQL肯定有问题如果typerefrows只有十几行那大概率是高效的。有一点要提醒rows小不代表SQL整体执行快。因为有时优化器为了减少读取行数选了某个索引但实际还需要回表处理大量数据。所以rows要结合Extra和key一起看别单独迷信某一行数字。5.2 key_len 能反推联合索引用了哪几列key_len表示MySQL在索引中使用了的字节数。它能帮你判断联合索引到底用到了几列。计算规则是上文说的(user_id, created_at)联合索引如果user_id是INT NOT NULLcreated_at是DATETIME NOT NULL查询条件是WHERE user_id 123 AND created_at 2025-01-01INT占4字节DATETIME占8字节MySQL 5.6.4之前是8字节之后实际还是8字节存储但有的版本会额外有小数秒存储一般按8字节算这张表没有NULL标记位耗尽的话user_id部分的key_len就是4加上created_at部分8就是12。如果key_len只有4说明只用了联合索引的第一列user_id如果key_len12说明两列都用了。这是一个非常实用的诊断技巧能避免你误以为联合索引里的列都生效了。字符串类型还要注意字符集的字节数utf8mb4一个字符最多4字节varchar还需额外2字节记录长度NULL字段再加1字节。5.3 select_type里的 DEPENDENT 和 DERIVEDDERIVED表示派生表也就是FROM子句里的子查询。比如SELECT * FROM (SELECT user_id, COUNT(*) cnt FROM orders GROUP BY user_id) t WHERE cnt 10;MySQL 5.7以前会把这个子查询结果物化成临时表再和外部查询关联在某些情况效率不高。8.0优化器可以做derived_merge很多时候能把派生表合并到主查询减少一次物化。MATERIALIZED表示物化子查询主要出现在IN (SELECT ...)的场景。MySQL会把子查询的结果物化成一张临时表再去关联外层查询。这通常比DEPENDENT SUBQUERY好因为它只执行一次。6. 实战案例一条慢SQL从EXPLAIN到优化6.1 复现慢查询假设有订单表CREATE TABLE orders ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, order_no VARCHAR(32) NOT NULL, status TINYINT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL, KEY idx_user_id (user_id), KEY idx_created_at (created_at) ) ENGINEInnoDB;线上反馈这个查询很慢SELECT order_no, status FROM orders WHERE user_id 123 ORDER BY created_at DESC LIMIT 10;6.2 第一轮EXPLAIN定位问题EXPLAIN SELECT order_no, status FROM orders WHERE user_id 123 ORDER BY created_at DESC LIMIT 10;关键输出是typekeyrowsExtrarefidx_user_id85Using filesort看到Using filesort基本可以断定user_id条件走了索引但created_at排序用不上索引MySQL只能先把85行结果捞出来再额外排序。数据量大时这个排序就是瓶颈。而且如果这张表的user_id分布不均匀某个用户有上万订单时排序成本直线上升。6.3 优化方案与二次验证直接把idx_user_id改成联合索引(user_id, created_at)ALTER TABLE orders DROP INDEX idx_user_id; ALTER TABLE orders ADD INDEX idx_user_id_created_at (user_id, created_at);再跑一次EXPLAIN SELECT order_no, status FROM orders WHERE user_id 123 ORDER BY created_at DESC LIMIT 10;输出变成typekeyrowsExtrarefidx_user_id_created_at85(空)Using filesort消失了。因为InnoDB索引本身就是按(user_id, created_at)排序的BTreeMySQL直接从索引末尾往前读天然就是created_at倒序。这个优化在订单详情、用户操作记录等“按用户查最近记录”的场景里特别常用。6.4 如果还能再进一步覆盖索引上面的例子查询列是order_no, status它们并不在联合索引里。所以MySQL用索引定位后还要回表拿这两列数据。如果想彻底避免回表可以建一个覆盖索引ALTER TABLE orders ADD INDEX idx_user_created_order (user_id, created_at, order_no, status);但注意覆盖索引覆盖的列越多索引体积越大写入性能越差。不要为了消除回表盲目堆列。一般我在实际中只有当回表比例很高、SQL高频执行时才考虑覆盖索引而且必须权衡写入场景。7. 常见坑与进阶工具7.1 EXPLAIN不是真实执行别被预估骗了EXPLAIN是基于统计信息的估算存在两个风险一是统计信息过期rows和实际情况严重不符二是优化器在某些情况下会估算错误选错索引。遇到后者可以先ANALYZE TABLE更新统计信息实在不行再用FORCE INDEX强制指定索引但这只是临时手段根因往往是索引设计不合理或SQL写法有问题。在MySQL 8.0里EXPLAIN FORMATJSON能看到更详细的成本计算信息包括cost_info、used_columns、attached_condition等。排查复杂问题的正确姿势是先跑一个EXPLAIN眼见为实再结合FORMATJSON看优化器成本评估别瞎猜。7.2 用 SHOW WARNINGS 看优化器改写的SQL一个很容易被忽略的技巧执行EXPLAIN后紧接着执行SHOW WARNINGS能看到MySQL优化器对SQL的改写结果。有时候你会惊讶地发现优化器把你写的子查询改写成了JOIN或者把IN改写成了EXISTS了解改写逻辑能帮你理解为什么实际执行计划和你想的不一样。EXPLAIN SELECT * FROM user_info WHERE id IN (SELECT user_id FROM orders WHERE status 1); SHOW WARNINGS;Message列里可能出现 “/* select#2 */ ...”。它能直观反映优化器怎么处理你的SQL对判断索引为何没生效非常有帮助。7.3 8.0新特性EXPLAIN ANALYZE 实测MySQL 8.0.18及以上版本支持EXPLAIN ANALYZE它和EXPLAIN最大的区别是EXPLAIN只给估算EXPLAIN ANALYZE是真实执行并返回每个步骤的实际耗时、实际行数。比如EXPLAIN ANALYZE SELECT order_no, status FROM orders WHERE user_id 123 ORDER BY created_at DESC LIMIT 10;输出类似- Limit: 10 rows (actual time0.02..0.03 rows10 loops1) - Sort: orders.created_at DESC (actual time0.02..0.02 rows10 loops1) - Index lookup on orders using idx_user_id (user_id123) (actual time0.01..0.01 rows85 loops1)注意Sort这个节点它对应上面的Using filesort。通过EXPLAIN ANALYZE能看到每一步实际花了多少时间、处理了多少行比单纯的EXPLAIN更贴近真实性能。生产环境执行时要注意它会真实执行SQL读操作没问题但如果是INSERT/UPDATE/DELETE配合它一定要谨慎建议只在测试环境或针对SELECT操作使用。我在实际排查中个人最依赖的组合是EXPLAIN快速定位问题列EXPLAIN ANALYZE确认真实耗时。两者结合绝大多数慢查询都能在几分钟内找到根因而且这套方法对于任何MySQL版本、任何业务场景都通用。最后再分享一个小技巧如果你遇到一条SQL怎么调都走不上理想的索引先SHOW INDEX FROM 表名看看索引基数再确认会不会是统计信息太久没更新直接ANALYZE TABLE刷新一下很多“索引失效”问题其实只是统计信息老了。
返回列表