ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MySQL EXPLAIN执行计划与索引优化:输出列解读与慢查询排查

MySQL EXPLAIN执行计划与索引优化:输出列解读与慢查询排查

1. 先弄清 EXPLAIN 到底在回答什么问题

很多人把 EXPLAIN 当成一个“出事才打开”的急救工具,慢查询报警了、接口超时了,才想起来在 SQL 前面敲四个字母。我自己的习惯恰恰相反:一条稍微复杂点的 SQL,尤其是在写 join、写子查询、写带排序分页的语句时,写完先过一遍 EXPLAIN,看完再提交到代码里。这个习惯养成之后,线上慢查询的数量会有肉眼可见的下降,因为你在一开始就把那些会退化成全表扫描的写法挡在门外了。

要说清楚 EXPLAIN,得先明白它输出的不是一个“执行过程录像”,而是一份执行前的计划书。MySQL 的优化器在拿到一条 SQL 之后,会做几件事:解析成语法树、做等价改写(比如把外连接转内连接、把子查询物化、消掉恒真恒假条件)、枚举可能的索引组合与表连接顺序、给每种组合估算一个成本值,最后挑成本最低的那套交给执行器去跑。EXPLAIN 展示的就是“被选中的那套方案”。所以你看 EXPLAIN 的时候,本质上是在读优化器的决策结果,而不是在读真实执行过程——这两件事的差别,是后面所有坑的根源。

1.1 优化器在纠结的两件事:走哪个索引、按什么顺序连表

单表查询的决策相对简单:有多个候选索引时选哪个,或者干脆不选索引走全表扫。多表 join 就复杂得多,N 张表有 N! 种连接顺序,优化器不可能全枚举,它用了一套基于成本模型的贪心搜索,参数optimizer_search_depth控制搜索深度。所以你看到的 join 顺序,是一个“在有限搜索空间里找到的较优解”,不是理论最优解。理解了这一点,你就不会在看到驱动表选得不如你意时觉得莫名其妙——它只是按它手里的统计信息和成本常数算出来的,而这两样东西都可能不准。

这也解释了为什么同一个 SQL 在测试库和生产库上 EXPLAIN 结果不一样:数据量不同、数据分布不同、统计信息采样时机不同,成本估算自然就不同。所以我一向不建议直接抄别人的 EXPLAIN 输出当结论,一定要在自己的数据上跑。

1.2 三种打开方式,别只会第一种

基础的EXPLAIN SELECT ...输出表格形式,适合快速扫一眼 type 和 key。EXPLAIN FORMAT=JSON SELECT ...输出成本明细,索引的 estimated_cost、rows_examined_per_scan 都在里面,适合深挖“为什么它觉得这样更便宜”。EXPLAIN ANALYZE SELECT ...(8.0.18 之后可用)会真的把语句执行一遍,输出每个算子的实际耗时和实际行数,这是排查估算偏差最有力的工具。

需要特别提醒:EXPLAIN ANALYZE会真实执行 SQL,写操作千万别拿它去跑。虽然它对 DML 的处理是执行后回滚,但在高并发场景下仍然会产生锁和间隙,我在测试环境里见过有人对一条 update 跑 EXPLAIN ANALYZE 把行锁住几十秒的。稳妥做法是先把 DML 改写成等价的 SELECT 再分析。

注意:EXPLAIN ANALYZE输出的是 TREE 格式,不是表格。想看表格得用普通 EXPLAIN,想看树形结构加实测数据就用 ANALYZE,两者不要混着理解。

2. 输出列逐个拆:哪些决定性能,哪些只是参考

一条 EXPLAIN 输出通常有十来列,新手容易平均用力,逐列看一遍然后什么也没记住。我的经验是分三档:决定性能的(type、key、key_len、rows、Extra)、帮助理解结构的(id、select_type、table、partitions)、辅助判断的(possible_keys、ref、filtered)。下面按这个顺序拆。

