目录
- [0. 开篇:一个消失的部门](#0. 开篇:一个消失的部门)
- [1. 实验环境](#1. 实验环境)
- [2. 内连接:你其实一直在用它](#2. 内连接:你其实一直在用它)
- [2.1 内连接的本质](#2.1 内连接的本质)
- [2.2 标准语法与案例](#2.2 标准语法与案例)
- [2.3 内连接的三种写法(完全等价)](#2.3 内连接的三种写法(完全等价))
- [2.4 为什么内连接里 ON 和 WHERE 可以互换](#2.4 为什么内连接里 ON 和 WHERE 可以互换)
- [3. 外连接:为什么需要它](#3. 外连接:为什么需要它)
- [3.1 一个必须回答的业务问题](#3.1 一个必须回答的业务问题)
- [3.2 左外连接 LEFT JOIN](#3.2 左外连接 LEFT JOIN)
- [3.3 右外连接 RIGHT JOIN](#3.3 右外连接 RIGHT JOIN)
- [3.4 左连接和右连接可以互换](#3.4 左连接和右连接可以互换)
- [4. ⚠️ 五个必踩的坑(本节价值最高)](#4. ⚠️ 五个必踩的坑(本节价值最高))
- [坑 1:外连接中 ON 和 WHERE 不等价](#坑 1:外连接中 ON 和 WHERE 不等价)
- [坑 2:left join 之后的 count(*) 会算错](#坑 2:left join 之后的 count(*) 会算错)
- [坑 3:where 右表字段 is null 会退化成内连接](#坑 3:where 右表字段 is null 会退化成内连接)
- [坑 4:MySQL 没有 FULL OUTER JOIN](#坑 4:MySQL 没有 FULL OUTER JOIN)
- [坑 5:多表外连接的顺序决定结果](#坑 5:多表外连接的顺序决定结果)
- [5. 完整图谱:JOIN 全家桶](#5. 完整图谱:JOIN 全家桶)
- [6. 补全:JOIN 是怎么执行的(性能篇)](#6. 补全:JOIN 是怎么执行的(性能篇))
- [7. 实战 OJ](#7. 实战 OJ)
- [8. 报错速查](#8. 报错速查)
- [9. 面试高频 10 问](#9. 面试高频 10 问)
- [10. 总结:一张图 + 一句口诀](#10. 总结:一张图 + 一句口诀)
- 附:一键可执行完整脚本
0. 开篇:一个消失的部门
先把上一篇那个「事故现场」原样复现一遍。
需求:显示每个部门的信息(部门名、编号、地址)和人员数量。
-- 写法 A:内连接
select d.dname, d.deptno, d.loc, count(*) as 部门人数
from emp e, dept d
where e.deptno = d.deptno
group by d.deptno, d.dname, d.loc;
结果:
+------------+--------+----------+----------+
| dname | deptno | loc | 部门人数 |
+------------+--------+----------+----------+
| ACCOUNTING | 10 | NEW YORK | 3 |
| RESEARCH | 20 | DALLAS | 5 |
| SALES | 30 | CHICAGO | 6 |
+------------+--------+----------+----------+
只有 3 行。dept 明明有 4 个部门,40 号 OPERATIONS 去哪了?
答案:被 where e.deptno = d.deptno 过滤掉了。
dept 里有 40 号,但 emp 里没有任何一条 deptno = 40 的记录。「配上对」这一步失败,行就消失了。
这在业务上是灾难:
- 新成立的组织架构,还没人入职 → 组织架构图里看不到这个部门
- 新建的分类,还没上商品 → 分类页里看不到这个分类
- 新注册的用户,还没下单 → 用户报表里统计不到他
所以你需要一种「哪怕配不上对,也把左边(或右边)的行留下」的连接方式。
这就是外连接。
1. 实验环境
本篇用两套数据:
① 课件原版的 stu / exam(用来演示外连接最直观)
create database if not exists join_demo default character set utf8mb4;
use join_demo;
-- 学生表
create table stu (id int, name varchar(30));
insert into stu values (1, 'jack'), (2, 'tom'), (3, 'kity'), (4, 'nono');
-- 成绩表
create table exam (id int, grade int);
insert into exam values (1, 56), (2, 76), (11, 8);
这两张表的数据是精心设计的,请看仔细:
| 学生 | id | 成绩 | id | |
|---|---|---|---|---|
| jack | 1 | 56 分 | 1 | |
| tom | 2 | 76 分 | 2 | |
| kity | 3 | 8 分 | 11 | |
| nono | 4 |
kity(3)和nono(4)没有成绩 ← 左连接要救它们11号成绩没有对应的学生 ← 右连接要救它
这两组「孤儿数据」,就是外连接存在的全部理由。
② 经典的 emp / dept(用来演示业务场景)
create table dept (
deptno int primary key,
dname varchar(14),
loc varchar(13)
);
insert into dept values
(10, 'ACCOUNTING', 'NEW YORK'),
(20, 'RESEARCH', 'DALLAS'),
(30, 'SALES', 'CHICAGO'),
(40, 'OPERATIONS', 'BOSTON'); -- ← 没有员工的部门
create table emp (
empno int primary key,
ename varchar(10),
job varchar(9),
mgr int,
hiredate date,
sal decimal(7,2),
comm decimal(7,2),
deptno int
);
insert into emp values
(7369, 'SMITH', 'CLERK', 7902, '1980-12-17', 800.00, NULL, 20),
(7499, 'ALLEN', 'SALESMAN', 7698, '1981-02-20', 1600.00, 300.00, 30),
(7521, 'WARD', 'SALESMAN', 7698, '1981-02-22', 1250.00, 500.00, 30),
(7566, 'JONES', 'MANAGER', 7839, '1981-04-02', 2975.00, NULL, 20),
(7654, 'MARTIN', 'SALESMAN', 7698, '1981-09-28', 1250.00, 1400.00, 30),
(7698, 'BLAKE', 'MANAGER', 7839, '1981-05-01', 2850.00, NULL, 30),
(7782, 'CLARK', 'MANAGER', 7839, '1981-06-09', 2450.00, NULL, 10),
(7788, 'SCOTT', 'ANALYST', 7566, '1987-04-19', 3000.00, NULL, 20),
(7839, 'KING', 'PRESIDENT', NULL, '1981-11-17', 5000.00, NULL, 10),
(7844, 'TURNER', 'SALESMAN', 7698, '1981-09-08', 1500.00, 0.00, 30),
(7876, 'ADAMS', 'CLERK', 7788, '1987-05-23', 1100.00, NULL, 20),
(7900, 'JAMES', 'CLERK', 7698, '1981-12-03', 950.00, NULL, 30),
(7902, 'FORD', 'ANALYST', 7566, '1981-12-03', 3000.00, NULL, 20),
(7934, 'MILLER', 'CLERK', 7782, '1982-01-23', 1300.00, NULL, 10);
2. 内连接:你其实一直在用它
2.1 内连接的本质(课件原话,一字不改)
内连接实际上就是利用
where子句对两种表形成的笛卡儿积进行筛选,我们前面学习的查询都是内连接,也是在开发过程中使用的最多的连接查询。
这句话是整篇的基石。 它包含两层意思:
① 内连接 = 笛卡尔积 + where 筛选
emp(14 行)× dept(4 行) = 56 行(笛卡尔积)
↓ where 筛掉 deptno 对不上的 42 行
14 行(真正的内连接结果)
② 你之前写的所有多表查询,都是内连接
select * from emp, dept where emp.deptno = dept.deptno; -- 这就是内连接!
select * from emp inner join dept on emp.deptno = dept.deptno; -- 同一个东西
所以「内连接」不是新东西,只是给「你早就在用的写法」起了个正式名字。
2.2 标准语法与案例
select 字段 from 表1 inner join 表2 on 连接条件 and 其他条件;
案例:显示 SMITH 的名字和部门名称
写法一(课件说的「前面的写法」):
select ename, dname
from EMP, DEPT
where EMP.deptno = DEPT.deptno and ename = 'SMITH';
写法二(标准的内连接写法):
select ename, dname
from EMP inner join DEPT on EMP.deptno = DEPT.deptno and ename = 'SMITH';
结果完全一样:
+-------+------------+
| ename | dname |
+-------+------------+
| SMITH | RESEARCH |
+-------+------------+
⚠️ 注意 inner 可以省略:emp join dept on ... 就是内连接。
2.3 内连接的三种写法(完全等价)
这三种写法,MySQL 优化器会生成完全相同的执行计划:
-- ① 隐式连接(逗号 + where)
select e.ename, d.dname from emp e, dept d where e.deptno = d.deptno;
-- ② 显式连接(inner join + on)
select e.ename, d.dname from emp e inner join dept d on e.deptno = d.deptno;
-- ③ 显式连接省略 inner
select e.ename, d.dname from emp e join dept d on e.deptno = d.deptno;
-- ④ 用 using 简化(同名列)
select e.ename, d.dname from emp e join dept d using (deptno);
| 写法 | 优点 | 缺点 | 推荐度 |
|---|---|---|---|
a, b where ... |
老写法,兼容性好 | 忘记 where 就变笛卡尔积,风险高 | ⚠️ |
a join b on ... |
语义清晰,忘写 on 会报错 | 稍啰嗦 | ⭐ 推荐 |
a join b using (col) |
更简洁,结果只保留一个同名列 | 只能是同名列 | ⭐ |
a natural join b |
自动找所有同名列 | 危险! 会连你不想要同名列也连上 | ❌ 别用 |
using 和 on 有一个重要区别:
-- on 写法:deptno 会出现两次(e.deptno 和 d.deptno)
select * from emp e join dept d on e.deptno = d.deptno;
-- 结果列:empno, ename, ..., deptno, deptno, dname, loc(两个 deptno!)
-- using 写法:deptno 只出现一次
select * from emp e join dept d using (deptno);
-- 结果列:empno, ename, ..., deptno, dname, loc(只有一个 deptno)
💡
natural join为什么危险? 它会自动把所有同名列 都当成连接条件。如果emp和dept除了deptno还有别的同名列(比如都加了create_time),它就会莫名其妙多一个连接条件,结果静默错误。永远别在生产环境用。
2.4 为什么内连接里 ON 和 WHERE 可以互换
-- 下面两条完全等价
select * from emp e join dept d on e.deptno = d.deptno where e.sal > 2000;
select * from emp e join dept d on e.deptno = d.deptno and e.sal > 2000;
为什么?因为在内连接里,ON 和 WHERE 都是「过滤」,只是执行时机上 ON 略早。
但是------这个「可以互换」在下一节的外连接里,立刻就不成立了。
这是本篇最重要的一条分界线。 请记住:
内连接:
ON和WHERE等价。 外连接:ON和WHERE完全不等价。(详见第 4 节坑 1)
3. 外连接:为什么需要它
3.1 一个必须回答的业务问题
回到开篇那个问题:
「显示每个部门的信息和人员数量」------40 号部门去哪了?
这个需求的关键词是「每个部门」 。它要求:不管这个部门有没有员工,都得出现在结果里。
「必须出现」= 外连接。
外连接的规则非常朴素:
把「外侧」那张表的所有行都留下,如果它在另一张表里配不上对,就用 NULL 填充。
3.2 左外连接:LEFT JOIN
如果联合查询,左侧的表完全显示,我们就说是左外连接。
语法:
select 字段名 from 表名1 left join 表名2 on 连接条件;
案例:查询所有学生的成绩,如果这个学生没有成绩,也要将学生的个人信息显示出来。
select * from stu left join exam on stu.id = exam.id;
结果(关键看最后两行):
+------+-------+------+-------+
| id | name | id | grade |
+------+-------+------+-------+
| 1 | jack | 1 | 56 |
| 2 | tom | 2 | 76 |
| 3 | kity | NULL | NULL | ← 没有成绩,但留下了!
| 4 | nono | NULL | NULL | ← 没有成绩,但留下了!
+------+-------+------+-------+
这两个 NULL 行,就是外连接的全部意义。
解剖这个过程:
① 左表 stu(4 行)全部保留
② 对 stu 的每一行,去 exam 里找 id 相同的
├─ jack(1) → 找到 exam(1, 56) → 拼上
├─ tom(2) → 找到 exam(2, 76) → 拼上
├─ kity(3) → 找不到 → 右边补 NULL
└─ nono(4) → 找不到 → 右边补 NULL
③ 右表 exam 中「没有对应学生」的行(id=11)被丢弃
注意第 ③ 步:left join 会丢弃右表中多余的行。
所以「谁在外侧,谁被保留」------这就是左连接和右连接的唯一区别。
3.3 右外连接:RIGHT JOIN
如果联合查询,右侧的表完全显示,我们就说是右外连接。
语法:
select 字段 from 表名1 right join 表名2 on 连接条件;
案例:对 stu 表和 exam 表联合查询,把所有的成绩都显示出来,即使这个成绩没有学生与它对应,也要显示出来。
select * from stu right join exam on stu.id = exam.id;
结果(关键看最后一行):
+------+------+------+-------+
| id | name | id | grade |
+------+------+------+-------+
| 1 | jack | 1 | 56 |
| 2 | tom | 2 | 76 |
| NULL | NULL | 11 | 8 | ← 没有学生,但成绩留下了!
+------+------+------+-------+
11 号成绩没有学生,但它被保留了。而 kity 和 nono 因为没有成绩,被丢弃了。
对比一下两种情况:
| 查询 | 保留谁 | 丢弃谁 |
|---|---|---|
stu left join exam |
所有学生(含没成绩的) | 没有学生的成绩(11 号) |
stu right join exam |
所有成绩(含没学生的) | 没有成绩的学生(kity、nono) |
3.4 左连接和右连接可以互换
这是外连接最重要的一个认知:
A left join B≡B right join A
不信?看课件的这个练习:
列出部门名称和这些部门的员工信息,同时列出没有员工的部门。
方法一(左连接,以 dept 为外侧):
select d.dname, e.*
from dept d left join emp e on d.deptno = e.deptno;
方法二(右连接,以 dept 为外侧):
select d.dname, e.*
from emp e right join dept d on d.deptno = e.deptno;
结果完全一样:
+------------+-------+--------+-----------+------+------------+---------+------+--------+
| dname | empno | ename | job | mgr | hiredate | sal | comm | deptno |
+------------+-------+--------+-----------+------+------------+---------+------+--------+
| RESEARCH | 7369 | SMITH | CLERK | 7902 | 1980-12-17 | 800.00 | NULL | 20 |
| SALES | 7499 | ALLEN | SALESMAN | 7698 | 1981-02-20 | 1600.00 |300.00| 30 |
| SALES | 7521 | WARD | SALESMAN | 7698 | 1981-02-22 | 1250.00 |500.00| 30 |
| RESEARCH | 7566 | JONES | MANAGER | 7839 | 1981-04-02 | 2975.00 | NULL | 20 |
| SALES | 7654 | MARTIN | SALESMAN | 7698 | 1981-09-28 | 1250.00 |1400.0| 30 |
| SALES | 7698 | BLAKE | MANAGER | 7839 | 1981-05-01 | 2850.00 | NULL | 30 |
| ACCOUNTING | 7782 | CLARK | MANAGER | 7839 | 1981-06-09 | 2450.00 | NULL | 10 |
| RESEARCH | 7788 | SCOTT | ANALYST | 7566 | 1987-04-19 | 3000.00 | NULL | 20 |
| ACCOUNTING | 7839 | KING | PRESIDENT | NULL | 1981-11-17 | 5000.00 | NULL | 10 |
| SALES | 7844 | TURNER | SALESMAN | 7698 | 1981-09-08 | 1500.00 | 0.00 | 30 |
| RESEARCH | 7876 | ADAMS | CLERK | 7788 | 1987-05-23 | 1100.00 | NULL | 20 |
| SALES | 7900 | JAMES | CLERK | 7698 | 1981-12-03 | 950.00 | NULL | 30 |
| RESEARCH | 7902 | FORD | ANALYST | 7566 | 1981-12-03 | 3000.00 | NULL | 20 |
| ACCOUNTING | 7934 | MILLER | CLERK | 7782 | 1982-01-23 | 1300.00 | NULL | 10 |
| OPERATIONS | NULL | NULL | NULL | NULL | NULL | NULL | NULL | NULL | ← 终于出现了!
+------------+-------+--------+-----------+------+------------+---------+------+--------+
注意最后一行:OPERATIONS 部门所有 emp 字段都是 NULL。
这就是「留在外侧、配不上对就用 NULL 填」的效果。
⚠️ 但这里有个细节要提醒:最后一行的 deptno 也是 NULL!
为什么?因为 select d.dname, e.* 只取了 e.deptno(e.* 里的),而这一行的 e 部分是 NULL。如果你要显示部门号,必须写 d.deptno。
-- 更完整的写法
select d.deptno, d.dname, e.empno, e.ename, e.sal
from dept d left join emp e on d.deptno = e.deptno;
4. ⚠️ 五个必踩的坑(本节价值最高)
坑 1:外连接中 ON 和 WHERE 不等价
这是外连接第一大坑,也是面试最爱问的。
先看需求:
查询所有学生的成绩,但如果成绩不及格(< 60),就当没查到(显示 NULL)。
❌ 错误写法(把条件写在 WHERE):
select * from stu left join exam on stu.id = exam.id
where exam.grade >= 60;
结果:
+------+------+------+-------+
| id | name | id | grade |
+------+------+------+-------+
| 2 | tom | 2 | 76 |
+------+------+------+-------+
只有 1 行!kity 和 nono 又消失了!
为什么? 因为 left join 补出来的那两行,exam.grade 是 NULL ,而 NULL >= 60 结果是 NULL(非 TRUE) ,被 WHERE 过滤掉了。
WHERE作用在「连接完成之后」,它会把外连接补的 NULL 行也一起筛掉。 一个外连接 + 一个作用在右表字段上的 WHERE 条件 = 退化成了内连接。
✅ 正确写法(把条件写在 ON):
select * from stu left join exam on stu.id = exam.id and exam.grade >= 60;
结果:
+------+------+------+-------+
| id | name | id | grade |
+------+------+------+-------+
| 1 | jack | NULL | NULL | ← 56 分,不满足 grade>=60,但学生保留了
| 2 | tom | 2 | 76 |
| 3 | kity | NULL | NULL |
| 4 | nono | NULL | NULL |
+------+------+------+-------+
4 行,所有学生都在,只是 jack 的成绩那一侧变成了 NULL。
这才是「左连接 + 右表条件」的正确姿势。
⭐ 记住这条铁律:
ON决定「怎么配对」,WHERE决定「配完之后留哪些行」。
ON里的条件只影响「能不能配上对」,配不上 → 右边补 NULL,左边照样留(因为它是外连接)WHERE里的条件影响「最终输出」,它会把 NULL 行也一起筛掉
用时间轴理解:
① FROM 生成笛卡尔积
② ON 按条件配对(外连接时,配不上的补 NULL) ← 在这一步,左表的行已经被"锁定"了
③ WHERE 过滤最终结果 ← 在这一步,你可能把第②步保住的行又筛掉了
这就是为什么 ON 里的条件「不影响左表的完整性」,而 WHERE 里的条件「会」。
再看一个更容易理解的版本:
-- 需求:所有部门 + 每个部门的「工资 > 2000」的员工
-- ❌ 错:写 WHERE 会去掉没有高薪员工的部门
select d.dname, e.ename from dept d left join emp e on d.deptno = e.deptno
where e.sal > 2000;
-- ✅ 对:写 ON,保留所有部门
select d.dname, e.ename from dept d left join emp e on d.deptno = e.deptno and e.sal > 2000;
坑 2:left join 之后的 count(*) 会算错
这是外连接第二大坑,也是统计报表出错的头号原因。
需求:统计每个部门的人数(包括 0 人的部门)。
-- ❌ 错:count(*) 会把空部门算成 1
select d.dname, count(*) as 人数
from dept d left join emp e on d.deptno = e.deptno
group by d.dname;
结果:
+------------+------+
| dname | 人数 |
+------------+------+
| ACCOUNTING | 3 |
| RESEARCH | 5 |
| SALES | 6 |
| OPERATIONS | 1 | ← 错了!应该是 0
+------------+------+
为什么是 1? 因为 left join 给 OPERATIONS 补了一行「全 NULL 的 emp 记录」 。count(*) 数的是「行数」,这一行也算一行 → 1。
✅ 正确写法:count(具体列)
select d.dname, count(e.empno) as 人数
from dept d left join emp e on d.deptno = e.deptno
group by d.dname;
结果:
+------------+------+
| dname | 人数 |
+------------+------+
| ACCOUNTING | 3 |
| RESEARCH | 5 |
| SALES | 6 |
| OPERATIONS | 0 | ← 对了!
+------------+------+
因为 count(列) 不统计 NULL ,补出来的那行 e.empno 是 NULL,所以不计入。
🔗 前后照应 :这是第 06 篇 7.2 节那张「
COUNT(*)/COUNT(1)/COUNT(列)」对照表的最实战的应用 。 当时我说「统计行数用COUNT(*)」,那是在单表 场景下。 在多表外连接场景下,COUNT(*)会算错,必须用COUNT(右表的主键/非空列)。一句话:join 之后,永远用
count(右表的列)。
同理,sum 也要小心:
-- 统计每个部门的总工资(空部门应该是 0,不是 NULL)
select d.dname, sum(e.sal) as 总工资 from dept d left join emp e on d.deptno = e.deptno
group by d.dname;
-- OPERATIONS: NULL(sum 忽略 NULL,没有任何非 NULL 值 → NULL)
-- 如果要显示 0:
select d.dname, ifnull(sum(e.sal), 0) as 总工资 from dept d left join emp e on d.deptno = e.deptno
group by d.dname;
-- OPERATIONS: 0
🔗 前后照应 :第 06 篇 7.3 节讲过「
SUM()空结果返回 NULL ,COUNT()返回 0 」。这里就是它的实战版------在外连接下,这个差异会被放大成「报表多一行错数据」或「报表少一个 0」。
坑 3:where 右表字段 is null 会退化成内连接
等等,这不是坑,这是「找差异数据」的经典写法。但前提是你要理解它。
需求:找出「一个员工都没有」的部门。
select d.*
from dept d left join emp e on d.deptno = e.deptno
where e.empno is null;
结果:
+--------+------------+--------+
| deptno | dname | loc |
+--------+------------+--------+
| 40 | OPERATIONS | BOSTON |
+--------+------------+--------+
这个写法的原理:
left join后,配不上对的部门,右边全是 NULLwhere e.empno is null就精确地挑出了「配不上对」的那些行
⚠️ 但注意:这里筛选的是「NULL」,不是「某个具体的值」。
-- ❌ 这里会把外连接退化成内连接
where e.sal > 2000
-- ✅ 这里不会(因为 is null 恰好是在找"没有匹配"的行)
where e.empno is null
为什么 is null 不会退化? 因为 is null 判断的就是「补出来的 NULL」,它的目的就是筛选「没配上对」的行 。而 e.sal > 2000 的目的是「筛选配上了对、且满足条件的行」,两者目的完全不同。
⭐ left join + is null 是查找「差异数据」的黄金组合,请背下来:
-- 找出「没有订单的用户」
select u.* from users u left join orders o on u.id = o.user_id where o.id is null;
-- 找出「没有商品的分类」
select c.* from category c left join goods g on c.id = g.category_id where g.id is null;
-- 找出「没有学生的班级」
select c.* from class c left join stu s on c.id = s.class_id where s.id is null;
它比 not in 好在哪?
| 写法 | NULL 安全 | 性能 | 可读性 |
|---|---|---|---|
where id not in (select ...) |
❌ 有 NULL 就全空 | 一般 | 好 |
where not exists (select 1 ...) |
✅ 安全 | 好 | 一般 |
left join ... where 右表.id is null |
✅ 安全 | 好(可用索引) | 好 |
🔗 前后照应 :第 06 篇 3.4.3 节那个
NOT IN + NULL的致命坑,解法三选一------left join是其中最不容易出错的。这也是我在上一篇 OJ 8.3 里推荐left join + is null的原因。
坑 4:MySQL 没有 FULL OUTER JOIN
标准 SQL 有「全外连接」------两边的行全保留:
-- 这在 MySQL 里会报语法错误!
select * from stu full outer join exam on stu.id = exam.id;
-- ERROR 1064 (42000): You have an error in your SQL syntax
MySQL 不支持 FULL OUTER JOIN。 但可以用 left join + union 模拟:
select s.id as sid, s.name, e.id as eid, e.grade
from stu s left join exam e on s.id = e.id
union
select s.id as sid, s.name, e.id as eid, e.grade
from stu s right join exam e on s.id = e.id;
结果:
+------+------+------+-------+
| sid | name | eid | grade |
+------+------+------+-------+
| 1 | jack | 1 | 56 |
| 2 | tom | 2 | 76 |
| 3 | kity | NULL | NULL |
| 4 | nono | NULL | NULL |
| NULL | NULL | 11 | 8 |
+------+------+------+-------+
5 行 = 内连接的 2 行 + 左独有 2 行 + 右独有 1 行。
为什么用 union 而不是 union all? 因为「两边都有的行」会被 left join 和 right join 各返回一次,必须去重。
🔗 前后照应 :第 08 篇 6.3 节讲过「
union会去重(慢),union all不去重(快)」。这里必须用去重的版本。
坑 5:多表外连接的顺序决定结果
假设有三张表:dept(4 行)、emp(14 行)、salgrade(5 行)。
需求:显示所有部门,以及每个部门员工的工资等级。
-- ✅ 正确:dept 在最左边(最外层)
select d.dname, e.ename, s.grade
from dept d
left join emp e on d.deptno = e.deptno
left join salgrade s on e.sal between s.losal and s.hisal;
结果:
+------------+--------+-------+
| dname | ename | grade |
+------------+--------+-------+
| RESEARCH | SMITH | 1 |
| SALES | ALLEN | 3 |
| ...
| OPERATIONS | NULL | NULL | ← 40 号部门还在
+------------+--------+-------+
OPERATIONS 部门保住了,而且它的 grade 也是 NULL------因为 e 是 NULL,e.sal 也是 NULL,NULL between losal and hisal 结果是 NULL,配不上任何等级。
⚠️ 如果你把 emp 写在外侧:
-- ❌ 这个查询是「所有员工 + 他们的部门 + 等级」,没有员工的部门会消失
select d.dname, e.ename, s.grade
from emp e
left join dept d on d.deptno = e.deptno
left join salgrade s on e.sal between s.losal and s.hisal;
结果:14 行,OPERATIONS 又消失了。
⭐ 结论:外连接链里,「谁在第一个,谁被无条件保留」。
多表连接的通用原则:
第一个表 = 你要「绝对不丢」的表(业务主表)
后续表 = 用 left join 一层层挂上去
每一层的「缺数据」都会自动补 NULL
⚠️ 但如果后面某一层需要「自己的一侧也全保留」,就必须用嵌套外连接或者调整顺序。
5. 完整图谱:JOIN 全家桶
一张图把 MySQL 支持的连接方式全画出来(假设 A、B 两张表各有交集):
① INNER JOIN(内连接)
结果 = A ∩ B(只要两边都对得上)
┌───────┬───────┐
│ A │ B │
│ ┌──┴──┐ │
│ │ 结果 │ │
│ └──┬──┘ │
└───────┴───────┘
② LEFT JOIN(左外)
结果 = A 全部 + B 中对得上的
┌───────┬───────┐
│ │ │
│ 结果 │ B │
│ │ │
└───────┴───────┘
③ RIGHT JOIN(右外)
结果 = B 全部 + A 中对得上的
┌───────┬───────┐
│ A │ │
│ │ 结果 │
│ │ │
└───────┴───────┘
④ FULL OUTER JOIN(MySQL 不支持,用 union 模拟)
结果 = A ∪ B
┌───────┬───────┐
│ │ │
│ 结果 │ 结果 │
│ │ │
└───────┴───────┘
⑤ CROSS JOIN(笛卡尔积)
A × B,不加 on 就是它
MySQL 语法速查:
| 你想要 | SQL 写法 | MySQL 支持 |
|---|---|---|
| 交集(内连接) | a inner join b on ... |
✅ |
| A 全保留 | a left join b on ... |
✅ |
| B 全保留 | a right join b on ... |
✅ |
| 并集(全外) | a left join b on ... union a right join b on ... |
✅(模拟) |
| 只 A 有(A 减 B) | a left join b on ... where b.id is null |
✅ |
| 只 B 有(B 减 A) | a right join b on ... where a.id is null |
✅ |
| 笛卡尔积 | a cross join b 或 a, b |
✅(⚠️ 危险) |
特别注意:「A 减 B」这种需求,SQL 里没有专门的语法,只能靠 left join + is null 表达。
这是 SQL 的一个「表达力缺口」,也解释了为什么外连接这么重要------它不只是「保留不匹配行」,它还能做「集合差」。
6. 补全:JOIN 是怎么执行的(性能篇)
6.1 连接的三种算法
MySQL(InnoDB)执行 JOIN 主要有三种算法:
| 算法 | 全称 | 适用 | 复杂度 |
|---|---|---|---|
| NLJ | Nested-Loop Join | 被驱动表的连接列有索引 | O(外层行数 × log(内层)) |
| BNL | Block Nested-Loop Join | 被驱动表没索引,用 join buffer 缓冲 | O(外层行数 × 内层行数) |
| Hash Join | --- | 8.0.18+,等值连接且无索引 | O(外层行数 + 内层行数) |
NLJ 的执行过程:
for each row in 驱动表(外层):
用连接条件去 被驱动表 的索引里查找匹配行 ← 这里是「索引查找」,O(log n)
拼成结果
这就是为什么「连接字段必须有索引」------没有索引就退化成 BNL,每次都要全表扫。
Hash Join(8.0.18+) 是个大杀器:把小的那张表在内存里建一个哈希表,然后扫大表逐行查哈希。等值连接、无索引的情况下,比 BNL 快几十倍。
-- 看有没有用上 Hash Join
explain select * from a join b on a.x = b.y;
-- Extra 列里出现 Using join buffer (hash join) 就是了
6.2 外连接的执行特殊在哪
left join 的执行必须保证「左表行不丢」,所以:
① 左表天然是「驱动表」(大部分情况)
-- dept(4 行)left join emp(14 行)
-- 外层扫 4 行,内层用 emp.deptno 索引查 → 4 × log(14)
如果优化器反转成 emp left join dept,语义就变了。所以外连接时优化器不能随意交换左右表。
② 优化器会把「某些 left join」转换成 inner join 来提速
-- 这个 left join 会被优化器改写成 inner join
select * from dept d left join emp e on d.deptno = e.deptno where e.sal > 2000;
为什么? 因为 where e.sal > 2000 已经保证了 e 不能是 NULL------既然右表一定存在,那 left join 就没有意义了,等价于 inner join。
这不是 bug,是优化。但这也解释了坑 1:你以为写的是外连接,实际跑的是内连接。
验证方法:
explain看select_type和Extra,或者直接把查询结果对比一下。
③ 外连接时,on 里的非等值条件可能让索引失效
-- on 里用 between,被驱动表(salgrade)很难用索引
left join salgrade s on e.sal between s.losal and s.hisal
这种「区间连接」是天然不友好的,因为 B+ 树只能精确匹配前缀。数据量大时要考虑预计算(比如把工资换算成等级存下来)。
6.3 三条优化铁律
铁律一:小表驱动大表
-- dept 4 行、emp 100 万行
-- ✅ dept 驱动:4 × log(100万) ≈ 4 × 20
-- ❌ emp 驱动:100万 × log(4) ≈ 100万 × 2
select * from dept d left join emp e on d.deptno = e.deptno;
铁律二:连接字段必须有索引
alter table emp add index idx_deptno (deptno);
铁律三:能用 join 就别在 where 里用函数
🔗 前后照应:第 07 篇 6.3 节「让字段裸奔」原则,在 JOIN 里同样适用。
-- ❌ 函数包字段,索引失效
left join emp e on date(e.hiredate) = d.some_date
-- ✅ 范围查询
left join emp e on e.hiredate >= d.some_date and e.hiredate < date_add(d.some_date, interval 1 day)
7. 实战 OJ
7.1 LeetCode 178:分数排名(rank-scores)
题目 :编写 SQL 查询来实现分数排名。如果两个分数相同,则两个分数排名应该相同;注意平分后的下一个名次应该是下一个连续的整数值(而不是跳跃的),即 1, 1, 1, 2, 3。
解法一:窗口函数(MySQL 8.0+,推荐)
select score, dense_rank() over (order by score desc) as `rank`
from scores
order by score desc;
dense_rank() 就是「密集排名」------值相同名次相同,且名次不跳号。 这正是题目要的。
三个排名函数对比(必须分清):
| 函数 | 值:100, 100, 90, 80 | 说明 |
|---|---|---|
row_number() |
1, 2, 3, 4 | 强行不重复 |
rank() |
1, 1, 3, 4 | 相同名次,后续跳号 |
dense_rank() |
1, 1, 2, 3 | 相同名次,后续不跳号 ← 本题要的 |
解法二:相关子查询(5.7 也能用,面试常考)
select s1.score,
(select count(distinct s2.score) from scores s2 where s2.score >= s1.score) as `rank`
from scores s1
order by s1.score desc;
思路:对每一行 s1,去数「有多少个 不同的分数 ≥ 它自己的分数」,这个数量就是它的名次。
为什么必须 distinct? 因为如果有三个 100 分,count(*) 会得到 3,名次变成 3(错),而 count(distinct score) 得到 1(对)。
这道题是「相关子查询」的教科书案例 :子查询引用了外层的 s1.score,所以外层每一行都要执行一次子查询。性能上是 O(n²),但在 LeetCode 的数据量下够用。
🔗 前后照应 :第 08 篇 5.5 节讲的「派生表(FROM 里的子查询)」是「非相关子查询」(只算一次);这里讲的是「相关子查询」(每行算一次)。两者的性能模型完全不同,要分清。
7.2 LeetCode 626:换座位(exchange-seats)
题目 :小美是一所中学的信息技术老师,她在一张 seat 表里存了学生的名字和它们对应的椅子 id。其中 id 是一个连续的递增列。小美想改变相邻俩学生的座位。请你写一个 SQL query 来输出小美改变之后的 seat 表的结果。
规则:
- 如果学生人数是奇数,最后一个学生的
id不变 - 否则交换相邻两个学生的
id
解法:
select
case
when id % 2 = 1 and id = (select max(id) from seat) then id -- 奇数且是最后一个:不变
when id % 2 = 1 then id + 1 -- 奇数:和下一个换
else id - 1 -- 偶数:和上一个换
end as id,
student
from seat
order by id;
结果示例:
原表 结果
+----+---------+ +----+---------+
| id | student | | id | student |
+----+---------+ +----+---------+
| 1 | Abbot | | 1 | Doris |
| 2 | Doris | | 2 | Abbot |
| 3 | Emerson | | 3 | Green |
| 4 | Green | | 4 | Emerson |
| 5 | Jeames | | 5 | Jeames |
+----+---------+ +----+---------+
三个考点:
① id % 2(取模)区分奇偶 ------ 就是第 07 篇讲的数学函数。
② case when 表达式 ------ SQL 里的「if-else」。
③ 子查询 (select max(id) from seat) ------ 注意这里在 SELECT 列表里用了对标量子查询,和「在 UPDATE 里引用同表」不同,这是允许的。
⚠️ 一个容易踩的坑 :不能写
update seat set id = ...,因为在UPDATE里同时修改和引用 id 会产生冲突。这类「交换」类题目,标准做法是「用 SELECT 生成新结果」,而不是原地 UPDATE。
case when 的语法速查:
case
when 条件1 then 结果1
when 条件2 then 结果2
else 结果3
end
⚠️
case从上到下匹配,命中第一个就停 (不是像switch那样全跑一遍)。所以条件顺序很重要------先写「特殊」的,再写「一般」的。本题里「奇数且是最后一个」必须先于「奇数」。
🔗 前后照应 :case when是第 07 篇「内置函数」的延伸(那里没讲,这里补上)。它是 SQL 里做「行内条件分支」的唯一手段,配合聚合还能做「行转列」(pivot):
select deptno, sum(case when job = 'CLERK' then 1 else 0 end) as 文员数, sum(case when job = 'MANAGER' then 1 else 0 end) as 经理数 from emp group by deptno;
7.3 补全:再来三道外连接经典题
① 找出「从没下过单」的用户
select u.id, u.name
from users u left join orders o on u.id = o.user_id
where o.id is null;
② 统计每个商品的销量(含 0 销量的商品)
select g.id, g.name, count(o.id) as 销量
from goods g left join orders o on g.id = o.goods_id
group by g.id, g.name;
-- 注意是 count(o.id) 不是 count(*)
③ 找出「连续两天没打卡」的员工
select e.id, e.name
from employees e
left join attendance a on e.id = a.emp_id and a.date = current_date()
left join attendance b on e.id = b.emp_id and b.date = date_sub(current_date(), interval 1 day)
where a.id is null and b.id is null;
这三道题的共同点: 「该有却没有」= left join + where 右表 is null。
8. 报错速查
| 错误码 | 报错文本 | 原因 | 解决 |
|---|---|---|---|
| 1064 | You have an error ... near 'full outer join' |
用了 MySQL 不支持的 FULL OUTER JOIN |
用 left join union right join 模拟 |
| 1052 | Column 'xxx' in field list is ambiguous |
同名列没加表前缀 | d.deptno / e.deptno |
| 1066 | Not unique table/alias |
同一张表用了两次相同别名 | 起不同别名 |
| 1054 | Unknown column 'xxx' in 'on clause' |
on 里用了不存在的列,或列名写错 |
检查列名 |
| 1109 | Unknown table 'xxx' in order clause |
order by 用了没在 from 里出现的别名 |
补上表 |
| 1111 | Invalid use of group function |
忘了把 count() 等聚合放进 having/select |
改位置 |
⚠️ 最隐蔽的「不是报错的错误」:
| 现象 | 真实原因 |
|---|---|
| 结果少了行 | 用了内连接,但业务要求「全保留」→ 改 left join |
| 结果数是 1 而不是 0 | count(*) 数了 left join 补的 NULL 行 → 改 count(右表列) |
left join 却像内连接 |
条件写在 WHERE 而不是 ON |
| 结果重复变多 | 连接条件不唯一(一对多、多对多),产生了行放大 |
最后一条「行放大」值得单独说:
-- 假设 emp 和 project 是多对多,一个人参加多个项目
select e.ename, p.pname from emp e left join project p on e.empno = p.leader;
-- 如果一个人是 3 个项目的 leader,他的记录会出现 3 次!
-- 这时 count(e.ename) 会统计成 3,而不是 1
解决:用 count(distinct e.empno),或者先做子查询聚合再 join。
9. 面试高频 10 问
Q1:内连接和外连接的区别? 内连接只保留「两边都能匹配」的行;外连接保留「某一侧的全部行」,另一侧匹配不上就补 NULL。
Q2:left join 和 right join 有什么区别? 只在于「保留哪一侧」:a left join b 保留 a 全部,a right join b 保留 b 全部。a left join b 等价于 b right join a。
Q3:外连接里 ON 和 WHERE 的区别? (最高频 ) ON 决定「怎么配对」,不影响外侧表的完整性;WHERE 作用在连接完成后,会筛掉补 NULL 的行------一个作用在右表字段上的 WHERE 条件会把外连接退化成内连接。
Q4:left join 之后 count(*) 为什么不对?怎么改? left join 会给没匹配的行补一行全 NULL 的记录,count(*) 会把它算成 1。应改用 count(右表的主键)。
Q5:MySQL 支持 FULL OUTER JOIN 吗? 不支持。用 left join ... union ... right join 模拟。
Q6:怎么查「没有关联数据」的记录? left join + where 右表.pk is null。比 not in 安全(不受 NULL 影响),比 not exists 更直观。
Q7:join 不加 on 会怎样? 变成笛卡尔积(等价 cross join),结果行数 = 两表行数之积。大表上等于自杀。
Q8:join 的性能怎么优化? ① 连接字段建索引(避免退化成 BNL);② 小表驱动大表;③ 尽早过滤(on / where 里减少行数);④ 8.0 能用 Hash Join;⑤ 用 explain 看执行计划。
Q9:using 和 on 的区别? using(col) 要求两边同名列,结果集只保留一个该列;on a.x=b.y 允许不同列名,结果集两列都在。
Q10:一张表能 join 自己吗? 可以,叫自连接,必须给两个不同的别名。结合外连接,可以做「找出没有上级的员工(如 KING)」这类需求。
补充一个 Q10 的实战:
-- 所有员工 + 他的领导(包括没有领导的 KING)
select e.ename as 员工, m.ename as 领导
from emp e left join emp m on e.mgr = m.empno;
-- KING 那行的「领导」是 NULL,这正是「最大的老板没有领导」
10. 总结:一张图 + 一句口诀
┌──────────────────────────────┐
│ 连接方式全景 │
└──────────────────────────────┘
│
┌───────────────────────────┼───────────────────────────┐
│ │ │
【内连接】 【外连接】 【模拟全外】
│ │ │
inner join left join left join
= 笛卡尔积 + where right join + union +
= 只留"配得上"的 = 保留一侧全部 right join
│ │
on 与 where 等价 on 与 where 不等价 ⚠️
│
┌───────────────┴───────────────┐
│ │
【保留全部】 【查找差异】
count(右表列) where 右表.pk is null
ifnull(sum, 0)
一句口诀:
「内连接求交集,外连接保一侧;条件放 ON 不放 WHERE,计数要数右表列。」
展开:
| 半句 | 含义 |
|---|---|
| 内连接求交集 | inner join 只留两边都对得上的,on 和 where 等价 |
| 外连接保一侧 | left join 保左、right join 保右,另一侧补 NULL |
| 条件放 ON 不放 WHERE | 外连接里,右表的过滤条件必须写 ON,写 WHERE 会退化成内连接 |
| 计数要数右表列 | count(右表.pk) 才能把「空关联」正确算成 0 |
最后,把这五篇串起来看:
| 篇 | 主题 | 一句话 |
|---|---|---|
| 05 | 表的约束 | 约束是门禁,脏数据进不来 |
| 06 | 增删改查 | 执行顺序决定一切 |
| 07 | 内置函数 | 函数别碰字段,索引就活了 |
| 08 | 复合查询 | 多表 = 笛卡尔积 + 筛选,聚合结果做临时表 |
| 09 | 内外连接 | 内连接求交集,外连接保一侧;条件放 ON,计数数右表 |
而这一篇又埋下了新的伏笔:
我们发现
join的性能完全取决于「连接字段有没有索引」。 没有索引,10 万行的表 join 一下就要全表扫 10 万次。那索引到底是什么?为什么它能这么快? B+ 树是怎么长出来的?为什么 MySQL 不用 B 树、红黑树、哈希?
------这就是下一篇《索引特性》要回答的问题。 那才是这套课件里最硬核的一篇。我们下一篇见。
附:一键可执行完整脚本
-- ============================================================
-- MySQL 内连接与外连接 · 全功能演示脚本
-- 环境:MySQL 5.7 / 8.0 均可(窗口函数需 8.0+)
-- ============================================================
drop database if exists join_demo;
create database join_demo default character set utf8mb4;
use join_demo;
-- ---------- 演示表 1:学生 / 成绩 ----------
create table stu (id int, name varchar(30));
insert into stu values (1,'jack'),(2,'tom'),(3,'kity'),(4,'nono');
create table exam (id int, grade int);
insert into exam values (1,56),(2,76),(11,8);
-- ---------- 演示表 2:部门 / 员工 ----------
create table dept (
deptno int primary key,
dname varchar(14),
loc varchar(13)
);
insert into dept values
(10,'ACCOUNTING','NEW YORK'),(20,'RESEARCH','DALLAS'),
(30,'SALES','CHICAGO'),(40,'OPERATIONS','BOSTON');
create table emp (
empno int primary key, ename varchar(10), job varchar(9),
mgr int, hiredate date, sal decimal(7,2), comm decimal(7,2), deptno int
);
insert into emp values
(7369,'SMITH','CLERK',7902,'1980-12-17',800.00,NULL,20),
(7499,'ALLEN','SALESMAN',7698,'1981-02-20',1600.00,300.00,30),
(7521,'WARD','SALESMAN',7698,'1981-02-22',1250.00,500.00,30),
(7566,'JONES','MANAGER',7839,'1981-04-02',2975.00,NULL,20),
(7654,'MARTIN','SALESMAN',7698,'1981-09-28',1250.00,1400.00,30),
(7698,'BLAKE','MANAGER',7839,'1981-05-01',2850.00,NULL,30),
(7782,'CLARK','MANAGER',7839,'1981-06-09',2450.00,NULL,10),
(7788,'SCOTT','ANALYST',7566,'1987-04-19',3000.00,NULL,20),
(7839,'KING','PRESIDENT',NULL,'1981-11-17',5000.00,NULL,10),
(7844,'TURNER','SALESMAN',7698,'1981-09-08',1500.00,0.00,30),
(7876,'ADAMS','CLERK',7788,'1987-05-23',1100.00,NULL,20),
(7900,'JAMES','CLERK',7698,'1981-12-03',950.00,NULL,30),
(7902,'FORD','ANALYST',7566,'1981-12-03',3000.00,NULL,20),
(7934,'MILLER','CLERK',7782,'1982-01-23',1300.00,NULL,10);
-- ---------- 2. 内连接 ----------
-- 课件两种写法
select ename, dname from EMP, DEPT where EMP.deptno = DEPT.deptno and ename = 'SMITH';
select ename, dname from EMP inner join DEPT on EMP.deptno = DEPT.deptno and ename = 'SMITH';
-- 四种等价写法
select e.ename, d.dname from emp e, dept d where e.deptno = d.deptno;
select e.ename, d.dname from emp e inner join dept d on e.deptno = d.deptno;
select e.ename, d.dname from emp e join dept d on e.deptno = d.deptno;
select e.ename, d.dname from emp e join dept d using (deptno);
-- on 与 where 在内连接里等价
select * from emp e join dept d on e.deptno = d.deptno where e.sal > 2000;
select * from emp e join dept d on e.deptno = d.deptno and e.sal > 2000;
-- ---------- 3. 外连接 ----------
-- 左外:保留所有学生
select * from stu left join exam on stu.id = exam.id;
-- 右外:保留所有成绩
select * from stu right join exam on stu.id = exam.id;
-- 左右互换:列出所有部门(含没有员工的部门)
select d.dname, e.* from dept d left join emp e on d.deptno = e.deptno;
select d.dname, e.* from emp e right join dept d on d.deptno = e.deptno;
-- ---------- 4. 五个坑 ----------
-- 坑 1:on 与 where 在外连接中不等价
select * from stu left join exam on stu.id = exam.id and exam.grade >= 60; -- ✅ 4 行
select * from stu left join exam on stu.id = exam.id where exam.grade >= 60; -- ❌ 1 行
-- 坑 2:count(*) vs count(右表列)
select d.dname, count(*) as 错误人数 from dept d left join emp e on d.deptno = e.deptno
group by d.dname;
select d.dname, count(e.empno) as 正确人数 from dept d left join emp e on d.deptno = e.deptno
group by d.dname;
-- 坑 3:left join + is null 找差异
select d.* from dept d left join emp e on d.deptno = e.deptno where e.empno is null;
-- 坑 4:模拟 FULL OUTER JOIN
select s.id as sid, s.name, e.id as eid, e.grade from stu s left join exam e on s.id = e.id
union
select s.id as sid, s.name, e.id as eid, e.grade from stu s right join exam e on s.id = e.id;
-- 坑 5:多表外连接的顺序
-- 以 dept 为最外层(正确)
select d.dname, e.ename, e.sal from dept d left join emp e on d.deptno = e.deptno;
-- ---------- 补充:左连接做「员工-领导」 ----------
select e.ename as 员工, m.ename as 领导
from emp e left join emp m on e.mgr = m.empno;
-- ---------- 补充:sum 的外连接兜底 ----------
select d.dname, ifnull(sum(e.sal), 0) as 总工资
from dept d left join emp e on d.deptno = e.deptno
group by d.dname;
-- ---------- 7. OJ ----------
-- LeetCode 178:分数排名(8.0+ 窗口函数)
-- select score, dense_rank() over (order by score desc) as `rank` from scores order by score desc;
-- 5.7 相关子查询写法
-- select s1.score, (select count(distinct s2.score) from scores s2 where s2.score >= s1.score) as `rank`
-- from scores s1 order by s1.score desc;
-- LeetCode 626:换座位
-- select case
-- when id % 2 = 1 and id = (select max(id) from seat) then id
-- when id % 2 = 1 then id + 1
-- else id - 1
-- end as id, student
-- from seat order by id;
-- ---------- 补充:case when 做行转列 ----------
select deptno,
sum(case when job = 'CLERK' then 1 else 0 end) as 文员数,
sum(case when job = 'MANAGER' then 1 else 0 end) as 经理数,
sum(case when job = 'ANALYST' then 1 else 0 end) as 分析师数
from emp group by deptno;
如果这篇博客帮你理清了某个点,欢迎点赞收藏;有任何疑问,评论区见,我会一条条回。