MySQL JOIN解析:朴实无华但食之有味

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.statusNULLNULL = '已完成' 结果也是 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 因为不存在"保留没匹配的行"这回事,ONWHERE 写效果是一样的,这也是为什么很多人没意识到这个区别------直到某天把内连接换成外连接,查询结果突然就不对了。


十二、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,就用它的 idorders.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_idorder_items.order_idorder_items.product_id 都提前建了索引,这是让 Index Nested Loop 生效的前提,也是 JOIN 性能优化里投入产出比最高的一件事。
  • JOIN 列类型必须一致 。如果一边是 INT 一边是 VARCHAR,会触发隐式类型转换,索引直接失效。
  • 别信手写 STRAIGHT_JOIN ,除非用 EXPLAIN 验证过优化器选错了驱动表------大多数情况下优化器的判断比手动指定更可靠。
  • 只 SELECT 需要的字段 ,避免 SELECT *,减少中间结果集在内存/临时表里的体积,对大表 JOIN 尤其明显。
  • 大表 JOIN 前留意 join_buffer_size,Block Nested Loop 场景下 buffer 太小会导致被驱动表被反复全表扫描多次。
  • 先用 EXPLAINtyperowstypeALLrows 预估值异常大,基本就是索引没用上,先排查这个再谈别的优化。

十四、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 同一张表按别名当两张表 树形/自引用结构,如推荐关系、上下级关系

以及两条最容易忘、但最容易出线上问题的规律:

  1. JOIN 之后再做聚合,一定先想清楚会不会因为"一对多"关系导致某一行被复制多次、聚合结果被放大。
  2. 外连接里,过滤右表字段的条件写在 ON 还是 WHERE,结果可能完全不同------写在 WHERE 里等于把外连接拍扁成内连接。
相关推荐
妙码生花1 小时前
从 PHP 到 AI + Golang,程序员自救转型手记(十六):目录结构更新、完善 token 系统(AI 表示 token 入库无需加密?)
前端·后端·ai编程
程序me1 小时前
Prompt、Context、Harness、Loop 之后是什么? AI工程下一个半年的关键词
前端·后端·ai编程
用户3169353811831 小时前
MySQL服务无法启动问题解决全记录
数据库
米沙AI1 小时前
go语言项目--实例化(图书管理)--v1
后端
MeixianAgent2 小时前
Python 回测数据入口怎么验?历史 K 线入库前先做 5 个检查
后端·python
9i编程2 小时前
SpringBoot 测试环境免发短信验证码方案,节省测试短信成本
后端
Ai拆代码的曹操2 小时前
把线程 Dump 读薄:从 BLOCKED/WAITING/RUNNABLE 到问题定位的完整方法论
后端
雪隐2 小时前
个人电脑玩AI-09让5060 Ti给你打工——让 AI 读懂你的资料
人工智能·后端
小满zs3 小时前
Go语言第一章(入门)
后端·go