2.1 id 与 select_type:先把查询的层级关系理干净

id相同的行属于同一层查询,从上往下执行;id不同的时候,数字越大越先执行。子查询、派生表(derived table)、UNION 都会产生不同的 id。select_type则告诉你这一行是什么角色:SIMPLE 是没子查询没 UNION 的简单查询;PRIMARY 是最外层;SUBQUERY 是 select 列表或 where 里的非相关子查询;DEPENDENT SUBQUERY 是相关子查询,意思是外层每取一行它就要重新算一次,这个标记出现时基本要警惕,它往往是性能杀手;DERIVED 是 from 子句里的派生表;UNION RESULT 是 UNION 的合并结果。

我见过不少人对 DEPENDENT SUBQUERY 没有概念,写了个where exists (select 1 from ...)觉得 exists 比 in 快,结果 EXPLAIN 一看是 DEPENDENT SUBQUERY 且被驱动表走全表扫,实际执行时间跟表行数的平方成正比。这种情况下,把相关子查询改写成 join,往往能立刻从秒级降到毫秒级。

2.2 type:整张表里最该先看的一列

type 表示访问类型,也就是“用什么方式定位到需要的行”。从好到坏大致是这个顺序:

type 值含义典型场景
system表只有一行常量表
const主键或唯一索引等值匹配,最多一行where id = 100
eq_ref联表时被驱动表用主键/唯一索引等值匹配join on 主键
ref普通索引等值匹配,可能多行where user_id = 100
range索引范围扫描between、>、in
index全索引扫描,扫整棵索引树覆盖索引但没过滤条件
ALL全表扫描无索引或索引失效

index和ALL都属于要警惕的级别。很多人看到index就放心了,觉得“用上索引了”,其实index是扫描整棵二级索引树,只是比扫聚簇索引的叶子节点轻一点,数据量大了一样慢。真正的分水岭在range及以上。

eq_ref和ref的区别也值得说一句:eq_ref 意味着被驱动表的这一列是唯一约束,每次匹配最多返回一行,所以扫描行数可控;ref 可能返回多行,需要在索引里继续向右找,代价跟命中的行数正相关。这也是为什么联表时被驱动表的连接列一定要有索引——没有索引就退化成 ALL 或者走 join buffer 做哈希匹配。

2.3 possible_keys、key、key_len:索引到底用了哪一段

possible_keys是优化器认为“理论上能用”的索引列表,key是最终选中的那个,possible_keys有值而key为 NULL,说明有候选但优化器算下来觉得不划算,走了全表扫。这种情况在小表上很常见(优化器认为扫全表比走二级索引再回表更快),在大表上出现就值得深究了。

key_len是最容易被忽略、但信息量极大的一列。它表示实际使用了索引的前多少字节,用来判断联合索引用到了第几列。

以我常用的这张订单表为例:

CREATE TABLE t_order ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL, status TINYINT NOT NULL DEFAULT 0, city VARCHAR(32) NOT NULL, amount DECIMAL(12,2) NOT NULL, created_at DATETIME NOT NULL, remark VARCHAR(255) DEFAULT NULL, PRIMARY KEY (id), KEY idx_user_status (user_id, status), KEY idx_created (created_at), KEY idx_city (city) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

计算 key_len 的规则是这样的:BIGINT NOT NULL占 8 字节;TINYINT NOT NULL占 1 字节;VARCHAR(32) NOT NULL用 utf8mb4 字符集,是 32×4 + 2(变长长度前缀)= 130 字节;如果这一列允许 NULL,还要再加 1 字节标记位。DATETIME在 5.6 之后是 5 字节,带毫秒的DATETIME(3)是 7 字节。

所以where user_id = 100 and status = 1命中idx_user_status时,key_len 应该是 8+1=9;只写where user_id = 100时 key_len 是 8。如果你明明写了两列条件,key_len 却只有 8,说明第二列没被用上——要么类型不匹配被截断了,要么顺序不对。

