在前面几篇文章中,我们已经多次使用JOIN来将两张或更多表的数据组合在一起查询。现在是时候系统地掌握联合查询的各种类型了。联合查询是关系型数据库最强大的特性之一,它将分散在不同表中的数据通过关联条件“连接”起来,让我们能够回答跨越多个实体的业务问题。
本文将详细讲解:
- 内连接(
INNER JOIN)——只返回匹配的行 - 外连接(
LEFT JOIN、RIGHT JOIN)——保留不匹配的行 - 多表连接——超过两张表的联合查询
- 自连接(Self Join)——同一张表连接自身
实战部分将围绕我们的图书管理系统,查询每位读者的借阅记录(含图书名称),并深入练习自连接场景。
1. 什么是 JOIN?
关系型数据库设计中,我们通常将数据拆分到不同的表中,以消除冗余(范式化)。但业务查询时往往需要把这些分散的信息重新拼起来。JOIN就是用来“拼”数据的机制。
连接的核心要素:
- 连接条件:指定两张表如何关联,通常是通过外键与主键的等值比较(如
a.reader_id = b.id)。 - 连接类型:决定了当某行在对方表中没有匹配时的处理方式(保留还是丢弃)。
MySQL 中支持的连接类型包括:INNER JOIN、LEFT [OUTER] JOIN、RIGHT [OUTER] JOIN,以及CROSS JOIN。OUTER关键字可省略。
2. 内连接:INNER JOIN
内连接是最常用的连接,它返回两个表中满足连接条件的所有行。如果某行在对方表中没有匹配,则不会出现在结果中。
2.1 基本语法
SELECT列列表FROM表AINNERJOIN表BON连接条件[WHERE过滤条件];ON后面是连接条件,通常使用=进行匹配。
2.2 示例:查询借阅记录及对应的图书名称
在我们的图书管理系统中,borrow_records表存储借阅的book_id,要显示书名就需要连接books表。
SELECTbr.idASrecord_id,b.title,br.borrow_date,br.due_dateFROMborrow_records brINNERJOINbooks bONbr.book_id=b.id;只有book_id在books表中存在的借阅记录才会返回。由于我们有外键约束,正常情况下所有记录都能匹配。但如果我们删除了某本书而没有级联删除借阅记录,那么对应的book_id就无法匹配,使用INNER JOIN这些记录会被“吞掉”(不显示)。
使用 WHERE 进一步过滤:
SELECTb.title,br.borrow_dateFROMborrow_records brINNERJOINbooks bONbr.book_id=b.idWHEREbr.borrow_date>='2025-03-01';2.3 隐式内连接(SQL-89 风格)
早期 SQL 写法也支持在FROM中列出多张表,并在WHERE中写连接条件:
SELECTb.title,br.borrow_dateFROMborrow_records br,books bWHEREbr.book_id=b.id;这种写法功能与INNER JOIN相同,但可读性和维护性较差,容易忘记连接条件变成笛卡尔积。推荐使用显式JOIN ... ON语法。
3. 外连接:保留不匹配的行
外连接与内连接的区别在于:即使对方表中没有匹配的行,也会保留“主表”中的行,并用NULL填充对方表的列。
3.1 左外连接:LEFT JOIN
LEFT JOIN保留左表(FROM 后的第一张表)的所有行,右表中无匹配的行则填NULL。
语法:
SELECT列列表FROM左表LEFTJOIN右表ON连接条件;需求:列出所有读者,包括那些没有借阅记录的读者
SELECTr.name,br.idASrecord_id,br.borrow_dateFROMreaders rLEFTJOINborrow_records brONr.id=br.reader_idORDERBYr.name,br.borrow_date;结果中,没有任何借阅记录的读者会显示一行,其record_id和borrow_date为NULL。用LEFT JOIN可以轻松找出“从未借书的读者”。
统计每位读者的借阅次数(包括 0 次):
SELECTr.name,COUNT(br.id)ASborrow_countFROMreaders rLEFTJOINborrow_records brONr.id=br.reader_idGROUPBYr.id,r.name;COUNT(br.id)只统计非 NULL 的借阅记录,所以没有借阅的读者计数为 0。如果使用INNER JOIN,他们将完全不出现。
3.2 右外连接:RIGHT JOIN
RIGHT JOIN保留右表的所有行,左表无匹配的行填NULL。
语法:
SELECT列列表FROM左表RIGHTJOIN右表ON连接条件;实际上,RIGHT JOIN可以通过交换表顺序用LEFT JOIN实现。例如:
-- 列出所有图书及其被借阅记录(包括从未被借过的书)SELECTb.title,br.borrow_dateFROMborrow_records brRIGHTJOINbooks bONbr.book_id=b.id;等价于:
SELECTb.title,br.borrow_dateFROMbooks bLEFTJOINborrow_records brONb.id=br.book_id;推荐始终使用LEFT JOIN,将“主表”放在左边,思维更直观。
3.3 全外连接(FULL OUTER JOIN)
MySQL 不直接支持FULL OUTER JOIN,但可以通过LEFT JOIN和RIGHT JOIN的UNION来模拟,它将保留两个表中的所有行。实际业务中较少使用。
4. 多表连接
连接不仅限于两张表,我们可以连续JOIN多张表来获取更丰富的信息。
需求:查询每位读者的借阅记录,包含读者姓名、图书名称、分类名称
这涉及四张表:readers、borrow_records、books、book_category、categories(五张)。
SELECTr.nameASreader,b.titleASbook,c.nameAScategory,br.borrow_date,br.due_dateFROMreaders rJOINborrow_records brONr.id=br.reader_idJOINbooks bONbr.book_id=b.idLEFTJOINbook_category bcONb.id=bc.book_idLEFTJOINcategories cONbc.category_id=c.idORDERBYr.name,br.borrow_date;注意:
- 图书可能没有分类(使用
LEFT JOIN保证图书信息不丢失)。 - 读者和借阅记录之间使用
INNER JOIN或LEFT JOIN取决于是否要包含没有借阅的读者。
连接顺序与性能:
MySQL 优化器会根据统计信息决定实际的连接顺序,不一定会按照你写的顺序执行。但作为开发者,我们可以把筛选力强的表写在前面(或在ON/WHERE中提前过滤),提升可读性。后续索引优化阶段会深入探讨连接优化。
5. 自连接:同一张表连接自身
自连接是一种特殊的连接,它把同一张表当作两张独立的表来使用,通过不同的别名区分。自连接通常用来查询表内部的层级关系或配对关系。
5.1 场景举例
- 员工表中,每个员工有一个
manager_id指向同表中经理的id。 - 分类表中,一个分类可能有父分类(
parent_id指向自己表中的id)。 - 在图书系统中,如果要找出“同一作者的其他书籍”,也可以用自连接。
5.2 语法与示例
给同一张表取两个不同的别名,然后像连接两张不同的表一样操作。
示例:找出同一作者出版的除自己外的其他图书
SELECTa.idASbook_id,a.titleASbook_title,b.idASother_book_id,b.titleASother_book_titleFROMbooks aJOINbooks bONa.author=b.authorANDa.id<>b.idORDERBYa.author,a.id;a和b都是books表的别名。- 连接条件:作者相同 (
a.author = b.author),但书不同 (a.id <> b.id)。
扩展:构造一个简单的员工表练习自连接
-- 创建员工表CREATETABLEemployees(idINTPRIMARYKEY,nameVARCHAR(50),manager_idINT);INSERTINTOemployeesVALUES(1,'CEO张',NULL),(2,'经理李',1),(3,'经理王',1),(4,'员工赵',2),(5,'员工孙',2),(6,'员工周',3);-- 查询每位员工及其经理的姓名SELECTe.nameASemployee,m.nameASmanagerFROMemployees eLEFTJOINemployees mONe.manager_id=m.id;这个查询使用LEFT JOIN确保CEO张(经理为 NULL)也能显示。
5.3 自连接处理树形结构
如果分类表有父子关系:
CREATETABLEcategory_tree(idINTPRIMARYKEY,nameVARCHAR(50),parent_idINT);-- 查询分类及其父分类SELECTc.nameAScategory,p.nameASparent_categoryFROMcategory_tree cLEFTJOINcategory_tree pONc.parent_id=p.id;自连接配合递归 CTE(MySQL 8.0+)可以处理无限层级,这会在高阶部分涉及。
6. 实战:查询每位读者的借阅记录(含图书名称)
综合运用以上知识,为图书管理系统编写几个典型的多表查询。
6.1 读者所有借阅记录(含书名和状态)
SELECTr.nameASreader,b.titleASbook,br.borrow_date,br.due_date,br.return_date,CASEWHENbr.return_dateISNOTNULLTHEN'已还'WHENbr.due_date<CURDATE()THEN'逾期'ELSE'借阅中'ENDASstatusFROMreaders rLEFTJOINborrow_records brONr.id=br.reader_idLEFTJOINbooks bONbr.book_id=b.idORDERBYr.name,br.borrow_dateDESC;- 第一层
LEFT JOIN保留了没有借阅记录的读者。 - 第二层
LEFT JOIN保证即使book_id无效(理论上不存在)也不会丢失借阅记录行。
6.2 找出从未借过书的读者
利用LEFT JOIN和IS NULL判断:
SELECTr.name,r.emailFROMreaders rLEFTJOINborrow_records brONr.id=br.reader_idWHEREbr.idISNULL;因为对没有借阅的读者,br.id为 NULL。
6.3 找出同时借了“MySQL 从入门到精通”和“算法导论”的读者
这需要自连接或多次 JOIN 同一张表:
SELECTDISTINCTr.nameFROMreaders rJOINborrow_records br1ONr.id=br1.reader_idJOINbooks b1ONbr1.book_id=b1.idANDb1.title='MySQL 从入门到精通'JOINborrow_records br2ONr.id=br2.reader_idJOINbooks b2ONbr2.book_id=b2.idANDb2.title='算法导论';这里将borrow_records连接了两次(通过不同别名br1、br2),各自关联不同的图书,确保同一读者既有这两本书的借阅记录。
6.4 图书推荐:同一作者的其他书
使用自连接生成推荐列表:
SELECTb1.titleASthis_book,b2.titleASrecommended_book,b1.authorFROMbooks b1JOINbooks b2ONb1.author=b2.authorANDb1.id<>b2.idORDERBYb1.title;7. JOIN 选择的注意事项
- 数据完整性:如果子表外键列缺少索引,连接会变慢,尤其是在大表上。通常我们应该为外键列建立索引,MySQL 创建外键约束时会自动添加(如果未手动建立)。
- NULL 的语义:外连接产生 NULL 时,要注意在 WHERE 条件中对 NULL 的判断(
IS NULL而非= NULL),以及在聚合函数中的行为。 - 可读性:即使查询优化器可能重排连接,但我们在书写时应考虑逻辑清晰度,把主表放左边,用
LEFT JOIN单向串联。
8. 小结
联合查询是 SQL 的精华所在,今天的内容可以分为四大块:
- INNER JOIN:返回两表匹配的行,最常用。
- LEFT JOIN / RIGHT JOIN:保留左表(或右表)的全部行,没有匹配则填 NULL,用于发现缺失数据。
- 多表连接:链式 JOIN 多张表,实现跨越多实体的复杂查询。
- 自连接:一张表当作两张用,解决层级、配对、推荐等问题。
在实战部分我们利用图书管理系统,从读者借阅详情、未借书读者,到“同作者推荐”,将各种连接技术融会贯通。
下一篇将是第二阶段第 5 篇——子查询与合并查询,我们将学习如何使用子查询来进一步过滤和计算,以及用UNION合并多个查询的结果。
思考题:
- 有两张表
A (id, val)和B (id, val),如何使用LEFT JOIN找出 A 中有而 B 中没有的id? - 如果我们有一个商品表和一个订单明细表,如何用 JOIN 找出从未下过单的用户?(提示:用户表 LEFT JOIN 订单表)
- 自连接时,如果不加
a.id <> b.id条件,会发生什么?
参考资料
- MySQL 8.0 Reference Manual - JOIN Syntax
- MySQL 8.0 Reference Manual - LEFT JOIN / RIGHT JOIN