子查询与合并查询:SQL 的高级过滤技巧

在上一篇中,我们系统学习了 JOIN 的各种用法,掌握了如何将多张表的数据横向连接在一起。但在很多场景下,我们需要将一个查询的结果作为另一个查询的输入,这种"查询中的查询"就是子查询 。此外,当需要将两个结构相似的结果集纵向合并时,就要用到合并查询UNION)。

本文将带你掌握:

  • 标量子查询、列子查询、行子查询
  • FROM 子句中的派生表
  • EXISTS / NOT EXISTS 关联子查询
  • UNIONUNION ALL 的使用场景
  • 实战:查找从未借书的读者,以及其他高级过滤

1. 什么是子查询?

子查询是嵌套在另一个 SQL 语句(SELECTINSERTUPDATEDELETE)中的 SELECT 查询。外层查询被称为"外部查询"或"主查询",内层称为"子查询"或"内层查询"。

子查询必须用括号包裹,通常可以出现在:

  • WHERE 子句中(最常见的用法)
  • FROM 子句中(充当临时表)
  • SELECT 列表中(作为计算列)

按返回结果分类:

  • 标量子查询:返回单个值(一行一列)
  • 列子查询:返回一列多行
  • 行子查询:返回一行多列
  • 表子查询:返回多行多列

2. WHERE 中的子查询

2.1 标量子查询

标量子查询返回单个值,可以与比较运算符(=>< 等)配合使用。

需求:查询库存大于平均库存的图书

sql 复制代码
SELECT title, stock
FROM books
WHERE stock > (SELECT AVG(stock) FROM books);

执行过程:先计算子查询得到平均库存(假设为 5.8),再对外层每一行判断 stock > 5.8

需求:查询与"张三"借阅了同一本书的读者

sql 复制代码
SELECT DISTINCT r.name
FROM readers r
JOIN borrow_records br ON r.id = br.reader_id
WHERE br.book_id = (
    SELECT book_id FROM borrow_records
    WHERE reader_id = 1
    ORDER BY borrow_date DESC
    LIMIT 1
);

这里子查询获取读者 ID=1(张三)最近借阅的一本书的 ID,然后外部查询找出所有借过这本书的读者。

2.2 列子查询(多行一列)

当子查询返回多行时,不能直接用 =,而要用 INANYALL 等多行操作符。

IN ------ 是否在列表中

查找所有被借阅过的图书:

sql 复制代码
SELECT title
FROM books
WHERE id IN (SELECT DISTINCT book_id FROM borrow_records);

NOT IN ------ 不在列表中

查找从未被借过的图书:

sql 复制代码
SELECT title
FROM books
WHERE id NOT IN (SELECT DISTINCT book_id FROM borrow_records);

注意NOT IN 有一个陷阱------如果子查询结果中包含 NULL,整个 NOT IN 结果将为空(因为 NULL 代表未知,任何值与 NULL 比较都返回 UNKNOWN)。为了避免此问题,可以在子查询中加 WHERE book_id IS NOT NULL,或者使用 NOT EXISTS(稍后介绍)。

ANY / ALL 与比较运算符组合

  • > ANY (子查询):大于子查询结果中的任意一个值,等价于大于最小值。
  • > ALL (子查询):大于子查询结果中的所有值,等价于大于最大值。
sql 复制代码
-- 查询库存大于"文学"分类中任意一本书的库存(即大于文学类最低库存)
SELECT title, stock
FROM books
WHERE stock > ANY (
    SELECT b.stock FROM books b
    JOIN book_category bc ON b.id = bc.book_id
    JOIN categories c ON bc.category_id = c.id
    WHERE c.name = '文学'
);

-- 查询库存大于"文学"分类中所有书的库存(即大于文学类最高库存)
SELECT title, stock
FROM books
WHERE stock > ALL (
    SELECT b.stock FROM books b
    JOIN book_category bc ON b.id = bc.book_id
    JOIN categories c ON bc.category_id = c.id
    WHERE c.name = '文学'
);

