ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

MySQL自然连接、内连接、外连接区别与选型避坑指南

MySQL自然连接、内连接、外连接区别与选型避坑指南

mysql 自然连接、内连接、外连接,这三个词摆在一起,几乎每本 SQL 入门书都会用同一张示意图一笔带过。可真到了线上,翻车的往往就是它们之间那些"看着差不多、实际差很远"的细节。我印象比较深的一次,是帮人排查一张报表:员工表和部门表关联,预期几十行,跑出来只剩三行。SQL 拿过来一看,NATURAL JOIN三个字赫然在目,而这两张表刚好都有一列create_time,自然连接把所有同名列都当成了连接条件,结果自然就空了。这类问题不会报错,只会安静地把数据变少或者变多,排查起来极其耗时。

这篇就围绕 MySQL 里这三类连接的语义差异、执行路径和实际选型展开。我会先把三种连接放在同一份数据上跑出结果做对照,让人一眼看清区别;再分别拆内连接、外连接、自然连接各自的坑点;最后落到优化器怎么看这些语句,以及实际项目里该怎么选。适合已经能写基本 SQL、但对连接语义边界还没完全吃透的开发者,也适合做数据核对、报表开发的同学拿来当检查清单用。

1. 三种连接先用同一份数据跑出结果对照

1.1 连接的本质:先做笛卡尔积,再按条件筛

理解连接,最省事的办法是先记住一句话:任何连接的第一步都是笛卡尔积,ON 条件只是在这个中间结果上做过滤。假设 A 表有 m 行,B 表有 n 行,那么两表连接产生的中间集合就是 m×n 行,然后再根据连接条件挑出符合条件的行。这个心智模型一旦建立,内连接、外连接、自然连接的区别就全部变成了"过滤规则不同"和"保留哪些行"的问题。

外连接之所以特殊,是因为它在笛卡尔积过滤之后,还多了一步"把没匹配上的驱动表行补回来,另一侧用 NULL 填充"。自然连接之所以危险,是因为它的过滤条件不是你写的,而是数据库根据列名自动推断的。内连接则是三者里语义最直白的:只保留两边都能匹配上的行,匹配不上的直接丢弃。

为了后面能对照着看,先建两张极简的表。员工表四个人,其中一个人没有分配部门;部门表三个部门,其中一个部门没有员工。这种"两边都有孤儿数据"的结构,最能暴露三种连接的差异。

CREATE TABLE employees ( id INT PRIMARY KEY, name VARCHAR(32), dept_id INT ); CREATE TABLE departments ( id INT PRIMARY KEY, dept_name VARCHAR(32) ); INSERT INTO employees VALUES (1, '张三', 10), (2, '李四', 20), (3, '王五', NULL), (4, '赵六', 10); INSERT INTO departments VALUES (10, '研发部'), (20, '市场部'), (30, '行政部');

注意employees表和departments表都有一个叫id的列。这不是我随手起的名字,而是真实项目里极常见的命名习惯——每张表都用id做主键。这个命名习惯本身没问题,但它和自然连接凑在一起,就是事故的起点。

1.2 同一份数据在三种连接下的输出差异

把三种写法放在一起跑,结果差异一目了然。下面的对照表按输出行数和关键特征整理,方便直接比对。

写法输出行数张三(10)王五(NULL)行政部(30)
INNER JOIN ... ON e.dept_id = d.id3保留丢弃丢弃
LEFT JOIN ... ON e.dept_id = d.id4保留保留,右表全 NULL丢弃
RIGHT JOIN ... ON e.dept_id = d.id4保留丢弃保留,左表全 NULL
NATURAL JOIN0丢弃丢弃丢弃

前三行的行为是符合直觉的:内连接只保留能配对的,左连接保住左表全部,右连接保住右表全部。第四行的结果才是真正值得注意的地方——NATURAL JOIN输出 0 行。原因在于它自动把两张表所有同名列都拿来当连接条件,也就是employees.id = departments.id AND employees.dept_id = departments.id,而员工 id 是 1 到 4,部门 id 是 10、20、30,两个条件同时成立的行根本不存在。

这个例子里,业务语义上正确的连接条件应该是e.dept_id = d.id,但自然连接自动推导出的是e.id = d.id,条件本身就用错了。它不会给你任何警告,也不会在语法检查阶段报错,只会安静地返回一个错误的结果集。

