上一节讲 JOIN 时,我们解决的是一类很常见的问题:订单表里只有客户编号,客户姓名在另一张表里,于是我们沿着关联条件把两张表横向拼起来,让一行里多出需要的列。
但小满商店接下来要做的事情不一样。财务同事手里有一份 2025 年成功付款客户名单,又有一份 2026 年成功付款客户名单,现在想把两份名单合成一份。两边本来就都是“客户编号、客户姓名”这两列,不需要再补列,需要的是把第二份结果接到第一份结果下面。
这正是集合操作解决的问题:JOIN 通常横向增加列,集合操作纵向增加行。

你很快会发现,所谓“合并”并不是只有一种意思:可以保留每一条来源记录,也可以只留下唯一客户;可以找两份名单都有的人,也可以找只在左边名单里的人。SQL 用 UNION ALL、UNION、INTERSECT 和 EXCEPT 把这些意图直接写在查询中。
这节课会一直使用小满商店的客户与订单数据。先把最重要的判断放在前面:
UNION ALL。UNION。INTERSECT,求“左边有、右边没有”使用 EXCEPT。ORDER BY。前几节已经建立了小满商店的固定数据。这个章节不再另造临时表,而是继续使用 customers、orders 与 payments。客户资料在 customers,订单归属和下单时间在 orders,支付是否成功则以 payments.status 为准。
先查询 2025 年成功付款的客户:
SELECT
c.customer_id,
c.customer_name
FROM customers AS c
INNER JOIN orders AS o
ON o.customer_id = c.customer_id
INNER JOIN payments AS p
ON p.order_id = o.order_id
WHERE p.status = '成功'
AND o.order_date >= '2025-01-01'
AND o.order_date < '2026-01-01'
ORDER BY c.customer_id;再查询 2026 年成功付款的客户:
SELECT
c.customer_id,
c.customer_name
FROM customers AS c
INNER JOIN orders AS o
ON o.customer_id = c.customer_id
INNER JOIN payments AS p
ON p.order_id = o.order_id
WHERE p.status = '成功'
苏小满、顾言和江行在两份名单里都出现,这给我们提供了跨来源重复;余晴只在 2025 年名单里,夏初、程一和安禾只在 2026 年名单里。订单 105 虽然发生在 2025 年,但对应支付状态是“已退款”,所以不属于成功付款记录。这个差别也说明,集合操作只负责组合两个查询已经选出的行,分支内部的业务过滤仍要先写准确。
集合操作处理的是每个 SELECT 已经产生的结果,不关心这些行来自同一张表还是不同表。两个查询都从 orders 读取完全没问题;一个查询来自历史表、另一个来自当前表也没问题。真正需要对齐的是结果的形状。
学习集合操作时,最容易误入的方向,是盯着表名想“这两张表能不能合并”。SQL 真正在意的不是源表,而是运算符左右两边各自产生了什么结果。
例如,下面两个查询都返回两列:
SELECT DISTINCT c.customer_id, c.customer_name
FROM customers AS c
INNER JOIN orders AS o
ON o.customer_id = c.customer_id
INNER JOIN payments AS p
ON p.order_id = o.order_id
WHERE p.status = '成功'
AND
2025 年名单是:
SELECT DISTINCT c.customer_id, c.customer_name
FROM customers AS c
INNER JOIN orders AS o
ON o.customer_id = c.customer_id
INNER JOIN payments AS p
ON p.order_id = o.order_id
WHERE p.status = '成功'
AND
2026 年名单是:
对集合操作来说,一行不是两个互不相干的值,而是一个整体。(2, '顾言') 与 (2, '顾言') 是重复行,(2, '顾言') 与 (2, '2025年') 则不是同一行。只要任意一个位置的值不同,两行就不同。
这里还要区分数学集合和 SQL 查询结果。数学集合天然不含重复元素,但普通 SELECT 可以返回重复行,所以 SQL 的结果更像一个允许重复的“袋子”。集合操作符是否去重,决定了这个袋子会不会被压成真正的集合。