2.4 rows 与 filtered:所有估算偏差的源头

rows是优化器估算的“需要扫描的行数”,注意是估算,不是实测。它来自 InnoDB 的索引统计信息,采样方式是随机读取若干个索引页,参数innodb_stats_persistent_sample_pages默认是 20 页。20 页对于一个几百万行的表来说,样本量很小,遇到数据倾斜严重的情况(比如某个城市占了 60% 的订单),估算偏差可以到几十倍。

filtered表示在扫描到的行里,有多少比例能通过这一层的条件过滤,是一个百分比。实际返回行数约等于rows × filtered%,但这个乘法在 join 场景下会层层传递,误差也层层放大。所以我认为rows的正确用法是“看数量级”,不是“当精确值用”。实际行数是多少,得靠 EXPLAIN ANALYZE 或者SHOW SESSION STATUS LIKE 'Handler_read%'去对。

我自己有一个判断标准:如果一个步骤的 rows 估算超过了总行数的 20% 到 30%,那走索引的意义就不大了,优化器选全表扫其实是合理的,这时候该想办法的是改写 SQL 减少扫描量,而不是硬塞一个索引。

2.5 Extra:信息密度最高的一列,也是最容易看漏的

Extra 里出现的关键词,基本就是这条 SQL 的体检结论。我把它整理成一张带优先级的对照表:

Extra 内容含义关注度
Using index覆盖索引,不回表好
Using index condition索引条件下推(ICP)好
Using where拿到行后再过滤中性,要结合 rows
Using filesort额外排序,无法用索引顺序必须优化
Using temporary用了临时表必须优化
Using join buffer (hash join)被驱动表无索引,走了哈希匹配视数据量
Using MRR多范围读,随机转顺序好
Using index for skip scan8.0.13 的索引跳跃扫描好但别依赖
Select tables optimized away直接从统计信息返回好
Impossible WHERE条件恒假,直接返回空好

Using filesort和Using temporary是我最不能忍的两个。filesort 意味着排序列的顺序和索引顺序对不上,只能在内存或磁盘上做一次排序;对于order by ... limit 10这种,MySQL 会先用 priority queue 做堆排序,代价还行;但如果是order by加上没有 limit 的全量返回,那就是实打实地把全部行排一遍,数据量一大直接溢出到磁盘归并。Using temporary通常出现在group by和distinct上,临时表可能是内存表也可能是磁盘表,磁盘临时表基本等同于一次落盘写。

另外 8.0 之后 join buffer 的 Extra 会显示(hash join),这是 8.0.18 引入的哈希连接,执行计划里没有索引可用的等值 join 会走这条路,比 5.7 的 Block Nested Loop 快很多,但也别当成万灵药——哈希表要在内存里建,join buffer 大小不够还是要分批。

3. 造一套数据,把几种典型执行计划跑出来

光看定义记不住,得有可复现的操作。下面这套我用了很多次,数据量不大但足以把各种 type 触发出来,跑在本地或者测试环境就行,别在生产上做。

3.1 建表与灌数据:一份能反复用的脚本

沿用上面那张t_order表,灌一百五十万行左右。用存储过程批量插入,比一行一行 insert 快得多:

DELIMITER $$ CREATE PROCEDURE fill_order(IN n INT) BEGIN DECLARE i INT DEFAULT 0; WHILE i < n DO INSERT INTO t_order (user_id, status, city, amount, created_at, remark) VALUES ( FLOOR(RAND() * 20000), FLOOR(RAND() * 5), ELT(FLOOR(RAND() * 5) + 1, '北京', '上海', '广州', '深圳', '成都'), ROUND(RAND() * 10000, 2), DATE_ADD('2024-01-01', INTERVAL FLOOR(RAND() * 365) DAY), NULL ); SET i = i + 1; END WHILE; END$$ DELIMITER ; CALL fill_order(1500000); ANALYZE TABLE t_order;

