游乐游手机版
首页/数据库/文章详情

MySQL JOIN解析:朴实无华却实用至上

时间:2026-07-07 07:05
通过搭建电商数据库示例,解析了交叉连接、内连接、左连接、右连接、自连接及多表连接的用法与原理,重点说明了左连接配合ISNULL查找无关联数据,并指出左连接后聚合时因重复行导致SUM计算错误的常见陷阱,同时强调了避免该错误的方法。

MySQL JOIN 全面解析:从原理到实战踩坑

为了彻底讲透各种 JOIN 类型、执行原理以及性能优化要点,我们精心搭建了一套模拟电商数据库。这样一来,所有 SQL 写法都能在同一套数据上运行,你可以直观地看到每种查询返回的结果,以及最容易出现错误的地方。

MySQL 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);

以下是精心设计的关键细节,都埋下了常见陷阱:

  • 刘洋(id=4)一单未下 —— 专门用于演示 LEFT JOIN 以及“查找无关联数据”的场景。
  • 订单 1001 和 1004 各包含了两种商品 —— 专门用来演示 JOIN 后进行聚合时容易踩的重复计算坑。
  • 用户表自带 referrer_id 字段 —— 专门用于演示自连接(SELF JOIN)。

数据关系结构如下:

users (1:N) orders (1:N) order_items (N:1) products
users (1:N) users  [自关联,referrer_id]

二、JOIN 的本质:先笛卡尔积,再过滤

想要理解所有 JOIN 类型,只需记住一句核心原理:

不同的 JOIN 类型,唯一的区别在于“丢弃多少”以及“丢弃不全时用什么填补”。


三、CROSS 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 很少直接使用,常见于生成日期维度表、排列组合类的报表需求。


四、INNER 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;
用户订单号下单日期金额
张伟10012024-01-10528.00
张伟10022024-02-1489.00
李娜10032024-01-20129.00
王芳10042024-03-01127.00
陈静10052024-03-15399.00

结果只有 5 行。刘洋消失了——因为他没有任何订单能和 orders 表匹配上,INNER JOIN 天然会将“无法配对”的行剔除。这是新手最容易掉入的陷阱:明明查询的是“用户列表”,结果因为使用 INNER JOIN 关联订单表,未下单的用户全部凭空消失。


五、LEFT 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;
用户订单号下单日期金额
张伟10012024-01-10528.00
张伟10022024-02-1489.00
李娜10032024-01-20129.00
王芳10042024-03-01127.00
刘洋NULLNULLNULL
陈静10052024-03-15399.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;
idname
4刘洋

这个“LEFT JOIN + IS NULL”的组合,等价于 NOT EXISTS,是查找“未关联数据”的标准写法,例如找未下过单的用户、未被领取的优惠券、没有库存记录的商品等。


六、RIGHT JOIN:与 LEFT JOIN 对称,实际项目中几乎不用

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 JOINRIGHT JOIN 通过 UNION 拼合:

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(自连接):一张表当两张表使用

查询每个用户的推荐人是谁:

SELECT a.name AS 用户, b.name AS 推荐人
FROM users a
LEFT JOIN users b ON a.referrer_id = b.id;
用户推荐人
张伟NULL
李娜张伟
王芳张伟
刘洋李娜
陈静NULL

自连接本质上与普通 JOIN 没有区别,只是左右两边使用同一张物理表,通过别名将其伪装成两张逻辑表。除了推荐关系,员工-上级、分类-父分类、评论-父评论这类“树形自引用”结构都可采用相同写法。


九、多表 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.001399.00
张伟1001已完成无线鼠标129.001129.00
张伟1002已完成显示器支架89.00189.00
李娜1003已取消无线鼠标129.001129.00
王芳1004已完成咖啡豆68.00168.00
王芳1004已完成保温杯59.00159.00
陈静1005待发货机械键盘399.001399.00

多表 JOIN 并不神秘,就是两两 JOIN 依次叠加。JOIN 等价于 INNER JOIN,中间任何一环未匹配上,该行就会从最终结果中消失——排查多表 JOIN 中“数据莫名其妙丢失”的问题时,务必挨个将 JOIN 拆开独立验证行数,不要只盯着最终结果猜测。


十、经典坑一:JOIN + 聚合,重复行会导致 SUM 计算错误

这是本文最值得牢记的一节。假设需求是“统计每个用户的订单总金额”,很多人会顺手将 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.00617.00
李娜129.00129.00(碰巧对)
王芳254.00127.00
陈静399.00399.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,而不是让明细表直接参与订单金额的聚合。


十一、经典坑二:外连接中 ON 和 WHERE 不是一回事

需求:查询所有用户,同时查看他们“已完成”状态的订单,没有已完成订单的用户也要保留。

--  错误写法:条件写在 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,使其作为关联条件的一部分,只影响“能否配对”,不影响“左表这一行是否保留”:

--  正确写法:条件写在 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已完成
李娜NULLNULL
王芳1004已完成
刘洋NULLNULL
陈静NULLNULL

五个用户全部保留,只是没有“已完成”订单的用户对应字段为 NULL

请牢记这条规律:在 LEFT/RIGHT JOIN 中,条件写在 ON 里只影响右表能否匹配上;条件写在 WHERE 里是对整个结果二次过滤,一旦这个条件涉及右表字段,就可能把外连接“拍扁”成内连接。INNER JOIN 因为没有“保留未匹配行”的概念,ONWHERE 效果相同,这也是许多人未意识到这个区别的原因——直到某天将内连接换成外连接,查询结果突然就不对了。


十二、JOIN 的底层执行原理

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 = '北京';

大致会得到如下执行计划(示意):

tabletypepossible_keyskeyrowsExtra
uALLNULLNULL5Using where
orefidx_user_ididx_user_id1NULL

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 太小会导致被驱动表被反复全表扫描多次。
  • 优先用 EXPLAIN 查看 typerowstypeALLrows 预估值异常大,基本就是索引未生效,先排查这个再谈其他优化。

十四、JOIN vs EXISTS:判断“存在性”时优先用 EXISTS

需求:找出下过“已完成”订单的用户。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同一张表按别名当两张表树形/自引用结构,如推荐关系、上下级关系

以及两条最容易被遗忘、却最容易引发线上问题的规律:

  1. JOIN 之后进行聚合,一定要先想清楚是否会因为“一对多”关系导致某一行被复制多次、聚合结果被放大。
  2. 外连接中,过滤右表字段的条件写在 ON 还是 WHERE,结果可能截然不同——写在 WHERE 里等于将外连接拍扁成内连接。
来源:https://juejin.cn/post/7657474252384534528
上一篇SQL存储过程如何精确计算两个日期之间的工作时长 下一篇乐观锁深度解析:MySQL与PostgreSQL实战及Spring Boot集成
本站内容用于信息整理与展示,如有侵权或内容问题请及时联系处理。

相关推荐

补充同频道和同主题内容,方便继续浏览更多相关内容。

同类最新

继续查看同栏目最近更新的文章。

更多
用Qwen大模型为MySQL查询推荐最佳可视化图表
数据库 · 2026-07-07

用Qwen大模型为MySQL查询推荐最佳可视化图表

如何用Qwen大模型为MySQL查询自动推荐最佳可视化图表 你是否希望从MySQL查出的销售数据自动生成柱状图,而不是对着满屏数字发呆?刚写完一条SELECT语句,却不确定该使用折线图还是热力图来展示时间趋势?或者你把查询结果复制进Excel后才想起,其实散点图更能说明问题。这些场景是不是很熟悉?

MongoDB 4.0事务处理机制底层原理详解
数据库 · 2026-07-07

MongoDB 4.0事务处理机制底层原理详解

MongoDB4 0多文档事务深度复用WiredTiger引擎原生多行事务能力,基于快照隔离和MVCC机制。事务启动获取clusterTime,读操作基于固定快照,写冲突在提交时检测。oplog异步刷盘可能影响持久性,生产环境需启用journal并控制事务超时。

Qwen大模型助力MySQL敏感数据脱敏与隐私保护
数据库 · 2026-07-07

Qwen大模型助力MySQL敏感数据脱敏与隐私保护

借助Qwen大模型一键生成合规的MySQL脱敏SQL语句 先看一个真实业务场景:你需要在MySQL中对姓名、手机号、身份证号这类敏感字段进行合规脱敏,且脱敏逻辑要具备可复用性、可审计性、可回溯能力。此时直接打开Qwen的Web界面或调用API,输入一条清晰指令就能搞定——例如:“请为MySQL表us

数据库里最反直觉的陷阱:NULL不等于空,90%新手踩过坑
数据库 · 2026-07-07

数据库里最反直觉的陷阱:NULL不等于空,90%新手踩过坑

NULL是数据库中表示“未知”的特殊标记,而非空值或0。它引入三值逻辑,导致用=NULL查不出数据、COUNT(column)忽略NULL、运算结果全为NULL、NOTIN遇NULL返回空、排序位置因数据库而异。正确处理需用ISNULL判断、COALESCE赋默认值、NOTEXISTS替代NOTIN,建表时尽量设置NOTNULL。

Qwen大模型生成MySQL性能优化量化对比报告测评
数据库 · 2026-07-07

Qwen大模型生成MySQL性能优化量化对比报告测评

Qwen大模型能够基于两份CSV文件,自动生成一份包含QPS、延迟等8项核心指标的MySQL优化量化对比报告。您只需导出规范的CSV数据,使用特定提示词触发解析,再将结果转为HTML或PDF格式即可交付。此外,通过三步验证流程,可确保所有数据真实可信,满足技术评审要求。需要一份能直接用于技术评审或D