[小技巧49]深入 MySQL JOIN 算法:从执行计划到性能优化

一、MySQL 支持的 JOIN 算法概览

MySQL 并不直接让用户指定使用哪种 JOIN 算法,而是由优化器(Optimizer) 根据表结构、索引、数据量、统计信息等自动选择最高效的执行策略。目前主要支持以下几种算法:

1. Simple Nested-Loop Join(SNLJ)

  • 原理:对驱动表(外层表)的每一行,遍历被驱动表(内层表)的所有行进行匹配。
  • 缺点:I/O 成本极高,尤其当内层表无索引时,复杂度为 O(N×M)。
  • 实际使用:MySQL 几乎不会在生产环境中使用纯 SNLJ,通常会升级为 Block 或 Index 变体。

2. Block Nested-Loop Join(BNLJ)

  • 原理 :引入 Join Buffer,一次性读取驱动表的多行到内存缓冲区,再用整个缓冲区去扫描内层表,减少内层表的重复扫描次数。
  • 触发条件 :被驱动表没有可用索引用于连接条件。
  • 配置参数join_buffer_size 控制缓冲区大小(默认 256KB)。
  • 注意:BNLJ 仍需全表扫描内层表,大数据量下性能较差。

3. Index Nested-Loop Join(INLJ)

  • 原理 :利用被驱动表上的索引,对驱动表的每一行,通过索引快速查找匹配行。
  • 触发条件 :被驱动表的连接字段上有有效索引(如 B+Tree 索引)。
  • 优势:避免全表扫描,I/O 大幅降低,是 OLTP 场景中最常见的高效 JOIN 方式。
  • 最佳实践:确保小表作驱动表,大表作被驱动表且有索引。

4. Hash Join(MySQL 8.0 新增)

  • 引入版本:MySQL 8.0.18正式支持。
  • 原理
    1. 选择较小的表(或结果集)构建 Hash 表(内存中);
    2. 遍历另一张表,对每行计算哈希值并在 Hash 表中查找匹配。
  • 优势 :无需索引即可高效处理等值连接(Equi-Join),特别适合大表 JOIN 且无索引的场景。
  • 限制
    • 仅支持 等值连接 (如 a.id = b.id),不支持 <, > 等非等值条件;
    • 若内存不足,会退化为磁盘 Hash(性能下降);
    • EXPLAIN 中显示为 HashJoin

版本差异说明

MySQL 5.7 及更早版本不支持 Hash Join,所有无索引的 JOIN 都只能使用 BNLJ,这也是 8.0 在复杂分析查询上性能显著提升的原因之一。

二、算法选择流程图

以下为 MySQL 优化器选择 JOIN 算法的逻辑流程(可用于绘制流程图):

复制代码
开始
│
├─ 是否为等值连接(Equi-Join)?
│   ├─ 否 → 尝试 INLJ(若有索引)或 BNLJ(若无索引)
│   └─ 是 → 
│        ├─ 被驱动表是否有可用索引?
│        │   ├─ 是 → 使用 Index Nested-Loop Join (INLJ)
│        │   └─ 否 → 
│        │        ├─ MySQL ≥ 8.0.18? 
│        │        │   ├─ 是 → 使用 Hash Join(若内存足够)
│        │        │   └─ 否 → 使用 Block Nested-Loop Join (BNLJ)
│        │        └─ (注:即使有 Hash Join,优化器仍可能因成本估算选择 BNLJ)
│
结束

三、JOIN 算法对比表

算法 是否需要索引 支持连接类型 时间复杂度(理想) 适用场景 MySQL 版本支持
Simple NLJ 所有 O(N×M) 极小数据集(几乎不用) 全版本(但极少使用)
Block NLJ (BNLJ) 所有 O(N×M / buffer_size) 无索引的小/中表 JOIN 全版本
Index NLJ (INLJ) (被驱动表) 所有 O(N×log M) 有索引的 OLTP 查询 全版本
Hash Join 仅等值连接 O(N + M) 大表等值 JOIN(无索引) 8.0.18+

注:N、M 分别为驱动表和被驱动表的行数。