这里有个细节值得说:灌完数据一定要跑一次ANALYZE TABLE。InnoDB 的统计信息是持久化的,但插入过程中不一定及时更新,导致优化器拿着旧统计做决策。我踩过一次坑,明明插了几十万行,EXPLAIN 的 rows 还是个小数字,查了半天以为是索引问题,其实只是统计没刷新。

3.2 案例一:索引明明建了,type 却是 ALL

先跑一条“看起来没问题”的查询:

EXPLAIN SELECT * FROM t_order WHERE user_id + 0 = 1000;

你会发现key是 NULL,type是 ALL,rows差不多是一百五十万。原因很直白:索引里存的是user_id的原始值,而user_id + 0是表达式,索引有序性在表达式结果上不成立,优化器只能全表扫。同理的还有WHERE DATE(created_at) = '2024-05-01'、WHERE LEFT(city, 1) = '北'。

这类写法在业务代码里极其常见,尤其是日期筛选。改法有两种,优先第一种:

-- 改成范围条件,索引可用 SELECT * FROM t_order WHERE created_at >= '2024-05-01 00:00:00' AND created_at < '2024-05-02 00:00:00';

范围写法能用上idx_created,type 变成 range,rows 直接掉到千级别。第二种是建函数索引,8.0.13 之后支持:

ALTER TABLE t_order ADD KEY idx_created_date ((DATE(created_at)));

函数索引的坑在于,你后续所有用到DATE(created_at)的查询都能命中它,但列本身改了什么函数(比如换成YEAR(created_at))就无效了,而且它会计入写放大成本,每插入一行要维护两棵索引树。频繁写入的表我一般不推荐上函数索引,除非查询的收益非常明确。

还有一种更隐蔽的索引失效:隐式类型转换。city是VARCHAR,如果写成WHERE city = 123,MySQL 会把字符串转成数字比较,等价于对列做了CAST(city AS DOUBLE),索引直接失效。这个坑在接口参数类型没校验的时候特别容易踩,代码里传进来个 String,框架一拼,值没加引号就出事了。

3.3 案例二:从 Using where 到 Using index 的覆盖索引改造

跑这一对查询对比:

EXPLAIN SELECT * FROM t_order WHERE user_id = 1000; EXPLAIN SELECT user_id, status FROM t_order WHERE user_id = 1000;

第一条的 Extra 是Using where(或者干脆没有),因为要返回*,索引叶子节点只有user_id和status(还有主键),必须回表拿amount、created_at这些列。第二条的 Extra 是Using index,需要的两列全在idx_user_status里,不需要回表。

回表的代价有多大?和行数成正比,而且是随机 IO。因为二级索引叶子节点里存的是主键,按主键再回聚簇索引找行,这些主键在数据页上分布是散的。这就是为什么明明走索引了还是慢——走了索引但回表几十万次,随机 IO 打满。所以“能覆盖就覆盖”是我写 SQL 的第一原则,尤其是高频查询,尽量让 select 的列都落在索引里。

但覆盖索引也不能乱加,这里有个权衡:idx_user_status已经能覆盖(user_id, status),如果你再来一个多条 select 列的场景,是不是要把amount、created_at都塞进联合索引?那样索引就膨胀了,更新成本的写放大、缓冲池的占用都会上升。我的原则是按查询频率排优先级,前三名的查询做覆盖优化,长尾查询就让它们回表,不要为了极个别场景建一个宽索引。

顺便说一下Using index condition,这是索引条件下推(ICP)。对于联合索引(user_id, status),条件是user_id = 1000 AND status = 1,在 MySQL 5.6 之前,存储引擎只能按user_id定位到索引记录,然后把整行(或者主键)返回给 server 层,server 层再过滤status。开启 ICP 后,status = 1这个判断下沉到存储引擎层,在索引上就过滤掉,减少回表次数。这个特性默认开启,一般不用管,但如果你看到 Extra 里没有它,值得看一眼optimizer_switch里的index_condition_pushdown是不是被关了。

