Files
cs-note/hhs/MySQL/02-SQL核心/11-UNION 与集合运算.md
2026-05-24 11:42:38 +08:00

474 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [MySQL, UNION, UNION ALL, INTERSECT, EXCEPT, 集合运算]
create time: 2026-05-16 00:00
---
# UNION / UNION ALL
## 概述
UNION 是 SQL 标准中的集合运算,用于将多个 SELECT 的结果纵向合并为一个结果集。核心应用场景包括:**分表数据汇总**、**多源 Feed 流合并**、以及**需要排序去重的跨查询聚合**。
## 基本语法
```sql
-- 两种形式
SELECT col1, col2 FROM table_a
UNION -- 去重(内部排序 + 去重)
SELECT col1, col2 FROM table_b;
SELECT col1, col2 FROM table_a
UNION ALL -- 不去重(直接拼接,性能更高)
SELECT col1, col2 FROM table_b;
```
## UNION vs UNION ALL
| 特性 | UNION | UNION ALL |
|------|-------|-----------|
| **去重** | ✅ 内部去重 | ❌ 保留所有行 |
| **性能** | 低(需排序去重) | 高(直接追加) |
| **ORDER BY 位置** | 只能放在最后一个 SELECT | 同上 |
| **适用场景** | 需要唯一结果的合并 | 已知不重复的合并 |
```mermaid
flowchart LR
A1["数据源 A"] --> U
B1["数据源 B"] --> U
U{"UNION"} --> R1["全部数据去重后输出"]
U2{"UNION ALL"} --> R2["全部数据直接拼接"]
style R1 fill:#FF9F43,color:#000
style R2 fill:#00D866,color:#fff
```
> [!TIP] 首选 UNION ALL
> 如果你能通过业务逻辑保证各部分结果不重复(比如按日期区间划分),**一律用 UNION ALL**。UNION 的去重操作需要 Sort/Dedup 阶段,在大结果集上是昂贵操作。
## INTERSECT 与 EXCEPT(MySQL 8.0.22+)
MySQL 8.0.22 开始支持标准 SQL 的 `INTERSECT`(交集)和 `EXCEPT`(差集),补齐了集合运算的最后一块拼图。
### 基本语法
```sql
-- INTERSECT:取两个查询的交集(两边都有的行)
SELECT city FROM customers_china
INTERSECT
SELECT city FROM customers_japan;
-- 结果:只返回同时存在于两个结果集中的城市
-- EXCEPT:取差集(左边有、右边没有的行)
SELECT city FROM customers_china
EXCEPT
SELECT city FROM customers_japan;
-- 结果:返回在中国有但日本没有的城市
```
> [!QUESTION] INTERSECT vs IN / EXISTS?
> 两者在语义上等价,但 `INTERSECT` 更直观地表达了"取交集"的意图。实际执行中,Optimizer 通常会将 INTERSECT 转换为 Semi Join 或 Anti Join——因此性能差异不大,选择哪种取决于**可读性**。
### INTERSECT / EXCEPT vs UNION 行为对比
```mermaid
flowchart LR
A["查询 A: {1,2,3}"] --> OP
B["查询 B: {2,3,4}"] --> OP
OP --> R1["UNION: {1,2,3,4}"]
OP --> R2["INTERSECT: {2,3}"]
OP --> R3["EXCEPT: {1}"]
style R1 fill:#74C0FC,color:#000
style R2 fill:#00D866,color:#fff
style R3 fill:#FF9F43,color:#000
```
| 操作 | 语义 | 去重 | 对应集合论 |
|------|------|------|-----------|
| `UNION` | A ∪ B | ✅ 默认去重 | 并集 |
| `UNION ALL` | A ∪ B(含重复) | ❌ | 多重集并 |
| `INTERSECT` | A ∩ B | ✅ 默认去重 | 交集 |
| `EXCEPT` | A − B | ✅ 默认去重 | 差集 |
> [!NOTE] 默认行为:去重
> INTERSECT 和 EXCEPT 默认都会去重(等价于 DISTINCT)。如果想去重保留所有行,目前 **MySQL 不支持 `INTERSECT ALL` / `EXCEPT ALL`**,需要通过 UNION ALL + 计数的方式模拟。
### 实用场景
```sql
-- 场景一:找出"既买了 A 又买了 B"的用户(交集)
SELECT user_id FROM orders WHERE product_id = 'A'
INTERSECT
SELECT user_id FROM orders WHERE product_id = 'B';
-- 等价的 EXISTS 写法(对比可读性)
SELECT DISTINCT o1.user_id
FROM orders o1
WHERE o1.product_id = 'A'
AND EXISTS (SELECT 1 FROM orders o2 WHERE o2.user_id = o1.user_id AND o2.product_id = 'B');
-- 场景二:找出"注册了但从未下单"的用户(差集)
SELECT id FROM users
EXCEPT
SELECT DISTINCT user_id FROM orders;
-- 等价的 NOT EXISTS 写法
SELECT u.id FROM users u
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);
```
> [!TIP] 何时选 INTERSECT/EXCEPT?
> - **逻辑简单、需要取交集/差集**:`INTERSECT` / `EXCEPT` 语法最清晰
> - **需要返回关联表的字段**:用 `EXISTS` / `NOT EXISTS` 或 JOIN,因为 INTERSECT/EXCEPT 只能操作整行或指定列
> - **需要高性能**:大表场景下 EXPLAIN 确认执行计划——Optimizer 通常会转换为 Semi/Anti Join,性能与手写子查询一致
### 优先级与括号
当混合使用多个集合运算时,默认**从左到右**执行。需要用括号改变优先级:
```sql
-- 先 UNION 再 INTERSECT(左到右)
SELECT id FROM t1
UNION
SELECT id FROM t2
INTERSECT
SELECT id FROM t3;
-- 实际含义:t1 UNION (t2 INTERSECT t3) ← 错误!
-- 实际执行:(t1 UNION t2) INTERSECT t3 ← 从左到右
-- ✅ 用括号明确意图
(SELECT id FROM t1 UNION SELECT id FROM t2)
INTERSECT
SELECT id FROM t3;
-- 也可以用 ORDER BY + LIMIT 修饰每个子句(需括号)
(SELECT id FROM t1 ORDER BY id LIMIT 10)
INTERSECT
(SELECT id FROM t2 ORDER BY id LIMIT 5);
```
> [!WARNING] 注意括号的必要性
> 当 INTERSECT/EXCEPT 与 ORDER BY/LIMIT 混用时,不加括号可能产生歧义。**养成给每个子查询加括号的习惯**,避免优先级意外。
---
## 实战场景
### 场景一:多表同构合并
```sql
-- ❌ 错误写法:WHERE / ORDER BY / LIMIT 只作用于最后一个 SELECT
SELECT * FROM logs_202601
UNION ALL
SELECT * FROM logs_202602
UNION ALL
SELECT * FROM logs_202603
UNION ALL
SELECT * FROM logs_202604
UNION ALL
SELECT * FROM logs_202605
WHERE status = 'error' -- ⚠️ 只过滤 logs_202605!
ORDER BY created_at DESC
LIMIT 50;
-- ✅ 正确写法:如果需要对每个分表单独过滤,用子查询包裹
SELECT * FROM
(SELECT * FROM logs_202601 WHERE status = 'error') AS t1
UNION ALL
SELECT * FROM
(SELECT * FROM logs_202602 WHERE status = 'error') AS t2
UNION ALL
SELECT * FROM
(SELECT * FROM logs_202603 WHERE status = 'error') AS t3
UNION ALL
SELECT * FROM
(SELECT * FROM logs_202604 WHERE status = 'error') AS t4
UNION ALL
SELECT * FROM
(SELECT * FROM logs_202605 WHERE status = 'error') AS t5
ORDER BY created_at DESC
LIMIT 50;
```
> [!NOTE] 分表合并的注意事项
> - `WHERE / ORDER BY / LIMIT` **仅作用于 UNION 中最后一个 SELECT**。前面的分表查询不做过滤,全部返回后再合并排序。
> - 如果每个分表数据量很大(百万级),建议**用子查询保护每个表的局部 ORDER BY + LIMIT**,先各取 Top-N 再全局排。这大幅减少中间结果集大小。
> - UNION ALL 不保证顺序,最终 ORDER BY 必不可少。
### 场景二:多表分页合并(Feed 流)
```sql
-- 需求: 用户的动态 Feed 由关注的人和发布的文章混合组成, 按时间排序分页
-- ❌ 应用层先查再排: 至少两次 DB 往返 + 内存归并排序
-- ✅ UNION ALL 一次搞定
SELECT user_id AS source_id, content, 'follow' AS source_type, created_at
FROM follow_feed WHERE user_id = 42
UNION ALL
SELECT article_id AS source_id, summary AS content, 'article' AS source_type, published_at
FROM articles WHERE author_id = 42
ORDER BY created_at DESC
LIMIT 20 OFFSET 0;
```
> [!TIP] 何时用 UNION vs CASE WHEN?
> - **数据来源是不同表或不同结构**: UNION ALL 是不二之选
> - **同表不同条件的聚合计数**: `SUM(CASE WHEN ...)` 单次扫描更高效
> - **经验法则**: UNION 的 SQL 可读性显著优于多个 OR 条件叠加时, 优先选 UNION
### 场景三:搜索多字段权重排序
```sql
-- 搜索商品:标题匹配权重 > 描述匹配 > 两者都匹配
SELECT id, title, description,
3 AS rank_score -- 标题命中,权重最高
FROM products
WHERE title LIKE '%runners%'
UNION ALL
SELECT id, title, description,
2 AS rank_score -- 描述命中,权重次之
FROM products
WHERE description LIKE '%runners%'
AND title NOT LIKE '%runners%'; -- 排除已在上面出现的
ORDER BY rank_score DESC, id;
```
> [!NOTE] 多条件搜索的 UNION ALL 策略
> - 通过不同 SELECT 赋予不同权重(`rank_score`),合并后统一 `ORDER BY` 即可实现"加权排序"
> - **注意去重边界**:第二个 SELECT 加 `NOT LIKE` 过滤可避免重复输出同一商品。如果无法在前端精确排重,可以考虑外层套一层 `DISTINCT`(代价是会退化为 UNION)或改用 `GROUP BY id`
```sql
-- ✅ 正确
SELECT id, name FROM products WHERE price > 100
UNION ALL
SELECT id, name FROM products WHERE category = 'sale';
-- ❌ 错误:列数不一致
SELECT id, name FROM products
UNION ALL
SELECT id FROM discounts;
-- ❌ 错误:ORDER BY 放在中间(MySQL 可能忽略或报错)
SELECT id FROM table_a
ORDER BY id
UNION ALL
SELECT id FROM table_b;
```
### UNION 进阶:ORDER BY 与 LIMIT 的行为
```sql
-- ⚠️ 问题:每个 SELECT 的 ORDER BY 在合并后无效
SELECT id FROM orders WHERE status = 'pending' ORDER BY created_at DESC
UNION ALL
SELECT id FROM orders WHERE status = 'shipped' ORDER BY created_at DESC;
-- 上面的 ORDER BY 基本被 MySQL 忽略
-- ✅ 正确解法:用子包装保护每个查询的排序
SELECT * FROM
(
SELECT id, created_at FROM orders WHERE status = 'pending'
ORDER BY created_at DESC LIMIT 50
) AS a
UNION ALL
SELECT * FROM
(
SELECT id, created_at FROM orders WHERE status = 'shipped'
ORDER BY created_at DESC LIMIT 50
) AS b
ORDER BY created_at DESC
LIMIT 20;
```
> [!QUESTION] 为什么子查询能保护 ORDER BY?
> MySQL 优化器发现 UNION 外还有 ORDER BY + LIMIT 时, 会认为内部排序有用,从而保留它。
> 但官方文档并不保证这种行为——这是基于执行计划的经验结论, 生产环境务必确认。
---
## UNION 的执行流程
```mermaid
flowchart TD
A["查询 1"] --> R1["结果集 1"]
B["查询 2"] --> R2["结果集 2"]
C["查询 N"] --> RN["结果集 N"]
R1 --> Temp["临时表 + 唯一索引<br/>UNION 有, UNION ALL 无"]
R2 --> Temp
RN --> Temp
Temp --> Dedup{"需要去重?"}
Dedup -->|是| Sort["排序 + 去重"]
Dedup -->|否| Direct["直接输出"]
Sort --> Output["最终结果集"]
Direct --> Output
style Dedup fill:#FF9F43,color:#000
style Sort fill:#EE5A24,color:#fff
```
## 执行计划特征(EXPLAIN)
用 `EXPLAIN` 观察 UNION 的执行特征,能直观看到 Optimizer 的处理策略:
```sql
CREATE TABLE users_a (id INT PRIMARY KEY, name VARCHAR(64), city_id INT);
CREATE TABLE users_b (id INT PRIMARY KEY, name VARCHAR(64), city_id INT);
EXPLAIN SELECT * FROM users_a WHERE city_id = 10
UNION ALL
SELECT * FROM users_b WHERE city_id = 10;
```
期望的 EXPLAIN 输出:
```
+----+--------------+------------+------+---------------+------+---------+-------+-------+------+
| id | select_type | table | type | key | extra| rows | ... | ref | |
+----+--------------+------------+------+---------------+------+---------+-------+-------+------+
| 1 | PRIMARY | users_a | ref | idx_city_id | NULL | 100 | ... | const | |
| 2 | UNION | users_b | ref | idx_city_id | NULL | 100 | ... | const | |
+----+--------------+------------+------+---------------+------+---------+-------+-------+------+
```
关键解读:
| 字段 | 含义 | UNION 中的表现 |
|------|------|--------------|
| **select_type** | 查询类型 | `PRIMARY`(第一个 SELECT) + 每个后续 SELECT 标记为 `UNION` |
| **table** | 涉及的表 | 每个 UNION 分支独立显示一行 |
| **Extra** | 附加信息 | UNION ALL 无额外信息;使用去重版 UNION 时 Extra 会出现 `Using temporary; Using filesort` |
> [!WARNING] UNION 去重的隐藏代价
>
> ```sql
> -- 对比两个版本的 Extra
> EXPLAIN SELECT * FROM users_a WHERE city_id = 10
> UNION ALL -- Extra: (空)
> SELECT * FROM users_b WHERE city_id = 10;
>
> EXPLAIN SELECT * FROM users_a WHERE city_id = 10
> UNION -- Extra: Using temporary; Using filesort
> SELECT * FROM users_b WHERE city_id = 10;
> ```
>
> MySQL 内部会将所有 UNION 分支的结果收集到一个**临时表**中,然后对这个临时表做**全表扫描 + 文件排序**来完成去重。当结果集很大时,这就是性能瓶颈所在。
>
> 生产排查建议:如果 UNION 查询慢,先用 `EXPLAIN` 确认 Extra 是否包含 `Using temporary`;再用 `EXPLAIN ANALYZE`(MySQL 8.0.16+)查看各分支的实际行数和耗时。
---
## 与 JOIN 的选择
```sql
-- 场景:获取每个部门的员工总数 + 总监姓名
-- JOIN 方案(交叉维度,适合取不同列)
SELECT d.name, COUNT(e.id) AS emp_count, mgr.name AS manager
FROM departments d
LEFT JOIN employees e ON d.id = e.dept_id
LEFT JOIN employees mgr ON d.manager_id = mgr.id
GROUP BY d.id;
-- UNION 方案(平行维度,适合合并同类数据)
SELECT dept_id, 'total' AS metric, COUNT(*) AS value FROM employees GROUP BY dept_id
UNION ALL
SELECT dept_id, 'managers' AS metric, COUNT(*) AS value
FROM employees WHERE is_manager = 1 GROUP BY dept_id;
```
> [!QUESTION] UNION 还是多个查询?
> 很多场景中 UNION 看起来方便,但背后可能有更好的解法:
> - **应用层合并**:在 Go/Java 中发两次查询然后合并数组(零 DB 压力)
> - **STORED PROCEDURE**:存储过程中多次查询 + 临时表
> - **视图**:封装 UNION 逻辑供多次复用
>
> 核心原则:**能不在数据库做的就不做**。UNION 的代价是排序、去重、临时表。
## 性能对比:UNION vs 替代方案
| 方案 | DB 往返次数 | 临时表开销 | 排序开销 | 适用规模 |
|------|------------|-----------|---------|---------|
| **UNION ALL** | 1 次 | 有(结果集缓冲) | 仅外层的 ORDER BY | 百万行级 |
| **UNION(去重)** | 1 次 | 有(唯一索引) | Sort + Dedup | 十万行以内 |
| **应用层 N 次查询** | N 次 | 无 | 内存归并 | 任意,受网络影响 |
| **SUM(CASE WHEN)** | 1 次 | 无 | 无 | 单表聚合计数 |
```mermaid
flowchart LR
A["数据源数量 > 1"] --> B{"能否用单次扫描解决?"}
B -->|是: 单表多条件| C["SUM CASE WHEN<br/>最优"]
B -->|否| D{"结果集是否已知不重复?"}
D -->|是| E["UNION ALL<br/>推荐"]
D -->|否| F["UNION<br/>去重"]
style C fill:#00D866,color:#fff
style E fill:#74C0FC,color:#000
style F fill:#FF9F43,color:#000
```
## 常见陷阱与排查
| 陷阱 | 症状 | 解法 |
|------|------|------|
| **列数不匹配** | `The used SELECT statements have a different number of columns` | 确保每个 SELECT 的列数完全相同;用 `NULL` 补齐 |
| **类型隐式转换** | UNION 中对应列类型不一致导致全表扫描或结果错误 | 手动 CAST 到一致类型(如 `CAST(0 AS CHAR)`) |
| **ORDER BY 位置错误** | MySQL 忽略分支内的 ORDER BY 或报错 1221 | ORDER BY / LIMIT 放整个 UNION 的最后;需要保护内部排序请用派生表包装 |
| **UNION ALL 产生重复行** | 业务期望唯一结果但实际有重复 | 检查是否真的有去重需求 — 有的话用 UNION;无法避免时用 DISTINCT/GROUP BY |
| **分表 WHERE 漏写** | WHERE 只过滤最后一个分表,前面全部数据入库 | 每个分表子查询独立加 WHERE,或用视图封装 |
| **分页错位** | OFFSET 导致合并后第一页显示第二页数据 | 先合并再整体 OFFSET,不能用局部 LIMIT + OFFSET 代替全局分页 |
### 实战排查 Checklist
遇到 UNION 相关慢查询或异常时,按以下顺序逐项检查:
```mermaid
flowchart TD
S["UNION 查询异常/慢"] --> C1{"EXPLAIN 看了吗?"}
C1 -->|没看| STOP["🛑 先跑 EXPLAIN<br/>别盲猜,看 select_type 确认分支数"]
C1 -->|看了| C2{"Extra 有 Using temporary 吗?"}
C2 -->|"是:UNION 去重"| D1["换 UNION ALL + 业务侧保证不重复"]
C2 -->|"否"| C3{"列数/列类型一致吗?"}
C3 -->|"不一致"| D2["CAST 统一类型<br/>否则可能隐式转换导致索引失效"]
C3 -->|"一致"| C4{"ORDER BY 在最终位置吗?"}
C4 -->|"否,放在中间"| D3["移动到最后<br/>或在每个分支外加子查询包裹"]
C4 -->|"是"| C5{"WHERE 对所有分支都生效吗?"}
C5 -->|"否"| D4["每个分子查询独立加条件"]
C5 -->|"是"| DONE["✅ SQL 结构正确,<br/>继续排查索引和数据分布"]
style STOP fill:#EE5A24,color:#fff
style D1 fill:#FF9F43,color:#000
style D2 fill:#FF9F43,color:#000
style D3 fill:#FF9F43,color:#000
style D4 fill:#EE5A24,color:#fff
style DONE fill:#00D866,color:#fff
```
## 核心要点回顾
| 主题 | 一句话 |
|------|--------|
| UNION ALL vs UNION | 能用 ALL 就绝不用 UNION — 去重的临时表+文件排序代价远超想象 |
| INTERSECT / EXCEPT | 8.0.22+ 原生支持交集与差集,语义比 IN/EXISTS 更直观,Optimizer 会转为 Semi/Anti Join |
| ORDER BY/LIMIT 作用域 | 只在 UNION 最后一个 SELECT 生效;跨表排序必须外层包装子查询 |
| 字段匹配规则 | 列数必须相同,类型应尽量兼容 — 列名取自第一个 SELECT |
| Feed 流/多源合并 | UNION ALL 的经典场景,一次 DB 往返搞定多数据源混合 |
| 分表汇总 | 同构分表最理想的聚合方式,但注意 WHERE 只对最后一个分支生效 |
| 执行计划标识 | PRIMARY + UNION 双行 — Extra 中出现 `Using temporary` 就是去重开销的信号 |
> [!TIP] 核心心法
> **UNION ALL 是你最好的朋友,UNION 是你的最后手段。**
> 写 UNION 查询前问自己三个问题:① 结果集真的需要去重吗?② 能不能用 CASE WHEN 单表扫描代替?③ 如果拆成 N 次简单查询,应用层合并是不是更清晰?大部分时候答案会让你回到 UNION ALL。
## 关联笔记
- [[hhs/MySQL/02-SQL核心/08-DQL SELECT 全解析]] — SELECT 基础语法与 UNION 的结合使用
- [[hhs/MySQL/02-SQL核心/07-DML 增删改]] — DML 中的批量操作与 UNION 的互补关系