--- 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 列
是否有索引?"} 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; ``` ``` 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 条件的数据类型一致吗?
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 质量问题