ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MySQL内连接、自然连接与外连接区别及LEFT JOIN实践

MySQL内连接、自然连接与外连接区别及LEFT JOIN实践

1. 先弄清楚这三种连接到底在解决什么问题

很多人写多表查询,写到一半突然卡住,不是因为语法不会,而是因为脑子里没有一张"行是怎么被拼出来的"地图。mysql 自然连接、内连接、外连接的区别这个问题,表面看是三个关键字的用法对比,实际上考的是你对结果集行数来源的理解。我见过太多人把LEFT JOIN写成内连接的语义,查出来的数据"少了几条",排查一下午最后发现是WHERE条件放错了位置。这类问题不需要背语法,需要的是想明白每一步到底在做什么。

这篇文章面向的是已经会写基础SELECT、但面对多表关联时还不够笃定的开发者,也适合那些"能跑就行"但一直没搞懂外连接为什么有时会返回空行的朋友。我会用一份可控的测试数据,把内连接、自然连接、外连接放在同一张桌面上逐行对比,讲清楚它们各自的行是怎么产生的、ON 和 WHERE 为什么在外连接里效果完全不同、NATURAL JOIN为什么在生产代码里几乎见不到。读完你应该能做到:看到一个关联需求,立刻判断该用哪种连接,并且预判出结果会有多少行。

需要提前说明一句:本文的结论基于 MySQL 8.0 的默认行为,部分写法在 5.7 上语法一致但优化器表现略有差异,涉及执行计划的段落我会单独标注。所有测试数据均为虚构,表结构是我按常见业务抽象出来的简化版本,你可以直接复制到本地库复现。

2. 所有连接的共同起点:笛卡尔积

2.1 连接的物理本质是什么

在讲三种连接之前,必须先压住一个底层概念:连接的第一步永远是笛卡尔积。以两张表 A(m 行)和 B(n 行)为例,数据库会先构造出 m × n 行的全部组合,然后再用条件去筛。这个描述在逻辑层面是准确的,物理层面优化器当然不会真的把所有组合都物化出来,但理解逻辑顺序对排查问题极其关键。

为什么这句话重要?因为当你看到一条LEFT JOIN查询返回了意料之外的行数,绝大多数情况不是连接类型写错了,而是连接条件没能唯一确定匹配关系。比如订单表里一个订单有 5 条明细,你用订单去左连明细,结果必然膨胀 5 倍,这不是"左连接有 bug",而是笛卡尔积在起作用,只不过被ON条件裁剪成了 5 行而不是全部组合。

我用一个生活化的类比:你有两摞扑克牌,左边一摞是"用户",右边一摞是"订单"。所谓连接,就是先把左边的每张牌和右边的每张牌都配一次对(这就是笛卡尔积),然后按规则把不符合的配对扔掉。内连接是"把配对成功的留下,其他全扔";左外连接是"左边的牌一张都不能丢,右边配不上的留空白"。你看,区别只在于配对失败时怎么处理,而不在于配对的过程。

2.2 一句话区分三种连接的处理策略

抛开语法细节,三者的差异可以压缩成下面这张表:

连接类型关键字未匹配行的处理结果集行数下界
内连接INNER JOIN/JOIN/,直接丢弃可以为 0
自然连接NATURAL JOIN丢弃,但连接条件由系统自动推断可以为 0
左外连接LEFT [OUTER] JOIN保留左表全部行,右表补 NULL≥ 左表行数
右外连接RIGHT [OUTER] JOIN保留右表全部行,左表补 NULL≥ 右表行数

这张表里最值得盯住的是最后一列。很多人对"左连接一定比内连接返回更多行"有直觉,但说不清为什么,原因就在这:左连接的下界是左表行数,而内连接的下界是 0。当你的业务逻辑是"每个用户都要显示,没下单的显示 0 单",那答案只能是左连接,内连接在语义上就直接排除了没有订单的用户。

