☰
MySQL连接查询全解:LEFT JOIN的ON与WHERE陷阱、性能优化与实战避坑
2026/9/28 13:33:31 网站建设 项目流程

上周帮团队排查一个SQL统计问题,同事写了一条LEFT JOIN(外连接的一种),本意是把所有用户都列出来,哪怕没有订单也要保留,结果报表里没下单的用户全都不见了。原因很简单:他在WHERE里加了过滤条件,把外连接活活变成了内连接的效果。

类似的坑,我在面试别人、带新人、看线上慢查询的时候见得太多了。MySQL 的表连接无非两类:内连接(INNER JOIN)和外连接(LEFT JOIN / RIGHT JOIN),语法上不难,但真正到业务里,ON和WHERE的区别、NULL 的语义、一对多导致的行膨胀,随便一个都能让结果错得离谱。这篇文章把我实际用到的写法、踩过的坑、排查思路一次性讲清楚,测试数据可以直接复制跑。

1. 为什么单表查询不够用:连接查询解决的根本问题

1.1 数据拆分:一张表装不下真实业务

很多人刚学SQL时习惯把所有信息塞进一张表。用一个电商订单场景举例:你想记录"谁买了什么、花了多少钱、用户住在哪",如果全部放一张表,张三下了两单,他的姓名、城市就要跟着订单记录存两次。地址改了要改两行,改漏了数据就矛盾。这就是典型的冗余和更新异常。

正规做法是拆成两张表:用户表只存用户信息,订单表只存订单信息,订单表里通过user_id指向用户表的主键。这是数据库范式的核心思想:每张表只负责一类实体,表与表之间用外键字段建立逻辑关系。

但拆完之后问题来了:业务查询经常需要"订单 + 用户姓名"这种合并视图。你不可能每次都在应用层先查订单再循环查用户,那就得靠SQL里的连接(JOIN)在数据库层面把多张表拼起来。所以连接查询不是一个附加功能,而是拆表设计的必然配套。

1.2 连接运算的本质:笛卡尔积加匹配条件

那"连接"底层到底做了什么?一句话:先做笛卡尔积,再用连接条件过滤。

笛卡尔积就是两张表所有行两两配对。users 表 4 行、orders 表 4 行,无条件下配对出来就是 16 行。这 16 行里只有users.id = orders.user_id的那些组合才是有业务意义的,其余全是噪音。连接查询要做的,就是通过ON后面的匹配条件,从笛卡尔积中挑出有效组合。

实际执行时MySQL优化器根本不会真的把16行全构建出来,而是会用索引直接定位匹配行。但理解"笛卡尔积 + 过滤"这个语义模型非常重要,因为它能解释很多诡异现象:比如为什么 JOIN 后行数变多了?因为一对多匹配时,左表一行会被复制成多行。

1.3 内连接与外连接的核心语义对比

抛开语法,先建立整体认知。内连接和外连接对"没有匹配上"的行的处理策略完全不同:

连接类型语义左表无匹配时右表无匹配时
INNER JOIN两边都匹配才出现丢弃丢弃
LEFT JOIN保留左表全部保留,右列补NULL不会发生
RIGHT JOIN保留右表全部不会发生保留,左列补NULL
FULL JOIN两边都全部保留保留,右列补NULL保留,左列补NULL

内连接是求交集,外连接是保留主表全集,非主表求交集以外的部分用 NULL 填充。MySQL 原生不支持 FULL JOIN,后面会讲等价写法。

为了演示,我先建两张测试表,后面所有SQL都可以直接跑:

CREATE TABLE users ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, city VARCHAR(50) DEFAULT '', created_at DATETIME ); CREATE TABLE orders ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0, created_at DATETIME ); INSERT INTO users (id, name, city) VALUES (1, '张三', '北京'), (2, '李四', '上海'), (3, '王五', '广州'), (4, '赵六', '深圳'); INSERT INTO orders (id, user_id, amount, status) VALUES (1, 1, 299.00, 1), (2, 1, 59.90, 0), (3, 2, 150.00, 1), (4, 4, 800.00, 0);

注意数据设计:张三有2个订单,李四、赵六各有1个,王五没有订单。这个结构能把连接查询的各种细节都暴露出来。

2. INNER JOIN:内连接正确写法与运行逻辑

2.1 显式JOIN和隐式JOIN:推荐前者

