我们先搭一套模拟的电商数据库,让所有 JOIN 类型、执行原理、性能优化点都跑在同一套数据上——你能亲眼看到每种写法到底返回了什么,以及哪里最容易写错。

四张表:用户表、商品表、订单表、订单明细表,标准的电商建模。
复制代码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);
关键设计点,都是故意埋的:
referrer_id —— 专门用来演示自连接(SELF JOIN)。数据关系长这样:
复制代码users (1:N) orders (1:N) order_items (N:1) products
users (1:N) users [自关联,referrer_id]
理解所有 JOIN 之前,只需要记住一句话:
不同的 JOIN 类型,区别只在于"扔掉多少"以及"扔不掉的时候用什么补位"。
复制代码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 很少直接用,常见于生成日期维度表、排列组合类的报表需求。
复制代码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 关联订单表,没下单的用户全部凭空消失。
复制代码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 最实用的场景不是查全量,而是"反向查询"——找出左表里在右表没有任何对应记录的行,套路固定:
复制代码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,是找"未关联数据"的标准写法,比如找没下过单的用户、没被领取的优惠券、没有库存记录的商品等。
复制代码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。
MySQL 没有 FULL OUTER JOIN 关键字,需要用 LEFT JOIN UNION RIGHT JOIN 拼出来:
复制代码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 才能把"左边有右边没有"和"右边有左边没有"的情况一起暴露出来,这在做数据一致性核对时很关键。
查每个用户的推荐人是谁:
复制代码SELECT a.name AS 用户, b.name AS 推荐人
FROM users a
LEFT JOIN users b ON a.referrer_id = b.id;
| 用户 | 推荐人 |
|---|---|
| 张伟 | NULL |
| 李娜 | 张伟 |
| 王芳 | 张伟 |
| 刘洋 | 李娜 |
| 陈静 | NULL |
自连接本质上和普通 JOIN 没有任何区别,只是左右两边用的是同一张物理表,靠别名把它伪装成两张逻辑表。除了推荐关系,员工-上级、分类-父分类、评论-父评论这类"树形自引用"结构都是同样的写法。
四张表一次性关联,还原出"谁在哪个订单里买了什么、多少钱":
复制代码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 拆开单独验证行数,而不是盯着最终结果猜。
这是本文最值得记住的一节。假设需求是"统计每个用户的订单总金额",很多人会顺手把 order_items 也带进来:
复制代码-- 错误写法
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:
复制代码-- 正确写法
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,而不是让明细表直接参与订单金额的聚合。
需求:查所有用户,同时看他们"已完成"状态的订单,没有已完成订单的用户也要保留。
复制代码-- 错误写法:条件写在 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,让它作为关联条件的一部分,只影响"能不能配对",不影响"左表这一行留不留":
复制代码-- 正确写法:条件写在 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 写效果是一样的,这也是为什么很多人没意识到这个区别——直到某天把内连接换成外连接,查询结果突然就不对了。
MySQL 执行 JOIN,本质上都是"拿一张表的每一行,去另一张表里找匹配",区别在于"找"的方式:
1. Index Nested Loop Join(索引嵌套循环,最常见也最快)
驱动表(通常是优化器认为结果集更小的那张表)逐行扫描,每一行都用 JOIN 列去被驱动表的索引上做一次查找。我们的 orders.user_id 建了索引,所以:
复制代码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 列必须有索引,这是性能的第一道也是最重要的一道防线。
orders.user_id、order_items.order_id、order_items.product_id 都提前建了索引,这是让 Index Nested Loop 生效的前提,也是 JOIN 性能优化里投入产出比最高的一件事。INT 一边是 VARCHAR,会触发隐式类型转换,索引直接失效。EXPLAIN 验证过优化器选错了驱动表——大多数情况下优化器的判断比手动指定更可靠。SELECT *,减少中间结果集在内存/临时表里的体积,对大表 JOIN 尤其明显。join_buffer_size,Block Nested Loop 场景下 buffer 太小会导致被驱动表被反复全表扫描多次。EXPLAIN 看 type 和 rows,type 是 ALL 或 rows 预估值异常大,基本就是索引没用上,先排查这个再谈别的优化。需求:找出下过"已完成"订单的用户。JOIN 写法容易带出重复行:
复制代码-- 用 JOIN,需要额外 DISTINCT 去重
SELECT DISTINCT u.name
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE o.status = '已完成';
用 EXISTS 更贴近语义,也天然不会有重复行问题:
复制代码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 | 同一张表按别名当两张表 | 树形/自引用结构,如推荐关系、上下级关系 |
以及两条最容易忘、但最容易出线上问题的规律:
ON 还是 WHERE,结果可能完全不同——写在 WHERE 里等于把外连接拍扁成内连接。