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

12 KiB
Raw Blame History

tags, create time
tags create time
MySQL
JOIN
Nested Loop
索引优化
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;

适用场景:

  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

流程概览

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。优化思路升级:

  1. 确保第 2 张及之后的每张表都有索引支撑 JOIN 条件——只有第 1 张表可以全表扫描
  2. 用小表驱动大表:EXPLAIN 输出从上到下依次是被驱动表,上面的驱动下面的
  3. 利用覆盖索引减少回表:如果 SELECT 的列都在索引里,InnoDB 可以直接从索引树返回结果
  4. 拆分解耦:超复杂的多表 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 万次行比较
  • 差距达 三个数量级,这就是为什么被驱动表索引如此重要。

关联笔记