This repository has been archived on 2026-05-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
all-in-kingsoft/hhs/MySQL/13-JOIN 原理与优化.md
T
2026-05-17 00:06:11 +08:00

296 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [MySQL, JOIN, Nested Loop, 索引优化]
create time: 2026-05-16 00:00
---
# JOIN 原理与优化
## 概述
JOIN 是关系型数据库的核心能力,也是性能问题的主要来源。理解 MySQL 的 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| 行
```
## 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 列<br/>是否有索引?"}
D -->|有| INLJ["Index Nested-Loop Join"]
D -->|无| BNLJ["Block Nested-Loop Join"]
INLJ --> I{"该索引是否为<br/>唯一索引 / 主键?"}
I -->|是| UNIJ["Unique NLJ<br/>一次查找即返回"]
I -->|否| INDEXJ["Non-Unique NLJ<br/>一次查找可能多行"]
BNLJ --> BUFF["读取 N 行 → Join Buffer<br/>被驱动表全扫 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;
```
```
Step 1: 从 users 表读 N 行 → Join Buffer (默认 256KB)
Step 2: 扫描 orders 表,对每一行与 Buffer 中的所有行比较
Step 3: Buffer 满了就输出匹配结果,清空再加载
Step 4: 重复直到读完 users 表
总成本 = ceil(rows_users / buffer_rows) × rows_orders
```
> [!NOTE] Join Buffer 大小
> ```sql
> SHOW VARIABLES LIKE 'join_buffer_size';
> -- 默认 256KB,可调至最大 4MB
> -- 每个连接独立分配,退出时释放
> -- 注意:它不参与排序也不去重,纯粹做行数据缓存
> ```
**BNLJ 的致命弱点**:被驱动表无论多大都必须全扫一次。如果两张都是百万级大表,代价呈爆炸式增长。
### 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) ≈ 1000 × 20 = 20000 次索引查找**
> - 对比 BNLJ:若走 BNLJ 则需 **1000 / buffer_rows × 1M** 次行比较 —— 差距巨大
**INLJ 的两个子分类**:
| 子类 | 索引类型 | 每次查找返回 | Extra 标识 |
|------|---------|------------|-----------|
| Unique NLJ | 主键 / 唯一索引 | 恰好 0 或 1 行 | `Using index condition` |
| Non-Unique NLJ | 普通二级索引 | 可能 0…N 行 | `Using index condition` |
## 驱动表选择
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 条件的数据类型一致吗?<br/>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。优化思路升级:
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 只用单列索引 | 创建覆盖联合索引 |
## 性能对比速查表
| 算法 | 最优场景 | 最坏场景 | 典型代价公式 |
|------|---------|---------|-------------|
| **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/14-子查询与派生表]] — EXISTS / IN 子查询与 JOIN 的替代关系
- [[hhs/MySQL/16-B+Tree 索引原理]] — JOIN 如何利用二级索引加速
- [[hhs/MySQL/19-EXPLAIN 完全指南]] — 通过 type 字段识别 JOIN 质量问题