1.3 自然连接在工程实践里基本被禁用的原因

把自然连接的问题归纳一下,核心是三条。第一,连接条件隐式化:条件不在 SQL 文本里,读代码的人必须去查两张表的完整结构才能知道它连了什么,代码审查时几乎无法被发现。第二,对表结构变更零防御:任何人往任意一张表加一个同名同类型的列,连接条件就悄悄变了,比如两张表都加tenant_id,自然连接立刻变成多条件连接,结果可能从"数据偏多"变成"数据偏少"。第三,没有同名列时退化成笛卡尔积:如果两张表恰好一列同名都没有,NATURAL JOIN的行为等价于CROSS JOIN,行数直接爆炸。

我在实际项目里的做法很明确:业务代码里一律不写NATURAL JOIN,只在一次性探索数据、临时写 ad-hoc 查询时偶尔用一下,而且用完立刻丢掉。团队里如果有新人写了这个,代码评审阶段就应该拦下来。这条规则听起来有点绝对,但比起事后排查数据对不上的成本,绝对一点更划算。

2. 内连接:看起来最简单,写错的方式却最多

2.1 ON 和 WHERE 在内连接里到底等不等价

网上流传很广的一个说法是"内连接里 ON 和 WHERE 完全等价,随便写"。这句话在结果集层面基本成立,因为优化器会把内连接的 ON 条件合并进 WHERE 一起处理,执行计划通常一致。但"结果一样"和"应该这么写"是两码事,我建议严格区分:连接条件放 ON,行过滤条件放 WHERE。原因有两个。

一是可读性。一条涉及四五张表的查询,如果所有条件都堆在 WHERE 里,读的人根本分不清哪个条件是表 A 和表 B 的连接键、哪个是业务过滤。等你哪天要把内连接改成左连接,就得从头捋一遍每个条件该往哪挪。二是可维护性。内连接改成外连接时,原本堆在 WHERE 里的连接条件如果不挪到 ON 里,语义立刻出错——这个问题在外连接那一节会详细讲。

有一种情况 ON 和 WHERE 确实不等价:ON 条件里包含子查询或者非确定性函数时,优化器的处理路径可能不同。不过这种写法本身就少见,也不推荐,正常业务查询尽量别在 ON 里塞子查询。

2.2 USING 关键字:列合并带来的输出变化

USING是介于ON和NATURAL JOIN之间的一个折中语法。它要求你显式写出列名,但会像自然连接一样把同名输出列合并成一列。

-- 用 ON 时,两表的 id 列都会输出,SELECT * 有 5 列 SELECT * FROM employees e JOIN departments d ON e.dept_id = d.id; -- 用 USING 时,前提是两表有同名列,同名列只输出一次 SELECT * FROM employees e JOIN departments d USING (dept_id);

第二句要求departments表也有dept_id列,这在实际表结构里不一定成立,所以USING的适用范围比想象中窄。它的坑在于:如果程序里用SELECT *然后按列位置取值(比如某些 ORM 的简易映射或者老式的 ResultSet 按索引读),USING会让输出列数比ON少,字段映射直接错位。我的建议是,生产代码里永远不要SELECT *做表连接,把需要的列一个个写出来,既避免这个问题,也减少不必要的回表和数据传输。

2.3 隐式连接与显式 JOIN 的执行差异

老派写法用逗号加 WHERE:

SELECT e.name, d.dept_name FROM employees e, departments d WHERE e.dept_id = d.id;

这种写法在 MySQL 里会被优化器改写成显式 JOIN,执行计划通常完全一致,性能上没有区别。它的真正风险是漏写 WHERE 条件。一旦条件漏了,语句照样能跑,返回的是完整的笛卡尔积。表小的时候察觉不到,表一大就是灾难。显式JOIN ... ON语法强制你把连接条件写在结构里,虽然理论上也能写成JOIN不带ON(等价于CROSS JOIN),但至少视觉上缺失得很明显,代码评审容易发现。

顺带说一句,MySQL 里JOIN、INNER JOIN、CROSS JOIN三者语法上是等价的,CROSS JOIN也可以带ON条件。这跟某些数据库的实现不一样,跨库迁移的时候要注意。