2.3 行子查询

行子查询返回一行多列,与外部查询的多列组合进行比较。

sql 复制代码
-- 查询与"张三"的出生日期和状态完全相同的读者
-- (假设 readers 表有 birthdate 和 status 列)
SELECT * FROM readers
WHERE (birthdate, status) = (
    SELECT birthdate, status FROM readers WHERE name = '张三'
);

行子查询在实际中使用较少,但理解它有助于阅读源码或某些自动生成的 SQL。


3. FROM 中的子查询:派生表

子查询也可以放在 FROM 子句中,充当一张临时表(派生表)。它必须有一个别名。

需求:统计每位读者的借阅次数,再筛选出借阅次数超过 2 次的读者

如果不用派生表,你可能尝试在 WHERE 中使用聚合函数------但 WHERE 不能使用聚合,这就需要用 HAVING。而使用派生表可以将统计结果当作一张表来查询:

sql 复制代码
SELECT reader_name, borrow_count
FROM (
    SELECT r.name AS reader_name, COUNT(br.id) AS borrow_count
    FROM readers r
    LEFT JOIN borrow_records br ON r.id = br.reader_id
    GROUP BY r.id, r.name
) AS reader_stats
WHERE borrow_count > 2;
  • 内层查询生成一张包含读者名和借阅次数的临时表 reader_stats
  • 外层查询从这张临时表中筛选 borrow_count > 2

派生表在复杂报表中经常使用,尤其在需要多次引用同一个聚合结果时。


4. EXISTS 与 NOT EXISTS

EXISTS 用于判断子查询是否返回至少一行。它通常是一个关联子查询------子查询中引用了外部查询的列。

语法

sql 复制代码
WHERE EXISTS (子查询)
WHERE NOT EXISTS (子查询)

4.1 用 EXISTS 找出有借阅记录的读者

sql 复制代码
SELECT name
FROM readers r
WHERE EXISTS (
    SELECT 1 FROM borrow_records br
    WHERE br.reader_id = r.id
);
  • 外部查询遍历 readers 每一行。
  • 对于每一行,执行子查询:看 borrow_records 中是否有该读者的借阅记录。
  • 如果子查询返回至少一行,EXISTSTRUE,保留该读者。

习惯上,EXISTS 子查询的 SELECT 列表写 1*,因为我们只关心行是否存在,不关心具体值。

4.2 用 NOT EXISTS 找出从未借书的读者

这正是我们这篇的实战核心:

sql 复制代码
SELECT name
FROM readers r
WHERE NOT EXISTS (
    SELECT 1 FROM borrow_records br
    WHERE br.reader_id = r.id
);

EXISTS vs IN

  • EXISTS 通常比 IN 更高效,尤其是子查询结果集很大的时候,因为 EXISTS 只要找到一行匹配就立即返回真,不需要生成完整结果集。
  • NOT EXISTS 不受 NULL 问题困扰,比 NOT IN 更安全。
  • 在关联列有索引时,两者性能差异不大,但 EXISTS 语义更清晰。

练习:查找至少被借阅过一次的图书

sql 复制代码
SELECT title
FROM books b
WHERE EXISTS (
    SELECT 1 FROM borrow_records br
    WHERE br.book_id = b.id
);

5. 合并查询:UNION 与 UNION ALL

JOIN横向 拼接,将不同表的列组合在一起;UNION纵向 拼接,将多个 SELECT 的结果集按行合并。使用 UNION 的前提是:

  • 每个 SELECT列数相同
  • 对应列的数据类型兼容

5.1 UNION vs UNION ALL

  • UNION:合并后自动去重 (相当于合并 + DISTINCT)。
  • UNION ALL:保留所有行,不去重,性能更高。

5.2 使用场景

场景1:合并两个相似的查询结果

假设我们要生成一份"联系人"列表,同时包含读者和作者(假设有一个独立的 authors 表):

sql 复制代码
SELECT name AS contact_name, '读者' AS contact_type FROM readers
UNION
SELECT author, '作者' FROM books
ORDER BY contact_name;

