MySQL连接原理深度解析:从算法到源码的全链路优化
第1章 连接操作全景图
在进入源码与优化策略之前,我们需要先从 连接(Join)的数学本质 和 SQL语义映射 入手,构建整体认知框架。只有理解了 为什么数据库需要JOIN 以及 不同JOIN语义的差异,才能更好地把握后续的执行引擎和性能优化。
1.1 连接的数学基础:从笛卡尔积说起
数据库的连接操作,本质上是 集合论中的笛卡尔积(Cartesian Product) 与 条件过滤 的结合。
笛卡尔积定义:
给定两个集合 A、B,笛卡尔积记为A × B
,其结果是所有可能的(a, b)
组合。
在数据库中,如果有表Orders
与Customers
,它们的笛卡尔积等价于:SELECT * FROM Orders, Customers;
结果会生成
|Orders| * |Customers|
行数据。
显然,笛卡尔积的数据量呈乘法级膨胀,因此在实际SQL中几乎不会直接使用,而是通过 连接条件(Join Condition) 约束结果。
1.2 连接的分类与SQL语义映射
MySQL遵循SQL:1992标准,支持以下几类连接:
INNER JOIN(内连接)
返回两表中满足连接条件的记录。SELECT * FROM Orders o INNER JOIN Customers c ON o.customer_id = c.id;
LEFT JOIN(左外连接)
保留左表的所有记录,若右表无匹配,则以NULL填充。RIGHT JOIN(右外连接)
保留右表的所有记录,若左表无匹配,则以NULL填充。FULL JOIN(全外连接) (MySQL不直接支持,但可通过UNION模拟)
1.3 Venn图文字描述法
为了帮助读者在没有图形的情况下理解连接语义,我们用文字版的Venn图进行描述:
INNER JOIN:取交集 → 两个集合重叠部分
LEFT JOIN:取左集合全部 + 右集合重叠部分
RIGHT JOIN:取右集合全部 + 左集合重叠部分
FULL JOIN:取并集 → 两个集合所有部分
1.4 MySQL连接处理的流程全景
一次连接查询的大致执行路径如下(文字版时序图):
[SQL Parser] → 解析JOIN语法树↓
[Optimizer] → 选择连接顺序 & 算法 (Nested Loop / BKA / Hash Join)↓
[Executor] → 为每个JOIN_TAB分配JOIN_BUFFER↓
[Storage Engine] → 逐行/批量读取数据行↓
[Join Condition Eval] → 判断是否满足ON条件↓
[Result Set] → 输出符合条件的行
这里的关键在于 优化器的决策:选择什么样的连接顺序、使用什么JOIN算法;以及 执行器的内存管理:如何利用JOIN_BUFFER缓存数据行以避免频繁磁盘访问。
1.5 本章小结
JOIN的本质是笛卡尔积 + 条件过滤。
INNER/LEFT/RIGHT/FULL JOIN可以用集合论和Venn图清晰表达。
MySQL在执行JOIN时,会经历 解析 → 优化 → 执行 → 返回结果 的完整流程。
优化器选择算法、执行器分配JOIN_BUFFER,是后续性能优化的关键点。
第2章 连接执行引擎解析
在 MySQL 中,JOIN 的执行完全由 SQL 执行器(Executor) 驱动。优化器负责选择 连接顺序 和 算法,而执行器则真正负责 逐行扫描、缓存管理、条件判断、结果输出。其中,JOIN_BUFFER 是整个执行过程中性能表现的关键。
2.1 JOIN_BUFFER 的地位与作用
为什么需要 JOIN_BUFFER?
当 MySQL 使用 Nested Loop Join (NLJ) 时,外层表每一行都会驱动内层表进行匹配。
如果内表没有合适的索引,就需要 全表扫描,成本极高。
为了减少反复扫描,MySQL 引入了 JOIN_BUFFER,将一部分行缓存起来,提高批量匹配效率。
👉 可以理解为:JOIN_BUFFER 是执行器在内存里开辟的一块 临时缓存区,帮助存放中间结果,避免频繁回表。
2.2 源码入口:JOIN 执行主流程
JOIN 的执行逻辑主要位于 sql/sql_executor.cc
和 sql/sql_select.cc
。
核心方法是:
bool JOIN::exec() {// 1. 初始化执行环境if (setup_join_buffers()) {return true; // 内存分配失败}// 2. 执行 join loopreturn do_select(this);
}
这里可以看到:
setup_join_buffers():为每个参与 JOIN 的表分配 buffer
do_select():进入执行循环,逐行读取并进行连接
2.3 JOIN_BUFFER 内存分配机制
源码片段(简化版):
void setup_join_buffer(JOIN_TAB *tab) {if (tab->need_buffer) {tab->buff = join_buffer_alloc(tab->buffer_size);init_io_cache(&tab->file, ...); // 初始化IO缓存}
}
几个关键点:
tab->need_buffer:优化器判定是否需要使用 buffer
join_buffer_alloc:实际申请内存
buffer_size:由参数
join_buffer_size
控制,默认 256KB
内存分配公式可以抽象为:
总JOIN_BUFFER消耗 ≈ join_buffer_size × (参与JOIN的表数 - 1)
⚠️ 如果连接表较多,JOIN_BUFFER 会被 乘法放大,因此配置过大会导致内存暴涨。
2.4 EXPLAIN 中的 "Using join buffer"
执行计划分析时,我们经常会看到:
EXPLAIN SELECT * FROM orders o
JOIN customers c ON o.customer_id = c.id;
可能输出:
id | select_type | table | type | key | Extra
1 | SIMPLE | o | ALL | NULL|
1 | SIMPLE | c | ALL | NULL| Using join buffer (Block Nested Loop)
Using join buffer (BNL)
表示内表没有索引可用,MySQL 使用了 Block Nested Loop Join,内表行被缓存到 JOIN_BUFFER 里进行匹配。在 MySQL 8.0 中,还可能看到:
BNL:Block Nested Loop
BKA:Batch Key Access
2.5 MySQL 5.7 vs 8.0 的 JOIN_CACHE 差异
MySQL 5.7
主要支持 BNL,JOIN_BUFFER 相对简单,只是行级缓存。MySQL 8.0
引入了 JOIN_CACHE Framework,支持:BNL(Block Nested Loop)
BKA(Batch Key Access)
HASH JOIN(从 8.0.18 开始引入,优化大表连接场景)
伪代码比较:
// MySQL 5.7
for (row in outer_table) {load rows from inner_table into join_buffer;compare row with buffer;
}// MySQL 8.0
for (batch of rows in outer_table) {probe inner_table using indexes (BKA);cache results in join_buffer;
}
可以看到,8.0 的 JOIN_BUFFER 不再只是单纯的内存缓存,而是演变为一个 多策略缓存框架。
2.6 本章小结
JOIN 执行核心流程:setup_join_buffers → do_select → 逐行匹配
JOIN_BUFFER 是执行器级别的优化工具,用于减少扫描成本
EXPLAIN 中的 "Using join buffer" 是识别 JOIN 执行方式的关键标志
从 5.7 到 8.0,JOIN_CACHE 演进为支持 BNL、BKA、HASH JOIN 的多策略体系
第3章 连接算法详解
MySQL 的 JOIN 执行,归根结底是基于不同场景下的 连接算法选择。优化器负责选择合适的算法,执行器依赖 JOIN_BUFFER 作为缓存区来支撑这些算法的运行。
3.1 Nested Loop Join (NLJ)
抽象原理
外层表每一行,去内层表逐行匹配。
最简单的连接方式,时间复杂度 O(M × N)。
SQL 示例
SELECT *
FROM orders o
JOIN customers c ON o.customer_id = c.id;
伪代码
for (row_o in Orders) {for (row_c in Customers) {if (row_o.customer_id == row_c.id) {output(row_o, row_c);}}
}
MySQL实现细节
如果
customers.id
上有索引,内层循环可通过 索引快速定位,复杂度近似 O(M × logN)。如果没有索引,就需要全表扫描,效率极低。
3.2 Block Nested Loop Join (BNL)
抽象原理
NLJ 的优化版本:将外层表的一批行放入 JOIN_BUFFER,然后批量与内层表比对。
避免频繁回表扫描,减少磁盘 I/O。
伪代码
while (fetch batch from Orders into join_buffer) {for (row_c in Customers) {for (row_o in join_buffer) {if (row_o.customer_id == row_c.id) {output(row_o, row_c);}}}
}
EXPLAIN 标志
会显示:
Using join buffer (Block Nested Loop)
特点
减少内层表重复扫描,但仍需全表遍历。
JOIN_BUFFER 大小直接影响性能。
3.3 Batch Key Access Join (BKA)
引入背景
BNL 仍需扫描内表 → 如果内表有索引,但不能高效利用,性能不足。
BKA 的目标:批量利用索引探查内表。
抽象原理
外表一批行放入 JOIN_BUFFER
从 JOIN_BUFFER 中批量取出 连接键
内表使用索引执行批量探查,返回结果
伪代码
while (fetch batch from Orders into join_buffer) {keys = extract_keys(join_buffer); // 批量收集 customer_idrows = index_lookup_batch(Customers, keys); // 批量索引探查for (row in rows) {if (match_condition(row, join_buffer)) {output(row);}}
}
EXPLAIN 标志
显示:
Using join buffer (Batched Key Access)
优点
大幅减少随机 I/O
特别适合 大表连接 + 有索引 的场景
3.4 Hash Join (MySQL 8.0.18+)
引入原因
对于没有索引的连接,大表之间的 BNL 依旧低效。
Hash Join 通过 哈希表 加速匹配。
抽象原理
选择较小的表(Build 表),建立哈希表:
key → rows
遍历大表(Probe 表),用连接键查哈希表,快速匹配
伪代码
hash_table = {}
for (row_c in Customers) {hash_table[row_c.id].add(row_c);
}for (row_o in Orders) {if (hash_table.contains(row_o.customer_id)) {output(row_o, hash_table[row_o.customer_id]);}
}
MySQL实现
MySQL 8.0.18 引入
Hash Join 通过
join_buffer
存放哈希表需要配置参数:
optimizer_switch='hash_join=on'
3.5 不同算法的性能对比
算法 | 索引依赖 | 内存依赖 | 时间复杂度 | 场景 |
---|---|---|---|---|
NLJ | 高 | 低 | O(M×N) / O(M×logN) | 小表连接,有索引 |
BNL | 无 | 高 | O(M×N),但减少I/O | 大表连接,无索引 |
BKA | 有 | 中 | 近似 O(M×logN),批量优化 | 大表连接,有索引 |
Hash Join | 无 | 高 | O(M+N) | 大表连接,无索引 |
3.6 本章小结
NLJ → 最简单,但依赖索引
BNL → 利用 JOIN_BUFFER,减少重复扫描
BKA → 批量索引探查,大表场景利器
Hash Join → MySQL 8.0 的新引擎,解决大表无索引 JOIN 的瓶颈
第4章 索引与连接性能优化
在前面我们看到,JOIN 的本质是 外层表驱动内层表匹配。而匹配过程的效率,几乎完全取决于 内表能否高效定位到数据。
👉 索引就是 MySQL 在 JOIN 性能上的“第一杀器”。
4.1 索引在 JOIN 中的角色
外表(Driving Table):负责提供连接键的输入。索引影响不大,主要影响扫描顺序。
内表(Driven Table):负责接收连接键查找数据。是否存在合适的索引,直接决定性能。
结论:JOIN 优化的重点,几乎总在 被驱动表(Driven Table) 上。
4.2 前缀索引与等值连接优化
有时候连接字段是长字符串,比如邮箱、URL。如果直接建全列索引,存储和维护成本高。
前缀索引示例
CREATE INDEX idx_email_prefix ON users(email(20));
适用场景
等值 JOIN 条件:
ON u.email = o.user_email
查询只在前缀范围内能有效过滤
如果选择过短,可能造成索引选择性下降
👉 调优策略:
测试不同前缀长度下的 Cardinality(区分度)
通过
SHOW INDEX FROM users;
查看选择性
4.3 覆盖索引与回表消除
在 JOIN 查询中,MySQL 如果需要额外回表取数据,会增加磁盘 I/O。
覆盖索引 可以避免这一点。
示例
CREATE INDEX idx_customer_country ON customers(country, id);
查询:
SELECT o.id, c.id FROM orders o JOIN customers c ON o.customer_id = c.id WHERE c.country = 'US';
覆盖索引包含
(country, id)
,因此WHERE
和JOIN
条件都能在索引层完成不需要回表,提高性能
4.4 Batch Key Access (BKA) 与索引批量探查
回忆第3章:BKA 的本质是 批量利用索引。
如果内表有索引(比如二级索引 customer_id
),BKA 可以将外表的多个键一次性送给存储引擎,执行批量查找。
👉 调优策略:
确保内表连接键有索引
调整
join_buffer_size
使得 BKA 批量效果最大化
4.5 连接顺序优化(Join Reordering)与索引选择
MySQL 优化器会尝试 重新排列 JOIN 顺序,其代价模型考虑了:
连接列是否有索引
索引的选择性(Cardinality)
表大小估算
示例
EXPLAIN SELECT * FROM big_table b JOIN small_table s ON b.key = s.key;
可能优化器选择:
small_table → big_table(如果 small_table.key 有高选择性索引)
这样大表扫描可以大幅缩小范围
4.6 分布式场景下的索引与 Sharding
在分布式数据库(如 MySQL Sharding 或 TiDB)中,JOIN 性能更依赖索引:
本地索引:如果 JOIN 字段是分片键,可以路由到单个分片执行 → 高效
全局索引缺失:需要跨分片 JOIN,代价高
优化策略:
尽量让 JOIN 字段成为分片键
使用预聚合表减少跨库 JOIN
引入全局二级索引(如 TiDB 的 Global Index)
4.7 索引优化效果对比实验
假设两表:
CREATE TABLE orders (id BIGINT PRIMARY KEY,customer_id BIGINT,amount DECIMAL(10,2)
);CREATE TABLE customers (id BIGINT PRIMARY KEY,country VARCHAR(50)
);
测试用例
EXPLAIN SELECT *
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE c.country = 'US';
无索引:
Using join buffer (BNL)
,全表扫描单列索引 (c.id):走索引,Nested Loop / BKA
组合索引 (c.country, id):WHERE 与 JOIN 同时命中 → 性能最佳
4.8 本章小结
JOIN 优化的核心 = 内表是否有高效索引
前缀索引:降低存储成本,但要关注选择性
覆盖索引:避免回表,提升查询性能
BKA:充分利用索引进行批量探查
Join Reordering:优化器会利用索引特性重新安排顺序
分布式场景:分片键 & 全局索引是关键
第5章 高级连接场景解析
在真实业务中,JOIN 往往不止两张表,甚至涉及 子查询、多表链式连接、以及复杂的 过滤条件。MySQL 在优化器和执行器层面都引入了特殊机制来提升这些场景下的性能。
5.1 子查询转换为 JOIN 的优化策略
背景
子查询(Subquery)在 MySQL 中性能往往较差,尤其是 相关子查询。
优化器会尝试将其改写为 半连接 (Semi-Join) 或 JOIN。
示例
-- 子查询写法
SELECT * FROM orders o
WHERE o.customer_id IN (SELECT c.id FROM customers c WHERE c.country = 'US');
优化器会将其改写为:
-- JOIN 化写法
SELECT o.* FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE c.country = 'US';
优点
子查询 → JOIN 转换后,可以利用索引、BKA、Hash Join
避免逐行执行子查询,提高整体性能
5.2 多表连接的执行路径选择
当连接涉及 3张及以上表 时,问题变成了:
👉 优化器如何选择连接顺序?
代价模型
优化器会基于 动态规划 (DP) 算法,计算每个可能的连接顺序的代价:
表大小
索引可用性
选择性估算
示例
EXPLAIN SELECT *
FROM orders o
JOIN customers c ON o.customer_id = c.id
JOIN addresses a ON c.address_id = a.id;
可能的执行顺序:
(orders JOIN customers) → addresses
(customers JOIN addresses) → orders
优化器会选择 预估结果集最小 的顺序。
5.3 半连接 (Semi-Join) 优化技术
背景
Semi-Join 指的是:
只关心 是否存在匹配,而不是返回所有匹配行。
常见于
EXISTS
、IN
子查询。
示例
SELECT * FROM customers c
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);
优化器可能使用的 Semi-Join 策略
Materialization:先将子查询结果物化到临时表,再做 JOIN
Duplicate Weedout:JOIN 后去重,避免重复匹配
Loose Index Scan:直接用索引扫描子查询的去重结果
Batched Key Access (BKA):对子查询键进行批量索引探查
5.4 条件预过滤 (Condition Filtering)
背景
当 JOIN 的结果集很大时,如果过滤条件在后期才生效,会造成不必要的计算。
👉 条件预过滤就是把 WHERE
条件 尽量提前 到内表访问阶段。
示例
SELECT * FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE c.country = 'US';
优化过程
如果
c.country
上有索引,优化器会在访问 customers 时就应用过滤条件避免将所有
customers
行拉入 JOIN_BUFFER,再做后置过滤
源码提示
在 sql/optimizer_costs.cc
中,优化器会为每个条件计算 过滤率 (filtering effect),用于判断条件能否下推。
5.5 本章小结
子查询 JOIN 化:将 IN/EXISTS 子查询转换为 JOIN,提高执行效率
多表 JOIN 顺序优化:优化器使用代价模型 + 动态规划,选择最优路径
半连接优化:Materialization、Loose Index Scan、BKA 等方式减少不必要计算
条件预过滤:将过滤条件尽量下推到内表,减少无效数据进入 JOIN_BUFFER
一个 用具体数据示例来展现 Join 的执行过程,包括:
两个表的数据
执行 Join 时,数据库如何扫描表
数据怎么进入内存 buffer
如何匹配、输出结果
那我们用一个最简单的 Hash Join 过程举例(文字流程图),涉及两个表:
表结构与数据
表A:订单表(orders)
order_id | customer_id
---------+------------1 | 1012 | 1023 | 103
表B:客户表(customers)
customer_id | name
------------+--------101 | Alice102 | Bob104 | Carol
我们执行 SQL:
SELECT o.order_id, c.name
FROM orders o
JOIN customers c
ON o.customer_id = c.customer_id;
文字版流程图(Hash Join示例)
【Step1】扫描表A (orders)-> 读取每一行(order_id, customer_id)-> 将(customer_id)作为key,放入Hash BufferBuffer内容:101 -> order_id=1102 -> order_id=2103 -> order_id=3【Step2】扫描表B (customers)-> 逐行读取(customer_id, name)-> 对每一行customer_id在Hash Buffer中查找匹配Row1: 101, Alice-> 在Buffer中找到 key=101-> 输出 (order_id=1, name=Alice)Row2: 102, Bob-> 在Buffer中找到 key=102-> 输出 (order_id=2, name=Bob)Row3: 104, Carol-> Buffer中没有 key=104-> 不输出【Step3】结果输出(1, Alice)(2, Bob)
总结:数据在内存 buffer 的流动
Build 阶段:先把 小表A 加载到内存 buffer,构建哈希表。
Probe 阶段:扫描 大表B,每行数据都去 buffer 查找匹配。
输出结果:匹配成功的数据组合后输出到结果集。
Block Nested Loop Join具体的例子
示例表
表A (驱动表)
A.id | A.name -------------- 1 | Alice 2 | Bob 3 | Carol
表B (被驱动表,有索引B.a_id)
B.id | B.a_id | B.city ----------------------- 10 | 1 | Beijing 11 | 2 | Shanghai 12 | 2 | Shenzhen 13 | 3 | Guangzhou
查询语句:
SELECT A.id, A.name, B.city
FROM A
JOIN B ON A.id = B.a_id;
BKA Join 执行过程(文字版流程图)
开始
│
├─▶ Step1: 从表A批量取数据到 Buffer
│ 取出 [ (1, Alice), (2, Bob), (3, Carol) ]
│
├─▶ Step2: 遍历 Buffer 中的数据,批量收集 join key
│ keys = [1, 2, 3]
│
├─▶ Step3: 去表B,用索引(B.a_id)批量查找 keys
│ - 查找 a_id=1 → 返回 (10,1,Beijing)
│ - 查找 a_id=2 → 返回 (11,2,Shanghai), (12,2,Shenzhen)
│ - 查找 a_id=3 → 返回 (13,3,Guangzhou)
│
├─▶ Step4: 将B表结果放入临时结果集,并与 Buffer 中A的行做匹配
│ 匹配结果:
│ (1,Alice) ↔ (10,1,Beijing)
│ (2,Bob) ↔ (11,2,Shanghai)
│ (2,Bob) ↔ (12,2,Shenzhen)
│ (3,Carol) ↔ (13,3,Guangzhou)
│
└─▶ Step5: 输出结果集(1,Alice,Beijing)(2,Bob,Shanghai)(2,Bob,Shenzhen)(3,Carol,Guangzhou)
🔑 和普通 NLJ 的区别
普通 NLJ:对 A 的每一行,立刻去 B 索引查一次 → 查索引次数 = A行数。
BKA:先把 A 的多行放进 Buffer,批量生成一组 join keys → 一次批量索引查找,大幅减少索引访问开销。