可以先把四种常用写法记成下面这张表:
EXCEPT 的方向尤其重要。A EXCEPT B 和 B EXCEPT A 通常不是同一个结果。并集与交集可以交换左右两边而不改变成员,差集不行。
集合操作不像 JOIN 那样通过 ON 指定匹配关系。数据库会直接把第一个查询的第一列与第二个查询的第一列放在同一结果列里,第二列与第二列放在一起,以此类推。因此,每次合并前都应该检查列数、位置含义和数据类型。

下面的查询无法成立:
SELECT customer_id, customer_name
FROM customers
UNION ALL
SELECT order_id, customer_id, total_amount
FROM orders;左边返回两列,右边返回三列。数据库没有办法决定右侧第三列应该放到哪里,因此会直接报错。
修复方式不是随便删一列,而是先明确最终结果每一列的业务含义。例如,我们想做一份统一的“业务事件流”,规定它必须有 event_id、subject_name、event_type 三列,那么两边都按这个形状投影:
SELECT
customer_id AS event_id,
customer_name AS subject_name,
'客户注册' AS event_type
FROM customers
UNION ALL
SELECT
o.order_id AS event_id,
c.customer_name AS subject_name,
'创建订单' AS event_type
FROM orders AS o
INNER JOIN customers AS c
ON c.下面这条 SQL 在列数和类型上都可能通过,但结果含义已经错位:
SELECT customer_id, customer_name
FROM customers
UNION ALL
SELECT product_name, product_id
FROM products;数据库不会看到 customer_id 和 product_id 名字相似,就自动把它们放在一起。它只认位置:客户编号会与商品名称落入第一列,客户姓名会与商品编号落入第二列。即使数据库通过隐式转换勉强执行,这份结果也没有清楚含义。
正确写法是让每个位置表达同一种东西:
SELECT
CAST(customer_id AS CHAR) AS object_id,
customer_name AS object_name
FROM customers
UNION ALL
SELECT
CAST(product_id AS CHAR) AS object_id,
product_name AS object_name
FROM products;现在第一列始终是对象编号,第二列始终是对象名称。把编号显式转换为字符还有一个好处:如果另一来源以后改用 C-001 这类带前缀的编号,结果类型依然明确。
“兼容”不一定要求两边声明完全相同,但它们必须能被数据库推导成一个合理的结果类型。整数和更大范围的整数通常可以合并,长度不同的字符串通常也可以合并;日期与毫无关系的金额放在同一位置,即使某个数据库愿意转换,也不代表这样做是对的。
迁移到不同数据库时,隐式转换还是常见风险。最稳妥的方式是显式 CAST,让两边对最终类型达成一致:
SELECT
CAST(customer_id AS CHAR(20)) AS object_id,
customer_name AS object_name,
CAST(registered_at AS DATETIME) AS occurred_at
FROM customers
UNION ALL
SELECT
CAST(order_id AS CHAR(20)) AS object_id,
CONCAT('订单-', order_id) AS object_name,
CAST
集合结果只能有一套列名。MySQL 取第一个查询块的列名或别名作为最终结果的列名,后续查询中的别名不会改写它。
SELECT
customer_id AS member_id,
customer_name AS member_name
FROM customers
UNION ALL
SELECT
customer_id AS buyer_id,
customer_name AS buyer_name
FROM customers;最终列名仍然是 member_id 和 member_name。这也是为什么第一个查询的别名要认真写:它既说明统一结果的含义,又会被最后的 ORDER BY 使用。
不要用 SELECT * 拼接长期维护的业务查询。只要某张表新增一列、调整列顺序或来源视图发生变化,集合兼容就可能被破坏。明确列名虽然多写几行,却把形状契约写进了 SQL。
财务要汇总 2025 年和 2026 年的成功付款客户,同时希望保留“一个客户在哪几年付过款”这个信息。跨年复购的客户应该出现两次,这时应使用 UNION ALL。
SELECT DISTINCT
c.customer_id,
c.customer_name
FROM customers AS c
INNER JOIN orders AS o
ON o.customer_id = c.customer_id
INNER JOIN payments AS p
ON p.order_id = o.order_id
WHERE p.status = '成功'
结果如下:
苏小满、顾言和江行各出现两次不是脏数据。每人一行来自 2025 年名单,另一行来自 2026 年名单。重复在这里承载了业务事实:他们连续两年都有成功付款记录。
如果还想让来源直接显示出来,可以给每个分支补一列常量:
SELECT DISTINCT
c.customer_id,
c.customer_name,
'2025年付款' AS source_name
FROM customers AS c
INNER JOIN orders AS o
ON o.customer_id = c.customer_id
INNER JOIN payments AS p
ON p.order_id = o.order_id
WHERE
这是一种很实用的写法。你不只得到合并结果,还保留了每一行的来源。后面进入字符串、日期和数值函数时,我们还可以继续加工这些标签与时间字段。
UNION ALL 不需要判断某一整行是否已经出现。数据库可以把各分支产生的行继续送向结果端。UNION 则必须维护去重状态,可能涉及排序、哈希或临时结构;数据量大时,这部分工作会占用额外的 CPU、内存,必要时还会使用磁盘空间。

