---
tags: [MySQL, JOIN, Nested Loop, 索引优化]
create time: 2026-05-16 00:00
---
# JOIN 原理与优化
## 概述
JOIN 是关系型数据库的核心能力,也是性能问题的主要来源。理解 MySQL 的 JOIN 执行算法才能写出高效的关联查询。
## 什么是 JOIN
在关系型数据库中,数据通常分散在多张表中——用户信息一张表,订单记录另一张表。**JOIN 就是把这些分散的表按某种规则"拼"在一起,形成一张完整的视图。**
> [!QUESTION] 💡 思考一下
> 想象你在 Excel 里有两张表:左边是学生名单(含学号),右边是成绩表(也有学号)。你想看到"每个学生对应的成绩"——你会怎么操作?
> **答**:你会通过"学号"这一列,把两行的数据对应起来。SQL 中的 JOIN 就是这个操作的自动化版本。
### 一个具体的例子
假设有两张表:
| users 表 | | orders 表 | |
|-----------|---|------------|---|
| **id** | **name** | **order_id** | **user_id** | **amount** |
| 1 | Alice | 101 | 1 | ¥200 |
| 2 | Bob | 103 | 2 | ¥80 |
| | | 102 | 3 | ¥150 |
我们想查 **"每个订单对应用户的名字"** ——单张表做不到,必须同时看 users 和 orders:
```sql
SELECT u.name, o.order_id, o.amount
FROM users u JOIN orders o ON u.id = o.user_id;
```
结果:
| name | order_id | amount |
|------|----------|--------|
| Alice | 101 | ¥200 |
| Bob | 103 | ¥80 |
> ⚠️ 注意:order_id=102(user_id=3)没有出现在结果中,因为 users 表里没有 id=3 的记录。这就是 JOIN 的匹配逻辑——**只有两边都能对上的行才会被选中**。
```mermaid
flowchart LR
subgraph users 表
A1["id=1 / Alice"]
A2["id=2 / Bob"]
A3["id=3(不存在)"]
end
subgraph orders 表
B1["order_id=101
user_id=1 ¥200"]
B2["order_id=102
user_id=3 ¥150"]
B3["order_id=103
user_id=2 ¥80"]
end
A1 -->|"ON id = user_id"| B1
A2 -->|"ON id = user_id"| B3
A3 -.->|"users 表中无 id=3"| B2
style A3 fill:#EE5A24,color:#fff
style B2 fill:#EE5A24,color:#fff
```
> [!NOTE] 为什么要拆成多张表?
> 你可能会问:为什么不把所有数据存在一张大表里?这涉及数据库设计的核心原则——**避免冗余**。
> - 如果订单表直接存用户名,用户改名时要更新几万条订单记录
> - 分表后只改 users 表一条记录,订单表通过 user_id 引用即可
> - 这就是「范式」(Normal Form)的思想——详见 [[hhs/MySQL/05-表设计/21-表结构设计三范式]]
### 一句话理解 JOIN 的本质
> **JOIN 就是"按条件逐行匹配两张表的数据"**。有索引时能跳过大部分不匹配的行(快),没索引时只能一行一行比对(慢)——后续所有优化都是围绕这一点展开的。
## 驱动表与被驱动表
> [!TIP] 为什么先讲这个?
> 在深入 JOIN 的执行算法之前,必须先理解**驱动表**和**被驱动表**的概念——它们是所有 JOIN 优化决策的基础。
当一个 JOIN 语句被执行时,MySQL 会将两张表区分角色:
| 角色 | 职责 | 通俗理解 |
|------|------|---------|
| **驱动表(Driving Table)** | 先读取数据,提供"查找的关键词" | 手持"点名册"的人 |
| **被驱动表(Driven Table)** | 根据驱动表提供的每行数据去匹配 | 拿着点名册逐一核对的人 |
上面的例子中:
```sql
-- users 是驱动表,orders 是被驱动表
SELECT u.name, o.order_id
FROM users u -- 👈 驱动表:先读它
JOIN orders o ON u.id = o.user_id; -- 👆 被驱动表:每次拿 u.id 来匹配
```
### 谁当驱动表重要吗?
**非常重要。** 核心原则:**用小表驱动大表**——驱动表行数越少,被驱动表被扫描的次数就越少。
```mermaid
flowchart TD
S["小表:100 行"] -->|"驱动"| L["大表:100 万行
匹配 100 次 ✅"]
L2["大表:100 万行"] -->|"驱动"| S2["小表:100 行
匹配 100 万次 ❌"]
style L fill:#00D866,color:#fff
style S2 fill:#EE5A24,color:#fff
```
MySQL 的 Optimizer(优化器)会**尝试自动选择最优顺序**——它会统计每张表的行数,评估成本后决定谁做驱动表。但当统计信息不准确或数据分布特殊时,优化器可能选错,这时就需要手动干预。
> [!TIP] 黄金法则
> **驱动表可以全表扫描,但被驱动表必须走索引。**
> 任何 JOIN 优化的目标都是确保被驱动表的访问方式足够高效。我们会在后面的「驱动表选择」深入学习如何判断和优化。
## JOIN 类型速览
```sql
-- Inner JOIN:只返回两边都匹配的行(最常用)
SELECT * FROM orders o INNER JOIN users u ON o.user_id = u.id;
-- LEFT OUTER JOIN:左表全保留,右表不匹配则为 NULL
SELECT o.id, u.username
FROM orders o LEFT JOIN users u ON o.user_id = u.id
WHERE u.id IS NULL; -- 找孤儿订单(用户已被删除)
-- RIGHT OUTER JOIN:等价于交换左右表的 LEFT JOIN
-- 实践中几乎不用 RIGHT JOIN,改成 LEFT JOIN 更易读
-- CROSS JOIN:笛卡尔积(慎用!)
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 内部又根据索引是否为唯一键进一步细分。
| 算法缩写 | 全称 | 触发条件 | 关键特征 |
|---------|------|---------|---------|
| **INLJ** | Index Nested-Loop Join | 被驱动表 JOIN 列有索引 | 每行做一次索引查找,O(log m) |
| **BNLJ** | Block Nested-Loop Join | 被驱动表无可用索引 | 多行拼成块写入 Join Buffer,逐表扫描 |
> [!NOTE] 为什么没有"Simple NLJ"?
> Simple NLJ 是学术上的概念——外层每行内层逐行比较。MySQL **从未使用**这种实现:有索引就走 INLJ(直接 SEEK),没索引就直接上 BNLJ(分块缓冲)。下文不再讨论 Simple NLJ。
### 决策对照表
当你写了一个 JOIN 后,Optimizer 的选择逻辑如下:
```mermaid
flowchart TD
J["Optimizer 开始评估"] --> D{"被驱动表 JOIN 列
是否有索引?"}
D -->|有| INLJ["Index Nested-Loop Join"]
D -->|无| BNLJ["Block Nested-Loop Join"]
INLJ --> I{"该索引是否为
唯一索引 / 主键?"}
I -->|是| UNIJ["Unique NLJ
一次查找即返回"]
I -->|否| INDEXJ["Non-Unique NLJ
一次查找可能多行"]
BNLJ --> BUFF["读取 N 行 → Join Buffer
被驱动表全扫 1 次"]
style UNIJ fill:#00D866,color:#fff
style INDEXJ fill:#FF9F43,color:#000
style BUFF fill:#EE5A24,color:#fff
```
> [!TIP] 一眼判断好坏
> - 走 **Unique NLJ** (eq_ref) → ✅ 最优
> - 走 **INLJ** (ref) → ✅ 良好
> - 走 **BNLJ** (ALL) → ⚠️ 需要加索引
### 1. Block Nested-Loop Join(BNL,块嵌套循环)
当被驱动表**没有可用索引**时启用。MySQL 会将多行驱动表缓存到 Join Buffer 中,一次性与被驱动表比较。
```sql
-- 假设 user.tag 和 order.tag 上都没有索引
-- Optimizer 会选择 BNLJ
SELECT * FROM users u JOIN orders o ON u.tag = o.tag;
```
> [!QUESTION] 💡 思考
> 如果被驱动表有百万行数据,每读一行都要跟驱动表的所有行做比较——这比数据库慢的原因更接近"人脑"的逻辑。你觉得 MySQL 在这种极端情况下会怎么优化?
> **答**:不要逐行带进来比较!先把驱动表的 N 行拼成一大块放到内存缓冲区里,然后对被驱动表只扫一遍就完事。这就是 **Block** Nested-Loop Join 的核心思想。
```mermaid
flowchart LR
S["从驱动表读 N 行"] --> B["写入 Join Buffer
默认 256KB"]
B --> P{"Buffer 满了?"}
P -->|否| R["扫描被驱动表
与 Buffer 中每行比较"]
P -->|是| O["输出已匹配结果"]
O --> C["清空 Buffer"]
C --> R
R --> S
```
```sql
-- BNLJ 成本估算公式
-- 总操作数 ≈ ceil(驱动表行数 / 每Buffer能存行数) × 被驱动表行数
-- 每Buffer行数 = floor(join_buffer_size / 单行字节数)
```
> [!NOTE] Join Buffer 大小
> ```sql
> SHOW VARIABLES LIKE 'join_buffer_size';
> -- 默认 256KB,可调至最大 4MB
> -- 每个连接独立分配,退出时释放
> -- 注意:它不参与排序也不去重,纯粹做行数据缓存
> ```
> [!WARNING] BNLJ 是最后的手段
> 被驱动表无论多大都必须全扫一次。两张百万级大表无索引 JOIN 的成本可达 **百亿级行比较**。
### 2. Index Nested-Loop Join(INLJ,索引嵌套循环)
最理想的 JOIN 方式——被驱动表可以通过索引快速定位。
```sql
-- orders.user_id 上有索引 → Optimizer 选 INLJ
SELECT * FROM users u JOIN orders o ON u.id = o.user_id;
-- 提示优化器使用特定 JOIN 顺序(非强制)
SELECT * FROM users u USE INDEX FOR JOIN (idx_user_id)
JOIN orders o ON u.id = o.user_id;
```
```mermaid
sequenceDiagram
participant Driver as 驱动表(users)
participant IDX as 二级索引(idx_user_id)
participant Target as 被驱动表(orders)
loop 每行驱动数据
Driver->>IDX: Seek key=user_id
IDX-->>Driver: 找到匹配的 ROWID
Driver->>Target: Read by ROWID (回表取完整行)
Target-->>Driver: 返回完整行
end
```
> [!NOTE] INLJ 的成本拆解
> - 单次查找代价 = log₂(索引页数),通常 ≈ 3~4 次磁盘随机读
> - 若驱动表 1000 行、被驱动表 100 万行:**1000 × log₂(1M) ≈ 20,000 次索引查找**
> - 对比 BNLJ(Buffer 每批存 50 行):ceil(1000/50) × 1,000,000 = **20,000,000 次行比较**
> - 差距达 **三个数量级**
**INLJ 的两个子分类**:
| 子类 | 索引类型 | 每次查找返回 | Extra 标识 |
|------|---------|------------|-----------|
| Unique NLJ | 主键 / 唯一索引 | 恰好 0 或 1 行 | `Using index condition` |
| Non-Unique NLJ | 普通二级索引 | 可能 0…N 行 | `Using index condition` |
### 成本对比:加一个索引能带来什么?
假设场景:**驱动表 1,000 行,被驱动表 100 万行**
```mermaid
quadrantChart
title JOIN 算法性能分布
x-axis "低效 ← → 高效"
y-axis "高成本 ← → 低成本"
"Block NLJ (无索引)": [0.08, 0.1]
"Non-Unique NLJ": [0.25, 0.75]
"Unique NLJ (主键)": [0.95, 0.95]
```
```sql
-- 量化对比 (操作次数)
-- Unique NLJ (主键): 1000 × log2(1000000) ≈ 20,000 次查找
-- Non-Unique NLJ (二级): 1000 × log2(1M) × 10 ≈ 200,000 次查找 (假设每 key 平均 10 匹配)
-- Block NLJ (无索引): ceil(1000/50) × 1,000,000 = 20,000,000 次行比较
```
> [!SUMMARY] 💡 一句话总结
> **同一个 JOIN,有索引和无索引相差三个数量级——这就是 MySQL 优化的核心杠杆。**
## 驱动表选择
MySQL 在解析 SQL 时,**默认从左到右**确定驱动表——左边第一张表就是驱动表。但 Optimizer 会在评估成本后决定是否交换表的顺序(以最小化被驱动表的扫描行数)。
```sql
-- 经验法则:用小表驱动大表
-- Optimizer 通常会根据统计信息自动决定最优顺序
SELECT * FROM small_table t1 JOIN large_table t2 ON t1.id = t2.small_id;
```
> [!TIP] 黄金法则
> **驱动表可以是全表扫描,但被驱动表必须走索引。**
> 任何 JOIN 优化都要回到这个原则:检查 EXPLAIN 中被驱动表的 type 是否为 ref / eq_ref / range,如果是 ALL 就说明优化失败了。
### STRAIGHT_JOIN:强制驱动表顺序
当 Optimizer 因统计信息过期或数据分布不均而选错顺序时,可以用 `STRAIGHT_JOIN` 强制指定:
```sql
-- 告诉 MySQL:不要用你的优化器,按我写的顺序执行
SELECT * FROM large_table t1 STRAIGHT_JOIN small_table t2
ON t1.id = t2.small_id;
```
**适用场景**:
1. EXPLAIN 显示被驱动表走了全表扫描(type = ALL)
2. 大表作为驱动表且小表能走索引(`small_id` 有索引)时,比反过来的成本低得多
3. 临时排查问题——固定顺序便于复现和优化
> [!WARNING] 谨慎使用 STRAIGHT_JOIN
> 它绕过了 Optimizer 的成本模型,仅在确认优化器做出错误选择时才用。数据分布变化后可能反而变慢。优先选择修复统计信息(`ANALYZE TABLE`)或调整索引。
### 驱动表 vs 被驱动表的判断标准
| 角色 | 访问方式 | 理想 type | 可接受 type |
|------|---------|----------|-----------|
| **驱动表** | 全表扫描 / 索引扫描 | `ALL`, `index` | — |
| **被驱动表** | 每行索引查找 | `eq_ref`, `ref` | `range`, `fulltext` |
| **两者都差** | ⚠️ 性能灾难 | — | `ALL` × 2 |
## JOIN 优化 Checklist
### 流程概览
```mermaid
flowchart TD
A["写好 JOIN 查询"] --> B{"EXPLAIN 分析"}
B --> C{"被驱动表 type"}
C -->|"eq_ref / ref"<| OK["✅ 走索引,优秀"]
C -->|"range"<| WARN["⚠️ 范围扫描,可接受"]
C -->|"ALL / index"<| BAD["❌ 全表/全索引扫描"]
BAD --> D{"Checklist 逐项排查"}
D --> E["被驱动表的 JOIN 条件列有索引吗?"]
D --> F["JOIN 条件的数据类型一致吗?
VARCHAR vs INT 会导致索引失效"]
D --> G["有没有函数包裹 JOIN 列?"]
D --> H["能不能把 JOIN 拆成多次单表查询?"]
style OK fill:#00D866,color:#fff
style BAD fill:#EE5A24,color:#fff
```
### 实战:EXPLAIN 输出解读
看一个具体的例子:
```sql
EXPLAIN SELECT * FROM users u
JOIN orders o ON u.id = o.user_id
JOIN products p ON o.product_id = p.id;
```
期望的 EXPLAIN 输出:
```
+----+-------------+-------+--------+---------------+---------+---------+-------------------+------+-------+
| id | select_type | table | type | key | extra | rows | filtered | ref | |
+----+-------------+-------+--------+---------------+---------+---------+-------------------+------+-------+
| 1 | SIMPLE | u | ALL | NULL | | 1000 | 100.00 | NULL | |
| 1 | SIMPLE | o | ref | idx_user_id | | 50 | 100.00 | u.id | |
| 1 | SIMPLE | p | eq_ref | PRIMARY | | 1 | 100.00 | o.product_id | |
+----+-------------+-------+--------+---------------+---------+---------+-------------------+------+-------+
```
逐字段说明:
| 字段 | 含义 | 本例中的解读 |
|------|------|------------|
| **table** | 当前行涉及的表 | 三表 JOIN 有三行输出 |
| **type** | 访问类型(关键指标) | `ALL` → 驱动表全扫(正常);`ref` → 被驱动表走普通索引;`eq_ref` → 被驱动表走主键/唯一索引 |
| **key** | 实际使用的索引 | `idx_user_id` 和 `PRIMARY` 均命中 |
| **rows** | 预估扫描行数 | 1000 × 50 × 1 = 50000 次索引查找,可接受 |
| **filtered** | WHERE 过滤后的比例 | 100% 表示 WHERE 还没起作用(无额外过滤条件) |
| **Extra** | 额外信息 | 无 `Using filesort` / `Using temporary`,说明执行计划健康 |
> [!NOTE] Extra 中需要警惕的关键字
> - `Using filesort` → 需要额外排序,考虑加联合索引
> - `Using temporary` → 用了临时表,常出现在 DISTINCT / GROUP BY / UNION 中
> - `Using index condition` → 下推索引条件,部分过滤在存储引擎层完成,是好事
### Multi-Join 处理策略(3+ 表)
生产中最常见的是 3 表以上 JOIN。MySQL 采用 **链式驱动**——前一张表的输出结果作为下一张被驱动表的输入。
```mermaid
sequenceDiagram
participant T1 as 表A (驱动)
participant T2 as 表B (被驱动)
participant T3 as 表C (被驱动)
participant T4 as 表D (被驱动)
Note over T1: 第一步:全扫 A
得到 N1 行中间结果
loop N1 行
T1->>T2: B ON A.x = B.x (走索引)
T2-->>T1: M1 行匹配
end
Note over T1,T2: 第二步:得到 M1 行中间结果
loop M1 行
T1->>T3: C ON B.y = C.y (走索引)
T3-->>T1: M2 行匹配
end
Note over T1,T3: 第三步:得到 M2 行中间结果
loop M2 行
T1->>T4: D ON C.z = D.z (走索引)
T4-->>T1: 最终结果
end
```
> [!QUESTION] 💡 思考
> 如果有 4 张表按顺序 JOIN,每张表都有合适的索引——那中间结果的行数会逐级递减还是递增?为什么?
> **答**:取决于 JOIN 条件的选择性。WHERE 过滤和精确匹配会让行数逐层减少;而一对多关联则可能逐层膨胀。**关键是要确保每一层的被驱动表都走索引。**
优化思路升级:
1. **确保第 2 张及之后的每张表都有索引支撑 JOIN 条件**——只有第 1 张表可以全表扫描
2. **用小表驱动大表**:EXPLAIN 输出从上到下依次是被驱动表,上面的驱动下面的
3. **利用覆盖索引减少回表**:如果 SELECT 的列都在索引里,InnoDB 可以直接从索引树返回结果
4. **拆分解耦**:超复杂的多表 JOIN 可以考虑拆成两步——先拿 ID 集合再批量查详情
```sql
-- 覆盖索引示例:索引已包含所有需要的列,无需回表
CREATE INDEX idx_order_cover ON orders(user_id, product_id, amount);
-- 此时下面这个查询可以直接走覆盖索引扫描
SELECT user_id, product_id, amount FROM orders WHERE user_id = 42;
```
### USING vs ON
```sql
-- 推荐:用 USING 更简洁(要求两列同名)
SELECT * FROM users u JOIN orders o USING (user_id);
-- 等价的 ON 写法
SELECT * FROM users u JOIN orders o ON u.user_id = o.user_id;
-- 进阶技巧:USING 的结果集中 user_id 只出现一列,避免列名冲突
```
### 常见 JOIN 陷阱
| 陷阱 | 示例 | 修复 |
|------|------|------|
| **类型不匹配** | `u.code VARCHAR` JOIN `o.code INT` | 统一类型 |
| **函数包裹** | `ON YEAR(u.created) = YEAR(o.created)` | 改用范围比较 |
| **NULL 值** | `ON t1.col = t2.col` 中一列为 NULL 不匹配 | 检查业务逻辑 |
| **隐式转换** | `WHERE varchar_col = 123` | 显式字符串比较 |
| **缺少复合索引** | 多条件 JOIN 只用单列索引 | 创建覆盖联合索引 |
## 进阶优化技巧
### 1. Index Condition Pushdown(ICP,索引条件下推)
传统扫描方式:二级索引找到 ROWID → 回表取完整行 → WHERE 过滤。ICP 将部分 WHERE 条件的过滤下沉到存储引擎层,在二级索引上就直接完成,减少回表次数。
```sql
-- 假设 (category, status) 上有联合索引
SELECT * FROM products WHERE category = 'electronics' AND status = 'active';
-- ICP 开启时 (SHOW STATUS LIKE 'Last_query_cost...'):
-- 普通模式: 二级索引扫描所有 category='electronics' 的 ROWID → 全部回表 → 再过滤 status
-- ICP 模式: 二级索引上同时匹配 category + status → 只回表符合两条件的行
```
> [!TIP] 如何确认 ICP 生效?
> EXPLAIN 的 Extra 列出现 `Using index condition`——这表示 MySQL 5.6+ 的 ICP 已启用。
### 2. Loose Scan(松散扫描)
当聚合函数(COUNT/DISTINCT/MAX/MIN)配合有序索引使用时,Optimizer 可以跳过中间重复值,直接"跳跃"到每组第一个记录。
```sql
-- 假设 (category, subcategory) 上有联合索引
-- 传统 GROUP BY: 扫描所有 10 万行 → 分组 → 每组选第一条
SELECT category, COUNT(DISTINCT subcategory) FROM products GROUP BY category;
-- Loose Scan: 直接从二级索引树中抽取每组的唯一子键
-- 代价从全表扫描降到仅遍历索引的第一层 ≈ O(unique_categories)
```
> [!NOTE] 触发条件
> - 聚合函数必须是 `MIN()/MAX()` 或 `COUNT(DISTINCT)`
> - GROUP BY 的列必须紧跟联合索引的最左前缀
> - 查询结果中不能有其他需要额外处理的列
### 3. Semi Join(半连接)
将子查询转化为 JOIN 的一种优化策略。MySQL 会对 IN / EXISTS 子查询自动做半连接转换。
```sql
-- 原始子查询
SELECT * FROM orders WHERE user_id IN (SELECT id FROM users WHERE level = 'vip');
-- 等价于半连接:orders 只需要知道"是否存在匹配",不需要返回 users 的所有匹配行
-- MySQL 内部可能选择以下策略之一:
-- DuplicateWeedout: 先做 JOIN 再去重
-- FirstMatch: 被驱动表找到第一行匹配就停止
-- Loosescan: 对被驱动表做 Loose Scan
```
> [!WARNING] 注意去重开销
> 半连接需要额外的去重步骤(DuplicateWeedout)。如果驱动表本身 SELECT DISTINCT,可以考虑先对驱动表去重再做 JOIN。
### 4. Derived Table Materialization(派生表物化)
复杂子查询作为 FROM 子句时,MySQL 会将其物化为临时表。过度物化会带来额外开销。
```sql
-- 派生表会被物化为临时表
SELECT * FROM (SELECT user_id, SUM(amount) FROM orders GROUP BY user_id) t
JOIN users u ON t.user_id = u.id;
-- 如果 optimizer_switch='derived_merge=on'(MySQL 5.7+ 默认),
-- 优化器会尝试将子查询合并到外层查询中,避免物化开销
```
> [!TIP] 调优建议
> ```sql
> -- 查看当前优化器开关状态
> SHOW VARIABLES LIKE 'optimizer_switch';
> -- derived_merge=on 是推荐的默认配置,能让 MySQL 自动展开简单派生表
> ```
## 性能对比速查表
| **算法** | **最优场景** | **最坏场景** | **典型代价公式** |
|------|---------|---------|-------------|
| **Unique NLJ** | 被驱动表是主键 / 唯一索引 | 驱动表大量行无匹配(返回 NULL) | 驱动表行数 × log(被驱动表) |
| **Non-Unique NLJ** | 被驱动表有普通二级索引 | 索引选择性差,大量重复值 | 驱动表行数 × log(被驱动表) × 平均匹配数 |
| **Block NLJ** | 被驱动表无索引 + 驱动表较小 | 大表 × 大表无索引 | ceil(驱动表行数 / Buffer行数) × 被驱动表行数 |
> [!WARNING] 数量级示意
> 假设驱动表 1000 行、被驱动表 100 万行:
> - Unique NLJ:≈ 1000 × 20 = **2 万次**索引查找
> - Block NLJ(Buffer 存 50 行):≈ ceil(1000/50) × 1,000,000 = **2000 万次**行比较
> - 差距达 **三个数量级**,这就是为什么被驱动表索引如此重要。
## 关联笔记
- [[hhs/MySQL/02-SQL核心/10-子查询与派生表]] — EXISTS / IN 子查询与 JOIN 的替代关系
- [[hhs/MySQL/03-索引与查询优化/12-B+Tree 索引原理]] — JOIN 如何利用二级索引加速
- [[hhs/MySQL/03-索引与查询优化/15-EXPLAIN 完全指南]] — 通过 type 字段识别 JOIN 质量问题