296 lines
12 KiB
Markdown
296 lines
12 KiB
Markdown
---
|
||
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 质量问题
|