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 scan | 8.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 把明显有问题的揪出来,改完再跑一次对比,确认收益是否真实存在——很多时候你以为的优化,在真实数据上根本看不出差别,那种时候就应该收手,把复杂度留给真正需要的场景。