2.4 多表内连接时,书写顺序不等于执行顺序

三张以上的表做内连接,SQL 里谁写在前面,不代表数据库就先读谁。MySQL 优化器会基于统计信息和成本估算决定驱动表,可能把你写在最后面的表当成驱动表。想确认实际执行顺序,看EXPLAIN的id和table列,id相同的行从上到下大致反映连接顺序,但id不同就要结合select_type判断。

如果确实需要强制某张表做驱动表(比如小表驱动大表能显著减少扫描量),可以用STRAIGHT_JOIN强制按书写顺序连接,或者用优化器提示:

SELECT /*+ JOIN_ORDER(d, e) */ e.name, d.dept_name FROM employees e JOIN departments d ON e.dept_id = d.id;

不过这类 hint 属于"最后手段",先确认统计信息是不是过期、索引是不是缺失,比上来就写 hint 更靠谱。我见过不少项目堆了一堆 hint,实际上是ANALYZE TABLE很久没跑、基数估算严重失真导致的。

3. 外连接:左右之分背后是"谁是主表"的问题

3.1 LEFT JOIN 的语义拆解与 NULL 填充位置

左连接的规则可以概括成一句话:左表每一行都必须在结果里出现至少一次,右表匹配不上就填 NULL。这个"每一行"是关键。很多人以为左连接是"左表所有列都保留、右表尽量匹配",其实更准确的理解是:结果集的行数以左表为基准,右表的匹配情况只决定每行右半部分有没有值。

右连接就是把左右对调,逻辑完全对称。工程上我基本只用LEFT JOIN,RIGHT JOIN只在一个场景下用——多表连接时前一张表已经被左连接固定住,后面要接的表需要以它为主,这时候用右连接可以让 SQL 的书写顺序和语义顺序一致。但这种场景不多,绝大多数情况下用左连接加调整书写顺序就能表达清楚,可读性更好。

NULL填充的具体位置也要注意:外连接产生的是"真正的 NULL",不是空字符串,也不是 0。这在后续做COUNT、SUM、字符串拼接时都会带来影响,后面专门说。

3.2 ON 和 WHERE 在外连接里的分工,写反了结果全错

这是外连接最容易出错的地方,也是我见过最多的线上问题。规则很简单:外连接的过滤条件写在 WHERE 里,会把外连接退化成内连接。

-- 错误写法:想要"所有员工 + 他们所在的研发部",结果只剩研发部员工 SELECT e.name, d.dept_name FROM employees e LEFT JOIN departments d ON e.dept_id = d.id WHERE d.dept_name = '研发部'; -- 正确写法:过滤条件放进 ON SELECT e.name, d.dept_name FROM employees e LEFT JOIN departments d ON e.dept_id = d.id AND d.dept_name = '研发部';

第一句为什么错?因为王五没有部门,右表的dept_name是 NULL,NULL = '研发部'的结果是 UNKNOWN,在 WHERE 里被当作不成立,整行被过滤掉。于是"所有员工"变成了"研发部员工",左连接的语义被悄悄吃掉。

第二句把条件放进 ON,过滤只作用于"右表能不能匹配上",不匹配的照样补 NULL 保留左表行。这才是左连接该有的行为。

这里要澄清一点:并不是说 WHERE 里永远不能出现右表字段。如果业务需求本身就是"只要那些有部门且部门是研发部的员工",那写 WHERE 也完全正确,只是这时候用INNER JOIN更直白。判断标准是:你先想清楚这一行"该不该保留",再决定条件放哪,而不是先写 SQL 再倒推语义。

3.3 MySQL 没有 FULL OUTER JOIN,怎么模拟

MySQL 至今不支持FULL OUTER JOIN语法。要表达"两边都保留"的语义,标准做法是左连接和右连接取并集:

SELECT e.name, d.dept_name FROM employees e LEFT JOIN departments d ON e.dept_id = d.id UNION SELECT e.name, d.dept_name FROM employees e RIGHT JOIN departments d ON e.dept_id = d.id;

注意这里用UNION而不是UNION ALL。因为两边都能匹配上的行会在两个查询里各出现一次,用UNION会自动去重。但如果结果集里本来就有完全重复的业务行(比如两个同名员工同一个部门),去重会把它们合并,这时候就得改用反连接的方式拼:先取左连接的完整结果,再UNION ALL上"右表有、左表没有"的那部分。