自然连接比较特殊,它属于内连接的一个语法变体,行数规则跟内连接完全一致,差别只在于连接条件不是你写的,是系统猜的。这个"猜"字,就是后面所有麻烦的源头。

3. 内连接:最常用,但坑都在条件的位置上

3.1 INNER JOIN 的三种等价写法

内连接是日常写得最多的一种,MySQL 支持三种写法,语义等价但可读性差异很大:

-- 写法一:显式 INNER JOIN(推荐) SELECT u.name, o.order_no FROM users u INNER JOIN orders o ON u.id = o.user_id; -- 写法二:省略 INNER,直接 JOIN(等价,推荐) SELECT u.name, o.order_no FROM users u JOIN orders o ON u.id = o.user_id; -- 写法三:逗号 + WHERE(隐式内连接,不推荐) SELECT u.name, o.order_no FROM users u, orders o WHERE u.id = o.user_id;

三种写法在 MySQL 里会被优化成同一个执行计划,性能上没有区别。但我个人坚决只用前两种,原因很实际:第三种写法把连接条件和过滤条件混在同一个WHERE里,一旦查询变复杂、加上三五张表,你就再也分不清哪个条件是"用来连表的"、哪个条件是"用来筛数据的"。等到某天需要把内连接改成左连接,你会发现无从下手,因为改FROM子句之后,原来的WHERE条件会自动把外连接退化成内连接,结果跟你预期完全不一样。

注意:隐式内连接在 SQL 标准里已经被标为不推荐,MySQL 8.0 的官方文档也明确建议使用显式JOIN语法。别图省那几个字符。

3.2 ON 和 WHERE 在内连接里为什么"没区别"

这是很多人第一个困惑点:都说ON和WHERE在外连接里影响巨大,那在内连接里呢?

答案是在内连接里两者效果完全相同。原因还是回到笛卡尔积:内连接的处理顺序是"先做笛卡尔积 → 用 ON 筛一遍 → 再用 WHERE 筛一遍",但因为两个筛选都是"不满足就丢弃整行",所以把条件写在哪儿,最终结果都一致。优化器甚至会直接把ON条件合并进WHERE一起处理。

我用一组等价查询验证一下:

-- 查询 A:条件写在 ON SELECT u.name, o.order_no FROM users u JOIN orders o ON u.id = o.user_id AND o.status = 1; -- 查询 B:条件写在 WHERE SELECT u.name, o.order_no FROM users u JOIN orders o ON u.id = o.user_id WHERE o.status = 1;

在 MySQL 8.0 上,这两条语句的EXPLAIN输出是逐字段一致的,扫描行数、使用的索引、连接算法都相同。所以你要是只写内连接,这个区别可以暂时不管。

但我要强调一个习惯问题:即使内连接里两者等价,也应该把"连表关系"放ON、"业务过滤"放WHERE。这不是性能要求,是可维护性要求。一旦哪天需求变成"未支付订单也要显示用户信息",你需要把JOIN改成LEFT JOIN,此时如果你的过滤条件在ON里,改起来就是改一个关键字;如果混在WHERE里,你得先拆条件再改,很容易拆错。

3.3 多表内连接时条件放错的连锁反应

三张表以上的内连接是最容易写乱的地方。举个例子,users(用户)、orders(订单)、order_items(订单明细),要查"下过已支付订单的用户及其商品":

SELECT u.name, o.order_no, i.product_name FROM users u JOIN orders o ON o.user_id = u.id JOIN order_items i ON i.order_id = o.id WHERE o.status = 1;

这条写法的执行顺序是:users与orders按user_id关联,结果再与order_items按order_id关联,最后用status过滤。逻辑上没问题,但如果数据量大,status这个条件能不能提前下推到第一步,会显著影响中间结果集的规模。MySQL 优化器通常会自动做这个下推,但当你发现查询慢得离谱时,手动调整写法(比如用子查询先把已支付订单筛出来再连)往往比等待优化器更可靠。