四、性能调优建议

  1. 优先为 JOIN 字段添加索引:可强制使用 INLJ,避免 BNLJ 或 Hash 的不确定性。
  2. 控制 join_buffer_size:若必须使用 BNLJ,适当增大缓冲区可减少磁盘 I/O。
  3. 小表驱动大表:无论哪种算法,驱动表越小,循环次数越少。
  4. 升级到 MySQL 8.0+:利用 Hash Join 提升复杂分析查询性能。
  5. 使用 EXPLAIN FORMAT=tree:MySQL 8.0 支持树形执行计划,可清晰看到是否使用 Hash Join。

示例:

sql 复制代码
EXPLAIN FORMAT=tree
SELECT * FROM orders o JOIN customers c ON o.cust_id = c.id;

输出中若含 -> Hash Join ...,即表示使用了 Hash Join。

五、驱动表和被驱动表

1. 基本定义

概念 说明
驱动表(Driving Table) 也叫外层表(Outer Table) ,是 JOIN 执行时先被扫描的表。它的每一行(或一批行)会用来去"驱动"对另一张表的查找。
被驱动表(Driven Table) 也叫内层表(Inner Table) ,是根据驱动表的值去查找匹配行的表。它的访问方式(全表扫描、索引查找、哈希查找)决定了 JOIN 效率。

💡 简单记忆:
驱动表 = 主动出击的一方
被驱动表 = 被查询的一方

2. 如何确定哪张是驱动表?

方法 1:看 EXPLAIN 输出(最可靠!)

在 MySQL 中,EXPLAIN 结果中靠前的表通常是驱动表(但要注意嵌套循环的方向)。

更准确的方法是看 Extra 或使用 EXPLAIN FORMAT=tree(MySQL 8.0+)

示例 1:普通 JOIN
sql 复制代码
EXPLAIN
SELECT * FROM orders o JOIN customers c ON o.cust_id = c.id;

假设输出:

复制代码
+----+-------------+-------+------------+------+---------------+---------+---------+------------------+------+----------+-------+
| id | select_type | table | type       | key  | key_len       | ref     | rows    | Extra            |
+----+-------------+-------+------------+------+---------------+---------+---------+------------------+------+----------+-------+
|  1 | SIMPLE      | c     | ALL        | NULL | NULL          | NULL    |   10000 |                  |
|  1 | SIMPLE      | o     | ref        | idx_cust_id | 4       | test.c.id |      5 | Using index      |
+----+-------------+-------+------------+------+---------------+---------+---------+------------------+------+----------+-------+

🔍 分析

  • customers (c) 先出现,且 type=ALL(全表扫描)→ 它是驱动表
  • orders (o) 后出现,用 ref 索引查找 → 它是被驱动表

所以:小表 customers 驱动大表 orders 的索引查找,这是高效做法!

方法 2:使用 EXPLAIN FORMAT=tree(MySQL 8.0 推荐)

sql 复制代码
EXPLAIN FORMAT=tree
SELECT * FROM orders o JOIN customers c ON o.cust_id = c.id;

输出可能类似:

复制代码
-> Nested loop inner join
    -> Table scan on c  -- 驱动表
    -> Index lookup on o using idx_cust_id (cust_id = c.id)  -- 被驱动表

这里明确显示:c 是驱动表,o 是被驱动表

方法 3:经验法则

  • 小表通常作驱动表:优化器倾向于让结果集小的表驱动大的。
  • 有 WHERE 条件过滤的表更可能成为驱动表:因为中间结果集更小。
  • LEFT JOIN 中,左表一定是驱动表(因为语义要求保留左表所有行)。
示例 2:LEFT JOIN
sql 复制代码
SELECT * FROM customers c LEFT JOIN orders o ON c.id = o.cust_id;
  • customers 一定是驱动表 (即使它很大),因为 LEFT JOIN 语义要求遍历 customers 所有行。

3. 经典例子对比

场景设定:

  • customers 表:1 万行(小表)
  • orders 表:100 万行(大表)
  • orders.cust_id 上有索引

好情况:小表驱动大表(高效 INLJ)