场景2:按条件拆分查询再合并

查询"库存为0的图书"和"库存超过10的图书",作为两个极端情况展示:

sql 复制代码
SELECT title, stock, '缺货' AS status FROM books WHERE stock = 0
UNION ALL
SELECT title, stock, '库存充足' AS status FROM books WHERE stock > 10
ORDER BY stock;

这里使用 UNION ALL 是因为两个集合显然不会重复。

5.3 UNION 的排序与限制

  • ORDER BY 只能放在最后一个 SELECT 之后,对整个合并结果排序。

  • 如果要单独对某个 SELECT 排序,可以配合括号和 LIMIT

    sql 复制代码
    (SELECT ... ORDER BY ... LIMIT 10)
    UNION ALL
    (SELECT ... ORDER BY ... LIMIT 10);

6. 子查询在 INSERT/UPDATE/DELETE 中的应用

子查询不仅用于 SELECT,还能嵌入到 DML 语句中。

6.1 INSERT ... SELECT

从其他表复制数据:

sql 复制代码
-- 将2025年的借阅记录归档到 borrow_archive 表
INSERT INTO borrow_archive (reader_id, book_id, borrow_date, due_date, return_date)
SELECT reader_id, book_id, borrow_date, due_date, return_date
FROM borrow_records
WHERE YEAR(borrow_date) = 2025;

6.2 UPDATE 结合子查询

将所有"文学"类图书的库存加 1:

sql 复制代码
UPDATE books
SET stock = stock + 1
WHERE id IN (
    SELECT book_id FROM book_category bc
    JOIN categories c ON bc.category_id = c.id
    WHERE c.name = '文学'
);

6.3 DELETE 结合子查询

删除没有任何借阅记录的读者:

sql 复制代码
DELETE FROM readers
WHERE id NOT IN (
    SELECT DISTINCT reader_id FROM borrow_records
);

或者用 NOT EXISTS(更安全):

sql 复制代码
DELETE FROM readers r
WHERE NOT EXISTS (
    SELECT 1 FROM borrow_records br WHERE br.reader_id = r.id
);

7. 实战:综合运用

让我们回到图书管理系统,完成几个有挑战性的查询。

7.1 查找从未借过书的读者(两种方法对比)

方法一:LEFT JOIN + IS NULL

sql 复制代码
SELECT r.name
FROM readers r
LEFT JOIN borrow_records br ON r.id = br.reader_id
WHERE br.id IS NULL;

方法二:NOT EXISTS

sql 复制代码
SELECT r.name
FROM readers r
WHERE NOT EXISTS (
    SELECT 1 FROM borrow_records br WHERE br.reader_id = r.id
);

两种结果相同,NOT EXISTS 通常更直观地表达了"不存在"的语义,且性能通常更好。

7.2 找出借阅最活跃的读者(借阅次数 >= 所有读者的平均借阅次数)

使用派生表 + 标量子查询:

sql 复制代码
WITH reader_stats AS (
    SELECT reader_id, COUNT(*) AS cnt
    FROM borrow_records
    GROUP BY reader_id
)
SELECT r.name, rs.cnt
FROM reader_stats rs
JOIN readers r ON rs.reader_id = r.id
WHERE rs.cnt >= (SELECT AVG(cnt) FROM reader_stats);

这里引入了 CTE(公用表表达式) ,MySQL 8.0 支持,比嵌套派生表更清晰。CTE 作为 WITH 子句定义,可被后续查询多次引用。

7.3 合并查询:生成"图书活跃度报告"

同时展示被借次数最多的 3 本书和最少的 3 本书:

sql 复制代码
(SELECT title, COUNT(*) AS borrow_count, '热门' AS label
 FROM books b
 JOIN borrow_records br ON b.id = br.book_id
 GROUP BY b.id, b.title
 ORDER BY borrow_count DESC
 LIMIT 3)