内连接有两种写法,结果完全一样:

-- 显式连接 SELECT u.name, o.amount FROM users u INNER JOIN orders o ON u.id = o.user_id; -- 隐式连接(老式写法) SELECT u.name, o.amount FROM users u, orders o WHERE u.id = o.user_id;

执行结果都是三条记录:张三299、张三59.9、李四150、赵六800,一共4行。等等,这里张三有两条订单所以是4行结果,对应4个订单记录,王五没有订单所以不出现。

我推荐一律用显式INNER JOIN写法。原因很实际:隐式写法如果哪天忘了写WHERE条件,直接变成笛卡尔积,几万行表和几万行表碰一下就是上亿行,线上环境很容易直接把数据库拖垮。显式写法的ON是强制结构,不写SQL直接报错,天然挡掉一类低级事故。

2.2 ON还是WHERE:内连接里它们等价

在内连接里,条件写ON和写WHERE的结果是一样的:

SELECT u.name, o.amount FROM users u INNER JOIN orders o ON u.id = o.user_id WHERE o.amount > 100; SELECT u.name, o.amount FROM users u INNER JOIN orders o ON u.id = o.user_id AND o.amount > 100;

两条SQL都会返回张三299、李四150、赵六800三行。原因是MySQL优化器会把WHERE条件下推到连接阶段提前过滤,内连接没有"保留未匹配行"的语义,所以条件放哪都一样。

但请记住这个结论:仅限内连接。一旦换成外连接,ON和WHERE就是天壤之别,这个第3章重点讲。

2.3 自连接:员工和经理在一张表里

内连接一个容易忽略的应用是自连接,也就是一张表和自己做JOIN。典型场景是员工表:

CREATE TABLE emp ( id INT PRIMARY KEY, name VARCHAR(50), manager_id INT ); INSERT INTO emp VALUES (1, '刘总', NULL), (2, '张伟', 1), (3, '李静', 1), (4, '王强', 2); SELECT e.name AS employee_name, m.name AS manager_name FROM emp e INNER JOIN emp m ON e.manager_id = m.id;

这里给emp起了两个别名e和m,左表当员工,右表当经理表。结果出来"张伟的经理是刘总、李静的经理是刘总、王强的经理是张伟"。刘总没有经理,内连接不会出现,这正好呼应了"内连接只保留匹配成功的行"。如果想把刘总也列出来,用LEFT JOIN就行。

2.4 非等值连接:ON不止等号

很多人以为ON只能写等值条件。其实连接条件可以是任意布尔表达式,大于、小于、区间都行。生活中最常见的例子是订单金额匹配优惠档位:

CREATE TABLE grade ( id INT PRIMARY KEY, grade_name VARCHAR(50), min_amt DECIMAL(10,2), max_amt DECIMAL(10,2) ); INSERT INTO grade VALUES (1, '普通会员', 0, 100), (2, '白银会员', 100.01, 500), (3, '黄金会员', 500.01, 99999); SELECT o.id, o.amount, g.grade_name FROM orders o INNER JOIN grade g ON o.amount BETWEEN g.min_amt AND g.max_amt;

这种非等值连接也是内连接,只是匹配规则不是"ID相等"而是"金额落在区间里"。理解这一点,ON的灵活性就打开了。

3. 外连接:LEFT JOIN和RIGHT JOIN的核心与致命细节

3.1 LEFT JOIN到底是怎么执行出来的

LEFT JOIN的完整语义是:左表的每一行都要出现在结果里。右表能匹配上,就拼接右表字段;右表匹配不上,右表全部字段填 NULL,左表照样保留。

看执行结果最直观:

SELECT u.id, u.name, o.id AS order_id, o.amount FROM users u LEFT JOIN orders o ON u.id = o.user_id ORDER BY u.id;

结果如下:

idnameorder_idamount
1张三1299.00
1张三259.90
2李四3150.00
3王五NULLNULL
4赵六4800.00

王五没有任何订单,但他的信息仍然保留,订单字段是 NULL。这就是外连接和内连接最本质的区别。

3.2 条件放ON和放WHERE,结果是两个世界

这是外连接里翻车率最高的一点。还是用 orders 表,需求变成:查所有用户,以及他们金额大于100的订单。

写法A,过滤条件放ON:

SELECT u.id, u.name, o.id AS order_id, o.amount FROM users u LEFT JOIN orders o ON u.id = o.user_id AND o.amount > 100 ORDER BY u.id;

写法B,过滤条件放WHERE:

SELECT u.id, u.name, o.id AS order_id, o.amount FROM users u LEFT JOIN orders o ON u.id = o.user_id WHERE o.amount > 100 ORDER BY u.id;

两种写法的结果差异巨大。写法A返回5行:张三有两行(299.00 和 NULL)、李四150、王五NULL、赵六800。张三金额59.9的订单虽然不满足条件,但左表保留语义生效,拼接了NULL。王五依然存在。

写法B返回3行:张三299、李四150、赵六800。王五消失了。为什么?因为WHERE o.amount > 100是在连接完成之后过滤整个结果集,王五那行的 amount 是 NULL,NULL > 100的结果是 NULL,不是 TRUE,于是被过滤掉了。

用白话说:ON里的条件决定"怎么匹配",WHERE里的条件决定"哪些行最终能活下来"。LEFT JOIN 的保底语义在WHERE阶段就失效了。上面写法B的结果,本质上和 INNER JOIN 加 WHERE 没区别。

所以写外连接时先想清楚:

  • 想过滤右表数据,但左表所有主行必须都在 → 条件放ON
  • 想对最终结果做全局裁剪 → 条件放WHERE

3.3 RIGHT JOIN:孤儿订单的展示

RIGHT JOIN和LEFT JOIN完全对称,只是主表换到了右边。实际业务里大家习惯把主表放左边,所以RIGHT JOIN用得少。但有一种场景它很顺手:查所有订单,即使订单对应的用户已经不存在(脏数据孤儿订单)。

我插入一条不存在的用户订单:

INSERT INTO orders (id, user_id, amount, status) VALUES (5, 99, 66.00, 1); SELECT o.id AS order_id, o.amount, u.name FROM users u RIGHT JOIN orders o ON u.id = o.user_id ORDER BY o.id;

结果里订单5会出现,但u.name是 NULL。这种"以右表为主表"的查询,用 RIGHT JOIN 最直观。如果你实在不习惯 RIGHT JOIN,把表的书写顺序调换、LEFT JOIN 也能实现同样效果,完全等价。

3.4 多表连续LEFT JOIN的链式保留问题

三张表以上连接时,LEFT JOIN 的坑会叠加。典型业务:用户 → 订单 → 订单明细。写出如下SQL:

SELECT u.name, o.id AS order_id, i.product_name FROM users u LEFT JOIN orders o ON u.id = o.user_id LEFT JOIN order_items i ON o.id = i.order_id;

这里每个 LEFT JOIN 都是一层"保留左数据集"的操作:第二层 LEFT JOIN 以"用户+订单"为左数据集,订单匹配不到明细时,明细列填 NULL,但订单行照样保留。

一个很容易犯的错是第三个连接条件写错表。比如有人写成i.user_id = u.id,那明细表会跨越订单表去匹配用户,整个数据逻辑就乱了。多表连接时,每一个 ON 条件都应该基于相邻的、刚刚连接进来的表,不要跨表连接。

另一个连锁问题是:多表 LEFT JOIN 后,后面的表字段大量为 NULL,统计时如果用了COUNT(*),这些 NULL 行也都会被算进去,结果和业务理解完全对不上。这个问题在第六章展开。

4. MySQL没有FULL JOIN:全外连接怎么用UNION拼出来

4.1 什么业务需要全外连接

全外连接(FULL JOIN)的语义是:左表和右表的所有行都保留,匹配不上的左右两侧分别补 NULL。什么时候会用到?

典型的对账场景。比如左边是白名单用户表,右边是实际访问记录表。你要清楚知道三件事:正常访问的用户(交集)、白名单里没来的(只在左)、来了但不在白名单的(只在右)。这种"两边都要齐全"的需求,就是 FULL JOIN 的用武之地。

可惜 MySQL 一直没提供原生的FULL OUTER JOIN语法。虽然8.x版本加了不少新特性,但这个空缺始终没补上。实际项目里一般用UNION组合左连接和右连接实现。

4.2 左连接加右连接再UNION

用我们前面的 users 和 orders(包含孤儿订单5),写这样一条SQL:

SELECT u.id AS user_id, u.name, o.id AS order_id, o.amount 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, o.amount FROM users u RIGHT JOIN orders o ON u.id = o.user_id;