我在实际项目里踩过一次坑:一张 200 万行的订单表关联明细表,WHERE o.status = 1因为status字段选择性太差(90% 都是已支付),优化器估计错误,选了错误的驱动表,查询跑了 40 多秒。后来改成先子查询过滤再连接,降到 1 秒以内。这件事让我养成了一个习惯:看到内连接慢,先用EXPLAIN看驱动表是谁,再看过滤条件有没有生效在驱动表上。

4. 自然连接:看上去省事,实际埋着三颗雷

4.1 NATURAL JOIN 系统到底帮你做了什么

自然连接的语法极简,连ON都不用写:

SELECT u.name, o.order_no FROM users u NATURAL JOIN orders o;

它的规则是:自动找出两张表中"列名相同"的所有列,用这些列做等值连接,并且结果集中这些同名列只保留一份。注意,是"所有列名相同的列",不是"所有语义相关的列"。这一点是理解所有问题的钥匙。

假设users表结构是(id, name, created_at, updated_at),orders表结构是(id, order_no, user_id, created_at, updated_at)。你写NATURAL JOIN,系统会自动用id、created_at、updated_at三列做连接条件,等价于:

ON u.id = o.id AND u.created_at = o.created_at AND u.updated_at = o.updated_at

看出问题了吗?id本来是用户主键,orders.id是订单主键,这两个字段只是名字碰巧相同,语义上毫无关系。用它们做等值连接,结果几乎必然是空集。这就是第一颗雷:同名列的语义错配。

4.2 三颗雷:名字撞车、审计字段、隐式转换

我把它拆开讲清楚,这三条是我在代码评审里反复强调的:

第一颗雷,主键撞名。如前所述,绝大多数业务表都有id主键,NATURAL JOIN会把它们当成连接条件,直接导致结果错误。这个问题在测试数据少的时候可能表现为"返回空",看起来像 bug,排查方向很容易跑偏到"是不是没数据"。

第二颗雷,审计字段。created_at、updated_at、deleted_at、tenant_id这些字段在规范化的表设计里几乎处处都有。用NATURAL JOIN时,它们全会变成连接条件的一部分。即使数据恰好能对上(比如同一秒创建的记录),也会让查询计划变得极其诡异。更麻烦的是tenant_id这种字段,虽然语义上确实应该一致,但把它放进连接条件会让索引选择变得不可控。

第三颗雷,隐式类型转换导致索引失效。如果两张表的同名列类型不完全一样(比如一张是INT,一张是BIGINT,或者一张有符号一张无符号),MySQL 会在比较时做隐式转换。一旦发生隐式转换,该列上的索引就用不上了,查询会退化成全表扫描。这个问题最阴险,因为语法上完全合法,结果也"看起来对",只是慢。我在一个报表查询里遇到过,NATURAL JOIN写得好好的,数据也正确,就是从 0.1 秒变成 8 秒,查了半天才发现是同名列的类型差异。

-- 快速检查两张表同名列的类型是否一致 SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'your_db' AND TABLE_NAME IN ('users', 'orders') ORDER BY COLUMN_NAME, TABLE_NAME;

执行这条查询,把结果按COLUMN_NAME分组看,凡是同一个列名出现两行且COLUMN_TYPE不一致的,都是潜在的风险点。

4.3 USING 子句:我给自然连接的替代方案

如果确实想享受"不用写等值条件"的便利,我会推荐USING,而不是NATURAL JOIN:

SELECT name, order_no FROM users JOIN orders USING (user_id);

USING的好处非常明确:

  • 连接列由你显式指定,不存在自动推断的风险
  • 结果集中该列只保留一份,省去了u.user_id/o.user_id的歧义问题(注意SELECT *时这一列不会重复出现)
  • 语法检查严格,写错列名会直接报错,而不是像NATURAL JOIN那样"悄悄地用另一套条件跑出结果"

