MySQL JOIN 全面解析:从原理到实战踩坑
我们先搭一套模拟的电商数据库,让所有 JOIN 类型、执行原理、性能优化点都跑在同一套数据上------你能亲眼看到每种写法到底返回了什么,以及哪里最容易写错。
一、先把"数据库"搭出来
四张表:用户表、商品表、订单表、订单明细表,标准的电商建模。
sql
CREATE DATABASE IF NOT EXISTS shop_demo DEFAULT CHARSET utf8mb4;
USE shop_demo;
-- 用户表
CREATE TABLE users (
id INT PRIMARY KEY,
name VARCHAR(50) NOT NULL,
city VARCHAR(50),
register_date DATE,
referrer_id INT, -- 推荐人id,指向users.id,用于自连接演示
INDEX idx_referrer (referrer_id)
);
INSERT INTO users VALUES
(1, '张伟', '北京', '2023-01-15', NULL),
(2, '李娜', '上海', '2023-02-20', 1),
(3, '王芳', '广州', '2023-03-10', 1),
(4, '刘洋', '北京', '2023-04-05', 2),
(5, '陈静', '深圳', '2023-05-18', NULL);
-- 商品表
CREATE TABLE products (
id INT PRIMARY KEY,
name VARCHAR(50) NOT NULL,
category VARCHAR(30),
price DECIMAL(10,2)
);
INSERT INTO products VALUES
(101, '机械键盘', '数码', 399.00),
(102, '无线鼠标', '数码', 129.00),
(103, '显示器支架', '数码', 89.00),
(104, '咖啡豆', '食品', 68.00),
(105, '保温杯', '生活', 59.00);
-- 订单表
CREATE TABLE orders (
id INT PRIMARY KEY,
user_id INT NOT NULL,
order_date DATE,
status VARCHAR(20),
total_amount DECIMAL(10,2),
INDEX idx_user_id (user_id)
);
INSERT INTO orders VALUES
(1001, 1, '2024-01-10', '已完成', 528.00),
(1002, 1, '2024-02-14', '已完成', 89.00),
(1003, 2, '2024-01-20', '已取消', 129.00),
(1004, 3, '2024-03-01', '已完成', 127.00),
(1005, 5, '2024-03-15', '待发货', 399.00);
-- 订单明细表
CREATE TABLE order_items (
id INT PRIMARY KEY,
order_id INT NOT NULL,
product_id INT NOT NULL,
quantity INT DEFAULT 1,
INDEX idx_order_id (order_id),
INDEX idx_product_id (product_id)
);
INSERT INTO order_items VALUES
(1, 1001, 101, 1),
(2, 1001, 102, 1),
(3, 1002, 103, 1),
(4, 1003, 102, 1),
(5, 1004, 104, 1),
(6, 1004, 105, 1),
(7, 1005, 101, 1);
关键设计点,都是故意埋的:
- 刘洋(id=4)一单没下 ------ 专门用来演示 LEFT JOIN 和"查找没有关联数据"的场景。
- 订单 1001 和 1004 各买了两件商品 ------ 专门用来演示 JOIN 之后做聚合会踩的重复计算坑。
- 用户表自带
referrer_id------ 专门用来演示自连接(SELF JOIN)。
数据关系长这样:
scss
users (1:N) orders (1:N) order_items (N:1) products
users (1:N) users [自关联,referrer_id]
二、JOIN 的本质:先笛卡尔积,再过滤
理解所有 JOIN 之前,只需要记住一句话:
JOIN = 两张表所有行两两组合(笛卡尔积),然后用 ON 条件把不满足的组合扔掉。
不同的 JOIN 类型,区别只在于"扔掉多少"以及"扔不掉的时候用什么补位"。
三、CROSS JOIN:不加条件的纯笛卡尔积
sql
SELECT u.name AS 用户, p.name AS 商品
FROM (SELECT * FROM users WHERE id IN (1,2)) u
CROSS JOIN (SELECT * FROM products WHERE id IN (101,102)) p;
| 用户 | 商品 |
|---|---|
| 张伟 | 机械键盘 |
| 张伟 | 无线鼠标 |
| 李娜 | 机械键盘 |
| 李娜 | 无线鼠标 |
2 个用户 × 2 个商品 = 4 行,没有任何过滤逻辑。这就是所有 JOIN 的原始形态。实际业务里 CROSS JOIN 很少直接用,常见于生成日期维度表、排列组合类的报表需求。
四、INNER JOIN(内连接):只留双方都匹配的行
sql
SELECT u.name AS 用户, o.id AS 订单号, o.order_date AS 下单日期, o.total_amount AS 金额
FROM users u
INNER JOIN orders o ON u.id = o.user_id
ORDER BY o.id;
| 用户 | 订单号 | 下单日期 | 金额 |
|---|---|---|---|
| 张伟 | 1001 | 2024-01-10 | 528.00 |
| 张伟 | 1002 | 2024-02-14 | 89.00 |
| 李娜 | 1003 | 2024-01-20 | 129.00 |
| 王芳 | 1004 | 2024-03-01 | 127.00 |
| 陈静 | 1005 | 2024-03-15 | 399.00 |
只有 5 行。刘洋消失了 ------因为他没有任何订单能和 orders 表匹配上,INNER JOIN 天生就会把"配不上对"的行直接剔除。这是新手最容易踩的坑:明明查的是"用户列表",结果因为用了 INNER JOIN 关联订单表,没下单的用户全部凭空消失。
五、LEFT JOIN(左外连接):左表一个都不能少
sql
SELECT u.name AS 用户, o.id AS 订单号, o.order_date AS 下单日期, o.total_amount AS 金额
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
ORDER BY u.id;
| 用户 | 订单号 | 下单日期 | 金额 |
|---|---|---|---|
| 张伟 | 1001 | 2024-01-10 | 528.00 |
| 张伟 | 1002 | 2024-02-14 | 89.00 |
| 李娜 | 1003 | 2024-01-20 | 129.00 |
| 王芳 | 1004 | 2024-03-01 | 127.00 |
| 刘洋 | NULL | NULL | NULL |
| 陈静 | 1005 | 2024-03-15 | 399.00 |
刘洋回来了,右表没有匹配的字段全部补 NULL。
LEFT JOIN 最实用的场景不是查全量,而是"反向查询"------找出左表里在右表没有任何对应记录的行,套路固定:
sql
SELECT u.id, u.name
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.id IS NULL;
| id | name |
|---|---|
| 4 | 刘洋 |
这个"LEFT JOIN + IS NULL"的组合,等价于 NOT EXISTS,是找"未关联数据"的标准写法,比如找没下过单的用户、没被领取的优惠券、没有库存记录的商品等。
六、RIGHT JOIN:跟 LEFT JOIN 对称,实践中几乎不用
sql
SELECT u.name AS 用户, o.id AS 订单号
FROM orders o
RIGHT JOIN users u ON o.user_id = u.id;
结果和上面的 LEFT JOIN 完全一样,只是把"谁是保留方"通过表的位置换了个说法。实际项目里,团队规范基本都不用 RIGHT JOIN------因为把主表(要保留全部数据的表)放在 FROM 后面、用 LEFT JOIN 关联,比"把主表放 JOIN 后面再用 RIGHT JOIN"更符合从左往右的阅读习惯。看到 RIGHT JOIN 基本可以判断这是从别处复制粘贴、没有整理过的 SQL。
七、FULL JOIN:MySQL 不支持,得用 UNION 模拟
MySQL 没有 FULL OUTER JOIN 关键字,需要用 LEFT JOIN UNION RIGHT JOIN 拼出来:
sql
SELECT u.id AS user_id, u.name, o.id AS order_id
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
UNION
SELECT u.id AS user_id, u.name, o.id AS order_id
FROM users u
RIGHT JOIN orders o ON u.id = o.user_id;
注意用 UNION 而不是 UNION ALL,因为两次查询里能匹配上的行会被算两遍,UNION 会自动去重。
在我们这份数据里,所有订单的 user_id 都能在 users 表里找到,所以这条语句的结果和单独一个 LEFT JOIN 一样,看不出 FULL JOIN 的价值。FULL JOIN 真正有用的场景是数据不完全一致 的时候------比如订单表里有条记录 user_id = 99,但 users 表里根本没有 id=99 这个用户(常见于软删除、数据迁移遗留、外键没有强约束)。这种"孤儿数据"用 LEFT JOIN 看不见(右表没匹配上的行会被漏掉),用 INNER JOIN 更是直接消失,只有 FULL JOIN 才能把"左边有右边没有"和"右边有左边没有"的情况一起暴露出来,这在做数据一致性核对时很关键。
八、SELF JOIN(自连接):一张表当两张表用
查每个用户的推荐人是谁:
sql
SELECT a.name AS 用户, b.name AS 推荐人
FROM users a
LEFT JOIN users b ON a.referrer_id = b.id;
| 用户 | 推荐人 |
|---|---|
| 张伟 | NULL |
| 李娜 | 张伟 |
| 王芳 | 张伟 |
| 刘洋 | 李娜 |
| 陈静 | NULL |
自连接本质上和普通 JOIN 没有任何区别,只是左右两边用的是同一张物理表,靠别名把它伪装成两张逻辑表。除了推荐关系,员工-上级、分类-父分类、评论-父评论这类"树形自引用"结构都是同样的写法。
九、多表 JOIN 实战:还原一笔完整订单
四张表一次性关联,还原出"谁在哪个订单里买了什么、多少钱":
sql
SELECT
u.name AS 用户,
o.id AS 订单号,
o.status AS 状态,
p.name AS 商品,
p.price AS 单价,
oi.quantity AS 数量,
p.price * oi.quantity AS 小计
FROM orders o
JOIN users u ON o.user_id = u.id
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
ORDER BY o.id;
| 用户 | 订单号 | 状态 | 商品 | 单价 | 数量 | 小计 |
|---|---|---|---|---|---|---|
| 张伟 | 1001 | 已完成 | 机械键盘 | 399.00 | 1 | 399.00 |
| 张伟 | 1001 | 已完成 | 无线鼠标 | 129.00 | 1 | 129.00 |
| 张伟 | 1002 | 已完成 | 显示器支架 | 89.00 | 1 | 89.00 |
| 李娜 | 1003 | 已取消 | 无线鼠标 | 129.00 | 1 | 129.00 |
| 王芳 | 1004 | 已完成 | 咖啡豆 | 68.00 | 1 | 68.00 |
| 王芳 | 1004 | 已完成 | 保温杯 | 59.00 | 1 | 59.00 |
| 陈静 | 1005 | 待发货 | 机械键盘 | 399.00 | 1 | 399.00 |
多表 JOIN 没有什么魔法,就是两两 JOIN 依次叠加。JOIN 等价于 INNER JOIN,中间任何一环没匹配上,这一行就会从最终结果里消失------多表 JOIN 排查"数据莫名其妙没了"的问题时,一定要挨个把 JOIN 拆开单独验证行数,而不是盯着最终结果猜。
十、经典坑一:JOIN + 聚合,重复行会把 SUM 算错
这是本文最值得记住的一节。假设需求是"统计每个用户的订单总金额",很多人会顺手把 order_items 也带进来:
sql
-- ❌ 错误写法
SELECT u.name, SUM(o.total_amount) AS 总金额
FROM users u
JOIN orders o ON u.id = o.user_id
JOIN order_items oi ON oi.order_id = o.id
GROUP BY u.id;
| 用户 | 总金额(错误结果) | 正确应该是 |
|---|---|---|
| 张伟 | 1145.00 | 617.00 |
| 李娜 | 129.00 | 129.00(碰巧对) |
| 王芳 | 254.00 | 127.00 |
| 陈静 | 399.00 | 399.00(碰巧对) |
张伟和王芳的结果全错了,李娜和陈静却是对的------这个坑最阴险的地方就在这里,它不是每次都错,只有当订单包含多个商品明细时才会翻车,很容易在测试阶段被忽略过去。
原因:订单 1001 有 2 条 order_items 记录,JOIN 之后订单 1001 这一行被复制成了 2 行,total_amount 也就被 SUM 累加了 2 次。张伟的两笔订单里有一笔(1001)被复制了两次,所以 528×2 + 89 = 1145;王芳的订单 1004 同理被复制两次,127×2 = 254。
正确写法:既然只是要订单总额,根本不需要关联 order_items:
sql
-- ✅ 正确写法
SELECT u.name, SUM(o.total_amount) AS 总金额
FROM users u
JOIN orders o ON u.id = o.user_id
GROUP BY u.id;
| 用户 | 总金额 |
|---|---|
| 张伟 | 617.00 |
| 李娜 | 129.00 |
| 王芳 | 127.00 |
| 陈静 | 399.00 |
如果确实需要同时展示订单金额和商品明细,正确姿势是把明细部分放进子查询/派生表先聚合好,再和订单表 JOIN,而不是让明细表直接参与订单金额的聚合。
十一、经典坑二:外连接里 ON 和 WHERE 不是一回事
需求:查所有用户,同时看他们"已完成"状态的订单,没有已完成订单的用户也要保留。
sql
-- ❌ 错误写法:条件写在 WHERE 里
SELECT u.name, o.id, o.status
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.status = '已完成';
| 用户 | 订单号 | 状态 |
|---|---|---|
| 张伟 | 1001 | 已完成 |
| 张伟 | 1002 | 已完成 |
| 王芳 | 1004 | 已完成 |
李娜、刘洋、陈静全部消失了,LEFT JOIN 名存实亡,行为和 INNER JOIN 一模一样。
原因:LEFT JOIN 先按 u.id = o.user_id 做关联并给没匹配上的行补 NULL,这一步在 WHERE 之前就已经完成了。WHERE o.status = '已完成' 是在关联结果之上再做一次过滤------李娜的订单状态是"已取消",这一行没被 WHERE 放过;刘洋压根没有订单,o.status 是 NULL,NULL = '已完成' 结果也是 NULL(不成立),同样被过滤掉。WHERE 是在最外层对整个结果集生效的,它不知道、也不管你是不是用了外连接。
正确写法:把过滤条件放进 ON,让它作为关联条件的一部分,只影响"能不能配对",不影响"左表这一行留不留":
sql
-- ✅ 正确写法:条件写在 ON 里
SELECT u.name, o.id, o.status
FROM users u
LEFT JOIN orders o ON u.id = o.user_id AND o.status = '已完成';
| 用户 | 订单号 | 状态 |
|---|---|---|
| 张伟 | 1001 | 已完成 |
| 张伟 | 1002 | 已完成 |
| 李娜 | NULL | NULL |
| 王芳 | 1004 | 已完成 |
| 刘洋 | NULL | NULL |
| 陈静 | NULL | NULL |
五个用户全部保留,只是没有"已完成"订单的人对应字段是 NULL。
记住这条规律 :LEFT/RIGHT JOIN 里,条件写在 ON 里只影响右表能不能匹配上;条件写在 WHERE 里是对整个结果二次过滤,一旦这个条件涉及右表字段,就有可能把外连接"拍扁"成内连接。INNER JOIN 因为不存在"保留没匹配的行"这回事,ON 和 WHERE 写效果是一样的,这也是为什么很多人没意识到这个区别------直到某天把内连接换成外连接,查询结果突然就不对了。
十二、JOIN 的底层执行原理
MySQL 执行 JOIN,本质上都是"拿一张表的每一行,去另一张表里找匹配",区别在于"找"的方式:
1. Index Nested Loop Join(索引嵌套循环,最常见也最快)
驱动表(通常是优化器认为结果集更小的那张表)逐行扫描,每一行都用 JOIN 列去被驱动表的索引上做一次查找。我们的 orders.user_id 建了索引,所以:
sql
EXPLAIN SELECT u.name, o.id, o.total_amount
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.city = '北京';
大致会得到这样的执行计划(示意):
| table | type | possible_keys | key | rows | Extra |
|---|---|---|---|---|---|
| u | ALL | NULL | NULL | 5 | Using where |
| o | ref | idx_user_id | idx_user_id | 1 | NULL |
users 表因为 city 没建索引,只能全表扫描(5 行,代价很小,不用担心),作为驱动表;每扫到一行 users,就用它的 id 去 orders.idx_user_id 上做一次索引查找(type=ref),直接定位,不用扫全表。这是效率最高的 JOIN 方式,核心前提是被驱动表的 JOIN 列必须有索引。
2. Block Nested Loop Join(块嵌套循环,没索引时的兜底方案)
如果被驱动表的 JOIN 列没有索引,MySQL 只能把驱动表的一批行先放进内存里的 join_buffer,然后对被驱动表做一次全表扫描,用 buffer 里的所有行去逐一比对,尽量把"扫多少次被驱动表"降到最低。执行计划里会看到 Extra: Using join buffer (Block Nested Loop),type 通常是 ALL------这是性能报警信号,说明该建索引了。
3. Hash Join(MySQL 8.0.18+ 引入)
在等值 JOIN 且没有可用索引的场景下,优化器现在优先选 Hash Join 而不是 Block Nested Loop:用较小的表在内存里建一个哈希表,另一张表逐行计算哈希去探测,复杂度比嵌套循环低得多。执行计划里会看到 Extra 里出现 hash join。这大幅缓解了"忘记建索引"场景下的性能问题,但它不能替代索引------能走索引的场景,Index Nested Loop 依然更快,Hash Join 只是"没有索引时的更优兜底"。
一句话总结优化方向:JOIN 列必须有索引,这是性能的第一道也是最重要的一道防线。
十三、性能优化清单
- JOIN 列一定要建索引 。本文的
orders.user_id、order_items.order_id、order_items.product_id都提前建了索引,这是让 Index Nested Loop 生效的前提,也是 JOIN 性能优化里投入产出比最高的一件事。 - JOIN 列类型必须一致 。如果一边是
INT一边是VARCHAR,会触发隐式类型转换,索引直接失效。 - 别信手写 STRAIGHT_JOIN ,除非用
EXPLAIN验证过优化器选错了驱动表------大多数情况下优化器的判断比手动指定更可靠。 - 只 SELECT 需要的字段 ,避免
SELECT *,减少中间结果集在内存/临时表里的体积,对大表 JOIN 尤其明显。 - 大表 JOIN 前留意
join_buffer_size,Block Nested Loop 场景下 buffer 太小会导致被驱动表被反复全表扫描多次。 - 先用
EXPLAIN看type和rows,type是ALL或rows预估值异常大,基本就是索引没用上,先排查这个再谈别的优化。
十四、JOIN vs EXISTS:判断"存在性"时别用 JOIN
需求:找出下过"已完成"订单的用户。JOIN 写法容易带出重复行:
sql
-- 用 JOIN,需要额外 DISTINCT 去重
SELECT DISTINCT u.name
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE o.status = '已完成';
用 EXISTS 更贴近语义,也天然不会有重复行问题:
sql
SELECT u.name
FROM users u
WHERE EXISTS (
SELECT 1 FROM orders o
WHERE o.user_id = u.id AND o.status = '已完成'
);
两条语句结果一样(张伟、王芳),但 EXISTS 版本不需要 DISTINCT 兜底,逻辑上更清楚地表达了"我只关心存不存在,不关心具体有几条"。判断存在性用 EXISTS/NOT EXISTS,需要拿到关联表的具体字段才用 JOIN,是比较通用的选择原则。
十五、速查表
| JOIN 类型 | 返回结果 | 典型场景 |
|---|---|---|
| INNER JOIN | 两表都匹配的行 | 查询"确实存在关联关系"的数据,如已下单用户的订单详情 |
| LEFT JOIN | 左表全部 + 右表匹配部分 | 保留主表全量,同时看有没有关联数据;配合 IS NULL 找"没有关联"的记录 |
| RIGHT JOIN | 右表全部 + 左表匹配部分 | 极少用,等价于换个顺序的 LEFT JOIN |
| FULL JOIN(UNION 模拟) | 两表全部,能匹配的合并 | 数据一致性核对,找双向的"孤儿数据" |
| CROSS JOIN | 笛卡尔积 | 排列组合类需求,如生成日期维度表 |
| SELF JOIN | 同一张表按别名当两张表 | 树形/自引用结构,如推荐关系、上下级关系 |
以及两条最容易忘、但最容易出线上问题的规律:
- JOIN 之后再做聚合,一定先想清楚会不会因为"一对多"关系导致某一行被复制多次、聚合结果被放大。
- 外连接里,过滤右表字段的条件写在
ON还是WHERE,结果可能完全不同------写在WHERE里等于把外连接拍扁成内连接。