18 KiB
tags, create time
| tags | create time | ||||||
|---|---|---|---|---|---|---|---|
|
2026-05-16 00:00 |
UNION / UNION ALL
概述
UNION 是 SQL 标准中的集合运算,用于将多个 SELECT 的结果纵向合并为一个结果集。核心应用场景包括:分表数据汇总、多源 Feed 流合并、以及需要排序去重的跨查询聚合。
基本语法
-- 两种形式
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 | 同上 |
| 适用场景 | 需要唯一结果的合并 | 已知不重复的合并 |
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(差集),补齐了集合运算的最后一块拼图。
基本语法
-- 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 行为对比
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 + 计数的方式模拟。
实用场景
-- 场景一:找出"既买了 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,性能与手写子查询一致
优先级与括号
当混合使用多个集合运算时,默认从左到右执行。需要用括号改变优先级:
-- 先 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 混用时,不加括号可能产生歧义。养成给每个子查询加括号的习惯,避免优先级意外。
实战场景
场景一:多表同构合并
-- ❌ 错误写法: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 流)
-- 需求: 用户的动态 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
场景三:搜索多字段权重排序
-- 搜索商品:标题匹配权重 > 描述匹配 > 两者都匹配
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
-- ✅ 正确
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 的行为
-- ⚠️ 问题:每个 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 的执行流程
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 的处理策略:
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 去重的隐藏代价
-- 对比两个版本的 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 的选择
-- 场景:获取每个部门的员工总数 + 总监姓名
-- 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 次 | 无 | 无 | 单表聚合计数 |
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 相关慢查询或异常时,按以下顺序逐项检查:
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 的互补关系