“好那咱们就直说EXPLAIN 都看不懂还谈什么定位低效 SQL很多做后端开发的朋友一碰到线上接口变慢、数据库 CPU 飙高第一反应就是“加索引”“上缓存”“分库分表”。但真要问他这条 SQL 为什么慢、慢在哪一步、是不是连索引都没用上他往往答不上来。原因很简单——他压根没认真看过 EXPLAIN 的输出。EXPLAIN 是 MySQL 自带的一条分析命令用法就一行在 SELECT 前面加个 EXPLAINMySQL 就会把这条 SQL 的执行计划打印出来。它告诉你MySQL 准备用哪个索引、先查哪张表、大概扫多少行、有没有临时表、有没有文件排序。**定位低效 SQL 的一切线索都在这份执行计划里。**这一讲我就把 EXPLAIN 从头到尾拆开从每一列的含义到实际案例再到常见的坑一次讲透。无论你是刚入门 MySQL 的萌新还是写了几年 SQL 想系统提升一下的老手这篇文章都能让你少走很多弯路。”1. 先搞清楚 EXPLAIN 到底在看什么1.1 EXPLAIN 是一条命令不是玄学很多人对 EXPLAIN 有误解觉得它是一个需要专门配置、需要特殊权限的“高级功能”。其实不是它就 MySQL 服务器自带的一条 SQL 命令语法也极其简单EXPLAIN SELECT * FROM orders WHERE order_no 20250101001;如果你用的是 MySQL 8.0 之后的版本还可以加FORMATJSON拿到更详细的结构化输出EXPLAIN FORMATJSON SELECT * FROM orders WHERE order_no 20250101001;这条命令执行之后MySQL 不会真的去把数据查出来而是根据统计信息、索引结构和优化器规则给你一份“它打算怎么执行这条 SQL”的方案。你可以把它理解为你让外卖平台派单平台给你看骑手的配送路线图——它骑哪条路、先去哪个商家、预计跑几公里。你没看到饭但你看到了配送逻辑。这份“路线图”里有若干列每一列都有自己的含义。如果你能流畅地说出每一列代表什么并判断哪些值是不正常的那么定位低效 SQL 的第一步就算迈出去了。1.2 输出里的每一列就是一次 SQL 执行的全过程记录EXPLAIN 的常规输出一般长这样mysql EXPLAIN SELECT o.id, o.order_no, u.name FROM orders o LEFT JOIN users u ON o.user_id u.id WHERE o.total_amount 1000;输出结果通常包含十几列。这里我先把最常用的列名和含义列出来后面再逐个细讲列名作用id执行计划中每一条查询的编号多表查询时尤其重要select_type这条查询的类型比如简单查询、子查询、联合查询table正在访问的表名或派生表、子查询的别名partitions命中的分区没做分区表时基本忽略type访问类型真正的性能核心没有之一possible_keysMySQL 认为可能用到的索引key实际选用的索引key_len使用的索引长度ref使用索引等值查询时与索引比较的列或常量rows优化器估算的需要扫描的行数filtered经过条件过滤后剩余行的比例估算Extra额外信息藏着大量性能线索的双关语很多人看 EXPLAIN 只看 type 和 rows这没错但不够。你必须把 key、key_len、Extra 和 rows 连起来看才能拼凑出完整的真相。比如 rows 很大但 type 是ref说明查询虽然能命中索引但索引区分度不够可能还要继续扫很多行又比如 type 是ALL但 rows 很小那也不用太紧张全表扫 5 条记录不算问题。注意EXPLAIN 输出的 rows 是优化器估算值不是真实扫描行数。想要真实值后面可以用SHOW PROFILE或开启optimizer_trace去查但日常分析中估算值已经足够暴露 SQL 的隐患了。2. type 列是重点中的重点看懂扫描方式才能知道慢在哪2.1 从 ALL 到 const这 9 种访问类型怎么理解EXPLAIN 输出里最重要的列就是type它直接告诉你 MySQL 是通过什么方式找到目标数据的。从性能最差到最好type 大致可以排成这样type 值通俗理解性能ALL全表扫描把整张表从头到尾翻一遍最差index全索引扫描把整个索引从头到尾翻一遍很差range只扫描索引中的某个范围一般ref使用非唯一索引等值匹配可能命中多行较好eq_ref使用唯一索引等值匹配最多命中一行很好const / system用主键或唯一索引直接定位一行最好fulltext全文索引检索场景特殊index_merge多个索引合并使用看情况我把最难理解的两个类型单独拎出来说一下。先看index。很多新手以为 type 里带 index 就是走索引了性能好。其实大错特错。index的全称是“全索引扫描”意思是 MySQL 确实没有去扫表里的数据行但它把整个索引文件从头到尾都过了一遍。这就像你要在一本新华字典里找一个生僻字但你不懂拼音也不会部首只能从第一页翻到最后一页——没错你翻的确实是一本书但这本书是字典索引不是小说全文表数据可是你依然把整本都翻完了。如果索引特别大这种扫描同样很慢。再看range。这个比较容易理解就是有范围的扫描比如WHERE id BETWEEN 100 AND 200、WHERE create_time 2025-01-01。它表示 MySQL 在索引上定位到了起始位置然后沿着索引顺序往后扫到边界为止。range 不是最优但通常是可接受的。如果你的 SQL 里到处都是range而且 rows 也很大那就要考虑查询条件是不是写得太宽了。一个非常实用的判断标准是线上核心 SQL 的 type 最好达到ref或eq_ref级别能是const那更好。如果看到ALL或index十有八九是这条 SQL 性能差的元凶。2.2 实际 SQL 里最常遇到的几个 type 怎么判断纸上谈兵没用我们拿几条不同的 SQL 来对比你记住这种感觉。第一条全表扫描EXPLAIN SELECT * FROM users WHERE nickname 小明;如果 users 表没有给 nickname 建索引那这条 SQL 的 type 就是ALLrows 会显示整个表的数据量。MySQL 只能把 users 表每条记录都拿出来逐个比对 nickname 字段。当表里只有 100 条数据时无所谓但一旦上了百万行每次这样的查询都是噩梦。第二条走非唯一索引-- 给 nickname 建了普通索引 idx_nickname EXPLAIN SELECT * FROM users WHERE nickname 小明;这时候 type 会变成refpossible_keys 和 key 都显示idx_nicknamerows 大幅下降。虽然同一个 nickname 下可能有多条用户记录但 MySQL 只需要在索引里定位到小明然后沿着这棵树找到全部对应主键再回表取数据。这就是从“挨家挨户敲门”到“直接翻楼栋索引找房间号”的转变。第三条走主键等值EXPLAIN SELECT * FROM users WHERE id 12345;type 会是constrows 是 1。MySQL 通过主键直接定位到那一行几乎瞬间完成。这也是为什么所有表都建议要有主键因为在许多场景下主键查询就是 MySQL 能给出的最好访问路径。我在实际排查中的一条心得不要只看 type 有没有变成 ref 就收工还要看 key_len。比如对nickname VARCHAR(50)建索引如果字符集是 utf8mb4一个字符最多占 4 字节那么 key_len 大约就是 50 * 4 200 上下。如果发现 key_len 明显小于建索引时字段定义应有的长度说明查询条件里可能对字段做了函数处理比如WHERE LEFT(nickname, 2) 小明导致索引只被用了一部分甚至完全失效。key_len 能帮你反向判断索引是否被“打折”使用了。3. Extra 列的隐藏信号filesort、temporary、index 这些词意味着什么3.1 Using filesort排序没走索引的代价Extra列是 EXPLAIN 输出里信息密度极高的一列。很多新手朋友习惯性忽略它但很多 SQL 慢的根源恰恰藏在这里。最需要警惕的词就是Using filesort。先举个例子。假设 orders 表里有索引idx_user_id只有 user_id 一列现在执行EXPLAIN SELECT * FROM orders WHERE user_id 1001 ORDER BY create_time DESC;你会发现 type 是refkey 是idx_user_id看起来挺好。但 Extra 里出现了Using filesort。这个意思是MySQL 先用索引找到了 user_id 1001 的所有订单但是这些订单在索引里并不是按 create_time 排序的所以 MySQL 不得不额外做一次排序操作也就是“文件排序”。文件排序不是磁盘文件而是在内存或磁盘临时区里的排序动作。如果结果集很大超过sort_buffer_size的限制排序会落到磁盘临时文件上那性能会断崖式下降。解决方案也很直白把排序字段和筛选字段做成联合索引。建一个(user_id, create_time)联合索引MySQL 扫描索引时每个 user_id 对应的 create_time 天然有序Using filesort就会消失变成Using index condition之类的友好提示。这里有一条黄金法则如果 SQL 里带 ORDER BY尽可能让排序字段出现在某个索引中并且做到筛选字段在左、排序字段在右。这样索引本身是有序的MySQL 拿着这个有序的索引一路扫过去就行完全免去排序步骤。3.2 Using temporary Using where Using index 的组合解读再看一个组合Using temporary; Using filesort这个几乎等于 SQL 差劲的判决书。出现Using temporary通常意味着 MySQL 为了完成 GROUP BY 或 DISTINCT 或子查询操作不得不创建一张临时表来存放中间结果。我遇到过一个非常典型的慢 SQLEXPLAIN SELECT user_id, COUNT(*) FROM orders GROUP BY user_id ORDER BY COUNT(*) DESC;如果 orders 表里没有合适的索引你会看到 type 是ALLExtra 同时出现Using temporary和Using filesort。这意味着 MySQL 先把全表数据扫出来然后临时存到一张隐形的临时表里做分组计数接着再排序输出。这三步没有一个能省每一步都耗内存耗 IO查询不慢才怪。这种 SQL 的优化方向其实不是单纯加索引而是要思考是不是真的需要一次性查询所有 user 的统计结果如果能加上时间范围条件比如只统计最近一周的数据那临时表的数据量会小很多Using temporary也会从内存临时表变成不经磁盘的轻量操作压力完全不同。还有几个 Extra 里的正向信号值得你认识。看到Using index时要开心一下它表示“覆盖索引”即 MySQL 只需要在索引文件里就能拿到全部需要的数据连回表都免了这是最理想的状态之一。看到Using index condition表示“索引条件下推”通常配合联合索引使用意思是部分 WHERE 条件被推到了索引层过滤减少了回表次数。这两个值出现时SQL 通常不会差到哪里去。提示Extra 里出现Impossible WHERE说明优化器判断 WHERE 条件永远为假这类 SQL 完全不用执行了检查一下条件里是不是写了1 2或者矛盾的条件吧。4. 一次真实的慢 SQL 定位从 EXPLAIN 到改写完整走一遍4.1 先建表造数据模拟一个典型的慢 SQL纸上练兵不如实战走一趟。我用一个非常常见的业务场景来说明订单表。假设有个电商系统需要查询某个用户在最近 30 天内的订单列表并按订单创建时间倒序。我先建一张表塞一些数据CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, order_no VARCHAR(64) NOT NULL, status TINYINT NOT NULL DEFAULT 0, total_amount DECIMAL(10,2) NOT NULL, created_at DATETIME NOT NULL ); -- 插入一批模拟数据 INSERT INTO orders (user_id, order_no, status, total_amount, created_at) SELECT FLOOR(1 RAND() * 5000), CONCAT(NO, UUID()), FLOOR(RAND() * 5), ROUND(RAND() * 1000, 2), DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 365) DAY) FROM information_schema.columns LIMIT 20000;注意这里故意没给任何二级索引。现在业务代码里有一条 SQL经常被用户反馈“页面转圈很久”。这条 SQL 长这样SELECT id, order_no, total_amount FROM orders WHERE user_id 1234 AND created_at DATE_SUB(NOW(), INTERVAL 30 DAY) ORDER BY created_at DESC;看起来逻辑很清晰查某个用户、最近 30 天、按时间倒序。但执行起来为什么慢我们直接上 EXPLAIN 看执行计划。4.2 用 EXPLAIN 逐步分析找到瓶颈执行下面的 EXPLAIN 命令EXPLAIN SELECT id, order_no, total_amount FROM orders WHERE user_id 1234 AND created_at DATE_SUB(NOW(), INTERVAL 30 DAY) ORDER BY created_at DESC;输出结果大致如下idselect_typetabletypepossible_keyskeykey_lenrowsExtra1SIMPLEordersALLNULLNULLNULL20000Using where; Using filesort看到这份执行计划我的眉头立刻皱起来了。两个红色警报type 是 ALLrows 是 20000Extra 里出现了 Using filesort。这三件事叠在一起基本可以确定这条 SQL 没有任何索引可用MySQL 把整张订单表扫了一遍扫完之后再对满足条件的记录做了一次内存排序。要知道这里只有 2 万行数据所以你可能觉得“还好没多慢”。但线上环境往往是千万级甚至上亿级数据同一条 SQL 在数据量涨到 500 万时就会变成接口超时的头号嫌疑犯。不要等线上出事故才想起优化EXPLAIN 就是让你提前发现这种隐患的体检单。4.3 改写 SQL 与调整索引看执行计划如何变化定位到问题后常规做法是给查询加上合适的索引。这个查询的核心过滤条件有两个字段user_id和created_at排序也是created_at。所以我建一个联合索引ALTER TABLE orders ADD INDEX idx_user_created (user_id, created_at);建完索引后再次执行 EXPLAINidselect_typetabletypepossible_keyskeykey_lenrowsExtra1SIMPLEordersrefidx_user_createdidx_user_created412Using index condition看到变化了type 从ALL变成了refrows 从 20000 降到了 12key 显示使用了我们刚建的联合索引Extra 也从Using filesort变成了Using index condition。这里值得说一下为什么 rows 估算只有 12。因为user_id 1234这个用户在 2 万条数据里只占了差不多 4 条订单联合索引帮 MySQL 定位到了user_id 1234对应的那一段索引再在段内按created_at天然有序扫描。整个查询就不再依赖“全表扫描 额外排序”这种笨重方案了。如果发现这个用户的订单还是很多排序依然要花时间还可以进一步优化成覆盖索引ALTER TABLE orders ADD INDEX idx_cover (user_id, created_at, id, order_no, total_amount);当 SELECT 列表里的所有字段都包含在索引中时Extra 会变成Using index这就是覆盖索引的形态。连回表拿数据都免了查询速度还能再提一个台阶。不过覆盖索引会占用更多存储写操作也稍有代价是否使用要根据实际业务权衡不必盲目追求。5. 常见问题与排查技巧实录5.1 常见问题速查表这么多年排查经验积累下来我发现用 EXPLAIN 看执行计划时大家反复踩的坑就那几个。整理成一个速查表方便你直接对着查症状可能原因解决思路type 为 ALL 且 rows 巨大没有可用索引或索引失效检查 WHERE 条件列是否有索引检查字段隐式类型转换比如字符串列没加引号type 为 index有索引但没用上只在扫整个索引检查查询条件是否能命中索引前缀考虑调整索引顺序或增加范围列Extra 为 Using filesort排序字段不在索引中建联合索引让 ORDER BY 的字段成为索引的一部分Extra 为 Using temporaryGROUP BY 或 DISTINCT 缺少索引支撑给分组字段建索引或改写 SQL缩小处理的数据范围key 显示 NULL 但 possible_keys 有值优化器认为索引不如全表扫检查统计信息是否过旧必要时执行 ANALYZE TABLE 刷新统计rows 非常大但实际返回很少字段区分度低统计信息不准或条件写得模糊考虑加组合索引提高区分度条件尽量用等值而非范围key_len 比预期短索引列使用了函数、运算或部分前缀尽量避免在索引列上做函数操作改写 WHERE 条件5.2 除了 EXPLAIN还要会看这三样东西EXPLAIN 是最重要的入门钥匙但只靠它还不够。我建议你把下面这三项技能也一并学会它们和 EXPLAIN 配合使用效果极佳。第一EXPLAIN ANALYZEMySQL 8.0把估算变成实测。普通 EXPLAIN 输出的是优化器的估算值可能出现偏差。EXPLAIN ANALYZE会真实执行 SQL并告诉你每个步骤实际消耗了多少时间和多少行数据。用法和 EXPLAIN 几乎一样EXPLAIN ANALYZE SELECT id, order_no, total_amount FROM orders WHERE user_id 1234 AND created_at DATE_SUB(NOW(), INTERVAL 30 DAY) ORDER BY created_at DESC;输出里会有 ACTUAL TIME 和 ACTUAL ROWS 字段。如果实际行数和估算行数差了一个数量级说明表的统计信息已经严重失真或者优化器选错了索引。此时你该做的不是改 SQL而是ANALYZE TABLE orders刷新统计。第二慢查询日志。在 MySQL 配置文件中开启slow_query_log并设置long_query_time它会自动记录超过阈值比如 1 秒的 SQL。拿到慢 SQL 之后再对着它跑 EXPLAIN你就有了一份明确的“待优化名单”。我平时排查线上性能问题的第一件事就是打开慢日志导出 Top 20 的慢 SQL然后挨个看执行计划。第三performance_schema 和 sys 库。MySQL 的sys库里有一张sys.statement_analysis视图它统计了所有 SQL 语句的总执行时间、平均耗时、扫描行数等。你可以直接查它来找出“扫描行数很多但返回行数很少”的语句这类 SQL 往往是典型的低效查询SELECT query, exec_count, rows_examined_avg, rows_sent_avg FROM sys.statement_analysis ORDER BY rows_examined_avg DESC LIMIT 10;这句 SQL 能够帮你筛出高代价查询再配合 EXPLAIN 定位问题细节效率极高。我在实际排查中还有一个习惯永远先看过滤性最好的字段再看排序需求。一个联合索引的设计往往要从业务真实查询出发而不是给每个字段单独建索引。单字段索引往往是低效的——MySQL 每次只能选择一个索引多个单列索引在复杂查询中经常帮不上忙。而联合索引一旦设计对一条慢 SQL 的耗时可以从 2 秒降到 20 毫秒这种收益比调任何 MySQL 参数都大得多。再看一遍开始那句话EXPLAIN 都看不懂还谈什么定位低效 SQL现在我们把它拆完了type、key、key_len、rows、Extra每一列都清清楚楚。下次你再遇到慢 SQL不要凭感觉瞎改先跑一条 EXPLAIN 看看它的执行计划。“先看执行计划再谈优化”这是很多 DBA 老手挂在嘴边的一句话现在你也掌握它的全部含义了。”