执行逻辑拆开看:第一个查询输出"所有用户 + 匹配到的订单",包含王五(订单列NULL);第二个查询输出"所有订单 + 匹配到的用户",包含孤儿订单(用户列NULL)。两个结果集用UNION合并时,交集部分(内连接的行)会重复出现一次,UNION 自动去重,最后得到完整全集:

  • 张三的2个订单
  • 李四的1个订单
  • 王五(用户侧保留,订单NULL)
  • 赵六的1个订单
  • 孤儿订单5(订单侧保留,用户NULL)

这就是 FULL JOIN 的效果。

4.3 大表场景下更省的实现思路

用UNION实现的问题在于:它会把两边查询的完整结果集都算出来,再去做去重排序,大表场景内存压力和临时表开销都不小。

另一种思路是只取两边各自独有的部分,再用开销更低的UNION ALL拼接。MySQL里判断"只属于一边"的经典手法是IS NULL:

SELECT u.id AS user_id, o.id AS order_id FROM users u LEFT JOIN orders o ON u.id = o.user_id WHERE o.id IS NULL UNION ALL SELECT u.id AS user_id, o.id AS order_id FROM users u RIGHT JOIN orders o ON u.id = o.user_id WHERE u.id IS NULL;

第一个查询找出"没有订单的用户"(王五),第二个查询找出"用户不存在的订单"(孤儿订单),两边都没有交集,直接UNION ALL不会重复,还省掉了去重开销。数据量大时这个写法明显更稳。

5. 连接查询的性能优化:执行计划、驱动表与索引

5.1 用EXPLAIN看懂连接执行顺序

连接查询一旦慢,第一步永远是看执行计划。对刚才的内连接跑一次EXPLAIN:

EXPLAIN SELECT u.name, o.amount FROM users u INNER JOIN orders o ON u.id = o.user_id;

输出大致是这样(不同版本字段略有差异):

+----+-------------+-------+--------+---------+--------+------+----------+ | id | select_type | table | type | key | ref | rows | Extra | +----+-------------+-------+--------+---------+--------+------+----------+ | 1 | SIMPLE | o | ALL | NULL | NULL | 5 | NULL | | 1 | SIMPLE | u | eq_ref | PRIMARY | o.user_id | 1 | NULL | +----+-------------+-------+--------+---------+--------+------+----------+

重点看几个信息:

  • table:连接中涉及的表,从上到下通常是执行顺序。
  • type:访问类型。ALL是全表扫描;eq_ref表示按主键或唯一索引精确匹配一行;ref表示按普通索引匹配多行。从快到慢大致是const > eq_ref > ref > range > index > ALL。
  • key:实际用到的索引。
  • rows:预估扫描行数。

这个例子中,orders 表先用全表扫描(5行),然后用o.user_id作为"钥匙"去 users 表按主键精确查找(eq_ref)。执行计划里第二个出现的表就是被驱动表,必须走索引。

5.2 驱动表与被驱动表:谁去驱动,谁必须索引

MySQL处理连接的方式可以理解成两层嵌套循环:外层循环遍历驱动表,每取到一行就去内层表查匹配。内层表就是被驱动表。这个模型下性能关键点非常明确:

  • 驱动表本身扫描多少行,决定了外层循环次数。
  • 被驱动表必须能通过索引快速定位,否则每来一行就全表扫一遍。

优化器一般会选择小表驱动大表,也就是预估行数少的作为驱动表。但在外连接里,LEFT JOIN的左表天然是主表,优化器通常不能随便调整它的驱动位置。所以如果你的 LEFT JOIN 左表特别大、右表连接列又没有索引,这个查询就会非常慢。

实践中最重要的操作就是:给被驱动表的连接列建索引。拿示例来说,如果想以 users 驱动、orders 被驱动,那orders.user_id就要建索引:

ALTER TABLE orders ADD INDEX idx_user_id (user_id);

建完索引后再看执行计划,orders表的访问类型会从ALL变成ref,性能提升立竿见影。

5.3 STRAIGHT_JOIN:想自己指定顺序时的办法

优化器大多数时候是聪明的,但偶尔也会犯浑,特别是在统计信息不准或者过滤条件很复杂时选错驱动表。如果你想强制指定驱动顺序,MySQL 提供了STRAIGHT_JOIN:

SELECT u.name, o.amount FROM users u STRAIGHT_JOIN orders o ON u.id = o.user_id;

这个写法会强制 users 作为驱动表,按书写顺序执行连接。但我建议不到万不得已别乱用:它会让优化器放弃自己计算连接顺序,一旦以后表的数据分布变化,强制顺序可能变成负优化。只有在 EXPLAIN 看到优化器明显选错驱动表、并且你确认自己更了解数据分布时,再考虑它。

5.4 连接列上的隐式转换与字符集问题

还有两个索引失效的常见原因,我必须单独提。

第一个是隐式类型转换。连接列一边是VARCHAR一边是INT,或者一边是字符串存了数字,MySQL 会把字符串转成数字比较,导致索引失效。比如连接条件写成u.id = o.user_id_str,如果两边类型不一致,执行计划里 type 会退化成ALL。写表结构时连接列尽量保持同类型,没有例外。

第二个是字符集和排序规则不一致。一张表是utf8mb4_general_ci,另一张是utf8mb4_unicode_ci,相等判断时无法直接使用索引。排查方法还是看执行计划,发现 key 是 NULL 但连接列明明有索引,优先查这两项。解决办法是统一表字符集:

ALTER TABLE orders CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

6. 连接查询实战经验:行膨胀、COUNT陷阱与NULL处理

6.1 一对多连接带来的行数膨胀

外连接最常见的"意外"是行数膨胀。users 对 orders 是一对多,连接后张三因为有两个订单,他的用户信息会被复制成两行。结果集的行数不是用户数,而是用户和订单匹配关系数 + 未匹配用户数。

很多人拿这个结果集去统计用户总量:

SELECT COUNT(*) FROM users u LEFT JOIN orders o ON u.id = o.user_id;

结果是多少?5。但 users 表只有4个用户。要数用户必须用:

SELECT COUNT(DISTINCT u.id) FROM users u LEFT JOIN orders o ON u.id = o.user_id;

回到4。在写连接查询前,先想清楚"结果集的粒度是什么":如果粒度是订单,那用户信息重复是正常的;如果粒度是用户,就要警惕因为一对多产生的重复行。

6.2 COUNT(*)和COUNT(列)的差异

统计每个用户的订单数时,这个差异最明显:

SELECT u.id, u.name, COUNT(o.id) AS order_cnt FROM users u LEFT JOIN orders o ON u.id = o.user_id GROUP BY u.id, u.name;

结果:张三2、李四1、王五0、赵六1。这里COUNT(o.id)只统计非 NULL 的订单ID,王五没有订单所以是0。如果手滑写成COUNT(*),王五那行会统计到1,因为COUNT(*)把整行都数进去了,包括那行右表全是 NULL 的行。

这个坑在统计报表里特别常见。记住一条规则:多表统计时,想数哪张表的量,就COUNT那张表的主键字段,别用COUNT(*)。

6.3 求差集:LEFT JOIN加IS NULL的经典写法

找"没有订单的用户"是外连接非常经典的应用。写法是 LEFT JOIN 后判断右表主键为 NULL:

SELECT u.id, u.name FROM users u LEFT JOIN orders o ON u.id = o.user_id WHERE o.id IS NULL;

结果只有王五。这里WHERE里的IS NULL判断和前面"过滤条件区分"不矛盾:它不是过滤右表字段值,而是利用"未匹配时右表全为 NULL"的特性筛选出左表独有的记录。判断字段建议用右表的主键(o.id),因为主键本来就不允许为 NULL,用它是绝对安全的标准。

6.4 写连接查询前先想清楚四件事

最后分享我写连接查询前会快速过一遍的检查清单,也当帮你做自查:

  • 结果集的粒度是什么?是主表的行还是明细表的行。
  • 哪种连接类型符合粒度需求?要全集就一定用外连接,不要幻想 WHERE 能救你。
  • 过滤条件该放哪?右表过滤放 ON,全局过滤放 WHERE,千万别混。
  • 统计字段选对了吗?数哪张表就 COUNT 哪张表的主键,警惕 NULL 和重复行。

连接查询的坑,绝大多数都能靠这四个问题提前挡掉。我在实际写SQL前习惯先在草稿上画一画两个集合的关系:求交集、左边全保留、两边全保留,画清楚了再动手,基本不会出方向性错误。这招对刚入门的朋友尤其有用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询