USING的局限也存在:它只支持等值连接,不适用于不等值关联(比如"找出金额在某个区间内的订单")。所以我给的建议是:等值连接且连接列名完全相同时可以用USING,其他一律写完整的ON。至于NATURAL JOIN,我的态度很明确——读别人的代码要能看懂,自己写就别用了。

提示:NATURAL JOIN与NATURAL LEFT JOIN、NATURAL RIGHT JOIN是一套语法。后者虽然保留了左表(或右表)的全部行,但连接条件依然是自动推断的,雷一个不少。别以为加上LEFT就安全了。

5. 外连接:真正的难点在 ON 和 WHERE 的分工

5.1 LEFT JOIN 的执行模型,用一张图说清楚

左连接的处理可以拆成三步,我按顺序写:

  1. 取左表的每一行(这是"驱动表",一行都不能少)
  2. 去右表找满足ON条件的行
  3. 找到了就拼接输出;找不到就用 NULL 填充右表的所有列,输出一行

右连接正好反过来,以右表为驱动表。理解这三步之后,很多"诡异"的结果都能解释。比如:

SELECT u.name, o.order_no FROM users u LEFT JOIN orders o ON o.user_id = u.id AND o.status = 1;

这条查询返回的是"所有用户,以及他们已支付的订单号"。没下过单的用户会出现在结果里,订单号是 NULL;下过单但全是未支付的用户也会出现,订单号同样是 NULL。这正好符合"每个用户都要显示"的业务需求。

对比一下等价改法:

SELECT u.name, o.order_no FROM users u LEFT JOIN orders o ON o.user_id = u.id WHERE o.status = 1;

这条查询的语义完全变了。WHERE是在连接完成之后执行的,此时没匹配上的行里o.status是 NULL,NULL = 1的结果是UNKNOWN,不是TRUE,所以整行被过滤掉。左连接被WHERE条件强行退化成内连接,那些没有已支付订单的用户全部消失。这是外连接里最高频的 bug,没有之一。

5.2 把过滤条件"嵌进"连接子查询的写法

上面那个需求,除了把条件放ON里,还有一种写法在复杂场景下更清晰:

SELECT u.name, t.order_no FROM users u LEFT JOIN ( SELECT user_id, order_no FROM orders WHERE status = 1 ) t ON t.user_id = u.id;

这种写法的优势在于:当过滤逻辑很复杂(涉及多个条件、子查询、聚合)时,ON后面的表达式会变得又长又难读,把它塞进一个派生表里,主查询的结构就干净了。代价是 MySQL 需要先物化这个派生表,在 8.0 之前这可能带来额外的开销,8.0 之后优化器能更好地做条件下推,实际影响通常可以接受。

我在做报表类查询时更倾向第二种,因为报表的过滤条件往往一改再改,藏在ON里的条件改起来容易顾此失彼。而在 OLTP 的简单查询里,直接写ON更省事。

5.3 MySQL 没有 FULL OUTER JOIN,三种替代写法实测

MySQL 至今不支持FULL OUTER JOIN,这是很多从其他数据库迁移过来的人会踩的坑。如果确实需要"两边的行都要保留",有三种替代方案:

方案一:UNION 拼接左右连接

SELECT u.name, o.order_no FROM users u LEFT JOIN orders o ON u.id = o.user_id UNION SELECT u.name, o.order_no FROM users u RIGHT JOIN orders o ON u.id = o.user_id;

注意这里我用了UNION而不是UNION ALL。因为两侧查询的"匹配成功"部分是完全重复的,必须去重。如果业务上可以接受重复,或者你确信不存在重复(比如其中一侧不可能有匹配),用UNION ALL性能会好一些。

方案二:UNION ALL 配合反连接

SELECT u.name, o.order_no FROM users u LEFT JOIN orders o ON u.id = o.user_id UNION ALL SELECT NULL, o.order_no FROM orders o WHERE NOT EXISTS (SELECT 1 FROM users u WHERE u.id = o.user_id);

