--- tags: [MySQL, EXPLAIN, 执行计划, 性能分析] create time: 2026-05-16 00:00 --- # EXPLAIN 完全指南 ## 概述 `EXPLAIN` 是 MySQL 性能分析的入口工具——它展示 SQL 语句的执行计划,告诉你优化器打算怎么用索引、怎么 JOIN、会不会用临时表和文件排序。 > [!QUESTION] 为什么不能直接靠"写了索引就一定能用到"? > 因为 MySQL 使用的是 **Cost-Based Optimizer (CBO)**:优化器会根据统计信息(行数、页大小、聚簇因子等)自行决定最优执行路径。有时优化器认为全表扫描比走索引更快(比如要查整张表 80% 的数据),这时即使有索引也不会使用。`EXPLAIN` 就是用来观察和优化器"想法"是否一致的镜子。 > [!TIP] EXPLAIN 输出的阅读顺序 > 面对一屏 EXPLAIN 输出,可以按"从大到小"的优先级快速判断: > 1. **type** — 最快判断索引使用情况(ALL = 🔴 全表扫描) > 2. **key** — 确认实际命中的索引名 > 3. **rows** — 预估扫描行数,决定是否需要优化 > 4. **Extra** — 有没有 filesort / temporary 等额外开销 > 5. **key_len** — 联合索引用了几列?只用了前缀? > 6. **ref** — 索引在跟什么做比较? > > 先扫 type + key 就能判断"这 SQL 有没有大问题",再看 rows + Extra 判断"问题有多严重"。 ## 基本用法 ```sql -- 标准 EXPLAIN EXPLAIN SELECT * FROM users u JOIN orders o ON u.id = o.user_id WHERE u.status = 1 AND o.amount > 100; -- JSON 格式(信息最全,推荐) EXPLAIN FORMAT=JSON SELECT * FROM users WHERE email = 'a@x.com'; -- 实际执行后再看成本(含统计信息) EXPLAIN ANALYZE SELECT ...; -- MySQL 8.0.18+ ``` ## 输出字段全览 > [!NOTE] 核心思路 > EXPLAIN 输出的每一行对应一张表的访问方式。多表 JOIN 时会输出多行,**id 相同时,行号越小(越靠上)越先执行**——第一行是驱动表,后续行是被驱动表。 | 字段 | 含义 | 重要程度 | 关注点 | |------|------|:--------:|--------| | **id** | 查询编号 | ⚪ 了解 | 相同 id = 同层查询,不同 id = 嵌套子查询 | | **select_type** | 查询类型 | 🟡 辅助 | SIMPLE/PRIMARY/SUBQUERY/DERIVED,判断复杂度 | | **table** | 访问的表 | ⚪ 了解 | 可能是别名或 `` 物化表 | | **type** | 访问类型 | 🔴 必看 | **索引使用的第一指标**(const > ref > range > ALL) | | **possible_keys** | 可能用到的索引 | 🟡 辅助 | NULL = 没有候选索引,需创建 | | **key** | 实际使用的索引 | 🔴 必看 | NULL = 优化器放弃索引,选了全表扫描 | | **key_len** | 索引使用字节数 | 🟡 辅助 | 判断联合索引是否被充分利用 | | **ref** | 索引比较对象 | 🟡 辅助 | const/列名/func,理解 JOIN 驱动关系 | | **rows** | 预估扫描行数 | 🔴 必看 | 越小越好,`rows × filtered%` ≈ 进入下一步的行数 | | **filtered** | WHERE 过滤率 (%) | 🟡 辅助 | 100% = 索引完全满足条件,越低说明回表后还要过滤 | | **Extra** | 附加信息 | 🔴 必看 | filesort/temporary = 🔴;Using index = 🟢 覆盖索引 | ## select_type:查询层级识别 `select_type` 告诉你这一行代表的是什么级别的查询——最外层、子查询、还是 UNION? | select_type | 含义 | 典型场景 | |-------------|------|----------| | **SIMPLE** | 简单查询(无子查询/UNION) | `SELECT * FROM t WHERE id = 1` | | **PRIMARY** | 最外层查询 | 包含子查询时,外层标记为 PRIMARY | | **SUBQUERY** | SELECT 列表或 WHERE 中的子查询 | `WHERE id IN (SELECT ...)` | | **DEPENDENT SUBQUERY** | 依赖外层结果的关联子查询 | `WHERE EXISTS (SELECT ... FROM t2 WHERE t2.id = t1.id)` | | **DERIVED** | FROM 子句中的派生表 | `FROM (SELECT ...) AS tmp` | | **UNION** | UNION 中第二个及之后的 SELECT | `SELECT ... UNION SELECT ...` | | **MATERIALIZED** | 物化子查询(8.0+) | 优化器将子查询结果存入临时表 | ```sql -- 一个包含子查询的查询 EXPLAIN SELECT u.username, order_count FROM users u JOIN ( SELECT user_id, COUNT(*) AS order_count FROM orders GROUP BY user_id ) AS oc ON u.id = oc.user_id WHERE u.id IN (SELECT user_id FROM vip_users); ``` ``` +----+-------------+------------+--------+---------------+---------+---------+------+------+-------+ | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | +----+-------------+------------+--------+---------------+---------+---------+------+------+-------+ | 1 | PRIMARY | | ALL | NULL | NULL | NULL | NULL | 1000 | | ← FROM 子句的派生表 | 1 | PRIMARY | u | eq_ref | PRIMARY | PRIMARY | 4 | oc.user_id | 1 | | | 2 | DERIVED | orders | index | idx_user_id | idx_user_id | 4 | NULL | 100K | Using index | | 3 | SUBQUERY | vip_users | ALL | NULL | NULL | NULL | NULL | 500 | | +----+-------------+------------+--------+---------------+---------+---------+------+------+-------+ ``` > [!QUESTION] 为什么 DERIVED 表的 rows 是 1000? > 因为派生表 `oc` 是先物化到临时表的,MySQL 对物化表的行数统计通常不精确。这也是为什么**尽量避免在大表上使用 FROM 子查询**——物化过程本身就有开销。MySQL 8.0 的 Merged Derived Table 优化可以将简单的派生表"合并"回外层查询,减少物化。 ## type 详解(最重要) ```mermaid flowchart LR system["system
只有1行"] --> const["const
常量级访问"] const --> eq_ref["eq_ref
唯一索引 × 驱动行"] eq_ref --> ref["ref
等值匹配多行"] ref --> range["range
索引范围"] range --> index["index
全索引扫描"] index --> ALL["ALL
全表扫描 🔴"] style system fill:#00D866,color:#fff style const fill:#00D866,color:#fff style eq_ref fill:#00D866,color:#fff style ref fill:#00B6BC,color:#fff style range fill:#FF9F43,color:#000 style index fill:#C44569,color:#fff style ALL fill:#EE5A24,color:#fff ``` ### 各级别详解 | 级别 | 名称 | 含义 | 示例 | |------|------|------|------| | **system** | 系统表 | 表只有一行(MyISAM 等引擎)| `SELECT * FROM (SELECT 1) t`| | **const** | 常量 | 最多一行匹配 | `WHERE primary_key = 1`| | **eq_ref** | 唯一索引 | 唯一索引扫描,每行匹配被驱动表一行 | `JOIN ON pk = ?` | | **ref** | 索引查找 | 等值匹配多行(非唯一索引 / 最左前缀的部分匹配)| `WHERE email_prefix = 'a@'` | | **range** | 范围扫描 | 索引上的范围查询 | `WHERE id > 100` | | **index** | 索引全扫 | 扫描整个索引树 | `SELECT COUNT(*)` | | **ALL** | 全表扫描 | 无可用索引,逐行扫描 | **需要优化** | > [!WARNING] 不要盲目追求 ref 以上级别 > `range` 在某些场景(如范围不大)完全可以接受。关键是看 `rows` 字段和 `Extra` 的组合。 ## key_len:索引使用了多少? `key_len` 表示 MySQL 在索引中实际使用的**字节数**。对于联合索引,它是判断"索引被用了几列"的最直接证据。 > [!QUESTION] 联合索引 `idx_a_b_c (a, b, c)`,EXPLAIN 显示 key_len = 8,用了几列? > 需要知道各列的数据类型才能算出来。key_len 的计算规则如下: ### 计算规则 | 类型 | 字节数 | 说明 | |------|--------|------| | `INT` | 4 | 固定 4 字节 | | `BIGINT` | 8 | 固定 8 字节 | | `CHAR(n)` | n × 字符集字节数 | utf8mb4 = 每字符 4 字节 | | `VARCHAR(n)` | n × 字符集字节数 + **2** | 额外 2 字节存长度 | | `DATE` | 3 | 固定 3 字节 | | `TIMESTAMP/DATETIME` | 5/8 | 固定长度 | | **NULLABLE 列** | +1 | 所有类型额外 +1 字节标记 NULL | > [!TIP] 速算公式 > `key_len = 各列字节数之和`,其中每列 = **类型基础长度 + (VARCHAR 额外 2) + (可 NULL 额外 1)** > > 反过来看:如果 key_len 比你预期的短,说明联合索引**只用到了前缀几列**。 ### 实战计算 ```sql CREATE TABLE users ( id BIGINT PRIMARY KEY, name VARCHAR(64) NOT NULL, -- utf8mb4 age INT, email VARCHAR(128) DEFAULT NULL, INDEX idx_name_age_email (name, age, email) ); ``` | 使用方式 | key_len 计算 | 说明 | |----------|-------------|------| | `WHERE name = 'Tom'` | 64 × 4 + 2 = **258** | VARCHAR(64) utf8mb4,NOT NULL 无额外字节 | | `WHERE name = 'Tom' AND age = 20` | 258 + 4 = **262** | age 是 INT,NOT NULL | | `WHERE name = 'Tom' AND age = 20 AND email = 'a@b.c'` | 262 + 128 × 4 + 2 + 1 = **777** | email 可 NULL,额外 +1 | ```sql EXPLAIN SELECT * FROM users WHERE name = 'Tom' AND age = 20; -- key: idx_name_age_email, key_len: 262 -- → 说明索引只用到了前 2 列(name + age),没用到 email ``` > [!NOTE] 核心用法 > 看到 `key_len` 后,对照表结构算一下"如果用满所有列应该是多少字节"。差得远 = 索引没充分利用,可能需要调整查询条件或索引顺序。 ## ref 字段:索引在跟谁比? `ref` 显示索引的查找值来自哪里——是常量、函数还是另一张表的列。 | ref 值 | 含义 | 示例 | |--------|------|------| | **const** | 与常量比较 | `WHERE id = 1` | | **func** | 与函数结果比较 | `WHERE created_at = NOW()` | | **db.table.column** | 与另一张表的列比较(JOIN) | `ON u.id = o.user_id` → ref 显示 `o.user_id` | | **NULL** | 无法使用等值比较 | range 扫描(如 `WHERE id > 100`)| ```sql EXPLAIN SELECT u.username, o.amount FROM users u JOIN orders o ON u.id = o.user_id WHERE o.status = 'pending'; ``` ``` +----+-------------+-------+------+---------------+--------------+---------+------------------+------+-------+ | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | +----+-------------+-------+------+---------------+--------------+---------+------------------+------+-------+ | 1 | SIMPLE | o | ref | idx_status | idx_status | 130 | const | 5000 | Using where | | 1 | SIMPLE | u | eq_ref | PRIMARY | PRIMARY | 8 | test.o.user_id | 1 | | +----+-------------+-------+------+---------------+--------------+---------+------------------+------+-------+ ``` - 第一行:`o` 表用 `idx_status` 索引,ref = `const`(`status = 'pending'` 是常量) - 第二行:`u` 表用主键,ref = `test.o.user_id`(被驱动表的列来自 `o.user_id`) > [!TIP] ref 帮你理解 JOIN 驱动关系 > 当 ref 显示另一张表的列名时,说明当前表是**被驱动表**。驱动表的行数(rows)越小,被驱动表的回表次数就越少——这就是为什么**小表驱动大表**的原则。 ## Extra 关键字解读 > [!SUCCESS] Using index — 覆盖索引(Index Only Scan) > 数据在索引中全部找到,**无需回表**。这是最优的 Extra 信息。 > [!INFO] Using where — 服务器层过滤 > 读完索引后还需 WHERE 条件过滤。**正常现象**,不代表性能问题。 > - **Using where + Using index** = 覆盖索引 + WHERE 过滤 → 最佳实践 ✅ > - **Using where 无 Using index** = 回表后才过滤 → 可考虑加索引优化 ⚠️ > [!TIP] Using index condition — 索引下推(ICP) > MySQL 5.6+ 引入,在存储引擎层预过滤,减少回表次数 ✅ #### ICP 深入理解:下推 vs 不下推 ```sql -- 假设有联合索引 idx_status_city (status, city) EXPLAIN SELECT * FROM users WHERE status = 'active' AND city LIKE '北京%'; ``` **Without ICP(MySQL 5.6 之前)**:存储引擎只用 `status` 过滤,拿到所有 active 用户的主键 → 逐行回表 → Server 层再过滤 `city LIKE '北京%'`。假设 active 用户有 10 万,就要回表 10 万次。 **With ICP(MySQL 5.6+)**:存储引擎在索引层**直接过滤** `city LIKE '北京%'`,只把满足两个条件的行回表。可能只回表 2000 次。 ```mermaid flowchart LR A["存储引擎层
idx_status_city"] --> B{"ICP 可用?"} B -->|No ICP| C["只用 status 定位
回表 100K 行"] C --> D["Server 层过滤 city
丢弃 98K 行 ⚠️"] B -->|With ICP| E["status 定位 + city 预过滤
索引层过滤后只剩 2K 行"] E --> F["只回表 2K 行 ✅"] style D fill:#EE5A24,color:#fff style F fill:#00D866,color:#fff ``` > [!NOTE] ICP 的适用条件 > ICP 只在**联合索引**且 WHERE 条件包含**索引列但不满足最左前缀**时才有意义。比如 `idx(a, b)`,`WHERE a > 1 AND b = 2` 中 `b = 2` 就是 ICP 下推的候选条件。如果只查 `WHERE a = 1`,ICP 无从发挥。 ### 其他 Extra 信息速查 | 关键词 | 含义 | 处理 | |--------|------|------| | `Impossible where` | WHERE 永远为假(如 `status = 1 AND status = 2`)| 检查 SQL 逻辑是否正确 | | `Select tables optimized` | 优化器发现子查询可展开 | 无需处理,已自动优化 ✅ | | `Using distinct` | 内部去重,类似 DISTINCT | 看能否改用 GROUP BY + 索引 | | `Scan & filter` | **InnoDB** 特有:全索引扫描后逐行过滤 | 考虑加更精确的索引 | ### Using temporary:何时出现 ```sql -- 常见触发场景 EXPLAIN SELECT city, AVG(age) FROM users GROUP BY city; -- Extra: Using temporary; Using filesort -- 需要用临时表存储每个 city 的聚合结果 EXPLAIN SELECT DISTINCT city FROM users; -- Extra: Using temporary -- DISTINCT 内部用临时表去重 ``` > [!WARNING] Using temporary + Using filesort > 当 GROUP BY 的分组列和 ORDER BY 的排序列不一致时,MySQL 会先建临时表再额外排序。 > **解决思路**:让索引同时满足 GROUP BY + ORDER BY 的顺序要求。 ### Using filesort:深度分析 ```mermaid flowchart TD A["WHERE status='pending'"] --> B["idx_status 索引扫描
拿到所有 pending 订单的 pk"] B --> C{"created_at 能在索引中找到吗?"} C -->|能:
联合索引 idx_status_created| D["直接有序返回
No filesort ✅"] C -->|不能:
单列索引 idx_status| E["取 pk 列表 → 回表拿完整行
→ 内存中排序
Filesort ⚠️"] D --> F["可能的解决方案"] E --> F F --> F1["添加联合索引 (status, created_at)"] F --> F2["调整 WHERE 条件使索引生效"] F --> F3["加大 sort_buffer_size"] style D fill:#00D866,color:#fff style E fill:#EE5A24,color:#fff ``` > [!QUESTION] 思考:ORDER BY 一定会触发 filesort 吗? > 不一定!如果查询使用了索引且排序字段与索引顺序一致,MySQL 可以直接按索引序扫描,跳过排序步骤。关键看 **索引是否天然有序**。 ```sql -- ✅ No filesort — 走联合索引天然有序 EXPLAIN SELECT * FROM orders WHERE status = 'pending' ORDER BY status, created_at; -- key: idx_status_created, Extra: Using where -- ❌ Using filesort — WHERE 用了另一个索引,无法利用排序 EXPLAIN SELECT * FROM orders WHERE user_id = 42 ORDER BY created_at DESC; -- key: idx_user_id, Extra: Using where; Using filesort ``` ## 实战案例分析 下面用一个完整的调优案例,把前面所有知识点串起来。 ### 场景:订单列表页越来越慢 ```sql -- 表结构 CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, status VARCHAR(20) NOT NULL, amount DECIMAL(10,2), created_at DATETIME NOT NULL, INDEX idx_user_id (user_id) ) ENGINE=InnoDB; -- 表中有 100 万行数据 -- 慢查询:某用户最近的已支付订单 EXPLAIN SELECT * FROM orders WHERE user_id = 42 AND status = 'paid' AND created_at >= '2026-01-01' ORDER BY created_at DESC LIMIT 20; ``` ### Step 1:看 type + key — 全表扫描? ``` +----+-------------+--------+------+---------------+------------+---------+-------+--------+---------------------------+ | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | +----+-------------+--------+------+---------------+------------+---------+-------+--------+---------------------------+ | 1 | SIMPLE | orders | ref | idx_user_id | idx_user_id| 8 | const | 98000 | Using where; Using filesort | +----+-------------+--------+------+---------------+------------+---------+-------+--------+---------------------------+ ``` - type = ref(走了索引 ✅),但 rows = 98000(user_id=42 有 9.8 万条订单,太多) - Extra = `Using where; Using filesort` 🔴 — 回表后还要过滤 status + created_at,再排序 > [!QUESTION] 问题出在哪? > `idx_user_id` 是单列索引,只帮你定位 user_id。`status` 和 `created_at` 的过滤都在回表后才做,9.8 万次回表 + filesort = 慢。 ### Step 2:创建联合索引 — 关注 key_len ```sql ALTER TABLE orders ADD INDEX idx_uid_status_created (user_id, status, created_at); ``` ```sql EXPLAIN SELECT * FROM orders WHERE user_id = 42 AND status = 'paid' AND created_at >= '2026-01-01' ORDER BY created_at DESC LIMIT 20; ``` ``` +----+-------------+--------+-------+---------------------------------------------+-------------------------+---------+------+------+----+ | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | +----+-------------+--------+-------+---------------------------------------------+-------------------------+---------+------+------+----+ | 1 | SIMPLE | orders | range | idx_user_id, idx_uid_status_created | idx_uid_status_created | 97 | NULL | 1200 | Using where | +----+-------------+--------+-------+---------------------------------------------+-------------------------+---------+------+------+----+ ``` **逐字段分析**: | 字段 | 变化 | 说明 | |------|------|------| | **type** | ref → range | 用 created_at 范围扫描,精确了 | | **key** | idx_user_id → idx_uid_status_created | 用上了新索引 ✅ | | **key_len** | 8 → 97 | 8 = 只用了 user_id(BIGINT);97 = user_id(8) + status(VARCHAR(20)×4+2+1=83) + created_at(DATETIME=5+1=6) → **三列全用上** ✅ | | **rows** | 98000 → 1200 | 索引直接过滤到 1200 行,减少了 **98.8%** | | **Extra** | Using filesort 消失 ✅ | created_at 在索引末尾,DESC 排序直接倒序扫描 | > [!NOTE] 关键决策点 > 这里 ORDER BY created_at DESC 恰好可以用到索引的倒序扫描(InnoDB 支持 Reverse Index Scan),所以 **filesort 消失了**。如果 ORDER BY 的列不在索引中,filesort 仍会出现。 ### Step 3:EXPLAIN ANALYZE 确认 ```sql EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 42 AND status = 'paid' AND created_at >= '2026-01-01' ORDER BY created_at DESC LIMIT 20; ``` ``` -> Limit: 20 row(s) -> Index range scan on orders using idx_uid_status_created (actual time=0.089..0.142 rows=20 loops=1) ``` - **rows=20**:只扫了 20 行就满足 LIMIT,配合索引的有序性,几乎零开销 - **actual time < 0.2ms**:从秒级优化到了亚毫秒级 🚀 ```mermaid flowchart LR A["Step 1
rows: 98K
filesort 🔴"] --> B["Step 2
rows: 1.2K
无 filesort ✅"] B --> C["Step 3
rows: 20
0.14ms 🚀"] style A fill:#EE5A24,color:#fff style B fill:#FF9F43,color:#000 style C fill:#00D866,color:#fff ``` ## EXPLAIN FORMAT=JSON 精读 `FORMAT=JSON` 输出最完整的执行计划,包含嵌套子查询、Cost 信息、索引选择细节。 ```json { "query_block": { "select_id": 1, "table": { "table_name": "orders", "access_type": "range", "possible_keys": ["idx_created_user"], "key": "idx_created_user", "key_length": "8", "rows": 5000, "filtered": 100.0, "index_condition": "orders.created_at >= '2026-01-01'", "cost_information": { "total_cost": "53000", ← 总成本(越小越好) "optimizer_cost": "53000" ← 优化器计算的成本值 } } } } ``` ### 关键字段说明 | JSON 字段 | 含义 | 实战要点 | |-----------|------|----------| | **access_type** | 与 `type` 等价 | range/ref 优先,ALL 需关注 | | **key_length** | 实际使用索引的字节数 | 越短说明用的列越少,可优化 | | **rows** | 预估扫描行数 | 估算值,可能与实际情况偏差 | | **filtered** | WHERE 筛选率 (%) | rows × filtered% = 进入下一步的行数 | | **index_condition** | ICP 预过滤条件 | 有 = 使用了索引下推 ✅ | | **using_temporary** / **using_filesort** | 布尔值 | true = 会触发对应操作 | ### Cost 分析:如何判断查询是否健康? > [!TIP] Cost 评估经验法则 > - **cost < 1000**:通常没问题 ✅ > - **cost 1000 ~ 10000**:中等负载可接受,关注高频 SQL ⚠️ > - **cost > 10000**:大概率需要优化 🔴 关键点:**optimizer_cost 是绝对值而非相对值**,不同版本 MySQL 的计算方式可能变化。更有价值的是 **对比两种方案的 cost 差值**——比如加了一个索引后 cost 从 50000 降到 5300,这就是有效的索引设计。 ```mermaid flowchart LR A["编写慢 SQL"] --> B["EXPLAIN 看执行计划"] B --> C{"access_type?"} C -->|ALL 全表扫描| D["检查 possible_keys
添加合适的索引"] C -->|range/ref/optimal| E{"Extra 中有
filesort/temporary?"} E -->|无 → 健康✅| F["无需额外处理"] E -->|有 → 优化⚠️| G["调整索引顺序或改写 SQL"] style F fill:#00D866,color:#fff style G fill:#FF9F43,color:#000 style D fill:#C44569,color:#fff ``` ## FORMAT=TREE:现代可读格式 MySQL 8.0.16+ 引入了 `FORMAT=TREE`,输出**缩进树状结构**,比传统表格和 JSON 都更直观。 ```sql EXPLAIN FORMAT=TREE SELECT u.username, o.amount FROM users u JOIN orders o ON u.id = o.user_id WHERE o.created_at >= '2026-01-01' ORDER BY o.created_at DESC LIMIT 20; ``` ``` -> Limit: 20 row(s) -> Nested loop inner join (cost=530 rows=20) -> Index range scan on o using idx_created_user (cost=530 rows=5000) -> Single-row index lookup on u using PRIMARY (id=o.user_id) (cost=0.25 rows=1) ``` ### 三种格式怎么选? | 格式 | 适用场景 | 优点 | 缺点 | |------|----------|------|------| | **默认表格** | 快速筛查 | 一行一表,一目了然 | 缺少 cost、缺少嵌套信息 | | **FORMAT=JSON** | 精细分析 | 信息最全,可编程解析 | 冗长,层级关系不直观 | | **FORMAT=TREE** | 日常推荐 | **层级清晰 + 含 cost + 易读** | 无法看到 possible_keys | | **ANALYZE** | 深度诊断 | 含真实执行时间 | 会实际执行 SQL | > [!TIP] 推荐工作流 > 日常排查先用 `FORMAT=TREE` 快速定位瓶颈(哪一步 cost 最高),需要细节时切到 `FORMAT=JSON` 看 key_len、possible_keys 等。 ## EXPLAIN ANALYZE:看到真实执行代价 `EXPLAIN ANALYZE` 是 MySQL 8.0.18+ 引入的功能——它会**真正执行一次 SQL**,然后返回实际运行时间、每行的实际扫描数等统计信息。 > [!WARNING] 注意事项 > EXPLAIN ANALYZE **会执行 SQL**。如果有写入操作(如 INSERT/UPDATE),需要谨慎;但对于纯 SELECT 查询可以放心使用。 ```sql -- 传统方式 vs 现代方式 EXPLAIN SELECT * FROM orders WHERE user_id = 42; -- → 只有预估数据,可能不准 EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 42; -- → 预估 + 实际运行结果 -- Extra: Rows examined: 42 (vs. rows estimate: 100) ``` ### 典型输出解读 ``` -> Index lookup on orders using idx_user_id (user_id=42) (actual time=0.034..0.156 rows=42 loops=1) ``` - **actual time**:首行耗时 .. 总耗时(微秒级) - **rows**:实际扫描行数(与预估 `rows` 对比,差距大说明统计信息过时) - **loops**:循环次数,JOIN 场景尤其重要 > [!QUESTION] 什么时候用 EXPLAIN vs EXPLAIN ANALYZE? > - **EXPLAIN**:快速预览,不执行 SQL,适合批量排查和 CI 门禁 > - **EXPLAIN ANALYZE**:精准诊断,需要实际执行,适合深入分析某个慢查询 > - 日常开发建议先用 `EXPLAIN` 快速筛查,再对问题 SQL 用 `EXPLAIN ANALYZE` 定位根因 ## 优化策略速查 遇到问题 SQL,按以下步骤排查: ```mermaid flowchart TD A["拿到慢 SQL"] --> B["EXPLAIN 看执行计划"] B --> C{"type = ALL?"} C -->|是| D["检查 possible_keys → 加索引"] C -->|否| E{"Extra 有 filesort?"} E -->|是| F["调整索引顺序,覆盖 ORDER BY"] E -->|否| G{"Extra 有 temporary?"} G -->|是| H["让 GROUP/DISTINCT 走索引"] G -->|否| I{"rows 很大?"} I -->|是→ rows × filtered% 仍大| J["优化 WHERE 条件或加更精确的索引"] I -->|否| K["查询健康 ✅"] style K fill:#00D866,color:#fff style D fill:#C44569,color:#fff style F fill:#FF9F43,color:#000 style H fill:#FF9F43,color:#000 style J fill:#FF9F43,color:#000 ``` ### 常见场景与对策 | 症状 | 根因 | 对策 | |------|------|------| | `type=ALL, Extra=Using where` | 无可用索引 | 添加 WHERE 列的索引 | | `Using filesort` | ORDER BY 字段不在可用索引中 | 创建 (WHERE列, ORDER BY列) 联合索引 | | `Using temporary; Using filesort` | GROUP BY 和 ORDER BY 不一致 | 索引顺序同时满足两者 | | `rows 预估 >> 实际行数` | 统计信息过时 | `ANALYZE TABLE 表名` 更新统计信息 | | `possible_keys` 有但 `key` 为 NULL | 优化器选了全表扫描 | 用 `FORCE INDEX` 强制指定,或改写查询 | ### 别忘了维护统计信息 ```sql -- 当 EXPLAIN 预估严重偏离实际时 ANALYZE TABLE orders; -- InnoDB 支持自动分析,但大批量写入后建议手动触发 SET GLOBAL innodb_stats_auto_recalc = ON; ``` ## 核心记忆点 > [!SUMMARY] EXPLAIN 六字口诀:型键行推排临 > 1. **型**(type):判断索引使用等级,ALL 是底线问题 > 2. **键**(key):确认命中哪个索引,NULL 需要警觉 > 3. **行**(rows):预估扫描行数,rows × filtered 决定实际负载 > 4. **推**(ICP):Using index condition = 存储引擎层预过滤,减少回表 > 5. **排**(filesort):ORDER BY 无法利用索引排序时触发 > 6. **临**(temporary):GROUP BY / DISTINCT 产生临时表 > [!CHECKLIST] EXPLAIN 审查清单 > - [ ] `type` 不是 ALL(或 rows 可接受) > - [ ] `key` 不是 NULL(有索引可用) > - [ ] `key_len` 覆盖了联合索引的关键列 > - [ ] `Extra` 没有 filesort(或 ORDER BY 有索引支撑) > - [ ] `Extra` 没有 temporary(或 GROUP BY 走了索引) > - [ ] `rows` × `filtered%` 在可接受范围 > - [ ] 预估 rows 与实际偏差不大(必要时 `ANALYZE TABLE`) ## 关联笔记 - [[hhs/MySQL/03-索引与查询优化/12-B+Tree 索引原理]] — type=ALL vs type=range 的底层差异 - [[hhs/MySQL/03-索引与查询优化/16-慢查询日志分析]] — 如何用 pt-query-digest 配合 EXPLAIN - [[hhs/MySQL/03-索引与查询优化/14-联合索引与最左前缀]] — 索引设计不当导致的 EXPLAIN 问题 - [[hhs/MySQL/03-索引与查询优化/13-聚簇索引与二级索引]] — 回表原理与 Extra 中 Using index 的底层原因