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.id | 3 | 保留 | 丢弃 | 丢弃 |
LEFT JOIN ... ON e.dept_id = d.id | 4 | 保留 | 保留,右表全 NULL | 丢弃 |
RIGHT JOIN ... ON e.dept_id = d.id | 4 | 保留 | 丢弃 | 保留,左表全 NULL |
NATURAL JOIN | 0 | 丢弃 | 丢弃 | 丢弃 |
前三行的行为是符合直觉的:内连接只保留能配对的,左连接保住左表全部,右连接保住右表全部。第四行的结果才是真正值得注意的地方——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 | 过滤后剩余行的百分比,过低说明索引选择性差 |
Extra | Using 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 JOIN | MySQL 无全外连接,只能模拟 |
| 临时探索数据 | 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(*),不等就说明有问题。这个对照操作花不到一分钟,但能挡掉绝大部分外连接写错的情况,比事后被业务方追着问数据为什么不对要划算得多。