所以,选择顺序可以很直接:
UNION ALL。UNION,并确认“唯一”是按哪些列定义的。不要因为担心源数据有问题,就习惯性用 UNION 把重复藏起来。重复订单、重复导入、重复事件可能正是需要排查的信息。用去重运算符遮住它们,会让结果看起来干净,却更难发现上游问题。
前面的年度名单在每个分支里先写了 SELECT DISTINCT,因为一位客户一年成功付款多次,在“年度付款客户名单”里仍然只应该出现一次。两个分支之间使用 UNION ALL,因为同一客户在哪几年成功付款,就应该留下几条来源记录。
也就是说,去重位置本身就在表达规则:
DISTINCT:每位客户每年只算一次。UNION ALL:客户在哪几年成功付款,就保留几年。如果直接把两个分支用 UNION 合起来,苏小满、顾言和江行的跨年信息会消失。SQL 没有写错,但业务含义变了。
现在换一个问题:财务只想知道 2025 年或 2026 年至少成功付款一次的客户有哪些,不关心他们出现在哪一年,也不关心出现几次。把刚才的 UNION ALL 改成 UNION 即可:
SELECT
c.customer_id,
c.customer_name
FROM customers AS c
INNER JOIN orders AS o
ON o.customer_id = c.customer_id
INNER JOIN payments AS p
ON p.order_id = o.order_id
WHERE p.status = '成功'
UNION 默认就是 UNION DISTINCT。显式写 UNION DISTINCT 也可以,但通常没有必要。
很多人看到 customer_id 相同,就以为 UNION 一定会合成一行。其实它比较的是结果中的完整行。
SELECT 2 AS customer_id, '顾言' AS customer_name, '2025年付款' AS source_name
UNION
SELECT 2 AS customer_id, '顾言' AS customer_name, '2026年付款' AS source_name;结果仍有两行:
第三列不同,所以两行不重复。如果目标是每个客户一行,就不要把来源列放进参与 UNION 的形状里;也可以先合并来源明细,再在外层按客户聚合。聚合会在后续课程专门展开。
上一些查询条件中,NULL = NULL 不会得到 TRUE。但集合操作的重复消除不能照搬这个判断方式。对于去重来说,同一位置上的两个 NULL 会被视为没有区别。
SELECT NULL AS phone
UNION
SELECT NULL AS phone;结果只有一行:
换成 UNION ALL,两行都会保留:
SELECT NULL AS phone
UNION ALL
SELECT NULL AS phone;多列也是按整行处理:(3, NULL) 与 (3, NULL) 会被 UNION 合成一行,(3, NULL) 与 (4, NULL) 不会。这里不是说 NULL 突然可以用等号比较了,而是说集合运算的“重复行判定”有自己的语义。
字符串是否被视为重复还会受到字符集与排序规则影响。例如,不区分大小写的排序规则可能把只在字母大小写上不同的文本视为相同。跨系统迁移名单时,不要只检查 VARCHAR,还要检查排序规则是否一致。
小满商店想找 2025 年和 2026 年都成功付过款的客户。先分别写出两份年度名单,再取交集,思路很贴近业务语言。
SELECT
c.customer_id,
c.customer_name
FROM customers AS c
INNER JOIN orders AS o
ON o.customer_id = c.customer_id
INNER JOIN payments AS p
ON p.order_id = o.order_id
WHERE p.status = '成功'
苏小满、顾言和江行在两个年度查询里都产生了相同的客户行,所以进入交集。INTERSECT 不要求两边来自同一张订单,也不比较订单编号;它只比较我们最终选出的 customer_id 与 customer_name。
MySQL 8.4 原生支持 UNION、INTERSECT 和 EXCEPT,三者也都支持 ALL 与 DISTINCT。不过,较早的 MySQL 并不是这样:INTERSECT 与 EXCEPT 从 MySQL 8.0.31 才加入。MySQL 5.7、MySQL 8.0.30 及更早的 8.0 版本不能直接执行这两个关键字。
因此,看到服务器大版本是 8.0 还不够,至少要检查到补丁版本:
SELECT VERSION();如果项目明确运行 MySQL 8.4,可以放心使用原生写法。如果应用需要兼容旧 MySQL,就应准备 EXISTS、NOT EXISTS 或连接改写。PostgreSQL 也原生支持这三类集合操作,但不同系统在类型推导、排序规则与附加限制上仍可能有差异,迁移时不能只验证关键字能否解析。
对于“2025 年成功付款,并且 2026 年也成功付款”的问题,可以把 2025 年名单保留在外层,再用 EXISTS 检查 2026 年是否存在同一客户:
SELECT DISTINCT
c.customer_id,
c.customer_name
FROM customers AS c
INNER JOIN orders AS o
ON o.customer_id = c.customer_id
INNER JOIN payments AS p
ON p.order_id = o.order_id
WHERE p.status = '成功'
这里的 DISTINCT 不能随手删掉。一个客户一年可能成功付款多次,外层连接会产生多行;原生 INTERSECT 默认去重,兼容写法也要显式恢复这个语义。
如果交集比较的列本身允许 NULL,普通等号无法让两个 NULL 匹配。MySQL 可以使用 NULL 安全等号 <=>。例如,要比较两份临时结果中的 phone:
SELECT DISTINCT a.phone
FROM customers AS a
WHERE a.registered_at < '2025-01-01'
AND EXISTS (
SELECT 1
FROM customers AS b
WHERE b.registered_at >= '2025-01-01'
AND b.phone <=> a.phone
);这条兼容写法会让两边的 NULL 联系方式像原生集合交集那样匹配。若改用 b.phone = a.phone,两边都是 NULL 的行反而会漏掉。
默认 INTERSECT 会去重。写成 INTERSECT ALL 时,某一行在结果中出现的次数,取左右两边出现次数的较小值。
(
SELECT 2 AS customer_id
UNION ALL SELECT 2
UNION ALL SELECT 2
)
INTERSECT ALL
(
SELECT 2 AS customer_id
UNION ALL SELECT 2
);左边有三个 2,右边有两个 2,交集最多配成两对,所以结果保留两个 2。这类计数语义很精确,但日常的“共同客户名单”通常要的是唯一客户,直接使用默认 INTERSECT 更清楚。
接着找“2025 年成功付过款,但 2026 年没有再次成功付款”的客户:
SELECT
c.customer_id,
c.customer_name
FROM customers AS c
INNER JOIN orders AS o
ON o.customer_id = c.customer_id
INNER JOIN payments AS p
ON p.order_id = o.order_id
WHERE p.status = '成功'
余晴在 2025 年的订单 106 有成功支付记录,但 2026 年没有成功付款,因此留下。苏小满、顾言和江行两年都成功付款,右侧能够找到相同行,所以被差集删除。
把左右顺序交换,问题会变成“2026 年成功付款,但 2025 年没有成功付款”:
SELECT
c.customer_id,
c.customer_name
FROM customers AS c
INNER JOIN orders AS o
ON o.customer_id = c.customer_id
INNER JOIN payments AS p
ON p.order_id = o.order_id
WHERE p.status = '成功'
旧版 MySQL 可以使用反存在条件表达同一个问题:
SELECT DISTINCT
c.customer_id,
c.customer_name
FROM customers AS c
INNER JOIN orders AS o
ON o.customer_id = c.customer_id
INNER JOIN payments AS p
ON p.order_id = o.order_id
WHERE p.status = '成功'
NOT EXISTS 关心的是相关子查询有没有至少一行,不会被子查询结果中的 NULL 以意外方式干扰。相比之下,很多人会写成 NOT IN:
-- 不建议把它当成通用的 EXCEPT 替代
SELECT customer_id
FROM customers
WHERE customer_id NOT IN (
SELECT referrer_id
FROM customers
);我们的数据里 referrer_id 含有 NULL。只要 NOT IN 的候选列表里出现 NULL,对许多左侧值的判断就会变成 UNKNOWN,最终可能一行也得不到。这并不是差集想表达的“右边找不到相同行”。需要兼容 EXCEPT 时,优先写 NOT EXISTS,并用清楚的相关条件定义“同一行”。
多列且允许 NULL 时,MySQL 的相关条件可以逐列使用 <=>:
SELECT DISTINCT a.customer_id, a.phone, a.email
FROM customers AS a
WHERE NOT EXISTS (
SELECT 1
FROM customers AS b
WHERE b.customer_id <=> a.customer_id
AND b.phone <=> a.phone
AND
这更接近原生 EXCEPT 对完整行和 NULL 的处理。只比较主键有时足够,但那代表“同一实体”;原生多列差集比较的是“同一完整行”。两者是不是同一个业务定义,需要你自己决定。
默认 EXCEPT 返回唯一行。EXCEPT ALL 则会按出现次数相减,结果次数不能小于零。
如果左边有三个 2,右边有两个 2,EXCEPT ALL 会留下一个 2。如果右边反而有四个 2,结果中一个 2 也不会留下。它不是简单的“右边出现过就全部删除”,而是袋子之间的数量扣减。
Oracle 的传统差集关键字常写成 MINUS,但 MySQL 8.4 不支持用 MINUS 代替 EXCEPT。跨数据库复制 SQL 时,应先确认目标方言,而不是只把表名改掉。
集合查询一旦超过两个分支,问题往往不再是“会不会写关键字”,而是数据库到底把哪几段组合在一起。括号、排序位置和运算符优先级必须说清楚。

下面的 ORDER BY 位于最后一个查询之后,它排序的是 UNION ALL 合并完成后的全部行:
SELECT
customer_id AS object_id,
customer_name AS object_name,
registered_at AS occurred_at
FROM customers
UNION ALL
SELECT
order_id AS object_id,
CONCAT('订单-', order_id) AS object_name,
order_date AS occurred_at
FROM orders
ORDER BY occurred_at DESC, object_id DESC;最终排序应使用集合结果的列名,也就是第一个查询定义的别名。MySQL 的集合结果排序不要写 customers.registered_at 这类带表名前缀的引用,因为外层面对的是合并后的结果,不再是某一个分支的源表。
结果列如果起了别名,排序就使用该别名:
SELECT registered_at AS occurred_at
FROM customers
UNION ALL
SELECT order_date
FROM orders
ORDER BY occurred_at;不要写成 ORDER BY registered_at,也不要依赖第二个查询的列名 order_date。
假设我们只想从 2025 年和 2026 年各取最近两笔成功付款订单,再把四笔候选订单合起来:
(
SELECT o.order_id, o.customer_id, o.order_date
FROM orders AS o
INNER JOIN payments AS p
ON p.order_id = o.order_id
WHERE p.status = '成功'
AND o.order_date >= '2025-01-01'
AND o
结果如下:
分支里的 ORDER BY ... LIMIT 2 决定每年选哪两行;最后一个 ORDER BY 决定四行合并后如何展示。两层排序职责不同。
如果分支中只有 ORDER BY 而没有 LIMIT,它通常不能保证最终顺序,MySQL 也可能消除这类对最终成员没有影响的排序。想控制最终展示顺序,仍然要在整个集合表达式末尾排序。
SELECT customer_id, customer_name
FROM customers
WHERE city = '上海'
UNION ALL
SELECT customer_id, customer_name
FROM customers
WHERE city = '杭州'
ORDER BY customer_id
LIMIT 3;这里的 LIMIT 3 从合并并排序后的结果中取三行,不是只限制第二个查询。若只想限制某个分支,就把该分支连同 ORDER BY、LIMIT 放进括号。
MySQL 8.4 与 PostgreSQL 都会先计算 INTERSECT,再处理同层级的 UNION 或 EXCEPT。因此:
A UNION B INTERSECT C等价于:
A UNION (B INTERSECT C)而不是:
(A UNION B) INTERSECT CUNION 与 EXCEPT 在没有括号时按从左到右结合。下面的表达式:
A UNION B EXCEPT C按下面的方式理解:
(A UNION B) EXCEPT C优先级规则虽然能让语句执行,但不代表它容易维护。混合两种以上集合运算符时,我建议直接写括号,把业务顺序摆在读者面前:
(
-- 2025 年或 2026 年成功付款的客户
SELECT ...
UNION
SELECT ...
)
EXCEPT
(
-- 已退款客户
SELECT ...
);这样以后增加第四个分支时,不需要重新在脑中推演优先级。
JOIN 和集合操作都能把多个查询牵到一起,所以很多问题表面上两种写法都能做。选择时先看你希望结果的形状怎么变化。

订单表只有 customer_id,我们想在同一行显示客户姓名。这是在原来的订单行右侧补充客户属性:
SELECT
o.order_id,
o.order_date,
c.customer_name,
c.city
FROM orders AS o
INNER JOIN customers AS c
ON c.customer_id = o.customer_id;结果仍以订单为一行,只是列变多了。连接还可能因为一对多关系让行数扩大,例如订单连接订单明细后,一张订单会出现多行。
2025 年与 2026 年成功付款客户名单都有 customer_id、customer_name 两列,目标是把一份名单接到另一份下面。这是行变多,使用 UNION ALL 或 UNION。
“连续两年成功付款的客户”可以写成两份年度名单的 INTERSECT,也可以写成一个名单加 EXISTS。前者更像集合题,业务意图一眼可见;后者在旧版 MySQL 中更通用,也方便在存在条件中引用外层值。
“2025 年成功付款但 2026 年没有再次成功付款”用 EXCEPT 很直接。需要兼容旧版 MySQL,或者右侧条件要与左侧多列相关时,NOT EXISTS 往往更方便。
下面是一张简化判断表:
判断时别先背关键字,先画结果形状。想让一行更宽,通常是 JOIN;想让结果更长,通常是集合操作。想做存在与排除,再在 INTERSECT、EXCEPT、EXISTS 和 NOT EXISTS 之间选择。
小满商店准备一轮复购回访。运营先要“两年内至少成功付款一次”的总名单,又想单独识别“连续两年成功付款”和“只在其中一年成功付款”的客户。我们先把每条规则拆成清楚的小查询,再组合。
第一步,得到 2025 年或 2026 年至少成功付款一次的唯一客户:
(
SELECT c.customer_id, c.customer_name, c.phone, c.email
FROM customers AS c
INNER JOIN orders AS o
ON o.customer_id = c.customer_id
INNER JOIN payments AS p
ON p.order_id = o
UNION 会把跨年客户合成一行,因此这份总名单共有七位客户:苏小满、顾言、江行、余晴、夏初、程一和安禾。
第二步,如果回访只针对连续两年成功付款的人,把结果换成交集:
(
SELECT c.customer_id, c.customer_name, c.phone, c.email
FROM customers AS c
INNER JOIN orders AS o
ON o.customer_id = c.customer_id
INNER JOIN payments AS p
ON p.order_id = o
第三步,若规则改为“只在其中一年成功付款”,可以把两个方向的差集再合并:
(
(
SELECT c.customer_id, c.customer_name
FROM customers AS c
INNER JOIN orders AS o
ON o.customer_id = c.customer_id
INNER JOIN payments AS p
ON p.order_id = o.order_id
WHERE p.status
这就是两个集合的“只出现一边”部分。语句看起来长,但每一段都在回答一个明确问题。真实项目里还可以用公共表表达式给这些名单命名,减少重复;公共表表达式会在后面的复杂查询内容中继续学习。
最后再处理联系方式是否可用。联系方式是客户自身属性,不需要再做一遍集合运算,放到外层过滤更清楚:
SELECT customer_id, customer_name, phone, email
FROM customers
WHERE phone IS NOT NULL
OR email IS NOT NULL;固定数据中这七位付款客户至少有手机号或邮箱,因此不会被这条条件排除。若将来有联系方式全部为空的付款客户,运营名单可以在集合计算完成后再过滤。这个顺序提醒我们,集合操作不是把所有条件都硬塞进一个技巧里:先用集合表达“名单之间的关系”,再用 WHERE 表达单行属性过滤,查询会更容易读。
去重过程有时会让结果看起来像排过序,但那不是查询承诺。执行计划、数据量或版本变化后,顺序都可能改变。只要展示顺序有要求,就在整个集合表达式末尾写 ORDER BY。
集合兼容看列数、位置和类型,不按列名自动配对。两个查询都叫 id,也可能一个是客户编号、一个是订单编号;能执行不代表有业务意义。
UNION 比较选出的完整行。主键相同但来源标签、状态或时间不同,仍然是不同的行。若只想按客户编号唯一,需要在设计结果形状时解决,而不是期待 UNION 猜出你的主键。
UNION 会隐藏重复,也会付出去重代价。对订单流水、日志、库存变动这类每条记录都有意义的数据,保留重复通常才是正确语义。
只要子查询可能产生 NULL,NOT IN 就可能把判断推入 UNKNOWN。NOT EXISTS 更适合表达“右侧不存在匹配行”。多列与 NULL 安全比较还要明确写出对应条件。
分支排序通常只是为了配合 LIMIT 选出该分支的某几行。集合结果本身依旧无序,最终展示仍由末尾 ORDER BY 决定。
遇到集合查询报错或结果不对,可以按下面的顺序检查:
SELECT,确认各自的过滤逻辑。NULL 是否参与重复、交集或差集判断。ORDER BY 放到集合表达式末尾。INTERSECT 或 EXCEPT 前检查 MySQL 是否至少为 8.0.31;本课程目标环境为 MySQL 8.4。请查询居住在上海或杭州的客户,输出 customer_id、customer_name、city,每位客户只保留一行,并按客户编号升序排列。要求使用集合操作。
请找出 2025 年或 2026 年成功付过款、但没有连续两年成功付款的客户。使用 EXCEPT 和 UNION,输出客户编号与客户姓名。
下面的查询能够让数据库尝试合并,但结果列含义错位。请指出问题并改正。
SELECT customer_id, customer_name
FROM customers
UNION ALL
SELECT product_name, product_id
FROM products;到这里,我们已经能控制多份结果之间的纵向关系:用 UNION ALL 保留全部行,用 UNION 得到唯一并集,用 INTERSECT 找共同部分,用 EXCEPT 找左侧独有部分;也知道了列按位置对齐、NULL 参与去重的方式、MySQL 8.4 的支持范围,以及如何用 EXISTS 和 NOT EXISTS 兼容旧版本。
但现在的结果还比较“原始”。客户姓名可能要统一格式,活动来源要拼成展示文案,订单金额要计算折扣,日期要截取月份,不同分支的类型也可能需要显式转换。下一节会进入 SQL 函数与数据处理,继续加工我们刚刚合并出的这些行。
苏小满、顾言和江行连续两年成功付款,所以被两个方向的差集排除;余晴只在 2025 年名单中,夏初、程一和安禾只在 2026 年名单中,因此被保留。