---
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["临时表 + 唯一索引
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
最优"]
B -->|否| D{"结果集是否已知不重复?"}
D -->|是| E["UNION ALL
推荐"]
D -->|否| F["UNION
去重"]
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
别盲猜,看 select_type 确认分支数"]
C1 -->|看了| C2{"Extra 有 Using temporary 吗?"}
C2 -->|"是:UNION 去重"| D1["换 UNION ALL + 业务侧保证不重复"]
C2 -->|"否"| C3{"列数/列类型一致吗?"}
C3 -->|"不一致"| D2["CAST 统一类型
否则可能隐式转换导致索引失效"]
C3 -->|"一致"| C4{"ORDER BY 在最终位置吗?"}
C4 -->|"否,放在中间"| D3["移动到最后
或在每个分支外加子查询包裹"]
C4 -->|"是"| C5{"WHERE 对所有分支都生效吗?"}
C5 -->|"否"| D4["每个分子查询独立加条件"]
C5 -->|"是"| DONE["✅ SQL 结构正确,
继续排查索引和数据分布"]
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 的互补关系