sql 复制代码
-- 优化器自动选择 customers 作驱动表
SELECT * FROM customers c JOIN orders o ON c.id = o.cust_id;
  • 驱动表:customers(1 万行)
  • 被驱动表:orders(用索引查找,每次 O(log N))
  • 总成本 ≈ 1 万 × log(100 万) ≈ 1 万 × 20 = 20 万次操作

坏情况:大表驱动小表(低效)

sql 复制代码
-- 假设因统计信息错误,优化器选 orders 作驱动表
SELECT * FROM orders o JOIN customers c ON o.cust_id = c.id;
  • 驱动表:orders(100 万行)
  • 被驱动表:customers(即使有主键索引,也要查 100 万次)
  • 总成本 ≈ 100 万 × log(1 万) ≈ 100 万 × 13 = 1300 万次操作(慢 65 倍!)

🔧 解决方案:用 STRAIGHT_JOIN 强制顺序(谨慎使用):

sql 复制代码
SELECT * FROM customers c STRAIGHT_JOIN orders o ON c.id = o.cust_id;

STRAIGHT_JOIN 是 MySQL 中一个强制控制表连接顺序的语法关键字,它会覆盖优化器的自动决策。

严格按照 FROM子句中表的书写顺序来执行,JOIN左边的表作为驱动表,右边的表作为被驱动表。

4. 常见误区澄清