UNION ALL
(SELECT title, COUNT(*) AS borrow_count, '冷门' AS label
 FROM books b
 JOIN borrow_records br ON b.id = br.book_id
 GROUP BY b.id, b.title
 ORDER BY borrow_count ASC
 LIMIT 3)
ORDER BY borrow_count DESC;

7.4 查找借阅了所有"技术"类图书的读者

这个需求比较高级,需要关联子查询配合双重否定逻辑:

"借阅了所有技术类图书的读者"等价于"不存在一本技术类图书没有被该读者借阅过"。

sql 复制代码
SELECT r.name
FROM readers r
WHERE NOT EXISTS (
    SELECT 1
    FROM books b
    JOIN book_category bc ON b.id = bc.book_id
    JOIN categories c ON bc.category_id = c.id
    WHERE c.name = '技术'
      AND NOT EXISTS (
          SELECT 1 FROM borrow_records br
          WHERE br.book_id = b.id AND br.reader_id = r.id
      )
);

这个查询比较绕,建议你放慢阅读:最内层 NOT EXISTS 表示"该读者没有借过这本书",外层 NOT EXISTS 表示"不存在这样的技术书"------即"该读者借过所有技术书"。


8. 小结

本文我们深入了子查询和合并查询的高级用法:

子查询类型 位置 返回值 常用操作符
标量子查询 WHERE / SELECT 单个值 =, >, <
列子查询 WHERE 一列多行 IN, NOT IN, ANY, ALL
行子查询 WHERE 一行多列 = (col1, col2)
派生表 FROM 多行多列 作为临时表
EXISTS WHERE 布尔值 EXISTS, NOT EXISTS
  • EXISTS / NOT EXISTS 是表达"存在/不存在"语义的首选,性能通常优于 IN / NOT IN,且没有 NULL 陷阱。
  • 合并查询 UNION / UNION ALL 纵向拼接结果集,注意列数和类型匹配。
  • 子查询可以与 INSERTUPDATEDELETE 结合,实现基于其他表数据的增删改。

现在你已经掌握了单个 SELECT 的几乎所有技能。下一个阶段我们将进入数据库的核心原理------索引、事务与 JDBC 编程,让性能与安全再上一个台阶。在此之前,别忘了完成第二阶段的最后一篇项目实战,它将综合运用我们学过的所有知识!

思考题

  1. NOT IN 有什么潜在陷阱?用 NOT EXISTS 如何改写?
  2. 派生表和 CTE (WITH) 有什么区别?分别在什么场景下使用?
  3. UNIONJOIN 的根本区别是什么?它们能互相替代吗?

参考资料


相关推荐
jingyu飞鸟1 小时前
linux系统二进制安装MySQL 8.4、8.0版本数据库,配置crontab和xtrabackup数据库热备份脚本
linux·数据库·mysql
小江的记录本1 小时前
【MySQL】《MySQL日志面试背诵版+思维导图》(核心考点 + MySQL 8.0最新优化)
java·数据库·后端·python·sql·mysql·面试
BD_Marathon1 小时前
SQL学习指南——创建和填充数据库
数据库·sql
TDengine (老段)1 小时前
TDengine RPC 通信层深度解析 — 协议格式、连接管理与重试机制
大数据·数据库·rpc·架构·时序数据库·tdengine·涛思数据
KaMeidebaby1 小时前
卡梅德生物技术快报|噬菌体筛选全流程技术方案:弧菌抑菌菌株筛选、特性鉴定与效果测试
前端·数据库·其他·百度·新浪微博
蜀道山老天师1 小时前
从零搭建 Prometheus 监控 MySQL:含二进制安装、授权、exporter 配置全流程
运维·数据库·mysql·adb·云原生·prometheus
yubin12855709231 小时前
mysql正则函数REGEXP
android·数据库·mysql
塔能物联运维2 小时前
存量机房低成本改造:塔能两相液冷实现投入与效益双赢
大数据·数据库·人工智能
2401_850491652 小时前
PHP 中处理会话数组时的类型错误解析与修复指南
jvm·数据库·python