SELECT e.name, d.dept_name FROM employees e LEFT JOIN departments d ON e.dept_id = d.id UNION ALL SELECT NULL, d.dept_name FROM departments d WHERE NOT EXISTS (SELECT 1 FROM employees e WHERE e.dept_id = d.id);

这种写法啰嗦,但能精确控制去重行为。实际做数据核对、找"两边对不上的记录"时,反连接(LEFT JOIN ... WHERE 右表主键 IS NULL或者NOT EXISTS)比模拟全外连接更常用、也更快。

3.4 外连接配 COUNT 时的经典数字陷阱

外连接加聚合函数,是统计类需求的标配组合,也是数字出错的常发地带。核心区别只有一条:COUNT(*)统计行数,COUNT(列名)只统计该列非 NULL 的值。

-- 需求:每个部门有多少员工,没有员工的部门显示 0 SELECT d.dept_name, COUNT(*) AS emp_cnt FROM departments d LEFT JOIN employees e ON e.dept_id = d.id GROUP BY d.dept_name;

上面这句对行政部会返回 1 而不是 0,因为左连接给行政部补了一行右表全 NULL 的记录,COUNT(*)把这行算进去了。正确写法是用右表的主键或者任意非空列:

SELECT d.dept_name, COUNT(e.id) AS emp_cnt FROM departments d LEFT JOIN employees e ON e.dept_id = d.id GROUP BY d.dept_name;

COUNT(e.id)遇到 NULL 会跳过,行政部就是 0。这个坑我在报表项目里见过不止一次,而且往往要等业务方质疑数据才被发现。类似的还有SUM:外连接产生的 NULL 行参与SUM时结果不变(NULL 被忽略),但如果用SUM(IFNULL(列, 0))这类写法,逻辑就要重新捋一遍,别想当然。

4. 自然连接:语法糖省下的键盘,都变成了排查时间

4.1 NATURAL JOIN 到底自动做了什么

NATURAL JOIN的规则可以精确描述成三步:第一,扫描两张表的列定义,找出所有名字相同且类型可比较的列;第二,用这些同名列的等值比较作为连接条件;第三,输出结果时,这些同名列只保留一份。换句话说,A NATURAL JOIN B等价于A JOIN B USING (所有同名列)。

理解这个等价关系,就能推导出它的所有风险。条件不是你写的,是数据库"猜"的。数据库猜的依据只有列名,完全不管业务语义。前面那个员工表和部门表都有id的例子,从列名角度看连接条件毫无破绽,从业务角度看完全是错的。

还有一种更隐蔽的情况:两表完全没有同名列。这时候NATURAL JOIN的行为退化成笛卡尔积,不报错,不警告,直接返回 m×n 行。表大一点,查询直接超时或者把内存打满。

4.2 加列引发的雪崩:同名列污染

我见过最典型的一次事故是这样的:某业务系统的两张表一开始只有id一个同名列,NATURAL JOIN一直在正常跑。后来做数据治理,两张表都加了tenant_id和create_time用于多租户隔离和审计。上线之后没人注意连接语句,结果连接条件从 1 个变成 3 个,create_time精确到秒,两张表分别在不同时刻写入,几乎不可能相等,结果集直接变成空。

这类问题的排查成本极高,因为它满足三个特征:不报错、不慢、结果结构完全正常,只是数据不对。等业务方发现数据缺失,再去定位,中间可能已经过去好几天。而且因为 SQL 语法本身没毛病,静态检查工具也发现不了。

4.3 NATURAL LEFT JOIN 这类变体的风险

自然连接还能和外连接组合,写成NATURAL LEFT JOIN,语义是"以左表为主,用所有同名列做连接条件"。这种写法在实际代码里出现频率很低,但风险比NATURAL JOIN更高——因为左连接本身就有"保留所有左表行"的语义,一旦连接条件被同名列污染,结果不是变空,而是变成"所有左表行都保留、右表全部补 NULL",表面上行数还对得上,实际所有关联字段都是空的,更难发现。