误区 正确理解
"FROM 后面的表就是驱动表" 不一定!优化器会重排 JOIN 顺序(除非用 STRAIGHT_JOIN
"LEFT JOIN 的右表可以是驱动表" 不可能!LEFT JOIN 必须左表驱动
"驱动表必须加索引" 驱动表不需要索引 (它是被全扫或按条件过滤的),被驱动表才需要索引

5. 总结:

驱动表 = 先扫描的表 = EXPLAIN 中靠前的表(在 Nested Loop 中)
被驱动表 = 被查找的表 = 是否有索引决定性能的关键
优化核心:让小表/过滤后小的结果集作驱动表,大表作被驱动表且有索引

六、实际应用

1. 第一步:创建表结构

sql 复制代码
-- 创建 customers 表(小表,主键为 id)
CREATE TABLE customers (
    id INT PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    email VARCHAR(150)
);

-- 创建 orders 表(大表,cust_id 为外键,有索引)
CREATE TABLE orders (
    order_id INT PRIMARY KEY,
    cust_id INT NOT NULL,
    amount DECIMAL(10,2),
    order_date DATE,
    -- 建议:为 JOIN 字段添加索引(用于触发 INLJ)
    INDEX idx_cust_id (cust_id)
);

💡 说明:

  • customers 模拟 小表(比如 100 行)
  • orders 模拟 大表(比如 10,000 行)
  • orders.cust_id 上有索引 → 可触发 Index Nested-Loop Join (INLJ)

2. 第二步:插入模拟数据

1. 插入 100 个客户
sql 复制代码
-- 插入 100 个客户(id 从 1 到 100)
INSERT INTO customers (id, name, email)
WITH RECURSIVE nums AS (
    SELECT 1 AS n
    UNION ALL
    SELECT n + 1 FROM nums WHERE n < 100
)
SELECT 
    n AS id,
    CONCAT('Customer_', LPAD(n, 3, '0')) AS name,
    CONCAT('customer', n, '@example.com') AS email
FROM nums;
2. 插入 10,000 条订单(随机分配给 100 个客户)
sql 复制代码
-- 插入 10,000 条订单,cust_id 随机在 1~100 之间
INSERT INTO orders (order_id, cust_id, amount, order_date)
WITH RECURSIVE nums AS (
    SELECT 1 AS n
    UNION ALL
    SELECT n + 1 FROM nums WHERE n < 10000
)
SELECT
    n AS order_id,
    FLOOR(1 + RAND() * 100) AS cust_id,  -- 随机客户 ID
    ROUND(10 + RAND() * 990, 2) AS amount, -- 金额 10~1000
    DATE_SUB(CURDATE(), INTERVAL FLOOR(RAND() * 365) DAY) AS order_date
FROM nums;

3. 第三步:验证数据

sql 复制代码
-- 查看表行数
SELECT COUNT(*) FROM customers;  -- 应为 100
SELECT COUNT(*) FROM orders;     -- 应为 10000

-- 查看索引
SHOW INDEX FROM orders;
-- 应看到 idx_cust_id 在 cust_id 上

4. 第四步:体验不同 JOIN 行为

1. 正常 JOIN(应使用 INLJ)

sql 复制代码
mysql> EXPLAIN FORMAT=tree SELECT c.name, o.amount FROM customers c JOIN orders o ON c.id = o.cust_id WHERE c.id <= 10\G
*************************** 1. row ***************************
EXPLAIN: -> Nested loop inner join  (cost=289.67 rows=999)
    -> Filter: (c.id <= 10)  (cost=2.26 rows=10)
        -> Index range scan on c using PRIMARY over (id <= 10)  (cost=2.26 rows=10)
    -> Index lookup on o using idx_cust_id (cust_id=c.id)  (cost=19.75 rows=100)

1 row in set (0.00 sec)

符合预期:Index lookup on o using idx_cust_id

2. 强制无索引 JOIN(先删索引,体验 BNLJ / Hash Join)

sql 复制代码
-- 删除索引(谨慎操作!仅用于测试)
DROP INDEX idx_cust_id ON orders;

-- 再次执行 EXPLAIN
mysql> EXPLAIN FORMAT=tree SELECT c.name, o.amount FROM customers c JOIN orders o ON c.id = o.cust_id WHERE c.id <= 10\G
*************************** 1. row ***************************
EXPLAIN: -> Nested loop inner join  (cost=4502.20 rows=9991)
    -> Filter: (o.cust_id <= 10)  (cost=1005.35 rows=9991) -- 先扫描 orders 表(驱动表)
        -> Table scan on o  (cost=1005.35 rows=9991) -- 全表扫描 orders(10,000 行)
    -> Single-row index lookup on c using PRIMARY (id=o.cust_id)  (cost=0.25 rows=1) -- 用主键查 customers

1 row in set (0.01 sec)

虽然JOIN 条件中写的是FROM customers c JOIN orders o ON c.id = o.cust_id 但MySQL

并没有用 customers 作驱动表,而是反过来用 orders 作驱动表,customers 反而成了被驱动表!

驱动表:orders(全表扫描)

被驱动表:customers(通过主键 PRIMARY 索引查找)

为什么会这样?为什么不用 customers 驱动?

因为优化器做了成本估算

"与其先读 10 行 customers(满足 c.id <= 10),再去 orders 里找匹配(但 orders.cust_id 没索引,每次都要全表扫 10,000 行)......

不如直接全表扫 orders(10,000 行),对每行用 cust_idcustomers 主键索引里查一下(每次 O(1)),总成本更低。"

成本对比(估算):
方案 操作 成本估算
方案A(你期望的) customers 驱动 扫描 10 行 customers→ 对每行全表扫 orders(10,000 行) 10 × 10,000 = 100,000 次读
方案B(实际选择的) orders 驱动 扫描 10,000 行 orders→ 对每行查 customers 主键(索引) 10,000 × 1 = 10,000 次读

所以优化器选了 方案B ------ 虽然看起来"反直觉",但在 orders.cust_id 无索引 的前提下,这是更优解

这说明了什么?
  1. JOIN 算法的选择高度依赖索引是否存在
  2. 驱动表不一定是 FROM 左边的表,优化器会重排;
  3. 即使小表在左,若大表字段无索引,优化器可能反向驱动
  4. 看到的 Nested loop 并非 BNLJ,而是"反向 INLJ" ------ 因为被驱动表(customers)有主键索引。

⚠️ 注意:这不是 Hash Join,因为虽然等值连接,但优化器认为 Nested Loop + 主键查找更便宜。

总结 & 行动建议
问题 结论
为什么驱动表是 orders 因为 orders.cust_id 无索引,反向驱动 + 主键查找更高效
这是哪种 JOIN 算法? Index Nested-Loop Join(INLJ) ,但方向是 orders → customers
如何让 customers 驱动? orders(cust_id) 加索引,或用 STRAIGHT_JOIN(不推荐无索引时用)
最佳实践是什么? 始终为 JOIN 字段(尤其是大表的外键)添加索引

3. 测试 STRAIGHT_JOIN

sql 复制代码
mysql> EXPLAIN FORMAT=tree SELECT c.name, o.amount FROM customers c STRAIGHT_JOIN orders o ON c.id =
o.cust_id\G
*************************** 1. row ***************************
EXPLAIN: -> Inner hash join (o.cust_id = c.id)  (cost=99927.47 rows=99910)
    -> Table scan on o  (cost=1.07 rows=9991)
    -> Hash
        -> Table scan on c  (cost=10.25 rows=100)

1 row in set (0.00 sec)
正确理解:Hash Join 的执行顺序 ≠ 驱动/被驱动关系

** 在 Nested-Loop Join(NLJ) 中:**

  • 先扫描的表 = 驱动表(外层表)
  • 后查找的表 = 被驱动表(内层表)
  • 顺序 = 执行逻辑

这时,"先扫谁"确实代表驱动关系。

Hash Join 中:

执行分为两个独立阶段

阶段 1️⃣:Build(构建哈希表)

  • 选择较小的表(或优化器认为更合适的表)加载到内存,建立哈希表;

  • 在你的输出中:

    customers 被用来构建哈希表

    复制代码
    -> Hash
        -> Table scan on c   ← 这是 Build 阶段!

阶段 2️⃣:Probe(探测匹配)

  • 全表扫描另一张表,对每行去哈希表中查找匹配;

  • 在输出中:

    复制代码
    -> Table scan on o   ← 这是 Probe 阶段!

关键点:

EXPLAIN FORMAT=tree 中,Probe 表写在前面,Build 表写在 Hash 子节点里 ------ 这是 MySQL 的显示习惯,不代表执行先后或驱动关系

实际上,Build 阶段必须先完成,才能开始 Probe。

所以真实执行顺序是:

  1. 先全扫 customers(c),构建哈希表 (基于 c.id);
  2. 再全扫 orders(o),用 o.cust_id 去哈希表里找匹配
谁是"驱动表"?

传统术语中 ,"驱动表"源于 Nested-Loop 模型。

但在 Hash Join 语境下,我们更准确的说法是:

概念 对应表
Build Table(构建表) customers(小表)
Probe Table(探测表) orders(大表)

STRAIGHT_JOIN 的语义是

"保持 FROM 中的连接顺序不变",即逻辑上是 customers JOIN orders,不能被重排为 orders JOIN customers。

MySQL 在实现 Hash Join 时,尊重了这个逻辑顺序,并据此决定:

  • 连接条件是 c.id = o.cust_id
  • 优化器自由选择哪边做 Build(通常选小的)

所以:

  • 从 SQL 语义和 STRAIGHT_JOIN 约束看customers 是左表,逻辑上的"驱动方";
  • 从 Hash Join 物理执行看customers 是 Build 表,orders 是 Probe 表;
  • 绝不是 orders 驱动了 customers
总结
误解 正确认知
"EXPLAIN 里先出现的表就是驱动表" ❌ 仅在 Nested-Loop 中成立;Hash Join 中先出现的是 Probe 表
"Table scan on o 在前 → o 是驱动表" ❌ 实际 c 是 Build 表(相当于驱动角色)
"STRAIGHT_JOIN 失效了" ❌ 它成功阻止了表顺序重排,只是算法换成了 Hash Join
"Hash Join 不受 STRAIGHT_JOIN 影响" ⚠️ 部分正确:它不影响算法选择,但连接方向(等式左右)仍由书写顺序决定

在 Hash Join 的 EXPLAIN FORMAT=tree 中,Hash 子节点里的表才是 Build 表(相当于传统驱动表),外面的表是 Probe 表。

七、面试题

面试题 1:

"MySQL 中 JOIN 为什么有时候很慢?可能有哪些原因?"

参考答案

  • 被驱动表缺少索引,导致使用 BNLJ(5.7)或低效 Hash Join(8.0);
  • 驱动表过大,导致 Join Buffer 溢出或 Hash 表过大;
  • 数据分布倾斜,导致 Hash 冲突严重;
  • 统计信息过期,优化器误判成本,选择了次优算法;
  • 使用了非等值连接,无法使用 Hash Join。

面试题 2:

"MySQL 8.0 的 Hash Join 和传统 BNLJ 有什么区别?什么情况下 Hash Join 更优?"

参考答案

  • 区别:Hash Join 基于哈希表实现,时间复杂度接近线性 O(N+M),而 BNLJ 是 O(N×M / buffer);Hash Join 不依赖索引,但仅支持等值连接。
  • 更优场景:两表都较大、无可用索引、且为等值连接时,Hash Join 显著优于 BNLJ。例如数据仓库中的事实表与维度表关联。

面试题 3:

"在 MySQL 中,当执行一个等值连接(比如 a.id = b.id),并且连接字段上存在索引时,MySQL 通常会使用哪种 JOIN 算法?为什么?在什么情况下即使有索引也可能不使用该算法?"

参考答案

第一层:直接回答核心算法

在大多数情况下,MySQL 会使用 Index Nested-Loop Join(INLJ)

因为被驱动表(inner table)的连接字段上有索引,优化器可以对驱动表的每一行,通过索引快速查找匹配行,避免全表扫描,从而大幅降低 I/O 和 CPU 开销。

第二层:解释执行逻辑

具体来说:

  • 优化器会选择结果集更小的表作为驱动表
  • 对驱动表的每一行,取出连接键的值;
  • 利用被驱动表上的索引进行点查(point lookup),获取匹配行;
  • 整个过程的时间复杂度约为 O(N × log M),远优于无索引时的 O(N×M)。

第三层:说明为何不用 Hash Join(即使 MySQL 8.0+)

虽然 MySQL 8.0 引入了 Hash Join,但在被驱动表有高效索引的场景下,优化器通常仍优先选择 INLJ,因为:

  • INLJ 内存开销极小;
  • 若驱动表很小(如 OLTP 场景),INLJ 的总成本更低;
  • Hash Join 需要将驱动表加载到内存构建哈希表,可能带来不必要的内存压力。

第四层:指出例外情况

即使连接字段有索引,以下情况也可能导致不使用 INLJ:

  1. 索引选择性很差(如布尔字段),回表成本高,优化器认为全表扫描更快;
  2. 驱动表非常大,总索引查找次数过多,Hash Join 反而更优;
  3. 索引无法用于连接条件 (如函数包裹:ON YEAR(a.date) = b.year);
  4. 复合索引未遵循最左前缀原则,导致索引不能用于 JOIN。

第五层:如何验证?

可通过 EXPLAIN FORMAT=tree 查看执行计划:

  • 若出现 Index lookup on ... using <index_name>,说明使用了 INLJ;
  • 若出现 Inner hash join,说明使用了 Hash Join;
  • 传统 EXPLAIN 中,被驱动表的 typerefeq_ref,也暗示 INLJ。
相关推荐
秋名山大前端2 小时前
前端大规模 3D 轨迹数据可视化系统的性能优化实践
前端·3d·性能优化
白云千载尽2 小时前
cosmos系列模型的推理使用——cosmos transfer2.5
算法·大模型·世界模型·自动驾驶仿真·navsim
2401_891450462 小时前
基于C++的游戏引擎开发
开发语言·c++·算法
@Aurora.2 小时前
优选算法【专题六_模拟】
算法
MicroTech20252 小时前
微算法科技(NASDAQ :MLGO)探索量子Hadamard门技术,增强量子图像处理效率
图像处理·科技·算法
码农多耕地呗2 小时前
mysql之深入理解b+树原理
数据库·b树·mysql
进击的小头2 小时前
移动平均滤波器:从原理到DSP ADC采样实战(C语言实现)
c语言·开发语言·算法
历程里程碑2 小时前
Linux 6 权限管理全解析
linux·运维·服务器·c语言·数据结构·笔记·算法
angushine2 小时前
鲲鹏ARM服务MySQL镜像方式部署主从集群
android·mysql·adb