vault backup: 2026-05-21 12:20:54

This commit is contained in:
hhs
2026-05-21 12:20:54 +08:00
parent 2e84683d9c
commit 531d4b7d8c
8 changed files with 867 additions and 89 deletions
@@ -101,6 +101,45 @@ sequenceDiagram
>
> **优先用 `ON DUPLICATE KEY UPDATE`**,除非你确实需要完整的「删+插」语义。
### INSERT ... SELECT
从另一张表或查询结果批量插入数据——这是日常开发中比 `VALUES` 批量插入更高频的用法。
```sql
-- 基础:从查询结果插入
INSERT INTO user_archive (id, username, email, archived_at)
SELECT id, username, email, NOW()
FROM users
WHERE status = 0 AND updated_at < '2025-01-01';
-- 搭配聚合:插入每日统计快照
INSERT INTO daily_stats (stat_date, order_count, total_amount)
SELECT CURDATE(), COUNT(*), SUM(amount)
FROM orders
WHERE DATE(created_at) = CURDATE();
```
> [!QUESTION] INSERT ... SELECT 会锁表吗?
> 这取决于事务隔离级别和索引情况:
> - 在 **RC(Read Committed)** 下:只锁定被插入的目标表,对源表加短暂的共享锁
> - 在 **RR(Repeatable Read)** 下:源表的读取走一致性快照,**不会阻塞源表的写入**
> - 目标表:插入的行会加行锁,大批量插入时注意不要超长事务
> [!CAUTION] INSERT ... SELECT 的两个常见坑
> 1. **SELECT 中不能引用正在被插入的目标表**(MySQL 会报 `Table is specified twice`)。解决办法:嵌套一层子查询
> ```sql
> -- ❌ 报错:直接引用目标表
> INSERT INTO orders_backup SELECT * FROM orders WHERE id IN (SELECT id FROM orders WHERE ...);
> -- ✅ 正确:用子查询隔离
> INSERT INTO orders_backup SELECT * FROM orders WHERE id IN (SELECT t.id FROM (SELECT id FROM orders WHERE ...) t);
> ```
> 2. **大批量插入时分批执行**,避免长事务持有过多锁。可以用 `LIMIT` + 循环分批:
> ```sql
> -- 每次只插入 5000 条,循环直到 affected_rows = 0
> INSERT INTO user_archive
> SELECT * FROM users WHERE status = 0 LIMIT 5000;
> ```
## UPDATE
UPDATE 的核心原则只有一条:**精准定位、最小影响**。先思考「哪些行需要改」,再写 SET 子句。
@@ -136,6 +136,80 @@ SELECT a.name, b.name FROM table_a CROSS JOIN table_b;
-- 结果 = |a| × |b| 行
```
## SELF JOIN(自连接)
自连接是**同一张表与自身进行 JOIN**——表没有变,只是用两个不同的别名把它"当成两张表"来用。这是处理**层次结构**和**行间比较**的经典手法。
### 场景一:组织架构树(上下级关系)
```sql
-- 找出每个员工及其直属上级的名字
SELECT e.name AS employee, m.name AS manager
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.id;
-- 结果:
-- | employee | manager |
-- | Alice | NULL | ← CEO,无上级
-- | Bob | Alice |
-- | Carol | Alice |
-- | David | Bob |
```
> [!NOTE] SELF JOIN vs 递归 CTE 的选择
> - **只查一层关系**(直接上级/下级):SELF JOIN 简洁高效
> - **需要遍历整棵树**(所有层级的汇报线):必须用递归 CTE——详见 [[hhs/MySQL/02-SQL核心/10-子查询与派生表]]
### 场景二:查找同一组内的相邻行
```sql
-- 找出连续两天都有订单的用户(日环比分析)
SELECT DISTINCT a.user_id
FROM orders a
INNER JOIN orders b
ON a.user_id = b.user_id
AND DATEDIFF(b.created_at, a.created_at) = 1
WHERE DATE(a.created_at) = '2026-05-20';
```
### 场景三:去重——保留每组最新的一条
```sql
-- 删除同一用户的历史记录,只保留最新一条
DELETE old FROM orders old
INNER JOIN orders new
ON old.user_id = new.user_id
AND old.created_at < new.created_at;
-- old 是"要删的旧记录",new 是"用来比较的新记录"
```
```mermaid
flowchart LR
subgraph "orders 表(别名 old)"
O1["user=1, 05-18"]
O2["user=1, 05-19"]
O3["user=1, 05-20"]
end
subgraph "orders 表(别名 new)"
N1["user=1, 05-18"]
N2["user=1, 05-19"]
N3["user=1, 05-20"]
end
O1 -->|"old.created_at < new.created_at"| N2
O1 --> N3
O2 --> N3
O3 -.->|"无更早匹配"| N3
style O1 fill:#EE5A24,color:#fff
style O2 fill:#EE5A24,color:#fff
style O3 fill:#00D866,color:#fff
```
> [!TIP] 自连接的性能注意事项
> - 自连接本质是同一张表扫描两次,如果表很大且没有索引,代价等同于两张大表的 BNLJ
> - **务必确保 JOIN 条件列有索引**(如 `manager_id`、`user_id + created_at`)
> - 如果只是做"每组取最新一条",`ROW_NUMBER()` 窗口函数通常比自连接 DELETE 更安全(详见 [[hhs/MySQL/02-SQL核心/08-DQL SELECT 全解析]])
## JOIN 执行算法
MySQL InnoDB 在执行 JOIN 时,**本质上只有两种核心策略**:被驱动表有索引时用 **INLJ**(Index Nested-Loop Join),没有索引时退化到 **BNLJ**(Block Nested-Loop Join)。而 INLJ 内部又根据索引是否为唯一键进一步细分。
@@ -40,6 +40,9 @@ SELECT
username,
(SELECT COUNT(*) FROM orders WHERE user_id = users.id) AS order_count
FROM users;
-- 副作用:这个查询其实也是"相关子查询"的典型例子
-- 因为内层的 user_id = users.id 引用了外层表的列
-- 每一行 users 的记录都会触发一次内层查询
-- 用法 2:在 WHERE 中等价于常量
SELECT * FROM products
@@ -50,6 +53,10 @@ INSERT INTO reports (month, order_total)
VALUES ('2026-05', (SELECT SUM(amount) FROM orders WHERE MONTH(created_at) = 5));
```
> [!QUESTION] 上面用法 1 的子查询,每一行都要重新执行吗?
> 是的——但不是因为"放在 SELECT 里",而是因为它引用了外层的 `users.id`。
> 这就引出了子查询最重要的分类维度:**相关 vs 无关**。
### 相关 vs 无关子查询
这是理解子查询性能的基石:**子查询是否引用了外层查询的列?**
@@ -436,6 +443,14 @@ WITH RECURSIVE org_chain AS (
SELECT * FROM org_chain ORDER BY level, name;
```
> [!QUESTION] 为什么叫「自我引用」?递归 CTE 到底怎么执行的?
> `org_chain` 这个名字在定义内部就被引用了——`FROM employees e INNER JOIN org_chain oc`。
> 第一轮:只执行锚点成员,得到根节点(CEO);
> 第二轮:用第一轮的结果去 JOIN employees,得到 CEO 的直接下属;
> 第三轮:用第二轮的结果去 JOIN,得到下属的下属……
> 直到某一轮 JOIN 结果为空,递归自动终止。
> **关键:没有终止条件 → 无限循环 → 触发 `cte_max_recursion_depth` 报错。**
> [!WARNING] 递归 CTE 的注意事项
> - MySQL 默认递归深度上限为 **1000**(`cte_max_recursion_depth`),超出会报错
> - 务必设置合理的终止条件,否则会无限递归直到达到上限
@@ -466,6 +481,16 @@ SELECT * FROM org_chain ORDER BY level, name;
> **子查询不是万能的——它首先是为了表达清晰,其次才是性能。**
> 写完后务必跑 `EXPLAIN`,确认没有意料之外的临时表或多表扫描。当数据量上去后,能改写成 JOIN 的子查询就尽量改写,因为 JOIN 的执行路径对 Optimizer 更加透明。
> [!TIP] 学习路径建议
> 如果你刚接触子查询,建议按这个顺序学习:
> 1. **标量子查询** → 先理解「子查询就是一次独立的 SELECT」
> 2. **相关 vs 无关** → 理解执行次数差异(本文核心概念)
> 3. **IN vs EXISTS** → 掌握两种过滤范式,重点理解 NOT IN 陷阱
> 4. **派生表** → 子查询作为虚拟表,理解物化与 Flattening
> 5. **CTE** → 用现代语法重写派生表,最终挑战递归 CTE
>
> 每一步都要跑 `EXPLAIN` 看执行计划——**看懂 EXPLAIN 比背语法重要十倍**。
## 关联笔记
- [[hhs/MySQL/02-SQL核心/08-DQL SELECT 全解析]] — SELECT 执行顺序与子查询的关系
@@ -1,5 +1,5 @@
---
tags: [MySQL, UNION, UNION ALL, 集合运算]
tags: [MySQL, UNION, UNION ALL, INTERSECT, EXCEPT, 集合运算]
create time: 2026-05-16 00:00
---
@@ -47,6 +47,114 @@ flowchart LR
> [!TIP] 首选 UNION ALL
> 如果你能通过业务逻辑保证各部分结果不重复(比如按日期区间划分),**一律用 UNION ALL**。UNION 的去重操作需要 Sort/Dedup 阶段,在大结果集上是昂贵操作。
## INTERSECT 与 EXCEPT(MySQL 8.0.22+)
MySQL 8.0.22 开始支持标准 SQL 的 `INTERSECT`(交集)和 `EXCEPT`(差集),补齐了集合运算的最后一块拼图。
### 基本语法
```sql
-- INTERSECT:取两个查询的交集(两边都有的行)
SELECT city FROM customers_china
INTERSECT
SELECT city FROM customers_japan;
-- 结果:只返回同时存在于两个结果集中的城市
-- EXCEPT:取差集(左边有、右边没有的行)
SELECT city FROM customers_china
EXCEPT
SELECT city FROM customers_japan;
-- 结果:返回在中国有但日本没有的城市
```
> [!QUESTION] INTERSECT vs IN / EXISTS?
> 两者在语义上等价,但 `INTERSECT` 更直观地表达了"取交集"的意图。实际执行中,Optimizer 通常会将 INTERSECT 转换为 Semi Join 或 Anti Join——因此性能差异不大,选择哪种取决于**可读性**。
### INTERSECT / EXCEPT vs UNION 行为对比
```mermaid
flowchart LR
A["查询 A: {1,2,3}"] --> OP
B["查询 B: {2,3,4}"] --> OP
OP --> R1["UNION: {1,2,3,4}"]
OP --> R2["INTERSECT: {2,3}"]
OP --> R3["EXCEPT: {1}"]
style R1 fill:#74C0FC,color:#000
style R2 fill:#00D866,color:#fff
style R3 fill:#FF9F43,color:#000
```
| 操作 | 语义 | 去重 | 对应集合论 |
|------|------|------|-----------|
| `UNION` | A ∪ B | ✅ 默认去重 | 并集 |
| `UNION ALL` | A ∪ B(含重复) | ❌ | 多重集并 |
| `INTERSECT` | A ∩ B | ✅ 默认去重 | 交集 |
| `EXCEPT` | A − B | ✅ 默认去重 | 差集 |
> [!NOTE] 默认行为:去重
> INTERSECT 和 EXCEPT 默认都会去重(等价于 DISTINCT)。如果想去重保留所有行,目前 **MySQL 不支持 `INTERSECT ALL` / `EXCEPT ALL`**,需要通过 UNION ALL + 计数的方式模拟。
### 实用场景
```sql
-- 场景一:找出"既买了 A 又买了 B"的用户(交集)
SELECT user_id FROM orders WHERE product_id = 'A'
INTERSECT
SELECT user_id FROM orders WHERE product_id = 'B';
-- 等价的 EXISTS 写法(对比可读性)
SELECT DISTINCT o1.user_id
FROM orders o1
WHERE o1.product_id = 'A'
AND EXISTS (SELECT 1 FROM orders o2 WHERE o2.user_id = o1.user_id AND o2.product_id = 'B');
-- 场景二:找出"注册了但从未下单"的用户(差集)
SELECT id FROM users
EXCEPT
SELECT DISTINCT user_id FROM orders;
-- 等价的 NOT EXISTS 写法
SELECT u.id FROM users u
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);
```
> [!TIP] 何时选 INTERSECT/EXCEPT?
> - **逻辑简单、需要取交集/差集**:`INTERSECT` / `EXCEPT` 语法最清晰
> - **需要返回关联表的字段**:用 `EXISTS` / `NOT EXISTS` 或 JOIN,因为 INTERSECT/EXCEPT 只能操作整行或指定列
> - **需要高性能**:大表场景下 EXPLAIN 确认执行计划——Optimizer 通常会转换为 Semi/Anti Join,性能与手写子查询一致
### 优先级与括号
当混合使用多个集合运算时,默认**从左到右**执行。需要用括号改变优先级:
```sql
-- 先 UNION 再 INTERSECT(左到右)
SELECT id FROM t1
UNION
SELECT id FROM t2
INTERSECT
SELECT id FROM t3;
-- 实际含义:t1 UNION (t2 INTERSECT t3) ← 错误!
-- 实际执行:(t1 UNION t2) INTERSECT t3 ← 从左到右
-- ✅ 用括号明确意图
(SELECT id FROM t1 UNION SELECT id FROM t2)
INTERSECT
SELECT id FROM t3;
-- 也可以用 ORDER BY + LIMIT 修饰每个子句(需括号)
(SELECT id FROM t1 ORDER BY id LIMIT 10)
INTERSECT
(SELECT id FROM t2 ORDER BY id LIMIT 5);
```
> [!WARNING] 注意括号的必要性
> 当 INTERSECT/EXCEPT 与 ORDER BY/LIMIT 混用时,不加括号可能产生歧义。**养成给每个子查询加括号的习惯**,避免优先级意外。
---
## 实战场景
### 场景一:多表同构合并
@@ -348,6 +456,7 @@ flowchart TD
| 主题 | 一句话 |
|------|--------|
| UNION ALL vs UNION | 能用 ALL 就绝不用 UNION — 去重的临时表+文件排序代价远超想象 |
| INTERSECT / EXCEPT | 8.0.22+ 原生支持交集与差集,语义比 IN/EXISTS 更直观,Optimizer 会转为 Semi/Anti Join |
| ORDER BY/LIMIT 作用域 | 只在 UNION 最后一个 SELECT 生效;跨表排序必须外层包装子查询 |
| 字段匹配规则 | 列数必须相同,类型应尽量兼容 — 列名取自第一个 SELECT |
| Feed 流/多源合并 | UNION ALL 的经典场景,一次 DB 往返搞定多数据源混合 |
@@ -132,7 +132,31 @@ sequenceDiagram
### 覆盖索引(Covering Index)
当查询需要的数据全部存在于某个索引的 B+ Tree 中时,就不需要再走聚簇索引了——这叫 **Index Only Scan**,在 `EXPLAIN` 中显示为 `Using index`。
什么是**回表**?二级索引的叶子节点只存了**索引列 + 主键**,不存完整行数据。当查询需要的列不在索引中时,MySQL 必须拿着主键再去聚簇索引里找完整行——这个"再跳一次"的过程就叫回表(详见下方 [[#聚簇索引 vs 二级索引]])。每回表一次就是一次**随机 IO**,匹配 1000 行就是 1000 次随机跳转,代价非常高。
**覆盖索引的核心思想**:如果查询所需的**所有列**都存在于某个索引的叶子节点中,就省掉回表这一步——直接从二级索引中把数据读完。这在 `EXPLAIN` 中显示为 `Using index`,也叫 **Index Only Scan**。
```mermaid
flowchart LR
subgraph "回表路径(普通查询)"
A1["二级索引<br/>叶子节点"] -->|"拿到主键 id"| A2["聚簇索引<br/>再次定位"]
A2 -->|"随机 IO"| A3["完整行数据"]
end
subgraph "覆盖索引路径(Index Only Scan)"
B1["二级索引<br/>叶子节点"] -->|"所有列都在这里"| B2["直接返回"]
end
style A1 fill:#FF9F43,color:#000
style A2 fill:#EE5A24,color:#fff
style A3 fill:#FF9F43,color:#000
style B1 fill:#00D866,color:#fff
style B2 fill:#00D866,color:#fff
```
> [!QUESTION] 为什么避免回表这么重要?
> 回表是**随机 IO**,而顺序 IO 和随机 IO 的性能差距是 **百倍到千倍** 级别(机械磁盘尤其明显,SSD 也有 10~50 倍差距)。
> 当匹配行数较多时,覆盖索引的收益会非常显著。
```sql
CREATE TABLE users (
@@ -145,16 +169,39 @@ CREATE TABLE users (
-- ❌ 必须回表:email 在索引里,但 name 不在
SELECT name, email FROM users WHERE email = 'test@example.com';
-- ① 走 idx_email 找到 id → ② 回聚簇索引拿 name
-- ① 走 idx_email 找到 id → ② 回聚簇索引拿 name(随机 IO)
-- ✅ 覆盖索引:不需要回表!
SELECT email FROM users WHERE email = 'test@example.com';
-- 只需从 idx_email 叶子节点直接拿到 email
-- 只需从 idx_email 叶子节点直接拿到 email,零回表
```
> [!NOTE] Covering Index 的判断方法
> - `Extra = Using index`(无 `Using where`)→ 完整覆盖,零回表
> - `Extra = Using index condition` → 索引下推(ICP),部分过滤在索引层面完成
#### 用联合索引构造覆盖索引
实际业务中,单列索引很难做到覆盖——查询通常需要多个字段。更常见的做法是**通过联合索引把查询涉及的列"包"进去**:
```sql
-- 高频查询:根据 user_id 查该用户已完成的订单金额
SELECT order_id, amount
FROM orders
WHERE user_id = 100 AND status = 'paid';
-- 方案 A:索引只有 (user_id) → 必须回表拿 status、order_id、amount
-- 方案 B:联合索引 (user_id, status, order_id, amount)
-- 叶子节点存了全部所需列 → 零回表 ✅
ALTER TABLE orders ADD INDEX idx_user_cover(user_id, status, order_id, amount);
```
> [!NOTE] 设计覆盖索引的思路
> 1. **先看高频查询**:`SELECT` 了哪些列?`WHERE` 和 `ORDER BY` 涉及哪些列?
> 2. **把 WHERE + ORDER BY + SELECT 涉及的列**按合理顺序放入联合索引
> 3. 等值列在前、范围列在后(最左前缀原则),`SELECT` 中无法参与过滤的列放最后
>
> 代价是索引更宽、占用更多空间、写入维护成本更高——需要权衡。
> [!NOTE] EXPLAIN 中怎么判断覆盖索引?
> - `Extra = Using index` → 完整覆盖,零回表
> - `Extra = Using index condition` → 索引下推(ICP),部分过滤在索引层面完成,但仍需回表
> - Extra 中**没有** `Using index` → 触发了回表
>
> 💡 更多细节(回表代价、ICP 原理、空间对比)详见 [[hhs/MySQL/03-索引与查询优化/13-聚簇索引与二级索引]]
@@ -7,7 +7,13 @@ create time: 2026-05-20 14:00
## 概述
InnoDB 使用 B+ Tree 组织所有索引,但根据「索引叶子节点存什么」的不同,分为两类:**聚簇索引**和**二级索引**。理解这两者的差异,是你设计高效查询和执行计划的起点。
InnoDB 使用 B+ Tree 组织所有索引,但根据「索引叶子节点存什么」的不同,分为两类:**聚簇索引**和**二级索引**。
打个比方:如果把数据库表想象成一本通讯录——
- **聚簇索引**就是通讯录正文本身:按姓名排序,每个条目后面直接写上了电话、地址等所有信息。
- **二级索引**就像附录的索引卡片:只写了「姓名 → 第几页」,想看完整信息还得翻回正文。
理解这两者的差异,是你设计高效查询和执行计划的起点。
为了方便说明,本文档将始终使用以下 `users` 表作为示例:
@@ -24,8 +30,8 @@ CREATE TABLE users (
```
这张表中:
- **聚簇索引**:`id`(PRIMARY KEY)
- **二级索引**:`idx_email`(email)、`idx_status_created`(status, created_at)
- **聚簇索引**:`id`(PRIMARY KEY)—— 因为 InnoDB 的规则是:谁是 PRIMARY KEY,谁就是聚簇索引。你可以这样理解:InnoDB **根本不单独存"表数据"**,它把整张表的数据直接塞进了以 `id` 为键的 B+ Tree 的叶子节点里。所以 `id` 这棵 B+ Tree 既是索引,也是数据本身。(后面会详细展开这条规则。)
- **二级索引**:`idx_email`(email)、`idx_status_created`(status, created_at) —— 这两个索引的叶子节点里不存完整行数据,只存「索引列的值 + `id`」,所以叫"二级":它们是依附于聚簇索引的"小抄",查到 `id` 后还得回聚簇索引里拿完整数据。
> [!QUESTION] 思考
> 假设一张用户表按 `id` 排序存储在磁盘上——
@@ -69,7 +75,9 @@ block-beta
## 聚簇索引(Clustered Index)
聚簇索引就是数据本身。InnoDB 表中**只能有一个聚簇索引**。
"聚簇"这个词听起来很抽象,但意思很简单:**数据按照索引的顺序"聚集"在一起存储**。在 InnoDB 里,聚簇索引的叶子节点不是"指向"数据,叶子节点**就是**数据——整行记录都直接存在里面。
正因为数据只有一份,所以一张表**只能有一个**聚簇索引。
### 聚簇索引的形成规则
@@ -89,6 +97,8 @@ block-beta
### 聚簇索引的特性
数据按主键顺序存储带来了两个直接好处:写入快(顺序追加)和范围查询快(连续扫描):
```mermaid
flowchart TD
A["按主键排序存储数据"] --> B["数据页紧凑排列"]
@@ -109,6 +119,15 @@ flowchart TD
除了聚簇索引外的所有索引都是二级索引。叶子节点存储的是:**索引列的值 + 主键值**。
> [!QUESTION] 为什么二级索引要存主键?
> 因为二级索引本身没有完整数据,但它知道"这条记录在哪"——通过主键就能回到聚簇索引里找到完整行。主键就是二级索引回聚簇索引的"导航坐标"。
>
> 整个过程分两步:
> 1. 在二级索引里找到匹配条件的记录,拿到主键 `id`
> 2. 拿着 `id` 去聚簇索引里找完整行数据
>
> 这个"从二级索引跳回聚簇索引取数据"的过程,就叫 **回表**。
```mermaid
flowchart TD
subgraph "二级索引页<br/>idx_status_created = (status, created_at)"
@@ -131,6 +150,8 @@ flowchart TD
## 回表的代价
刚才说了回表是"跳回聚簇索引取数据",但这个跳转是有代价的——它意味着额外的磁盘 I/O。下面这张图展示了具体过程:
```mermaid
sequenceDiagram
participant App as 应用层
@@ -152,7 +173,9 @@ sequenceDiagram
### 减少回表的策略(Covering Index)
要减少回表,最直接的方法是让查询**完全在二级索引中完成**——这就是 Covering Index。
回表既然是因为二级索引里"信息不够"才跳回聚簇索引,那最直接的解决办法就是:**让二级索引里包含查询需要的所有信息**,这样就不需要跳回去了。
这就是 Covering Index(覆盖索引)——索引"覆盖"了查询的全部需求,查完索引就够了,不用回表。
```sql
-- ❌ 差:Covering Index 未命中,需要回表拿 name 字段
@@ -220,11 +243,13 @@ flowchart TD
```
> [!HIGHLIGHT] ICP 的本质
> 把 **Server 层**的过滤条件(如 `created_at > ...`)**下推到 Handler 层**,在读取二级索引页时就完成判断,命中了才回表。
> 用生活化的类比来说:以前的做法是,快递员(存储引擎)在仓库(二级索引)里找到 1000 个包裹,全部搬出来,再由前台(Server 层)一个一个拆开检查是不是客户要的。ICP 的做法是,直接告诉快递员"只搬 status=1 且 created_at 在这个日期之后的",在仓库里就筛掉了 950 个,只搬出 50 个。
>
> 技术上说,就是把过滤条件下推到存储引擎层,在读取二级索引页时就完成判断,命中了才回表。
>
> **适用条件:**
> - 必须是二级索引(聚簇索引不走 ICP)
> - 过滤条件中能利用到的部分必须在索引列范围内
> - 必须是二级索引(聚簇索引本身已经是完整数据,不存在"下推"的问题)
> - 过滤条件必须能利用到索引列(否则在索引里也没法判断)
> - MySQL 5.6+ 默认开启,无需额外配置
```sql
@@ -9,23 +9,36 @@ create time: 2026-05-16 00:00
联合索引(Composite Index)是将多个列放在同一个 B+ Tree 中组织的索引。它的核心法则是**最左前缀匹配原则**——理解这一点就能避开 80% 的索引设计失误。
## 联合索引的结构
## 从"字典查词"理解联合索引
```
联合索引 idx(a, b, c) 的 B+ Tree 结构:
翻开一本中文词典,里面的词条按照**拼音 → 笔画 → 部首**的顺序排列:
1. 先按拼音排序(a, b, c...)
2. 拼音相同时,按笔画排序
3. 笔画也相同时,按部首排序
你想查"爱"字,先翻到拼音 `ai` 的区域(第一列),再找 10 画的(第二列),最后精确定位(第三列)——这就是**最左前缀**的直觉。
反过来,如果有人问"10 画的字在第几页?"——你没法回答,因为 10 画的字**分散在每个拼音区**里,没有连续的区域可以翻到。
> [!QUESTION] 思考
> 联合索引 `idx(a, b, c)` 就像这本字典。条件必须从第一列开始依次匹配,不能"跳着查"。下面我们来看看它的具体结构。
## 联合索引的结构
叶子节点按 a 排序,a 相同时按 b 排序,a 和 b 都相同时按 c 排序:
[a=1, b=10, c=3] → data
[a=1, b=10, c=7] → data
[a=1, b=20, c=1] → data
[a=1, b=20, c=9] → data
[a=2, b=5, c=2] → data
[a=2, b=15, c=4] → data
[a=3, b=10, c=1] → data
[a=3, b=10, c=8] → data
[a=3, b=30, c=5] → data
```
| a | b | c | data |
|---|---|---|------|
| 1 | 10 | 3 | row1 |
| 1 | 10 | 7 | row2 |
| 1 | 20 | 1 | row3 |
| 1 | 20 | 9 | row4 |
| 2 | 5 | 2 | row5 |
| 2 | 15 | 4 | row6 |
| 3 | 10 | 1 | row7 |
| 3 | 10 | 8 | row8 |
| 3 | 30 | 5 | row9 |
> [!QUESTION] 这意味着什么?
> 联合索引的本质是**多级排序**。你可以把它想象成 SQL 的 `ORDER BY a, b, c`。索引中的数据已经按照这个顺序排好了。
@@ -63,16 +76,35 @@ flowchart TD
### 为什么 b=2 单独查不了?
```
查询 WHERE b=2 时,B+ Tree 的搜索路径是什么样的?
```mermaid
graph LR
subgraph SQ1["✅ WHERE a=1 二分查找定位"]
direction LR
A1["a=1 区域连续"] --> A2["直接定位起始行"] --> A3["顺序扫描即可"]
end
[b=20, c=1] 的位置在 [b=10, c=7] 之后,但它们之间穿插着 [b=5, c=2]...
数据在整个树中分散存放,没有连续的 b=2 区域 → 需要全树扫描
subgraph SQ2["❌ WHERE b=2 需全表扫描"]
direction LR
B1["b=10 在 a=1 下"] --> B4["b=10 在 a=3 下"] --> B2["b=2 散落各处"] --> B3["无法定位起点"]
end
而 WHERE a=1 则不同:
[a=1, ...] 的所有记录都在树的左侧一段连续区域内 → 二分查找即可定位
style SQ1 fill:#E8F8F5,stroke:#00D866,stroke-width:2px
style SQ2 fill:#FDEDEC,stroke:#EE5A24,stroke-width:2px
style A1 fill:#00D866,color:#fff
style A2 fill:#00D866,color:#fff
style A3 fill:#00D866,color:#fff
style B1 fill:#EE5A24,color:#fff
style B2 fill:#EE5A24,color:#fff
style B3 fill:#EE5A24,color:#fff
style B4 fill:#EE5A24,color:#fff
```
B+ Tree 中的数据**先按 a 排序**,只有 a 相同时 b 才有序。单独查 `b=2` 时,满足条件的行分散在不同的 a 值下面,没有连续的 `b=2` 区域可供二分查找——只能全树扫描。
> [!QUESTION] 思考
> 规律是什么?只有匹配了索引的**第一列**,才能继续利用第二列;匹配了前两列,才能利用第三列。条件必须从索引的最左列开始,依次匹配,不能跳过中间列。
> 而对于 `WHERE a=1 AND c=3`,虽然 a 匹配了第一列,但跳过了 b,所以 c 无法走索引。不过别担心,MySQL 优化器足够聪明——它会自动调整 WHERE 条件的顺序来匹配索引,**SQL 中条件的书写顺序不影响索引的使用**。
## 范围查询后的断裂
联合索引遇到范围查询(`>`, `<`, `BETWEEN`, `LIKE 'prefix%'`)后,右侧列的索引失效。
@@ -91,7 +123,9 @@ SELECT * FROM orders WHERE status = 'paid'
```
> [!QUESTION] 为什么范围查询会打断后续列?
> 想象一下,当 `status='paid'` 锁定一个子树后,该子树内部按 `created_at` 升序排列。一旦用 `>` 做了范围扫描,得到的是一个 `created_at` 连续递增的数据段——这个段内的 `type` 值是乱序穿插的,无法再用二分查找定位 `type='online'`。
> 一个生活类比:联合索引就像一本按**省份 → 城市 → 区县**排序的通讯录。你可以先翻到"广东省"(等值),再在里面翻"深圳市"(等值),最后找"南山区"——很快。但如果你只知道"某个日期之后创建的订单"(范围),你得到的是一个连续但内部无序的数据段,就像拿到"2026 年之后的广东省所有城市"的名单——这个名单里的区县是乱序的,你没法再按区县快速定位。
>
> 简单说:**范围扫描得到的是一个"连续但内部无序"的区间**,后续列无法利用索引的有序性进行二分查找。
```mermaid
graph LR
@@ -126,36 +160,115 @@ CREATE INDEX idx_status_time ON orders (status, created_at);
-- 让每个索引专注于它的典型查询模式
```
### EXPLAIN 验证:从 range 到 ref
用 EXPLAIN 对比两种索引的效果:
```sql
-- ❌ 用 idx_sct(status, created_at, type)
EXPLAIN SELECT * FROM orders
WHERE status = 'paid' AND created_at > '2026-01-01' AND type = 'online';
```
```
+----+------+---------------+---------+-------+-----------------+------+----------+-------------+
| id | type | possible_keys | key | ref | key_len | rows | filtered | Extra |
+----+------+---------------+---------+-------+-----------------+------+----------+-------------+
| 1 | range| idx_sct | idx_sct | NULL | ... | 1280 | 33.33 | Using where |
+----+------+---------------+---------+-------+-----------------+------+----------+-------------+
-- type=range:范围扫描后,type='online' 需要逐行过滤(filtered=33.33%)
```
```sql
-- ✅ 用 idx_stc(status, type, created_at)
EXPLAIN SELECT * FROM orders
WHERE status = 'paid' AND type = 'online' AND created_at > '2026-01-01';
```
```
+----+------+---------------+---------+------------------+---------+------+----------+-------+
| id | type | possible_keys | key | ref | key_len | rows | filtered | Extra |
+----+------+---------------+---------+------------------+---------+------+----------+-------+
| 1 | ref | idx_stc | idx_stc | const,const | ... | 42 | 100.00 | NULL |
+----+------+---------------+---------+------------------+---------+------+----------+-------+
-- type=ref:等值列全部命中,范围扫描在最后一步,filtered=100%!
```
> [!TIP] 联合索引列序黄金法则
> 1. **等值优先于范围**:等值列放在前面
> 2. **选择性高的列靠前**:区分度大的列(如 user_id)比区分度小的(如 gender)靠前
> 3. **前缀长度要短**:如果需要 LIKE,尽量用较短的前缀
> 3. **范围列放在最后**:让前面的等值列尽量多命中,范围列最后扫描
## 索引失效的典型场景
```sql
-- ❌ 场景 1:隐式类型转换
ALTER TABLE users ADD INDEX idx_phone (phone);
-- phone 是 VARCHAR 类型
SELECT * FROM users WHERE phone = 13800138000; -- 传入 INT!
-- MySQL 会把 varchar 转成 int 再比较 → 函数作用于列 → 索引失效
以下 5 个场景都会导致索引失效,逐一排查可解决大部分问题。
-- ❌ 场景 2:函数/表达式包裹索引列
SELECT * FROM users WHERE YEAR(created_at) = 2026;
-- 应对:改为范围查询 WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01'
> [!WARNING] 场景 1:隐式类型转换
> ```sql
> ALTER TABLE users ADD INDEX idx_phone (phone); -- phone 是 VARCHAR 类型
> SELECT * FROM users WHERE phone = 13800138000; -- 传入 INT!
> ```
> MySQL 会把 varchar 列隐式转换为 int 再比较——函数作用于列,索引失效。
>
> **解决**:传入字符串 `WHERE phone = '13800138000'`。
-- ❌ 场景 3:LIKE 以通配符开头
SELECT * FROM users WHERE name LIKE '%abc%';
-- 应对:考虑全文索引 FT
> [!WARNING] 场景 2:函数/表达式包裹索引列
> ```sql
> SELECT * FROM users WHERE YEAR(created_at) = 2026;
> ```
> 索引列被函数包裹后,B+ Tree 无法直接定位。
>
> **解决**:改为范围查询 `WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01'`。
-- ❌ 场景 4:OR 条件中有列没有索引
SELECT * FROM users WHERE email = 'a@x.com' OR phone = '138...';
-- 如果 email 有索引但 phone 没有,整个查询不走索引
-- 应对:给 phone 加索引,或用 UNION ALL 拆分
> [!WARNING] 场景 3:LIKE 以通配符开头
> ```sql
> SELECT * FROM users WHERE name LIKE '%abc%';
> ```
> 前缀未知,无法利用索引的有序性定位。
>
> **解决**:使用前缀匹配 `LIKE 'abc%'`,或考虑全文索引。
-- ❌ 场景 5:字符集不一致导致隐式转换
-- 表 charset=utf8mb4,客户端 charset=gbk → 自动转换
-- 应对:确保连接字符集一致 SET NAMES utf8mb4
> [!WARNING] 场景 4:OR 条件中有列没有索引
> ```sql
> SELECT * FROM users WHERE email = 'a@x.com' OR phone = '138...';
> ```
> 如果 email 有索引但 phone 没有,整个查询不走索引。
>
> **解决**:给 phone 加索引,或用 `UNION ALL` 拆分为两个独立查询。
> [!WARNING] 场景 5:字符集不一致导致隐式转换
> ```sql
> -- 表 charset=utf8mb4,客户端 charset=gbk → 自动转换
> ```
> 跨字符集连接时,MySQL 需要逐行转换再比较,索引失效。
>
> **解决**:确保连接字符集一致 `SET NAMES utf8mb4`。
### 索引失效决策图
```mermaid
flowchart TD
Q0{"查询条件是否包含索引最左列?"}
Q1{"最左列是否被函数/表达式包裹?"}
Q2{"列类型与传入值类型是否一致?"}
Q3{"OR 条件中所有列是否都有索引?"}
Q4{"字符集是否一致?"}
OK["✅ 索引生效"]
FAIL["❌ 索引失效,排查修复"]
Q0 -->|是| Q1
Q0 -->|否| FAIL
Q1 -->|否| Q2
Q1 -->|是| FAIL
Q2 -->|是| Q3
Q2 -->|否| FAIL
Q3 -->|是| Q4
Q3 -->|否| FAIL
Q4 -->|是| OK
Q4 -->|否| FAIL
style OK fill:#00D866,color:#fff
style FAIL fill:#EE5A24,color:#fff
```
## 最左前缀的灵活应用
@@ -183,12 +296,53 @@ GROUP BY type;
-- → 同上,子区间内 type 已有序,分组可以直接跳过
```
### Go 中利用索引的查询设计
在 Go 后端中,构建查询时应当有意识地按照索引列序组织条件:
```go
// 按索引列序拼接查询条件,确保每个条件都能命中 idx_stc(status, type, created_at)
func buildOrderQuery(status string, orderType string, since time.Time) (string, []any) {
conditions := []string{"1=1"}
args := []any{}
if status != "" {
conditions = append(conditions, "status = ?")
args = append(args, status)
}
if orderType != "" {
conditions = append(conditions, "type = ?")
args = append(args, orderType)
}
if !since.IsZero() {
conditions = append(conditions, "created_at > ?") // 范围列放最后
args = append(args, since)
}
query := "SELECT * FROM orders WHERE " + strings.Join(conditions, " AND ")
return query, args
}
```
> [!NOTE] 关键理解
> ORDER BY / GROUP BY 能复用联合索引,靠的不是"巧合",而是 B+ Tree **叶子节点本身有序**这一物理特性。只要 WHERE 过滤条件匹配了联合索引的左侧列,剩余列就是有序的——优化器只是利用了已有的顺序,并没有多做一次排序。
> [!TIP] 一索引多用
> 一个好的联合索引可以同时服务 WHERE、ORDER BY、GROUP BY 三种需求。在设计索引时要考虑查询的整体模式,而不是单一查询。
## 联合索引列序决策表
| 查询模式 | 列序建议 | 示例索引 | 说明 |
|----------|----------|----------|------|
| 等值 + 等值 | 高选择性列在前 | `idx(user_id, status)` | 两个等值条件都能走 ref |
| 等值 + 范围 | 等值列在前,范围列最后 | `idx(status, created_at)` | 等值定位起点,范围最后扫描 |
| 等值 + 等值 + 范围 | 等值列全在前,范围列最后 | `idx(status, type, created_at)` | 让尽量多的等值列命中 |
| 等值 + ORDER BY | 等值列 → 排序列 | `idx(status, type)` | 排序列可复用索引有序性 |
| 等值 + GROUP BY | 同 ORDER BY | `idx(status, type)` | 分组复用索引有序性 |
| 范围 + 范围 | 选择性高的范围列在前 | `idx(created_at, amount)` | 两个范围条件,只能一个走索引 |
> [!QUESTION] 什么时候该拆索引?
> 当两种查询模式的列序**互相矛盾**时(如一个需要 `(a, b, c)`,另一个需要 `(b, a, c)`),与其在同一个索引上做取舍,不如创建两个独立索引,让每个索引专注于它的典型查询模式。但要注意——索引越多,写入开销越大,需要在读写之间找到平衡。
## 关联笔记
- [[hhs/MySQL/03-索引与查询优化/13-聚簇索引与二级索引]] — 聚簇索引本身就是特殊的联合索引 (PK)
@@ -12,6 +12,17 @@ create time: 2026-05-16 00:00
> [!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
@@ -27,15 +38,65 @@ EXPLAIN FORMAT=JSON SELECT * FROM users WHERE email = 'a@x.com';
EXPLAIN ANALYZE SELECT ...; -- MySQL 8.0.18+
```
## 关键字段速查
## 输出字段全览
| 字段 | 含义 | 关注点 |
|------|------|--------|
| **type** | JOIN 类型 | 是否走索引(ref/range 优,ALL 差)|
| **key** | 实际使用的索引 | NULL = 没用索引 |
| **rows** | 预估扫描行数 | 越小越好 |
| **Extra** | 附加信息 | 重点关注 Using where/index/filesort/temporary |
| **cost** | 预估成本(JSON 格式) | optimizer_cost 越低越好 |
> [!NOTE] 核心思路
> EXPLAIN 输出的每一行对应一张表的访问方式。多表 JOIN 时会输出多行,**id 相同时,行号越小(越靠上)越先执行**——第一行是驱动表,后续行是被驱动表。
| 字段 | 含义 | 重要程度 | 关注点 |
|------|------|:--------:|--------|
| **id** | 查询编号 | ⚪ 了解 | 相同 id = 同层查询,不同 id = 嵌套子查询 |
| **select_type** | 查询类型 | 🟡 辅助 | SIMPLE/PRIMARY/SUBQUERY/DERIVED,判断复杂度 |
| **table** | 访问的表 | ⚪ 了解 | 可能是别名或 `<derivedN>` 物化表 |
| **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 | <derived2> | 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 详解(最重要)
@@ -72,6 +133,90 @@ flowchart LR
> [!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)
@@ -85,6 +230,35 @@ flowchart LR
> [!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["存储引擎层<br/>idx_status_city"] --> B{"ICP 可用?"}
B -->|No ICP| C["只用 status 定位<br/>回表 100K 行"]
C --> D["Server 层过滤 city<br/>丢弃 98K 行 ⚠️"]
B -->|With ICP| E["status 定位 + city 预过滤<br/>索引层过滤后只剩 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 信息速查
| 关键词 | 含义 | 处理 |
@@ -151,37 +325,115 @@ ORDER BY created_at DESC;
## 实战案例分析
下面用一个完整的调优案例,把前面所有知识点串起来。
### 场景:订单列表页越来越慢
```sql
-- 原始查询(慢)
EXPLAIN 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
-- 表结构
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;
```
-- 典型 bad result:
-- type: ALL, rows: 1000000, Extra: Using where; Using filesort; Using temporary
-- → 全表扫描 + 临时表 + 文件排序
### Step 1:看 type + key — 全表扫描?
-- 修复方案
-- 1. 创建复合索引
ALTER TABLE orders ADD INDEX idx_created_user (created_at, user_id);
```
+----+-------------+--------+------+---------------+------------+---------+-------+--------+---------------------------+
| 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 |
+----+-------------+--------+------+---------------+------------+---------+-------+--------+---------------------------+
```
-- 2. 查询改写(反向排序优化)
-- 先用索引定位 top 20 的 pk,再 JOIN 拿数据
SELECT u.username, o.amount
FROM orders o
JOIN users u ON u.id = o.user_id
WHERE o.created_at >= '2026-01-01'
ORDER BY o.created_at DESC
- 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;
```
-- 新的 EXPLAIN:
-- type: range on orders, ref on users
-- key: idx_created_user
-- Extra: Using index condition; Using where
-- → 索引范围扫描 + 快速消除 filesort
```
+----+-------------+--------+-------+---------------------------------------------+-------------------------+---------+------+------+----+
| 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<br/>rows: 98K<br/>filesort 🔴"] --> B["Step 2<br/>rows: 1.2K<br/>无 filesort ✅"]
B --> C["Step 3<br/>rows: 20<br/>0.14ms 🚀"]
style A fill:#EE5A24,color:#fff
style B fill:#FF9F43,color:#000
style C fill:#00D866,color:#fff
```
## EXPLAIN FORMAT=JSON 精读
@@ -244,6 +496,39 @@ flowchart LR
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**,然后返回实际运行时间、每行的实际扫描数等统计信息。
@@ -321,8 +606,28 @@ ANALYZE TABLE orders;
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 的底层原因