我的态度很直接:这类写法在生产代码里应该被完全禁掉。如果非要用语法糖减少重复,用USING(列名)更安全,至少列名是显式写出来的,加新列不会自动改变连接条件。

4.4 什么场景下可以考虑使用自然连接

说了这么多缺点,也得给个相对中立的使用边界。以下几种情况可以考虑:临时查询探索数据,表结构完全清楚,且确认两表只有一个语义正确的同名列;教学演示,用来说明连接概念;两张表结构高度对称的宽表做快速比对。

除此之外,尤其是任何要进代码仓库、要被别人阅读、要长期维护的 SQL,都不建议用。判断标准其实很简单:如果这段 SQL 三个月后由另一个人来看,他能不能只看 SQL 本身就知道连接条件是什么?自然连接永远通不过这个测试。

5. 优化器怎么执行连接:理解它才知道该改什么

5.1 Nested Loop Join 的基本形态

MySQL 处理连接的主流算法是嵌套循环。外层循环遍历驱动表的每一行,内层循环到被驱动表里找匹配的行。如果被驱动表的连接列上有索引,内层循环每次是索引查找,复杂度接近 O(驱动表行数 × log 被驱动表行数);如果没有索引,内层循环每次都是全表扫描,复杂度退化成笛卡尔积级别。

这解释了一条最实用的优化原则:驱动表选小表,被驱动表的连接列必须有索引。反过来,如果被驱动表的连接列没索引,哪怕驱动表只有几十行,被驱动表几百万行,每条驱动记录都要扫一遍全表,查询时间直接不可接受。

外连接在这条规则上有个额外约束:驱动表是固定的。左连接的驱动表必须是左表,右连接必须是右表,这个顺序不能由优化器随意调整,因为调整会改变语义。所以左连接写反了主表,优化器也救不了你。这也是为什么外连接比内连接更容易出现性能问题——你实际上是在用 SQL 的书写顺序替优化器做决策。

5.2 MySQL 8.0 的 Hash Join 与 BNL 的替换

MySQL 8.0.18 引入了 Hash Join,用于等值连接且被驱动表没有可用索引的场景。原理是把较小的那部分数据在内存里建成哈希表,然后遍历另一部分数据做哈希探测,复杂度接近线性。8.0.20 之后,原来的 Block Nested-Loop 算法被 Hash Join 取代。

这对开发者意味着什么?以前遇到"被驱动表没索引"的连接,只能眼睁睁看着 BNL 扫来扫去;现在优化器可能自动切成 Hash Join,性能好很多。但 Hash Join 有前提:必须是等值连接,且内存够用。连接条件里写>、<、!=或者LIKE '%xx'这类非等值条件,还是走嵌套循环。连接数据量超过join_buffer_size会溢写到磁盘,性能会明显下降。

判断有没有走 Hash Join,看EXPLAIN的Extra列,出现Using join buffer (hash join)就是走了。如果看到Using join buffer (Block Nested Loop),说明版本较老或者条件不满足 Hash Join 的触发条件。

5.3 EXPLAIN 里应该重点看哪几列

MySQL 的EXPLAIN输出信息很多,看连接性能主要盯这几列。

列名关注点
type访问类型,ALL是全表扫描,ref/eq_ref/range是走索引
key实际使用的索引,为 NULL 说明没走索引
rows预估扫描行数,驱动表这一行要尽量小
filtered过滤后剩余行的百分比,过低说明索引选择性差
ExtraUsing join buffer说明没索引;Using temporary/Using filesort说明有额外排序开销

一个典型的问题是:type显示ALL、rows很大、Extra是Using join buffer (hash join)。这时候虽然 Hash Join 能跑,但本质上还是全表数据参与哈希构建,数据量一大照样慢。正确的做法是给连接列补索引,让type变成ref,rows降下来,Extra里不再出现 join buffer。

另一个容易忽略的点是filtered。假设驱动表rows是 1000,filtered是 1%,实际传给下一张表的只有 10 行;如果filtered是 100%,就是 1000 行全部传下去。驱动表的选择不只看行数,还要看过滤后的实际行数。

6. 实战选型:三种连接到底该怎么选

6.1 选型决策表

把前面讨论的内容压缩成一张决策表,实际写 SQL 时可以直接对照。