这种写法的思路是:左连接负责"左表全部 + 匹配部分",反连接(NOT EXISTS)负责"右表中没被匹配上的部分",两部分拼起来正好是全外连接,且没有去重开销。在数据量大的时候,这个版本通常比UNION快。

方案三:业务层拆分查询。说实话,我在实际项目里从没真正需要过全外连接。绝大多数业务需求都能拆成两个独立的查询,在应用层合并,可读性和可控性反而更好。全外连接更多是 SQL 教学里的概念题,真到生产环境,先问一句"这个需求是不是可以换个角度表达",往往能省掉很多麻烦。

5.4 右连接为什么用得少

RIGHT JOIN在语义上跟LEFT JOIN完全对称,没有任何功能上的独有性。实际代码里少见的原因很朴素:人的阅读习惯是从左到右,左表是"主体",右表是"附属",用LEFT JOIN表达"主体全部保留"最自然。

-- 这两条完全等价 SELECT u.name, o.order_no FROM users u LEFT JOIN orders o ON u.id = o.user_id; SELECT u.name, o.order_no FROM orders o RIGHT JOIN users u ON u.id = o.user_id;

我的建议是统一用LEFT JOIN。如果遇到需要右连接的场景,把两张表的位置调换一下再写左连接,可读性会更好。混用左右连接是代码审查里我最容易看花眼的地方之一,尤其是在多表连接的中间层,一个RIGHT JOIN夹在几个LEFT JOIN之间,维护者的心智负担会陡增。

6. 建表实测:同一份数据跑四种连接

6.1 测试数据准备

光说理论不够直观,我准备了一份最小可复现的数据。表结构做了简化,但保留了线上环境常见的几个特征:主键列名相同、审计字段相同、存在 NULL 值、存在无法匹配的行。

CREATE TABLE users ( id INT PRIMARY KEY, name VARCHAR(20), created_at DATETIME ); CREATE TABLE orders ( id INT PRIMARY KEY, user_id INT, order_no VARCHAR(20), created_at DATETIME ); INSERT INTO users VALUES (1, 'A同学', '2024-01-01 10:00:00'), (2, 'B同学', '2024-01-02 10:00:00'), (3, 'C同学', '2024-01-03 10:00:00'), (4, 'D同学', '2024-01-04 10:00:00'); INSERT INTO orders VALUES (101, 1, 'SO001', '2024-02-01 10:00:00'), (102, 1, 'SO002', '2024-02-02 10:00:00'), (103, 2, 'SO003', '2024-02-03 10:00:00'), (104, NULL, 'SO004', '2024-02-04 10:00:00'), (105, 9, 'SO005', '2024-02-05 10:00:00');

这份数据的设计意图很明确:

  • A同学有 2 个订单,用于观察一对多导致的行膨胀
  • B同学有 1 个订单,正常的匹配情况
  • C同学、D同学没有订单,用于观察外连接补 NULL
  • SO004的user_id是 NULL,用于观察 NULL 作为连接键的表现
  • SO005的user_id是 9,在users表里不存在,用于观察"右表孤儿行"
  • 两张表的id和created_at列名相同但语义无关,正好用来演示NATURAL JOIN的翻车

6.2 四组查询的结果逐行对比

查询一:内连接

SELECT u.id AS uid, u.name, o.id AS oid, o.order_no FROM users u JOIN orders o ON u.id = o.user_id;

结果 3 行:A同学对应SO001、SO002,B同学对应SO003。C同学、D同学因为没有匹配被丢弃,SO004(user_id 为 NULL)和SO005(user_id 为 9)因为左表找不到对应也被丢弃。

查询二:自然连接

SELECT u.id, u.name, o.order_no FROM users u NATURAL JOIN orders o;

结果0 行。原因很直接:系统自动用了id和created_at两列做连接条件。users.id是 1~4,orders.id是 101~105,光主键这一条就不可能匹配;就算主键碰巧有交集,created_at的值也完全不同。这就是我前面说的"名字撞车"最典型的翻车现场。

