外连接详解 (OUTER JOIN)
什么是外连接
外连接(OUTER JOIN)不仅返回匹配的记录,还会保留一侧(或两侧)表中没有匹配的记录,用 NULL 填充缺失的列值。
通俗理解: 就像班级配对活动,即使有些学生没有配对成功,我们也要把他们列出来(标注为"无配对")。
回顾内连接的问题
还记得内连接的例子吗?赵六因为没选课就"消失"了:
Student 表:
+------+--------+
| Sno | Sname |
+------+--------+
| S001 | 张三 |
| S002 | 李四 |
| S003 | 王五 |
| S004 | 赵六 | ← 她没选课
+------+--------+SC 表(选课表):
+------+------+-------+
| Sno | Cno | Grade |
+------+------+-------+
| S001 | C001 | 90 |
| S001 | C002 | 85 |
| S002 | C001 | 88 |
| S003 | C003 | 92 |
+------+------+-------+内连接结果: 赵六不见了
SELECT S.Sno, S.Sname, SC.Cno, SC.Grade
FROM Student S
INNER JOIN SC ON S.Sno = SC.Sno;+------+--------+------+-------+
| Sno | Sname | Cno | Grade |
+------+--------+------+-------+
| S001 | 张三 | C001 | 90 |
| S001 | 张三 | C002 | 85 |
| S002 | 李四 | C001 | 88 |
| S003 | 王五 | C003 | 92 |
+------+--------+------+-------+外连接的解决方案
如果我们想保留赵六呢? 就用外连接!
与内连接的区别:
- 内连接(INNER JOIN):只返回匹配的记录,不匹配的直接丢弃
- 左外连接(LEFT JOIN):保留左表所有记录,右表不匹配的用 NULL 填充
- 右外连接(RIGHT JOIN):保留右表所有记录,左表不匹配的用 NULL 填充
- 全外连接(FULL JOIN):保留两个表的所有记录,不匹配的都用 NULL 填充
内连接 vs 外连接对比图
外连接的类型
1. 左外连接 (LEFT OUTER JOIN / LEFT JOIN)
核心概念: 保留左表的所有记录,即使右表没有匹配的记录。
通俗理解: "以左表为主",左表的每一行都要出现在结果中,右表没匹配的就填 NULL。
语法:
SELECT 列名
FROM 表1 -- 左表(主表)
LEFT JOIN 表2 -- 右表
ON 表1.列名 = 表2.列名;完整示例
需求: 我想看所有学生的选课情况,包括那些还没选课的学生!
-- 左外连接:Student 在左边,SC 在右边
SELECT S.Sno, S.Sname, SC.Cno, SC.Grade
FROM Student S -- 左表(保留所有记录)
LEFT JOIN SC -- 右表
ON S.Sno = SC.Sno;执行过程(逐步演示):
第1步:取左表第1行(张三 S001)
→ 在右表 SC 中找 Sno = 'S001'
→ 找到2条记录
→ 生成2行结果(有完整数据)
第2步:取左表第2行(李四 S002)
→ 在右表 SC 中找 Sno = 'S002'
→ 找到1条记录
→ 生成1行结果(有完整数据)
第3步:取左表第3行(王五 S003)
→ 在右表 SC 中找 Sno = 'S003'
→ 找到1条记录
→ 生成1行结果(有完整数据)
第4步:取左表第4行(赵六 S004) ← 关键!
→ 在右表 SC 中找 Sno = 'S004'
→ 找到0条!
→ 仍然生成1行结果,但 Cno 和 Grade 填充 NULL执行结果:
+------+--------+------+-------+
| Sno | Sname | Cno | Grade |
+------+--------+------+-------+
| S001 | 张三 | C001 | 90 |
| S001 | 张三 | C002 | 85 |
| S002 | 李四 | C001 | 88 |
| S003 | 王五 | C003 | 92 |
| S004 | 赵六 | NULL | NULL | ← 赵六出现了!但选课信息是 NULL
+------+--------+------+-------+关键观察:
- 赵六出现了! 这就是左外连接的作用
- 左表(Student)的所有4个学生都在结果中
- 赵六没有选课,所以 Cno 和 Grade 是 NULL
数据流程图:
实际应用场景
场景1:找出未选课的学生
-- 利用 NULL 来找出未选课的学生
SELECT S.Sno, S.Sname
FROM Student S
LEFT JOIN SC ON S.Sno = SC.Sno
WHERE SC.Sno IS NULL; -- 右表为 NULL 说明没匹配结果:
+------+--------+
| Sno | Sname |
+------+--------+
| S004 | 赵六 | ← 只有赵六没选课
+------+--------+场景2:统计每个学生的选课数量(包括0门的)
SELECT S.Sno, S.Sname, COUNT(SC.Cno) AS CourseCount
FROM Student S
LEFT JOIN SC ON S.Sno = SC.Sno
GROUP BY S.Sno, S.Sname;结果:
+------+--------+-------------+
| Sno | Sname | CourseCount |
+------+--------+-------------+
| S001 | 张三 | 2 |
| S002 | 李四 | 1 |
| S003 | 王五 | 1 |
| S004 | 赵六 | 0 | ← 显示为 0,而不是消失
+------+--------+-------------+注意: 必须用 COUNT(SC.Cno) 而不是 COUNT(*),因为 COUNT(列名) 不会计入 NULL 值。
2. 右外连接 (RIGHT OUTER JOIN / RIGHT JOIN)
核心概念: 保留右表的所有记录,即使左表没有匹配的记录。
通俗理解: "以右表为主",右表的每一行都要出现在结果中,左表没匹配的就填 NULL。
语法:
SELECT 列名
FROM 表1 -- 左表
RIGHT JOIN 表2 -- 右表(主表,保留所有)
ON 表1.列名 = 表2.列名;完整示例
Course 表(课程表):
+------+--------------+--------+
| Cno | Cname | Credit |
+------+--------------+--------+
| C001 | 数据库 | 4 |
| C002 | 操作系统 | 3 |
| C003 | 计算机网络 | 3 |
| C004 | 人工智能 | 4 | ← 注意:没人选这门课!
| C005 | 软件工程 | 3 | ← 也没人选
+------+--------------+--------+需求: 我想看所有课程的选课情况,包括那些还没人选的课程!
-- 右外连接:SC 在左边,Course 在右边
SELECT C.Cno, C.Cname, SC.Sno, SC.Grade
FROM SC
RIGHT JOIN Course C -- Course 在右边(保留所有课程)
ON SC.Cno = C.Cno;执行结果:
+------+--------------+------+-------+
| Cno | Cname | Sno | Grade |
+------+--------------+------+-------+
| C001 | 数据库 | S001 | 90 |
| C001 | 数据库 | S002 | 88 |
| C002 | 操作系统 | S001 | 85 |
| C003 | 计算机网络 | S003 | 92 |
| C004 | 人工智能 | NULL | NULL | ← 没人选,学生信息 NULL
| C005 | 软件工程 | NULL | NULL | ← 也没人选
+------+--------------+------+-------+关键观察:
- 所有5门课程都出现了
- C004 和 C005 没人选,所以 Sno 和 Grade 是 NULL
- 数据库课程有2个学生选,出现2行
右连接 = 左连接(换个位置)
重要提示: 右外连接可以改写为左外连接,只需要调换表的位置!
-- 方式1:RIGHT JOIN
SELECT C.Cno, C.Cname, SC.Sno, SC.Grade
FROM SC
RIGHT JOIN Course C ON SC.Cno = C.Cno;
-- 方式2:LEFT JOIN(等价,推荐)
SELECT C.Cno, C.Cname, SC.Sno, SC.Grade
FROM Course C -- 换到左边
LEFT JOIN SC ON SC.Cno = C.Cno; -- SC 换到右边结果完全相同! 实际开发中,大家更习惯用 LEFT JOIN,所以 RIGHT JOIN 用得较少。
实际应用
找出没有学生选修的课程:
SELECT C.Cno, C.Cname
FROM SC
RIGHT JOIN Course C ON SC.Cno = C.Cno
WHERE SC.Cno IS NULL; -- 左表为 NULL 说明没匹配结果:
+------+--------------+
| Cno | Cname |
+------+--------------+
| C004 | 人工智能 |
| C005 | 软件工程 |
+------+--------------+3. 全外连接 (FULL OUTER JOIN)
核心概念: 保留两个表的所有记录,不管是否有匹配。
通俗理解: 左表和右表的记录都要保留,没匹配的两边都填 NULL。这是"最宽容"的连接方式。
语法(标准SQL):
SELECT 列名
FROM 表1
FULL OUTER JOIN 表2
ON 表1.列名 = 表2.列名;** 重要:MySQL 不支持 FULL OUTER JOIN!** 需要用 UNION 模拟。
完整示例
需求: 我想看完整的学生-课程匹配情况:
- 选了课的学生和课程
- 没选课的学生(赵六)
- 没人选的课程(C004, C005)
MySQL 的实现方法(UNION):
-- 全外连接 = 左外连接 UNION 右外连接
SELECT S.Sno, S.Sname, C.Cno, C.Cname, SC.Grade
FROM Student S
LEFT JOIN SC ON S.Sno = SC.Sno
LEFT JOIN Course C ON SC.Cno = C.Cno
UNION
SELECT S.Sno, S.Sname, C.Cno, C.Cname, SC.Grade
FROM Course C
LEFT JOIN SC ON C.Cno = SC.Cno
LEFT JOIN Student S ON SC.Sno = S.Sno
WHERE S.Sno IS NULL; -- 只要第一部分没有的(没匹配的课程)执行结果:
+------+--------+------+--------------+-------+
| Sno | Sname | Cno | Cname | Grade |
+------+--------+------+--------------+-------+
| S001 | 张三 | C001 | 数据库 | 90 | ← 正常选课记录
| S001 | 张三 | C002 | 操作系统 | 85 | ← 正常选课记录
| S002 | 李四 | C001 | 数据库 | 88 | ← 正常选课记录
| S003 | 王五 | C003 | 计算机网络 | 92 | ← 正常选课记录
| S004 | 赵六 | NULL | NULL | NULL | ← 没选课的学生
| NULL | NULL | C004 | 人工智能 | NULL | ← 没人选的课程
| NULL | NULL | C005 | 软件工程 | NULL | ← 没人选的课程
+------+--------+------+--------------+-------+分析结果:
- 前4行:正常的学生选课记录
- 第5行:赵六(学生存在,但没选课)→ 课程信息 NULL
- 第6-7行:C004、C005(课程存在,但没人选)→ 学生信息 NULL
执行原理图解
实际应用
需求: 统计学生选课和课程被选情况
SELECT
COALESCE(S.Sname, '(无学生)') AS StudentName,
COALESCE(C.Cname, '(无课程)') AS CourseName,
COALESCE(SC.Grade, 0) AS Grade,
CASE
WHEN S.Sno IS NULL THEN '课程未被选'
WHEN C.Cno IS NULL THEN '学生未选课'
ELSE '正常选课'
END AS Status
FROM Student S
LEFT JOIN SC ON S.Sno = SC.Sno
LEFT JOIN Course C ON SC.Cno = C.Cno
UNION
SELECT
COALESCE(S.Sname, '(无学生)') AS StudentName,
COALESCE(C.Cname, '(无课程)') AS CourseName,
COALESCE(SC.Grade, 0) AS Grade,
CASE
WHEN S.Sno IS NULL THEN '课程未被选'
WHEN C.Cno IS NULL THEN '学生未选课'
ELSE '正常选课'
END AS Status
FROM Course C
LEFT JOIN SC ON C.Cno = SC.Cno
LEFT JOIN Student S ON SC.Sno = S.Sno
WHERE S.Sno IS NULL;结果:
+--------------+--------------+-------+--------------+
| StudentName | CourseName | Grade | Status |
+--------------+--------------+-------+--------------+
| 张三 | 数据库 | 90 | 正常选课 |
| 张三 | 操作系统 | 85 | 正常选课 |
| 李四 | 数据库 | 88 | 正常选课 |
| 王五 | 计算机网络 | 92 | 正常选课 |
| 赵六 | (无课程) | 0 | 学生未选课 |
| (无学生) | 人工智能 | 0 | 课程未被选 |
| (无学生) | 软件工程 | 0 | 课程未被选 |
+--------------+--------------+-------+--------------+这样就能清楚地看到完整情况了!
实际应用场景
场景1:找出未选课的学生
-- 使用左外连接 + WHERE 过滤
SELECT S.Sno, S.Sname
FROM Student S
LEFT JOIN SC ON S.Sno = SC.Sno
WHERE SC.Sno IS NULL;场景2:统计每个学生的选课数量(包括未选课的学生)
SELECT S.Sno, S.Sname, COUNT(SC.Cno) AS CourseCount
FROM Student S
LEFT JOIN SC ON S.Sno = SC.Sno
GROUP BY S.Sno, S.Sname;注意:
- 使用
COUNT(SC.Cno)而不是COUNT(*) COUNT(*)会统计 NULL 行,导致未选课学生显示为 1
场景3:查询学生选课情况,显示未选课原因
SELECT
S.Sno,
S.Sname,
COALESCE(C.Cname, '未选课') AS CourseName,
COALESCE(CAST(SC.Grade AS CHAR), '无成绩') AS Grade
FROM Student S
LEFT JOIN SC ON S.Sno = SC.Sno
LEFT JOIN Course C ON SC.Cno = C.Cno;场景4:MySQL中实现全外连接
-- 使用 UNION 模拟 FULL OUTER JOIN
SELECT S.Sno, S.Sname, SC.Cno, SC.Grade
FROM Student S
LEFT JOIN SC ON S.Sno = SC.Sno
UNION
SELECT S.Sno, S.Sname, SC.Cno, SC.Grade
FROM SC
RIGHT JOIN Student S ON S.Sno = SC.Sno
WHERE S.Sno IS NULL;NULL 值处理
1. 判断 NULL
-- 正确写法
WHERE SC.Sno IS NULL
-- 错误写法(不会得到预期结果)
WHERE SC.Sno = NULL2. 处理 NULL(使用 COALESCE 或 IFNULL)
-- COALESCE:返回第一个非 NULL 值
SELECT S.Sname, COALESCE(SC.Grade, 0) AS Grade
FROM Student S
LEFT JOIN SC ON S.Sno = SC.Sno;
-- IFNULL (MySQL)
SELECT S.Sname, IFNULL(SC.Grade, 0) AS Grade
FROM Student S
LEFT JOIN SC ON S.Sno = SC.Sno;性能对比
| 连接类型 | 结果集大小 | 性能 | 使用频率 |
|---|---|---|---|
| INNER JOIN | 最小 | 最快 | |
| LEFT JOIN | 中等 | 较快 | |
| RIGHT JOIN | 中等 | 较快 | |
| FULL OUTER JOIN | 最大 | 最慢 |
常见错误
错误1:混淆 WHERE 和 ON
-- 错误:WHERE 会在连接后过滤,破坏外连接效果
SELECT S.Sno, S.Sname, SC.Grade
FROM Student S
LEFT JOIN SC ON S.Sno = SC.Sno
WHERE SC.Grade > 80; -- 这会排除所有未选课的学生!
-- 正确:应该在 ON 中添加条件
SELECT S.Sno, S.Sname, SC.Grade
FROM Student S
LEFT JOIN SC ON S.Sno = SC.Sno AND SC.Grade > 80;错误2:聚合函数使用不当
-- 错误:COUNT(*) 会计入 NULL 行
SELECT S.Sno, COUNT(*) AS CourseCount
FROM Student S
LEFT JOIN SC ON S.Sno = SC.Sno
GROUP BY S.Sno;
-- 正确:COUNT(列名) 不计入 NULL
SELECT S.Sno, COUNT(SC.Cno) AS CourseCount
FROM Student S
LEFT JOIN SC ON S.Sno = SC.Sno
GROUP BY S.Sno;练习题
题目
- 查询所有学生及其选课数量(包括未选课的学生显示为0)
- 找出所有没有被任何学生选修的课程
- 查询每个院系的学生人数和平均选课数
- 使用 UNION 实现完整的全外连接查询
答案与详解
练习1:查询所有学生及其选课数量(包括未选课的学生显示为0)
答案:
SELECT
S.Sno,
S.Sname,
COUNT(SC.Cno) AS CourseCount
FROM Student S
LEFT JOIN SC ON S.Sno = SC.Sno
GROUP BY S.Sno, S.Sname
ORDER BY CourseCount DESC;详解:
- 为什么用 LEFT JOIN?
- 需要保留所有学生,包括未选课的学生
- 如果用 INNER JOIN,未选课的学生会被排除
- 为什么用
COUNT(SC.Cno)而不是COUNT(*)?COUNT(*)会计入所有行,包括 NULL 行- 未选课的学生会被计为 1 而不是 0
COUNT(SC.Cno)不计入 NULL,正确返回 0
- GROUP BY 原因:需要按学生分组统计每个学生的选课数
对比示例:
错误示例对比:
-- 错误1:使用 INNER JOIN(未选课学生不显示)
SELECT S.Sno, S.Sname, COUNT(SC.Cno) AS CourseCount
FROM Student S
INNER JOIN SC ON S.Sno = SC.Sno -- 错误!
GROUP BY S.Sno, S.Sname;
-- 错误2:使用 COUNT(*)(未选课学生显示1)
SELECT S.Sno, S.Sname, COUNT(*) AS CourseCount -- 错误!
FROM Student S
LEFT JOIN SC ON S.Sno = SC.Sno
GROUP BY S.Sno, S.Sname;
-- 正确:LEFT JOIN + COUNT(列名)
SELECT S.Sno, S.Sname, COUNT(SC.Cno) AS CourseCount
FROM Student S
LEFT JOIN SC ON S.Sno = SC.Sno
GROUP BY S.Sno, S.Sno;练习2:找出所有没有被任何学生选修的课程
答案:
-- 方法1:使用 LEFT JOIN + WHERE IS NULL(推荐)
SELECT C.Cno, C.Cname
FROM Course C
LEFT JOIN SC ON C.Cno = SC.Cno
WHERE SC.Cno IS NULL;
-- 方法2:使用 NOT EXISTS
SELECT C.Cno, C.Cname
FROM Course C
WHERE NOT EXISTS (
SELECT 1 FROM SC WHERE SC.Cno = C.Cno
);
-- 方法3:使用 NOT IN(注意NULL问题)
SELECT C.Cno, C.Cname
FROM Course C
WHERE C.Cno NOT IN (
SELECT Cno FROM SC WHERE Cno IS NOT NULL
);详解:
方法1(LEFT JOIN + IS NULL):
- 原理:左连接保留所有课程,未被选修的课程在 SC 表中没有匹配,对应字段为 NULL
- 判断条件:
WHERE SC.Cno IS NULL筛选出没有匹配的课程 - 优点:性能好,逻辑清晰
- 注意:必须用
IS NULL而不是= NULL
方法2(NOT EXISTS):
- 原理:对每门课程,检查是否存在选课记录
- 优点:语义清晰,短路求值(找到一条就停止)
- 性能:通常比 NOT IN 好
方法3(NOT IN):
- 原理:课程号不在选课表的课程号列表中
- ** 重要**:如果子查询包含 NULL,NOT IN 可能返回空结果
- 解决:添加
WHERE Cno IS NOT NULL
可视化对比:
性能对比:
| 方法 | 性能 | 可读性 | 推荐度 |
|---|---|---|---|
| LEFT JOIN + IS NULL | ★★★★★ | ★★★★☆ | |
| NOT EXISTS | ★★★★☆ | ★★★★★ | |
| NOT IN | ★★☆☆☆ | ★★★☆☆ |
练习3:查询每个院系的学生人数和平均选课数
答案:
SELECT
S.Sdept AS Department,
COUNT(DISTINCT S.Sno) AS StudentCount,
COUNT(SC.Cno) AS TotalCourseSelections,
ROUND(COUNT(SC.Cno) * 1.0 / COUNT(DISTINCT S.Sno), 2) AS AvgCoursesPerStudent
FROM Student S
LEFT JOIN SC ON S.Sno = SC.Sno
GROUP BY S.Sdept
ORDER BY AvgCoursesPerStudent DESC;详解:
为什么用 LEFT JOIN?
- 确保统计所有学生,包括未选课的学生
- 如果用 INNER JOIN,未选课的学生会被排除,导致平均值偏高
关键计算:
COUNT(DISTINCT S.Sno):院系的学生总数(去重)COUNT(SC.Cno):该院系所有学生的选课总数- 平均选课数 = 选课总数 / 学生总数
注意事项:
- 使用
* 1.0进行浮点数除法(避免整数除法) ROUND(..., 2)保留两位小数- 必须用
COUNT(DISTINCT S.Sno)避免重复计数学生
- 使用
计算示例:
错误示例:
-- 错误1:使用 INNER JOIN(排除未选课学生)
SELECT S.Sdept, COUNT(DISTINCT S.Sno), AVG(CourseCount)
FROM Student S
INNER JOIN (
SELECT Sno, COUNT(*) AS CourseCount FROM SC GROUP BY Sno
) T ON S.Sno = T.Sno
GROUP BY S.Sdept;
-- 问题:未选课的学生不被计入学生总数
-- 错误2:不使用 DISTINCT(重复计数学生)
SELECT S.Sdept, COUNT(S.Sno) AS StudentCount -- 错误!
FROM Student S
LEFT JOIN SC ON S.Sno = SC.Sno
GROUP BY S.Sdept;
-- 问题:每个选课记录都计数一次学生
-- 正确写法
SELECT S.Sdept, COUNT(DISTINCT S.Sno) AS StudentCount
FROM Student S
LEFT JOIN SC ON S.Sno = SC.Sno
GROUP BY S.Sdept;练习4:使用 UNION 实现完整的全外连接查询
**题目场景:**查询所有学生和所有课程的完整选课关系,包括未选课的学生和未被选的课程。
答案:
-- 完整的全外连接(包含所有学生-课程组合的选课情况)
SELECT
COALESCE(S.Sno, SC.Sno) AS Sno,
S.Sname,
COALESCE(C.Cno, SC.Cno) AS Cno,
C.Cname,
SC.Grade
FROM Student S
LEFT JOIN SC ON S.Sno = SC.Sno
LEFT JOIN Course C ON SC.Cno = C.Cno
UNION
SELECT
SC.Sno,
S.Sname,
C.Cno,
C.Cname,
SC.Grade
FROM Course C
LEFT JOIN SC ON C.Cno = SC.Cno
LEFT JOIN Student S ON SC.Sno = S.Sno
WHERE SC.Sno IS NULL OR S.Sno IS NULL;详解:
UNION 的两部分:
第一部分(LEFT JOIN Student):
- 保留所有学生及其选课记录
- 包括未选课的学生(Grade 为 NULL)
第二部分(LEFT JOIN Course):
- 保留所有课程
- 筛选出未被任何学生选修的课程
WHERE SC.Sno IS NULL OR S.Sno IS NULL避免与第一部分重复
UNION vs UNION ALL:
UNION:自动去重,合并结果UNION ALL:不去重,保留所有记录
可视化示例:
完整示例数据:
假设:
- Student: S001(张三), S002(李四), S003(王五)
- Course: C001(数据库), C002(操作系统), C003(网络)
- SC: (S001, C001, 90), (S001, C002, 85), (S002, C001, 88)
结果集:
Sno | Sname | Cno | Cname | Grade
------|-------|------|------------|-------
S001 | 张三 | C001 | 数据库 | 90 ← 有选课记录
S001 | 张三 | C002 | 操作系统 | 85 ← 有选课记录
S002 | 李四 | C001 | 数据库 | 88 ← 有选课记录
S003 | 王五 | NULL | NULL | NULL ← 未选课学生(第一部分)
NULL | NULL | C003 | 网络 | NULL ← 未被选课程(第二部分)MySQL实现全外连接的替代方案:
-- 方案1:UNION(如上)
-- 方案2:使用 COALESCE 简化
SELECT
COALESCE(L.Sno, R.Sno) AS Sno,
COALESCE(L.Sname, R.Sname) AS Sname,
COALESCE(L.Cno, R.Cno) AS Cno,
COALESCE(L.Cname, R.Cname) AS Cname,
COALESCE(L.Grade, R.Grade) AS Grade
FROM (
SELECT S.Sno, S.Sname, C.Cno, C.Cname, SC.Grade
FROM Student S
LEFT JOIN SC ON S.Sno = SC.Sno
LEFT JOIN Course C ON SC.Cno = C.Cno
) L
LEFT JOIN (
SELECT S.Sno, S.Sname, C.Cno, C.Cname, SC.Grade
FROM Course C
LEFT JOIN SC ON C.Cno = SC.Cno
LEFT JOIN Student S ON SC.Sno = S.Sno
) R ON L.Sno = R.Sno AND L.Cno = R.Cno;