还记得第一次被人丢过来一句“你先 explain 一下”的时候吗?我当时盯着终端里那行密密麻麻的输出,每个单词都认识,连在一起就完全不知道在说什么。后来和慢 SQL 打了几年交道才慢慢想明白一个事:SQL 优化这件事,百分之八十的起点都在 Explain 这条命令上。它不会直接告诉你“该加哪个索引”,但它会把数据库优化器当时究竟打算怎么执行这条 SQL 的底牌翻出来给你看。这篇文章我会从执行计划的基本逻辑讲起,把 MySQL 的 EXPLAIN 输出逐列拆开,再用几个真实排查案例讲讲我怎么靠它把慢 SQL 从几百毫秒压到个位数毫秒,最后还会聊一个开发环境里非常常见、但总被人当成报错的 Git 提示——它其实也在对你喊“explain”。如果你刚接触执行计划,或者正被慢 SQL 折磨,这篇文章应该能帮你把这件事彻底串起来。
1. Explain 是在“解释”什么——先把执行计划这件事讲明白
1.1 数据库并不是照着你的 SQL 字面意思跑的
很多人第一次看到 EXPLAIN 的输出时会困惑:我写的 SQL 明明很简单,为什么执行计划里有那么多步骤?这背后藏着一个数据库初学者最容易忽略的事实:SQL 是一种声明式语言,你告诉数据库的是“我最终要什么结果”,而不是“你应该怎么去取”。
比如 SELECT * FROM A JOIN B ON A.id = B.aid WHERE A.status = 1,数据库执行时完全可以先扫 B 再回表 A,也可以先通过索引定位 A 中 status=1 的行再拿 aid 去 B 里匹配,还可以把 A、B 都丢进哈希表做一次 Hash Join。这些操作对最终结果没有任何影响,但执行成本可能差出几个数量级。
优化器的职责,就是在语法解析、逻辑改写之后,基于统计信息选出一套它认为成本最低的执行路径。EXPLAIN 这条命令,本质就是把优化器选好的这套“操作方案”翻译成人类能读懂的格式输出出来。你可以把它理解成导航软件:你输入目的地,它告诉你“我打算走哪条路、经过哪些路口、预计多久到”。导航会因为实时路况改变推荐路线,数据库执行计划也会因为数据分布、索引、统计信息的变化而改变——这也是为什么执行计划不是一成不变的。
1.2 执行计划里最值得关注的三件事
拿到任何数据库的执行计划,不论 MySQL、PostgreSQL 还是 SQLite,我建议你都只盯三件事。
第一是访问路径。数据到底是怎么被找到的?是全表扫描,还是沿着索引一层层定位?这直接决定了单表读取的成本。全表扫描在小表上无所谓,但一旦表里有几百万行,代价就完全不一样了。
第二是连接顺序与连接方式。多表 JOIN 时,谁是驱动表、谁是被驱动表,优化器用 Nested Loop 还是 Hash Join,这决定了中间结果集会被放大多少倍。很多慢 JOIN 不是索引问题,而是驱动表选错了,导致内层循环执行了百万次。
第三是排序与分组策略。GROUP BY、ORDER BY、DISTINCT 这类操作,到底是直接利用索引天然有序的顺序,还是把数据先取回来再额外排一次、甚至用到临时表?很多“明明有索引还是很慢”的怪事,根子都出在这个环节。
理解了这三个观察维度,再去看 EXPLAIN 输出,你就不会迷失在一堆列名里了——每列不过是在回答这三个问题中的某一部分。
1.3 成本模型:为什么优化器会选“看起来很笨”的方案
如果你用 EXPLAIN 看执行计划,发现优化器选的路径居然不是你以为的那条,先别急着骂优化器。它做决策的依据是表统计信息里的行数、基数、数据分布,估算出每个执行步骤的代价,然后求和取最小。这套成本模型在多数情况下是可靠的,但也有两个很常见的失效场景。
一是统计信息过期。表数据从几万行涨到几百万行,但 ANALYZE TABLE 迟迟没跑,优化器还以为表很小,自然倾向于全表扫描。二是数据分布极度不均匀,比如 status 字段里 99% 都是 1,那优化器可能认为“查 status=1 走全表扫描比走索引更快”,因为索引回表的随机 IO 成本实在太高了。
所以我的原则是:执行计划是优化器基于现有信息做出的“大概率最优”判断,不是“绝对真理”。它告诉你的是数据库打算怎么做、以及它估算的成本是多少,至于这个方案实际行不行,最终还要结合真实执行时长和数据分布来验证。后面章节里我会反复用这个思路。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从一条慢 SQL 说起:MySQL EXPLAIN 输出逐列拆解
2.1 一张真实场景下的 EXPLAIN 输出长什么样
先看一条很典型的慢 SQL,模拟一下“用户查自己最近的订单列表”:
sql复制EXPLAIN SELECT u.name, o.amount, o.status
FROM orders o
JOIN users u ON u.id = o.user_id
WHERE o.created_at >= '2024-01-01'
AND o.status = 1
ORDER BY o.created_at DESC
LIMIT 20;
执行后你可能会看到这样一张表:
| id | select_type | table | type | possible_keys | key | key_len | rows | Extra |
|---|---|---|---|---|---|---|---|---|
| 1 | SIMPLE | o | ALL | idx_status, idx_created | NULL | NULL | 5234179 | Using where; Using filesort |
| 1 | SIMPLE | u | eq_ref | PRIMARY | PRIMARY | 4 | 1 | NULL |
先说结论:这张表里 orders 表出现了 ALL 全表扫描,扫描行数估算 523 万,同时还有 Using filesort,两件事凑一起基本就解释了为什么接口会慢到让人崩溃。下面把每一列拆开看。
2.2 id、select_type、table:理解查询里到底有几张表
id 列表示这是第几个查询块。同一个 SELECT 语句里多张表 JOIN 时,id 通常相同;有子查询时,id 会递增,而且 id 越大越先执行。table 列显示的是访问哪张表,注意它可能会出现 <derived2>、<union1,2> 这类形式,表示这里访问的是派生表或 UNION 的临时结果,而不是物理表。
select_type 常见的有 SIMPLE(简单查询,没有子查询和 UNION)、PRIMARY(最外层查询)、SUBQUERY(子查询)、DERIVED(派生表)、UNION(UNION 中后面的 SELECT)。这个字段主要是帮你识别出 SQL 的结构复杂度,实际排查中它不像 type、rows 那么关键,但如果看到 DEPENDENT SUBQUERY,就要警觉——这类子查询外层每处理一行都会执行一次,性能通常很糟糕。
2.3 type 列:访问类型直接决定查询的下限
type 是执行计划里最值得先看的一列,它描述的是“用什么样的方式去这张表里找数据”。从好到差大致是:
| 访问类型 | 含义 | 出现场景 |
|---|---|---|
| system | 表只有一行 | 系统表或统计表 |
| const | 最多返回一行,主键/唯一索引等值匹配 | WHERE id = 1 |
| eq_ref | 被驱动表通过主键/唯一索引等值匹配 | JOIN 时被驱动表查主键 |
| ref | 通过普通索引等值匹配 | WHERE user_id = 10086 |
| range | 索引上的范围扫描 | WHERE created_at >= '2024-01-01' |
| index | 扫描整棵索引树 | 覆盖索引但没过滤条件 |
| ALL | 全表扫描 | 最坏情况 |
日常开发里,我给自己定的线是:核心业务表上的查询,type 至少要达到 ref 或 range。出现 index 时如果返回行数很少,通常说明查询条件不在索引列上;出现 ALL 时,只要表数据量不是几十行的小字典表,基本就是索引没设计对或条件写法让索引失效了。
2.4 key、key_len:索引到底用到了什么程度
possible_keys 列出优化器觉得可能用到的索引,key 是它最终选中的索引。一个比较常见的情况是 possible_keys 里有索引,但 key 是 NULL,这说明优化器觉得“用这个索引还得回表,不如全扫描便宜”,或者你的 SQL 写法让索引没法被使用。
key_len 比 key 更容易被忽略,但它信息量很大。它是 MySQL 根据索引字段定义计算出的“预计使用索引字节长度”,通过它你可以判断一个复合索引到底用到了哪几列。举个例子,假设有个索引 idx_name_age(name varchar(20) NOT NULL, age int NOT NULL),在 utf8mb4 字符集下:
- 只用 name 列时,key_len = 20 × 4 + 1 = 81。多出来的 1 字节是 varchar 变长字段的长度前缀。
- 如果 name 列允许 NULL,还要再加 1 字节,key_len = 82。
- name 和 age 都用上时,key_len = 81 + 4 = 85。
所以当你建了 (a, b, c) 复合索引,EXPLAIN 显示的 key_len 只覆盖 a,就说明 b、c 没有被真正用于过滤或排序,索引效果大打折扣。这个技巧在排查“复合索引好像没生效”的疑难杂症时非常管用。
2.5 rows 和 filtered:优化器对代价的估算
rows 是优化器估算的需要读取的行数,注意它只是估算值,并不代表真实执行时真的读了这么多行。filtered 是 MySQL 5.7 之后出现的列,表示存储引擎返回的数据经过 Server 层条件过滤后,还有百分之多少能留下。比如 rows=100000、filtered=1,意味着估算读取 10 万行、最终只有 1000 行满足条件,大量的行被白白读出来再丢掉,这通常就是索引设计不合理的信号。
排查 JOIN 时,要特别留意驱动表的 rows 和 filtered 的乘积,因为驱动表每扫出一行,都要去被驱动表匹配一次。这个乘积基本决定了连接过程的总工作量。
2.6 Extra 里那些“暗号”怎么读
Extra 列往往是判断问题性质的关键,几个高频词值得背下来:
- Using filesort:排序没有走索引,需要额外排序。性能杀手,尤其面对大结果集。
- Using temporary:用了临时表,常见于 GROUP BY、DISTINCT、UNION 以及某些子查询。结果集一大会触发磁盘临时表,极慢。
- Using index:直接读取索引就拿到了所有需要的数据,不需要回表。这是覆盖索引,属于好消息。
- Using index condition:触发了索引下推(ICP),MySQL 在存储引擎层先过滤一部分索引字段,减少回表次数,一般是良性状态。
- Using where:存储引擎返回数据后,Server 层还要再做条件过滤。如果伴随 type=ALL,基本等于“大量行被读出来再过滤”。
结合前文的例子,Extra 里出现 Using where; Using filesort,就能很快推断出 orders 表全表扫了 523 万行,然后还要把符合条件的行再做一次文件排序,不快才怪。
3. 三起慢查询排查实录:执行计划是怎么“破案”的
3.1 全表扫描的订单查询:加一个复合索引后从 460ms 降到 5ms
之前接手过一个线上订单接口,查询条件很简单:
sql复制SELECT user_id, status, amount
FROM orders
WHERE user_id = 10086
AND created_at >= '2024-06-01'
ORDER BY created_at DESC;
EXPLAIN 结果很残酷:orders 表 type=ALL,key=NULL,rows=5234179,Extra 里有 Using where 和 Using filesort。这个表当时已经接近 600 万行,每次接口调用都要把整张表扫一遍,再排序,数据库 CPU 直接被打满。
问题的解决思路是建立一个合适的复合索引。那索引字段怎么选?我的判断逻辑是:等值条件放最左,范围条件放后面,排序字段要尽量赖上索引序。user_id = 10086 是等值条件,放在最左;created_at >= '2024-06-01' 是范围条件,放第二位,这样可以基于 user_id 过滤后再沿索引范围扫描 created_at;而 ORDER BY created_at DESC 又正好能利用索引天然有序的特性,把 Using filesort 消掉。
所以执行:
sql复制ALTER TABLE orders ADD INDEX idx_user_created (user_id, created_at);
再次 EXPLAIN,type 变成了 range,key_len 按 int+datetime 计算大约 9,rows 从五百多万降到一千多,Extra 里的 Using filesort 也消失了。接口耗时应声从 460ms 掉到 5ms 左右。
这里要特别说一句:为什么不建 (created_at, user_id)?因为如果以时间范围做最左列,user_id 的等值条件就无法继续利用索引过滤,type 还是 range 但不那么精准。除非你还有大量“按时间段不分用户”的统计查询,否则在当前“查某个用户订单列表”的业务场景下,(user_id, created_at) 是更对症的。
3.2 有索引却还在 filesort:排序字段被函数“污染”了
另一个让我印象很深的案例,是后台内容管理列表,数据量不大但也有几十万行。SQL 长这样:
sql复制SELECT id, title, created_at
FROM articles
WHERE author_id = 88
ORDER BY DATE(created_at) DESC;
表面上看,表上已有 idx_author_created(author_id, created_at),EXPLAIN 也显示 type=ref、key 确实走了这个索引。但 Extra 里仍然赫然写着 Using filesort。
问题出在 ORDER BY 后面跟的不是 created_at 本身,而是 DATE(created_at)。索引里存储的是完整的 datetime 值,按原始值是有序的,可优化器没法用这个顺序去满足“按日期排序”的需求,它只能把 author_id=88 的所有行都取回来,再对 DATE(created_at) 的结果做一次排序。
解决办法不是加索引,而是把 SQL 改成让排序列保持原始状态:
sql复制SELECT id, title, created_at
FROM articles
WHERE author_id = 88
AND created_at >= '2024-08-01'
AND created_at < '2024-09-01'
ORDER BY created_at DESC;
改成范围条件后,执行计划 type 变成 range,range 扫描天然按索引顺序读,Using filesort 消失。这个案例给了一个通用教训:别在索引列上套函数,不论 WHERE 还是 ORDER BY。统一让列保持“干净”,优化器才有力气用索引。
3.3 JOIN 时索引莫名失效:字符集不一致的隐形坑
还有一次排查环境差异,同样的 SQL 在测试环境十几毫秒、上到预发环境就要一秒钟。EXPLAIN 一拉,发现其中一个表的 type 是 ALL。可是这张表关联字段上明明有索引,possible_keys 里也显示有索引,key 却是 NULL。
最后核对表结构,发现两个库里的表字符集不一样。orders 表的 user_id 是 utf8mb4,members 表的 user_id 是 utf8mb3(老式 utf8)。MySQL 在做关联比较时,需要对字符集不一致的列做隐式转换,转换后索引就失效了。
这种情况和你平时看到的“字段类型不匹配导致索引失效”是同一个道理,只是更隐蔽,不逐列对比表结构根本发现不了。修复方案也很简单,把 members 表的字段改成与 orders 一致的 utf8mb4,重新 EXPLAIN 后 type 从 ALL 变成 ref,查询速度恢复。换库、迁移、合并数据源之后如果发现执行计划变得奇怪,优先怀疑字符集、排序规则、字段类型这类“看不见的不一致”。
4. 换个数据库,Explain 的“话术”完全不同
4.1 PostgreSQL:cost 并不是时间,要配合 ANALYZE 一起看
PostgreSQL 的 EXPLAIN 输出和 MySQL 很不一样,它是树状的,每个节点都带 cost=启动代价..总代价、rows、width。这里最容易踩的误区是:cost 数字并不是毫秒或秒,它只是优化器内部的一种抽象代价单位,不同系统之间没法直接对比。
如果你只执行 EXPLAIN,PostgreSQL 不会真的运行 SQL。想看真实执行情况,得用 EXPLAIN ANALYZE,它会把 SQL 真的跑一遍,然后告诉你每个节点的实际时间、实际行数、循环次数:
sql复制EXPLAIN ANALYZE
SELECT u.name, o.amount
FROM orders o
JOIN users u ON u.id = o.user_id
WHERE o.status = 1
ORDER BY o.created_at DESC;
输出里会出现类似这样的内容:
code复制Sort (cost=20834.33..21084.33 rows=100000 width=24) (actual time=120.345..152.678 rows=99870 loops=1)
Sort Method: quicksort Memory: 25kB
-> Hash Join (cost=... rows=...) (actual time=... rows=... loops=1)
读 PostgreSQL 执行计划的习惯,我是从最内层往外读,因为内层节点总是先执行。重点对比每个节点的“行数估算”和“实际行数”,如果差距非常大,说明统计信息不准确,这时候哪怕 EXPLAIN 显示走索引,真实跑起来也可能翻车。另外要注意 Sort 节点里的 Sort Method,一旦出现 external merge Disk,就说明排序数据量超过了 work_mem,数据落盘了,这会非常慢,通常需要调整 work_mem 或改写 SQL 减少排序量。
4.2 SQLite:EXPLAIN QUERY PLAN 的极简哲学
SQLite 作为嵌入式数据库,执行计划输出的风格比 MySQL、PostgreSQL 朴素得多。平时排查根本不用去看它的底层层字节码(EXPLAIN 不带 QUERY PLAN 时输出一大堆 OP_* 指令),你只要用 EXPLAIN QUERY PLAN 就够了:
sql复制EXPLAIN QUERY PLAN
SELECT * FROM users WHERE name = 'alice';
输出会很直白:
code复制QUERY PLAN
|--SEARCH users USING INDEX idx_users_name (name=?)
看到 SEARCH 且后面跟着 USING INDEX,说明这条查询成功用到了索引。如果输出是 SCAN users,那就是全表扫描。SQLite 不会给你估算行数和成本,它只告诉你访问方式,但排查逻辑和 MySQL 完全一致:SCAN 大表 + 查询频繁,就去看索引和 SQL 写法。
4.3 不同数据库的执行计划带给我们的共同启示
跨完三个数据库,你会发现所谓执行计划,形式上差别很大,核心信息永远是那么几件事:到底扫描全表还是走索引、JOIN 顺序怎么排、排序有没有额外代价、估算行数和实际是否吻合。只要你带着这套框架去读,任何数据库的 EXPLAIN 输出都能很快找到方向。
而且三个数据库都强调同一个点:EXPLAIN 只是优化器的“想法”,EXPLAIN ANALYZE 或实际执行才是“现实”。MySQL 5.7 前的 EXPLAIN 不真跑 SQL,PostgreSQL 的 EXPLAIN 也不真跑,只有带 ANALYZE 才有真实执行时间。这也是我在生产环境排查时,喜欢先用 EXPLAIN 看方案、再用 ANALYZE 验证效果的原因。
5. 那个总在弹出的 Git 提示也在求你“explain”一下
5.1 这行英文不是报错,而是 Git 的提交信息模板
很多开发在 git pull 或者 git merge 时,突然被弹进一个 vim 界面,里面写着:
code复制Merge branch 'feature/login'
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
第一次遇到的人很容易以为自己把仓库搞坏了,其实这只是一个非常正常的编辑器界面。Git 在创建 merge commit(合并提交)时,会打开编辑器等你写一段提交说明,模板里的那句话是在提醒:请写下为什么需要这次合并,尤其是当你把一个更新过的上游分支合进自己的主题分支时。
SQL 的 EXPLAIN 是让数据库解释“这条 SQL 我打算怎么执行”,Git 的这个提示则是让你解释“这次合并我为什么需要进入历史”。本质上都是在要求你为某个决策补上一段“原因说明”。
5.2 不想被它“拦住”的几种处理方式
如果你只是想让合并顺利通过,最直接的方式就是在编辑器里写上一句合并原因,然后保存退出。比如:
code复制Merge branch 'feature/login'
将登录模块合并入主线,完成第三方账号登录功能
如果团队对 merge commit 信息没有强制要求,又不想每次都被编辑器打断,可以用:
bash复制git merge --no-edit feature/login
这样 Git 会直接使用默认的合并信息,不再打开编辑器。更彻底一点,可以让 Git 认为编辑器“什么都不做就直接通过”:
bash复制GIT_EDITOR=true git merge feature/login
但要提醒一句:GIT_EDITOR=true 之后,你连写说明的机会都没了。如果你压根不想产生 merge commit,那应该用的是:
bash复制git merge --ff-only feature/login
或者把 pull 改成 rebase 策略。这样就不会有“需要写合并说明”的窗口弹出来,因为根本不会创建额外合并节点。
5.3 因为“没解释”而难以回溯的一次事故
我自己曾经在合并一个跨了好几周的功能分支时,图省事直接用了默认合并信息。结果两个月后线上出了问题,git log 里那条记录只有 Merge branch 'feature/payment-v2',完全看不出当初合并时的决策背景,也不知道那个分支里包含了哪些高风险变更。我只好从一堆提交里逐个翻 commit message 去猜,排查成本非常高。
从那以后,我养成了一个习惯:涉及多分支合并且需要保留合并节点时,我一定用 --no-ff 创建合并提交,并在信息里写清三个问题——合入了什么、为什么合、关联的单号或背景。别小看这句 merge message,它和 SQL 执行计划一样,都是给未来的自己留的“决策快照”。
6. 把 Explain 变成直觉:日常排查慢 SQL 的固定动作
6.1 一个可复用的慢 SQL 排查流程
我在团队里带人排查慢 SQL,会让他们养成一套固定动作。
第一步,先确认慢 SQL 从哪来。开启慢查询日志是基本操作:
sql复制SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;
SHOW VARIABLES LIKE 'slow_query_log%';
long_query_time 的单位是秒,这里设成 1 秒,超过 1 秒的查询就会被记录。注意这个开关对已有连接不一定即时生效,最好新开一个会话再验证。
第二步,拿到慢 SQL 后先 EXPLAIN,别急着改。把执行计划里的 type、key、key_len、rows、Extra 逐项看一遍,回答三个问题:是不是全表扫?索引用到了哪一列?有没有 filesort 或 temporary?
第三步,根据结论修改 SQL 或索引,然后重新 EXPLAIN 对比,最好再实际执行一次看耗时。MySQL 8.0.18 之后可以直接用:
sql复制EXPLAIN ANALYZE SELECT ...;
它会真实执行 SQL,并输出每个操作的实际耗时与循环次数,比单纯的 EXPLAIN 更直观,也更容易定位“估算与现实的差距”。
6.2 看执行计划时我先问自己的三个问题
第一,为什么没走索引?如果 possible_keys 有值、key 为 NULL,要么是索引选择性太差,要么是统计信息过期,要么是条件里对索引列做了函数或隐式转换。先补一条 ANALYZE TABLE 刷新统计信息,再检查 SQL 写法,最后一招才是改索引。
第二,rows 估算和真实行数差距大不大?执行计划只是估算,遇到极端数据分布,优化器可能选错路。此时可以用 EXPLAIN ANALYZE 看实际行数,差距大就说明统计信息不可靠,需要更新统计信息或考虑改用更简单的查询条件。
第三,Extra 里有没有 filesort/temporary?有的话,先看排序、分组字段能不能通过调整索引来“免排”,比如把 WHERE 的等值字段和 ORDER BY 字段放进同一个复合索引;再不行,再看 SQL 能不能改写,减少参与排序的数据量。
6.3 几个常年管用的索引设计判断方法
索引设计是个大话题,但日常高频场景其实可以浓缩成几条经验。
等值条件要放在复合索引最左。范围条件放中间会影响后面的列,所以设计时尽量把范围查询的列往后放,或者改用 < 和 >= 把范围拆成多个等值条件。排序字段尽量和过滤字段放进同一个索引,并且排序方向保持一致,这样能省掉 filesort。查询列如果能全部覆盖在索引里,就尽量别回表,这也叫覆盖索引优化,Extra 里会出现 Using index,是理想状态。
另外一定记得:索引不是越多越好。每一个索引都会拖慢 INSERT、UPDATE 和 DELETE,还会占用磁盘空间。加索引前先看看现有索引里有没有能被复用的,改名、扩列往往比新建一个更干净。我还会定期查一下 sys.schema_unused_indexes,把长时间没人用的索引清理掉。
6.4 一个我很推荐的小习惯:把 explain 输出“存档”
最后分享一个我坚持了很久的习惯。每次优化一条慢 SQL,我会把修改前后的 EXPLAIN 输出
