12 KiB
tags, create time
| tags | create time | ||||
|---|---|---|---|---|---|
|
2026-05-16 00:00 |
JOIN 原理与优化
概述
JOIN 是关系型数据库的核心能力,也是性能问题的主要来源。理解 MySQL 的 JOIN 执行算法才能写出高效的关联查询。
JOIN 类型速览
-- 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 的选择逻辑如下:
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 中,一次性与被驱动表比较。
-- 假设 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 大小
SHOW VARIABLES LIKE 'join_buffer_size'; -- 默认 256KB,可调至最大 4MB -- 每个连接独立分配,退出时释放 -- 注意:它不参与排序也不去重,纯粹做行数据缓存
BNLJ 的致命弱点:被驱动表无论多大都必须全扫一次。如果两张都是百万级大表,代价呈爆炸式增长。
2. Index Nested-Loop Join(INLJ,索引嵌套循环)
最理想的 JOIN 方式——被驱动表可以通过索引快速定位。
-- 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;
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 会在评估成本后决定是否交换表的顺序(以最小化被驱动表的扫描行数)。
-- 经验法则:用小表驱动大表
-- 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 强制指定:
-- 告诉 MySQL:不要用你的优化器,按我写的顺序执行
SELECT * FROM large_table t1 STRAIGHT_JOIN small_table t2
ON t1.id = t2.small_id;
适用场景:
- EXPLAIN 显示被驱动表走了全表扫描(type = ALL)
- 大表作为驱动表且小表能走索引(
small_id有索引)时,比反过来的成本低得多 - 临时排查问题——固定顺序便于复现和优化
[!WARNING] 谨慎使用 STRAIGHT_JOIN 它绕过了 Optimizer 的成本模型,仅在确认优化器做出错误选择时才用。数据分布变化后可能反而变慢。优先选择修复统计信息(
ANALYZE TABLE)或调整索引。
驱动表 vs 被驱动表的判断标准
| 角色 | 访问方式 | 理想 type | 可接受 type |
|---|---|---|---|
| 驱动表 | 全表扫描 / 索引扫描 | ALL, index |
— |
| 被驱动表 | 每行索引查找 | eq_ref, ref |
range, fulltext |
| 两者都差 | ⚠️ 性能灾难 | — | ALL × 2 |
JOIN 优化 Checklist
流程概览
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 输出解读
看一个具体的例子:
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。优化思路升级:
- 确保第 2 张及之后的每张表都有索引支撑 JOIN 条件——只有第 1 张表可以全表扫描
- 用小表驱动大表:EXPLAIN 输出从上到下依次是被驱动表,上面的驱动下面的
- 利用覆盖索引减少回表:如果 SELECT 的列都在索引里,InnoDB 可以直接从索引树返回结果
- 拆分解耦:超复杂的多表 JOIN 可以考虑拆成两步——先拿 ID 集合再批量查详情
-- 覆盖索引示例:索引已包含所有需要的列,无需回表
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
-- 推荐:用 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 质量问题