如果你把这条语句换成显式写法,就能看出它到底在做什么:

EXPLAIN FORMAT=TREE SELECT u.id, u.name, o.order_no FROM users u NATURAL JOIN orders o;

EXPLAIN的输出里会明明白白列出实际使用的连接列。当你不确定NATURAL JOIN用了哪些列时,这是最靠谱的确认方式,比翻文档快得多。

查询三:左外连接

SELECT u.id AS uid, u.name, o.order_no FROM users u LEFT JOIN orders o ON u.id = o.user_id;

结果 5 行:A同学两行(SO001、SO002),B同学一行(SO003),C同学一行(order_no为 NULL),D同学一行(order_no为 NULL)。注意行数不是 4 而 是 5,因为A同学有两个订单,一对多的关系导致左表的这一行被展开成了两行。"左连接返回左表行数"这句话只在"右表至多匹配一行"时成立,一旦右表有多条匹配,行数就会膨胀。这个细节经常被忽略。

查询四:右外连接

SELECT u.id AS uid, u.name, o.order_no FROM users u RIGHT JOIN orders o ON u.id = o.user_id;

结果 4 行:A同学两行、B同学一行、SO004一行(name为 NULL,因为user_id是 NULL,NULL 不参与等值匹配)、SO005一行(name为 NULL,因为user_id为 9 找不到用户)。

这里有个非常值得注意的点:SO004的user_id是 NULL,它没有被匹配到,但在右连接里它被保留了下来。这说明 NULL 不是"匹配失败后被丢弃",而是"作为连接键时永远不匹配,但作为驱动表的行时依然被保留"。这两件事经常被混为一谈。

6.3 结果对照表:一眼看清差异

数据行内连接自然连接左连接右连接
A同学 + SO001保留无保留保留
A同学 + SO002保留无保留保留
B同学 + SO003保留无保留保留
C同学 + 无订单丢弃无保留(补 NULL)丢弃
D同学 + 无订单丢弃无保留(补 NULL)丢弃
SO004(user_id 为 NULL)丢弃无丢弃保留(补 NULL)
SO005(user_id 为 9)丢弃无丢弃保留(补 NULL)
结果行数3054

这张表建议你保存下来。下次写关联查询之前先问自己一句:"我要的结果里,哪些行是必须出现的?"如果是左表的全部行,左连接;如果是右表的全部行,右连接;如果两边只需要交集,内连接。自然连接?从这张表的结果就能看出来,它在真实数据上大概率不是你想要的东西。

7. 常见问题与排查实录

7.1 连接之后行数暴涨,先别急着怀疑连接类型

这是最常见的求助场景:明明两边都是 1000 行,连接完变成 50 万行。原因几乎总是用于连接的键在某一侧不唯一。

排查套路我固定用这三步:

-- 第一步:确认连接键在两侧的唯一性 SELECT COUNT(*) AS total, COUNT(DISTINCT user_id) AS uniq FROM orders; -- 第二步:找出重复的键和重复次数 SELECT user_id, COUNT(*) AS cnt FROM orders GROUP BY user_id HAVING cnt > 1 ORDER BY cnt DESC LIMIT 10; -- 第三步:确认业务上是否允许重复(比如订单明细天然一对多)

如果第一步跑出来total远大于uniq,说明右表在连接键上是一对多,行膨胀是预期行为,不是 bug。如果业务上确实希望"每个用户只出一行",那要么先用聚合把右表收敛到唯一,要么改用子查询取"最新一条"或"金额最大的一条"。硬连是解决不了的。

注意:COUNT(DISTINCT col)会忽略 NULL。如果你的连接键允许为 NULL,这个统计结果会偏小,需要额外处理。

7.2 左连接查出来的数据"变少了"

症状:明明写的是LEFT JOIN,但结果里没有那些"右边没数据"的行。