业务需求推荐写法理由
只要两边都匹配上的数据INNER JOIN ... ON语义最直白,优化器自由度最高
以一边为主,另一边可有可无LEFT JOIN ... ON(或右连接)主表行全部保留,NULL 填充另一侧
找"主表有、从表没有"的孤儿数据LEFT JOIN ... WHERE 从表主键 IS NULL反连接,比NOT IN在 NULL 场景下更安全
两边都要保留LEFT JOIN UNION RIGHT JOINMySQL 无全外连接,只能模拟
临时探索数据NATURAL JOIN快,但用完即弃,不进代码库
同名列显式且确定JOIN ... USING(列名)比自然连接安全,比 ON 简洁

表格之外还有一条经验:能内连接就别外连接,能单表就别连接。外连接的分析成本、索引要求、NULL 处理复杂度都比内连接高一个量级。很多需求看起来必须外连接,其实拆成两条简单查询在应用层拼装更清晰,也更好优化。

6.2 几个真实踩过的坑

第一个坑是外连接条件写进了 WHERE。某次做员工考勤报表,需求是"所有员工 + 本月请假天数",SQL 写成了LEFT JOIN 请假表 ... WHERE 请假月份 = 当前月。结果没有请假的员工全部消失,报表人数比 HR 系统少了一截。修复方式就是把月份条件挪进ON,或者用IFNULL在 SELECT 里处理。这个错误之所以常见,是因为 SQL 本身语法合法、查询还很快,只有对数据才能发现异常。

第二个坑是多表连接中的外连接被后续内连接"截断"。三张表 A LEFT JOIN B LEFT JOIN C,一切正常;后来为了过滤某个状态,在末尾加了一句INNER JOIN D,结果 A 中那些在 D 里匹配不上的行全被丢弃,前两个左连接的语义全部失效。修复思路有两种:把 D 也改成左连接,或者把 D 的过滤下推到子查询里。选择哪种取决于业务语义——到底是要"所有 A"还是"有 D 的 A"。

第三个坑是自然连接加列。前面已经详细说过,某张表新增了同名列,自然连接的条件悄悄变化,数据从有变空或者从少变多。这类问题唯一的预防手段就是禁用自然连接,没有别的办法。

第四个坑是外连接配聚合函数。除了前面说的COUNT(*)问题,还有SUM在空集上的行为。SUM(NULL)返回 NULL 而不是 0,如果前端拿到 NULL 直接展示会出问题,通常需要在 SQL 层用IFNULL(SUM(...), 0)兜底,或者在应用层处理。

6.3 一份可以直接套用的自检清单

写完一条多表连接 SQL,提交之前过一遍下面这几条,能挡掉大部分低级错误。

  • 连接条件是否全部写在ON里,业务过滤条件是否全部写在WHERE里,两者有没有混放。
  • 外连接的主表是否符合业务语义("我要的是左表全部,还是右表全部"),有没有写反。
  • WHERE里有没有引用外连接从表的列(除了IS NULL这种反连接用法),有的话确认是不是有意为之。
  • 聚合函数用的是COUNT(*)还是COUNT(列名),和"要不要统计空行"的需求是否一致。
  • 有没有使用NATURAL JOIN,如果有,能不能换成显式ON或USING。
  • SELECT *有没有用在多表连接上,能不能改成明确列出所需列。
  • EXPLAIN跑一下,被驱动表的连接列有没有索引,type是不是ALL,rows是否在可接受范围。
  • 多表连接里是否混用了内连接和外连接,内连接的位置会不会把前面外连接保留的行截断。

这套清单看起来琐碎,但每一条都对应过真实的问题。我个人在改别人 SQL 的时候,基本是照着这个顺序从头过一遍,比直接跑EXPLAIN调优更高效——很多问题压根不是性能问题,是语义写错了,跑得越快错得越多。

最后分享一个我自己养成的小习惯:任何涉及外连接的查询,我都会额外写一条"只查左表"或者"只查右表"的对照查询,两边行数对一下。比如左连接之后的COUNT(*)必须等于左表的COUNT(*),不等就说明有问题。这个对照操作花不到一分钟,但能挡掉绝大部分外连接写错的情况,比事后被业务方追着问数据为什么不对要划算得多。

RELATED READING

延伸阅读

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