diff --git a/hhs/MySQL/02-SQL核心/07-DML 增删改.md b/hhs/MySQL/02-SQL核心/07-DML 增删改.md
index 63584ba..957c33d 100644
--- a/hhs/MySQL/02-SQL核心/07-DML 增删改.md
+++ b/hhs/MySQL/02-SQL核心/07-DML 增删改.md
@@ -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 子句。
diff --git a/hhs/MySQL/02-SQL核心/09-JOIN 原理与优化.md b/hhs/MySQL/02-SQL核心/09-JOIN 原理与优化.md
index fad9499..51953d0 100644
--- a/hhs/MySQL/02-SQL核心/09-JOIN 原理与优化.md
+++ b/hhs/MySQL/02-SQL核心/09-JOIN 原理与优化.md
@@ -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 内部又根据索引是否为唯一键进一步细分。
diff --git a/hhs/MySQL/02-SQL核心/10-子查询与派生表.md b/hhs/MySQL/02-SQL核心/10-子查询与派生表.md
index 1451e91..0af3899 100644
--- a/hhs/MySQL/02-SQL核心/10-子查询与派生表.md
+++ b/hhs/MySQL/02-SQL核心/10-子查询与派生表.md
@@ -36,10 +36,13 @@ graph BT
```sql
-- 用法 1:在 SELECT 中调用
-SELECT
+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 执行顺序与子查询的关系
diff --git a/hhs/MySQL/02-SQL核心/11-UNION 与集合运算.md b/hhs/MySQL/02-SQL核心/11-UNION 与集合运算.md
index 17edd82..7846391 100644
--- a/hhs/MySQL/02-SQL核心/11-UNION 与集合运算.md
+++ b/hhs/MySQL/02-SQL核心/11-UNION 与集合运算.md
@@ -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 往返搞定多数据源混合 |
diff --git a/hhs/MySQL/03-索引与查询优化/12-B+Tree 索引原理.md b/hhs/MySQL/03-索引与查询优化/12-B+Tree 索引原理.md
index d611ca4..53f3c1c 100644
--- a/hhs/MySQL/03-索引与查询优化/12-B+Tree 索引原理.md
+++ b/hhs/MySQL/03-索引与查询优化/12-B+Tree 索引原理.md
@@ -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["二级索引
叶子节点"] -->|"拿到主键 id"| A2["聚簇索引
再次定位"]
+ A2 -->|"随机 IO"| A3["完整行数据"]
+ end
+
+ subgraph "覆盖索引路径(Index Only Scan)"
+ B1["二级索引
叶子节点"] -->|"所有列都在这里"| 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-聚簇索引与二级索引]]
diff --git a/hhs/MySQL/03-索引与查询优化/13-聚簇索引与二级索引.md b/hhs/MySQL/03-索引与查询优化/13-聚簇索引与二级索引.md
index 15bdbb9..bcbc3dd 100644
--- a/hhs/MySQL/03-索引与查询优化/13-聚簇索引与二级索引.md
+++ b/hhs/MySQL/03-索引与查询优化/13-聚簇索引与二级索引.md
@@ -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 "二级索引页
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
diff --git a/hhs/MySQL/03-索引与查询优化/14-联合索引与最左前缀.md b/hhs/MySQL/03-索引与查询优化/14-联合索引与最左前缀.md
index f1d9c91..f28ef57 100644
--- a/hhs/MySQL/03-索引与查询优化/14-联合索引与最左前缀.md
+++ b/hhs/MySQL/03-索引与查询优化/14-联合索引与最左前缀.md
@@ -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,14 +123,16 @@ SELECT * FROM orders WHERE status = 'paid'
```
> [!QUESTION] 为什么范围查询会打断后续列?
-> 想象一下,当 `status='paid'` 锁定一个子树后,该子树内部按 `created_at` 升序排列。一旦用 `>` 做了范围扫描,得到的是一个 `created_at` 连续递增的数据段——这个段内的 `type` 值是乱序穿插的,无法再用二分查找定位 `type='online'`。
+> 一个生活类比:联合索引就像一本按**省份 → 城市 → 区县**排序的通讯录。你可以先翻到"广东省"(等值),再在里面翻"深圳市"(等值),最后找"南山区"——很快。但如果你只知道"某个日期之后创建的订单"(范围),你得到的是一个连续但内部无序的数据段,就像拿到"2026 年之后的广东省所有城市"的名单——这个名单里的区县是乱序的,你没法再按区县快速定位。
+>
+> 简单说:**范围扫描得到的是一个"连续但内部无序"的区间**,后续列无法利用索引的有序性进行二分查找。
```mermaid
graph LR
A["等值列 = 定位起点"] -->|"精确找到起始位置"| B
B["范围列 = 确定终点"] -->|"划定扫描区间"| C
C["右侧列 = 失效"] -->|"区间内无序"| D["退化为 WHERE 过滤"]
-
+
style A fill:#00D866,color:#fff
style B fill:#00B6BC,color:#fff
style D fill:#EE5A24,color:#fff
@@ -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)
diff --git a/hhs/MySQL/03-索引与查询优化/15-EXPLAIN 完全指南.md b/hhs/MySQL/03-索引与查询优化/15-EXPLAIN 完全指南.md
index bb06d17..138371f 100644
--- a/hhs/MySQL/03-索引与查询优化/15-EXPLAIN 完全指南.md
+++ b/hhs/MySQL/03-索引与查询优化/15-EXPLAIN 完全指南.md
@@ -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** | 访问的表 | ⚪ 了解 | 可能是别名或 `` 物化表 |
+| **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 详解(最重要)
@@ -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["存储引擎层
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 信息速查
| 关键词 | 含义 | 处理 |
@@ -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
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 精读
@@ -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 的底层原因