原因基本锁定在WHERE条件上。前面讲过,WHERE o.xxx = ?会把 NULL 行过滤掉。排查方法很直接:把WHERE里所有涉及右表的条件,逐个挪到ON里试,看结果行数怎么变。

-- 退化写法:WHERE 里有右表条件 SELECT u.name, o.order_no FROM users u LEFT JOIN orders o ON u.id = o.user_id WHERE o.status = 1; -- 这一行是罪魁祸首 -- 修正写法:条件挪进 ON SELECT u.name, o.order_no FROM users u LEFT JOIN orders o ON u.id = o.user_id AND o.status = 1;

还有一个隐蔽的变体,条件不是写在WHERE里,而是写在HAVING里,效果一样,也会把 NULL 行干掉。看到LEFT JOIN配HAVING就要提高警惕。

7.3 NULL 相关的三个高频误区

误区一:NULL = NULL为真。在 SQL 里,任何值与NULL做比较,结果都是UNKNOWN(三值逻辑),不是TRUE也不是FALSE。所以ON a.col = b.col里如果两侧都是 NULL,匹配不成立。这就是为什么SO004那条记录,无论左连接还是内连接都匹配不上任何人。

要判断 NULL,只能用IS NULL/IS NOT NULL,或者用<=>(NULL 安全等值):

-- 这两条等价 SELECT * FROM orders WHERE user_id IS NULL; SELECT * FROM orders WHERE user_id <=> NULL; -- NULL 安全等值可以用来匹配两侧都为 NULL 的情况 SELECT * FROM users u JOIN orders o ON u.id <=> o.user_id;

<=>是个冷门但好用的运算符。用它做连接条件,两侧都是 NULL 时会被判定为匹配。实际业务里这个需求不多,但知道它存在,能避免在排查时走弯路。

误区二:COUNT(*)和COUNT(列名)结果不同。左连接之后统计数量,COUNT(*)会把补 NULL 的行也算进去,COUNT(o.id)则只统计真正匹配上的行。

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

对C同学来说,cnt_all是 1,cnt_matched是 0。如果你的业务是"统计每个用户的订单数",必须用COUNT(o.id),用COUNT(*)会把没下单的用户算成 1 单,这个 bug 特别隐蔽,因为它在"大部分用户都有订单"的数据上看起来完全正常。

误区三:SUM遇到 NULL 的处理。SUM会忽略 NULL,这和COUNT的行为一致,所以SUM(o.amount)在左连接里不会因为补 NULL 而报错,结果是NULL而不是 0。如果要显示 0,得用COALESCE(SUM(o.amount), 0)。这个小细节在报表里天天遇到。

7.4 常见问题速查表

现象最可能的原因快速验证
自然连接返回 0 行同名列语义不匹配(如两侧id)EXPLAIN FORMAT=TREE看实际连接列
左连接结果少了行WHERE里有右表条件把条件挪进ON再看行数
连接后行数翻倍右表在连接键上不唯一COUNT(*)对比COUNT(DISTINCT 键)
查询突然变慢同名列类型不一致,索引失效information_schema.COLUMNS查COLUMN_TYPE
COUNT结果偏大用COUNT(*)统计左连接改成COUNT(右表主键)
右表 NULL 行没出现连接键为 NULL,等值比较不成立用<=>或先过滤 NULL
全外连接语法报错MySQL 不支持FULL OUTER JOIN改用UNION或反连接拼接

8. 选型建议与几个性能层面的实操心得

8.1 什么场景用哪种连接,我给一张决策表

写查询时我的判断顺序是这样的,基本能覆盖 90% 的情况:

