📖 什么是 JOIN?
JOIN 是 SQL 中用于根据两个或多个表之间的关联条件,从多个表中组合数据的操作。理解 JOIN 的底层实现原理,对于写出高效 SQL、提升数据库性能至关重要。
为什么需要 JOIN?
🎯 数据规范化 减少冗余
数据库设计遵循范式,将数据分散到多个表中,避免重复存储。例如:用户表、订单表、商品表分开存储。
🔗 数据关联 业务需要
实际业务中,数据天然存在关联关系。订单属于某个用户,订单包含多个商品,JOIN 是关联这些数据的唯一方式。
⚡ 性能优化 关键点
不合理的 JOIN 可能导致性能灾难。理解底层算法和驱动表选择,是 SQL 优化的核心技能。
JOIN 的本质
-- 典型的 JOIN 示例 SELECT u.name, o.order_id, o.amount FROM users AS u INNER JOIN orders AS o ON u.id = o.user_id WHERE o.status = 'completed'; -- JOIN 的抽象理解:笛卡尔积 + 条件过滤 -- 1. 计算笛卡尔积: users × orders -- 2. 应用 ON 条件: 筛选匹配的记录 -- 3. 应用 WHERE 条件: 进一步过滤 -- 4. 输出最终结果
⚙️ JOIN 连接算法
MySQL 实现了三种主要的连接算法,每种算法适用于不同的场景和数据分布。
🔄 嵌套循环连接 (NLJ) O(n×m)
最基础的算法,像嵌套的 for 循环一样逐行匹配。
// 伪代码实现 for each row_a in table_A: for each row_b in table_B: if row_a.id == row_b.a_id: emit(row_a, row_b)
#️⃣ 哈希连接 (Hash Join) O(n+m)
将小表加载到哈希表,用大表逐行探测。适合等值连接。
// 伪代码实现 hash_table = buildHashTable(smaller_table) for each row in larger_table: key = row.join_key if hash_table.contains(key): emit(row, hash_table.get(key))
📋 合并连接 (Sort Merge Join) O(n log n + m log m)
先对两个表按连接键排序,然后像拉链一样同步推进。
// 伪代码实现 sorted_A = sort(table_A, join_key) sorted_B = sort(table_B, join_key) i = j = 0 while i < len(sorted_A) and j < len(sorted_B): if sorted_A[i].key < sorted_B[j].key: i++ else if sorted_A[i].key > sorted_B[j].key: j++ else: emitMatches(sorted_A[i], sorted_B[j])
MySQL 如何选择连接算法?
优化器成本计算
-- MySQL 优化器基于成本选择执行计划 -- 关键成本因素: cost = read_cost -- 读取数据页成本 + eval_cost -- 条件评估成本 + new_cost_cpu -- 结果处理成本 + mem_cost -- 内存使用成本 -- 使用 EXPLAIN 查看成本: EXPLAIN FORMAT=JSON SELECT * FROM t1 JOIN t2 ON t1.id = t2.t1_id\G
🔀 七种 JOIN 类型详解
SQL 标准定义了 5 种基本 JOIN 类型,通过组合可得到 7 种实际用法。下面用动画演示每种 JOIN 的行为。
INNER JOIN 演示
users 表
只返回两边都匹配的记录
orders 表
结果集
JOIN 类型对比表
| 类型 | 关键字 | 返回结果 | 应用场景 |
|---|---|---|---|
| 内连接 | INNER JOIN / JOIN |
只返回两边都有匹配的记录 | 获取共同存在的数据,关联主表和从表 |
| 左连接 | LEFT JOIN |
返回左表全部记录,右表无匹配则为 NULL | 保留左表数据,查看关联的从表信息 |
| 右连接 | RIGHT JOIN |
返回右表全部记录,左表无匹配则为 NULL | 保留右表数据,或当左表数据不重要时 |
| 全连接 | FULL OUTER JOIN |
返回两表所有记录,无匹配则为 NULL | 获取全部数据,MySQL 需用 UNION 模拟 |
| 交叉连接 | CROSS JOIN |
笛卡尔积,返回所有可能的组合 | 生成测试数据、时间维度表等 |
| 左排除连接 | LEFT JOIN ... WHERE 右表 IS NULL |
返回左表独有的记录 | 查找左表中有、右表中没有的数据 |
| 右排除连接 | RIGHT JOIN ... WHERE 左表 IS NULL |
返回右表独有的记录 | 查找右表中有、左表中没有的数据 |
各类型 SQL 示例
-- INNER JOIN: 只返回两边都匹配的记录 SELECT u.id, u.name, o.order_id FROM users u INNER JOIN orders o ON u.id = o.user_id; -- 结果: Alice(id=1), Bob(id=2) -- 不包含: Carol(id=3), Dave(id=4), user_id=5, user_id=6
-- LEFT JOIN: 返回左表全部记录 SELECT u.id, u.name, o.order_id FROM users u LEFT JOIN orders o ON u.id = o.user_id; -- 结果: Alice, Bob, Carol(id=3, order=NULL), Dave(id=4, order=NULL) -- 右表无匹配的记录,填充 NULL
-- RIGHT JOIN: 返回右表全部记录 SELECT u.id, u.name, o.order_id FROM users u RIGHT JOIN orders o ON u.id = o.user_id; -- 结果: Alice(id=1), Bob(id=2), (id=NULL, order=OrderE), (id=NULL, order=OrderF) -- 左表无匹配的记录,填充 NULL
-- FULL OUTER JOIN: MySQL 不直接支持,用 UNION 模拟 SELECT u.id, u.name, o.order_id FROM users u LEFT JOIN orders o ON u.id = o.user_id UNION SELECT u.id, u.name, o.order_id FROM users u RIGHT JOIN orders o ON u.id = o.user_id; -- 结果: 包含所有记录,无匹配处填充 NULL -- 或使用 UNION ALL + WHERE 排除重复
-- 左排除连接: 找出左表独有的记录 SELECT u.id, u.name, o.order_id FROM users u LEFT JOIN orders o ON u.id = o.user_id WHERE o.user_id IS NULL; -- 右排除连接: 找出右表独有的记录 SELECT u.id, u.name, o.order_id FROM users u RIGHT JOIN orders o ON u.id = o.user_id WHERE u.id IS NULL; -- 实际场景: 找出没有订单的用户 / 没有归属的订单
🚗 驱动表选择:大表驱动 vs 小表驱动
这是面试和实战中最常被问到的问题。理解驱动表的选择原则,能让你的 SQL 性能提升 10 倍甚至 100 倍。
什么是驱动表?
核心原则:小表驱动大表
🎯 为什么小表驱动?
假设:驱动表 100 行 × 被驱动表 100,000 行 = 10,000,000 次查找
反之:驱动表 100,000 行 × 被驱动表 100 行 = 10,000,000 次查找
次数相同?但实际差异在IO 和索引!
🔑 关键因素
- 被驱动表的连接条件是否有索引
- 小表更容易放入内存
- 嵌套循环的内层循环次数决定 IO 开销
- 大表走索引扫描 vs 全表扫描差距巨大
小表驱动的真实案例
性能对比计算器
📊 性能分析结果
驱动表选择决策树
STRAIGHT_JOIN,可以强制指定驱动表,但这通常是最后手段。
🏆 最佳实践与性能优化
1️⃣ 确保被驱动表有索引
连接条件(ON 子句)的列必须在被驱动表上有索引,这是 Index NLJ 的前提。
CREATE INDEX idx_user_id ON orders(user_id);
2️⃣ 避免 SELECT *
只查询需要的列,减少网络传输和内存占用。特别是在 JOIN 时,不要从驱动表读入不必要的列到 join_buffer。
3️⃣ 用小表驱动大表
在 JOIN 之前用 WHERE 条件过滤,减少驱动表的行数。如果可以,先在子查询中过滤数据。
-- ✅ 推荐:先过滤 SELECT * FROM (span class="keyword">SELECT * FROM small WHERE active = 1) s JOIN big b ON s.id = b.s_id;
4️⃣ 避免复杂表达式
ON 条件中的表达式会导致无法使用索引。
-- ❌ 错误:无法使用索引 ON a.id = b.id + 0 -- ✅ 正确:保持原样 ON a.id = b.id
5️⃣ 利用 EXPLAIN 分析
使用 EXPLAIN 查看执行计划,关注 type、key、rows、Extra 列。
EXPLAIN SELECT * FROM t1 JOIN t2\G
6️⃣ 考虑数据分布
如果数据严重倾斜(倾斜键问题),小表驱动可能反而更慢。需要用 ANALYZE TABLE 更新统计信息。
常见 JOIN 性能问题
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 被驱动表全表扫描 | ON 条件列无索引 | 创建索引 |
| 笛卡尔积 | ON 条件缺失或错误 | 检查 JOIN 条件 |
| 临时表 + filesort | GROUP BY / ORDER BY 无法使用索引 | 调整索引或拆分查询 |
| 连接列类型不匹配 | 隐式类型转换导致索引失效 | 确保类型一致 |
现代 MySQL (8.0+) 的改进
- Hash Join:对大表等值连接性能提升显著,无需索引
- Lateral Derived Tables:允许子查询引用父查询的列
- 优化器成本模型改进:更准确地估算行数和选择执行计划
- JSON EXPLAIN:查看详细的成本估算