3.4 案例三:join 的驱动表选错,代价差几十倍

造一张用户表配合测试:

CREATE TABLE t_user ( id BIGINT UNSIGNED NOT NULL, nick VARCHAR(64) NOT NULL, level TINYINT NOT NULL, PRIMARY KEY (id), KEY idx_level (level) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

然后跑:

EXPLAIN SELECT o.id, u.nick FROM t_order o JOIN t_user u ON u.id = o.user_id WHERE o.user_id = 1000;

这里o是被驱动表还是驱动表,取决于优化器的选择。如果o在user_id上有索引,u是主键,那大概率是u做驱动或者o做驱动都能走索引,差别不大。真正出问题的是连接列没索引的情况,比如ON u.id = o.status,被驱动表没有可用索引,Extra 会显示Using join buffer (hash join),MySQL 会在内存里给驱动表建哈希表,逐行探测。

哈希 join 在数据量小的时候很快,但驱动表一大,join buffer(默认join_buffer_size是 256KB)装不下,就要分块落盘,性能急剧下降。这也是为什么我坚持“被驱动表的连接列必须有索引”这条老规矩,别看哈希 join 的实现很现代,它终究是拿内存换随机的方案。

另外有一个判断 join 顺序的实用技巧:EXPLAIN 输出的行顺序就是执行顺序,第一行是驱动表。如果你发现小表被放在了后面,可以在 SQL 里加STRAIGHT_JOIN强制顺序,或者调整查询结构让优化器更容易算对。当然,改之前先确认真的是顺序问题——很多时候优化器算得比你准,它选大表驱动是因为大表经过 where 过滤后行数更少。

3.5 案例四:Using filesort 与 Using temporary

这两个是排序和分组的典型标记,跑一下:

EXPLAIN SELECT * FROM t_order WHERE user_id = 1000 ORDER BY created_at DESC LIMIT 20;

大概率会看到Using where; Using filesort。因为idx_user_status的排序是(user_id, status),不是(user_id, created_at),满足不了ORDER BY created_at。优化分两种:一是把索引改成(user_id, created_at),让过滤和排序共用一个索引,Extra 里的 filesort 就消失了;二是如果数据量不大,接受这次排序也是可以的,因为带了 LIMIT 20,MySQL 用优先队列排序,只维护 20 个元素,实际很快。

Using temporary常见于 group by:

EXPLAIN SELECT city, COUNT(*) FROM t_order GROUP BY city;

如果city上有索引,MySQL 可以直接按索引顺序扫描,边走边聚合,Extra 不会出现临时表;如果没有索引,就得先建一张临时表,把行塞进去再聚合。8.0 之后 group by 不再隐式排序,这是个改进,但临时表的代价依然存在。对于这种聚合查询,加一个city索引的收益是很明显的。

还有一个细节:ORDER BY和WHERE用不同索引时,MySQL 通常只能二选一,优先用过滤性强的那个,排序就退化成 filesort。想两者兼得,就得设计一个联合索引同时覆盖过滤列和排序列,并且过滤列在左、排序列在右。

4. 当表格形式的 EXPLAIN 不够用时

普通 EXPLAIN 的三条信息是可以直接看的:走没走索引、访问类型、有没有 filesort。但它有两个盲区:一是 rows 是估算,你不知道真实扫了多少行;二是你只看到“结果”,看不到“理由”。下面三个工具分别补齐这两块。

4.1 EXPLAIN ANALYZE:把估算换成实测

EXPLAIN ANALYZE SELECT o.id, o.amount FROM t_order o WHERE o.user_id = 1000 AND o.status = 1 ORDER BY o.created_at DESC LIMIT 10;

输出是树形的,每个节点后面会带(actual time=... rows=... loops=...)。这里要重点对比两件事:括号里的估算 rows 和 actual rows 的差距,以及每个节点的耗时占比。我在一次排查中遇到过估算 rows 是 12、实际 rows 是 40000 的情况,差距三千多倍,原因就是数据倾斜——那个user_id恰好是某个大客户,订单量远超平均值。

loops这个字段容易被忽略但很关键。它表示这个算子被调用了多少次,在嵌套循环 join 里,内层节点的 loops 等于外层的行数。所以内层的 actual time 要乘以 loops 才是真实总耗时,直接看单次时间会严重低估。

4.2 FORMAT=JSON:看成本是怎么算出来的

EXPLAIN FORMAT=JSON SELECT * FROM t_order WHERE user_id = 1000 AND status = 1;

输出里能看到query_cost、read_cost、eval_cost、prefix_cost等一系列字段,以及rows_examined_per_scan和rows_produced_per_join。前者是扫描行数,后者是过滤后的产出行数,两者一除就是 filtered 的由来。

我用这个格式主要看两件事:一是候选索引的成本对比,有些版本会在considered_execution_plans里列出所有候选方案及其成本,能直接看出优化器为什么放弃了某个索引;二是成本模型参数是否合理,MySQL 默认认为读一页内存数据的成本是 1,随机读一页的成本是 4(optimizer_switch里的成本常数),SSD 环境下这个比例未必准确,可以通过mysql.optimizer_cost_constants的表来校准。不过这是高阶操作,改之前一定要在预发环境验证,改错成本常数可能把所有执行计划搞乱。

4.3 optimizer_trace:优化器为什么放着好索引不用

这是排查“索引明明存在但不走”的终极武器:

SET optimizer_trace = 'enabled=on'; SET optimizer_trace_max_mem_size = 1048576; SELECT * FROM t_order WHERE user_id = 1000 AND status = 1; SELECT TRACE FROM information_schema.OPTIMIZER_TRACE\G SET optimizer_trace = 'enabled=off';

TRACE 字段是一大段 JSON,里面有rows_estimation阶段每个索引的估算行数、considered_execution_plans阶段的成本对比、chosen_range_access_summary的最终选择。我用它定位过一类问题:多列条件的范围查询,两个索引各自的成本差不多,优化器做了 index merge,结果 merge 完的回表成本比单走一个索引还高。看了 trace 才明白是index_merge的估算模型偏乐观。

有两点要注意:一是这个开关是 session 级的,用完必须关,否则每条 SQL 都记录,内存和性能都会受影响;二是输出可能非常长,普通客户端会被截断,建议在命令行里用\G输出或者通过程序读取。

5. 排查实录:几个反复出现的坑

5.1 rows 估算和实际差得离谱怎么办

刚才提到过,rows 的采样页数是 20,这个值对数据倾斜的表远远不够。可以临时调大:

SET GLOBAL innodb_stats_persistent_sample_pages = 100; ANALYZE TABLE t_order;

采样页数调大能改善估算精度,代价是 ANALYZE 变慢。调完记得观察一下效果,如果还是偏差很大,那说明问题不在采样,而在数据本身就不适合用单值统计来估算——比如一个 where 条件里某几个值占了 90% 的数据,优化器不管怎么采都很难给每个取值一个足够精确的估算。这种场景下更实际的做法是改写 SQL,把大客户和小客户区分开查询,而不是硬调统计参数。

还有一种情况是统计信息长期不更新。InnoDB 在表数据变化超过一定比例(默认 10%)时会自动触发统计更新,但那是异步的,也不是实时的。对于频繁增删的表,我一般会在业务低峰期做一次定时 ANALYZE,特别是那些白天写入量大、第二天就要做报表查询的表。

5.2 索引在、type 却是 ALL:先查这五件事

我在实际排查中总结了一份清单,按出现频率排序:

可能原因检查方法处理方式
类型隐式转换看列定义是 varchar 还是数值,参数是否带引号强制参数类型匹配
列上用了函数或运算检查 where 左侧是否是裸列改写成范围条件
联合索引未走最左列看 key_len 是否只覆盖了前缀调整索引顺序或加单列索引
区分度太低SELECT COUNT(DISTINCT col)/COUNT(*)通常不值得建索引
表太小rows 只有几百行全表扫反而更快,不用管

区分度这个点单独说一下。status只有 0 到 4 五个值,如果分布均匀,每个值占 20%,走索引要回表,回表的随机 IO 比顺序扫全表还贵,所以优化器选 ALL 是合理的。要解决的不是让它走索引,而是考虑把status从索引里挪出去,换成选择性更高的组合,比如(user_id, status),用user_id先把范围缩小到几十行,status再作为过滤条件。

5.3 深分页:type 没问题,但还是很慢

EXPLAIN SELECT * FROM t_order ORDER BY id LIMIT 1000000, 20;

EXPLAIN 显示type=index、key=PRIMARY,看起来没毛病,实际跑起来要几秒。因为 MySQL 要先扫过前一百万行然后丢掉,只留最后 20 行,扫描量摆在那儿。这种问题的优化思路不是改索引,而是改写法,用延迟关联:

SELECT o.* FROM t_order o JOIN (SELECT id FROM t_order ORDER BY id LIMIT 1000000, 20) t ON o.id = t.id;

子查询里只取主键,走的是覆盖索引(PRIMARY本身就是 id),成本低得多;拿到 20 个 id 之后再回表取整行,只有 20 次随机 IO。实测下来,百万级偏移量从 3 秒多降到几十毫秒。

如果业务允许,更彻底的办法是改成游标分页,用WHERE id > 上次最大 id LIMIT 20,彻底干掉偏移量。这需要前端和接口配合改,但收益最大。

5.4 一个容易被忘掉的因素:字符集与排序规则

两张表 join 时,如果连接列一边是utf8mb4_general_ci、另一边是utf8mb4_0900_ai_ci,MySQL 在比较时要做隐式转换,索引可能失效。这种情况在跨部门、跨系统对接的表上并不罕见。检查方法很简单,SHOW CREATE TABLE对比一下排序规则,不一致就统一。我在一个项目的字段对齐上踩过这个坑,两张表都用了 utf8mb4,但一张是 8.0 默认的新排序规则,另一张是老库迁过来的旧规则,join 慢得离谱,统一之后计划立刻恢复正常。

6. 我读 EXPLAIN 的固定顺序

看了这么多年的执行计划,我形成了一套固定动作,基本上三十秒能判断出这条 SQL 有没有风险。

第一眼扫type列,从上往下找有没有ALL或index。有的话先别急着改索引,看一眼rows——如果 rows 只有几百,全表扫是合理的,放过它;如果 rows 是几十万上百万,那就必须处理。

第二眼看key和key_len,确认联合索引用到了第几列。如果 key_len 只覆盖了第一列,而 SQL 里明明带了第二列的条件,那就是没走全,值不值得加索引要看这一列的过滤性。

第三眼看Extra,搜两个词:filesort 和 temporary。出现了就想办法消掉,消不掉就确认数据量是否在可控范围(带 LIMIT 的排序一般可以接受)。

最后才看rows × filtered的乘积累不累,join 场景下这一步最关键,因为估算误差会沿着连接层级放大。

说到底,EXPLAIN 这东西没有捷径,看多了自然有感觉。但有一点我想强调:别过度优化。我见过团队里有人为了把Using where变成Using index,给一个低频查询加了三个宽索引,结果写性能掉了 30%,得不偿失。索引是拿写入换读取的交易,每一笔都要算清楚值不值。我自己的做法是,先用 EXPLAIN 把明显有问题的揪出来,改完再跑一次对比,确认收益是否真实存在——很多时候你以为的优化,在真实数据上根本看不出差别,那种时候就应该收手,把复杂度留给真正需要的场景。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进