业务需求推荐写法理由
只要两表都有的数据INNER JOIN语义最直接,优化器选择最多
左表全部保留,右表可有可无LEFT JOIN+ 条件放ON语义明确,不易误退化
右表全部保留调换表序改LEFT JOIN可读性优于RIGHT JOIN
找出"左表有但右表没有"LEFT JOIN ... WHERE 右表主键 IS NULL反连接标准写法
连接列名同名且等值JOIN ... USING (col)省去列名歧义,安全
需要两边全部保留UNION或UNION ALL+ 反连接MySQL 无全外连接
任意场景避免NATURAL JOIN连接条件不可控

关于"反连接"那条,展开说一句。LEFT JOIN ... WHERE b.id IS NULL和NOT EXISTS在 MySQL 8.0 里通常会被优化成相同的执行计划,性能相当。但前者有个陷阱:如果b.id本身允许为 NULL,IS NULL会把"匹配上了但值为 NULL"的行也混进来。所以判断"右表没有匹配行"时,一定要用右表的主键或非空列做IS NULL判断,别用可能为空的业务列。

8.2 索引该怎么建:连接列上必须有索引

外连接的性能,很大程度取决于被驱动表的连接列上有没有索引。以users LEFT JOIN orders ON users.id = orders.user_id为例,users是驱动表,orders是被驱动表,那么orders.user_id上必须有索引,否则每一行左表数据都要去右表做一次全表扫描。

-- 被驱动表的连接列建索引 ALTER TABLE orders ADD INDEX idx_user_id (user_id); -- 如果连接条件里还带业务过滤,考虑联合索引 ALTER TABLE orders ADD INDEX idx_user_status (user_id, status);

联合索引的字段顺序很讲究:连接列在前,过滤列在后。因为 MySQL 使用索引时遵循最左前缀原则,(user_id, status)可以同时服务于"按 user_id 连接"和"连接后按 status 过滤"两个动作。反过来(status, user_id)就只能用上status,连接时还是要回表,效率差很多。

我在一个批量导出功能里做过对比测试:同样是 10 万用户关联 50 万订单,orders.user_id无索引时耗时 12 秒以上,加了索引后降到 0.3 秒。这个差距说明,讨论"左连接快还是内连接快"意义不大,索引才是决定性的。内连接因为可以在优化器阶段做更多变换(比如把过滤条件下推、调整驱动表顺序),在缺索引的情况下往往比外连接表现好一些,但这不应该成为选择连接类型的理由。

8.3 用 EXPLAIN 验证,别靠猜

最后分享一个我的固定习惯:任何涉及两张以上表的查询,上线前必须跑一遍EXPLAIN。不是走形式,是看三个关键列:

  • type:如果是ALL,说明在做全表扫描,考虑加索引
  • rows:预估扫描行数,如果远大于实际需要的行数,说明条件没生效
  • Extra:出现Using join buffer通常意味着被驱动表没走索引;出现Using temporary; Using filesort说明有额外的排序开销

MySQL 8.0 的EXPLAIN FORMAT=TREE更直观,它会用树形结构展示实际的连接顺序和每个节点的代价,比传统表格版更容易看出"谁驱动谁"。对于NATURAL JOIN这种连接条件不透明的写法,这个命令几乎是唯一的排查手段。

EXPLAIN FORMAT=TREE SELECT u.name, o.order_no FROM users u LEFT JOIN orders o ON u.id = o.user_id WHERE o.status = 1;

跑完你会清楚看到status这个条件是在连接前还是连接后生效的。如果发现它没有下推到orders表的扫描阶段,那就是我前面说的"退化成内连接"或者"索引没用上",需要回头调写法。

我个人在实际操作中的体会是,这三种连接的区别,本质上是**"匹配失败的行怎么办"**这一个问题的三种答案。内连接说丢弃,自然连接说丢弃但条件我来定,外连接说保留并补 NULL。把这个核心想透,剩下的语法、索引、优化都是在这个基础上的延伸。真正容易出错的从来不是记不住关键字,而是没想清楚"我到底要哪些行出现在结果里"。写查询之前先用一句话把需求说清楚——"我要所有用户,包括没下单的"——连接类型自然就选出来了,比对着文档一个个试要快得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进