1. 先从一次线上慢查询说起:UNION 和 UNION ALL 的真实差异起点
项目里有一个月度订单汇总报表,上线头几天还挺正常,某个周一早上监控突然报警:一条 SQL 的 95 分位耗时从前一天的 300ms 涨到了 9 秒多。拉出来一看,查询长这样:
SELECT order_id, user_id, amount, created_at FROM orders_2024_07 UNION SELECT order_id, user_id, amount, created_at FROM orders_2024_08;代码本身没毛病,逻辑也直白:七月的订单加上八月的订单,两张表拼一起。问题出在UNION这个关键字上。把它换成UNION ALL之后,查询耗时重新回到了 300ms 以内。那次之后,我对UNION和UNION ALL的理解才算真正落到了执行层面。
1.1 一个最直观的例子:两段数据合并后少了几行
先看一眼基础语义。假设我有两张表,staff_a和staff_b,存的是两个小组的成员名单。staff_a里有三个人:
| id | name |
|---|---|
| 1 | 张伟 |
| 2 | 李娜 |
| 3 | 王强 |
staff_b里也有三个人:
| id | name |
|---|---|
| 2 | 李娜 |
| 3 | 王强 |
| 4 | 赵敏 |
如果执行:
SELECT id, name FROM staff_a UNION SELECT id, name FROM staff_b;结果会得到 id 为 1、2、3、4 的 4 行,其中 id=2 和 id=3 的重叠记录只保留一次。
如果执行:
SELECT id, name FROM staff_a UNION ALL SELECT id, name FROM staff_b;结果会得到 6 行,id=2 和 id=3 各出现两次,完全按照“第一条查询结果 + 第二条查询结果”的顺序堆叠。
这就是两者最直观的区别:UNION等价于“合并并去重”,UNION ALL等价于“合并但不去重”。
1.2 为什么“去重”不是一个免费附加项
很多人会想:去重不就是把相同行删掉吗,能有多大事?问题在于,数据库要判断“两行是否相同”,不是凭空一看就知道的,它得对所有合并后的行做一次全量比对。去重的本质是一个分组操作,要么把所有行排个序然后相邻比较,要么建一张哈希表逐行判断是否见过。排序和哈希,听上去都不复杂,但当数据量到了几百万行、上千万行,这个操作的代价就完全不可忽略了。
我那次遇到的 9 秒案例,两张表各 200 多万行,UNION的去重逻辑在临时表上跑了整整 8 秒多。换成UNION ALL以后,数据库只需要把第二段结果直接追加到第一段结果后面,省掉了整个去重流水线。9 秒对 300ms,这就是“免费去重”和“付费去重”的区别。
2. 执行计划视角:从临时表、排序缓冲区到哈希去重
2.1 直接观察执行计划
判断一个查询到底干了什么,最靠谱的方法不是猜,而是看执行计划。以 PostgreSQL 为例,上面那个 UNION 查询的执行计划通常长这样:
Unique (cost=...) -> Sort (cost=...) Sort Key: id -> Append (cost=...) -> Seq Scan on staff_a -> Seq Scan on staff_bMySQL 8.0 的 EXPLAIN 里,则可能看到Using temporary和Using filesort;SQL Server 的图形执行计划里往往出现Distinct Sort或Hash Match。这些算子的含义是一致的:合并环节只是把所有行堆在一起,去重环节由后续单独的算子负责。
换成UNION ALL之后,执行计划里就只剩 Append / Concatenation 之类的合并算子,所有去重相关节点全部消失。一张图看下来,UNION 比 UNION ALL 多走了整整一段路。
2.2 UNION 的排序/哈希代价如何被放大
去重成本受两个因素影响:数据总量和重复率分布。
- 数据总量:UNION 要把两边结果合并成完整结果集再去重,所以它处理的数据量是 A+B,而不是 max(A,B)。两张大表合并,漂移就大了。
- 重复率分布:如果两边数据交集很少,UNION 仍然要把全部数据排序或哈希一遍,不会有“反正重复少,可以少干点活”这种好事。哪怕最后只去重掉一行,排序还是要排完。
- 列宽:去重比对的对象是整行所有列。列越多、字符串越长,排序比较或哈希计算的成本越高。我在实际中见过有人把 30 多个字段全部 SELECT 出来再做 UNION,去重成本上升得特别明显。
还有一点容易踩:如果参与的列里有大字段(TEXT、长 VARCHAR),排序阶段可能要处理大量变长数据,内存缓冲区放不下就直接往磁盘写,I/O 消耗立刻上去。
2.3 数据库引擎之间的实现差异
不同数据库对 UNION 的底层实现不完全一样,但核心思路大同小异:
| 数据库 | 常见去重方式 | UNION ALL 对应节点 |
|---|---|---|
| MySQL | 临时表 + 唯一索引 / filesort | Union result |
| PostgreSQL | HashAggregate 或 Unique + Sort | Append |
| SQL Server | Hash Match / Distinct Sort | Concatenation |
| Oracle | SORT UNIQUE / HASH UNIQUE | UNION-ALL |
MySQL 的 UNION 会先在临时表里建立唯一索引(主键)来拦截重复行;PostgreSQL 更倾向于先 Sort 再 Unique,或者直接 HashAggregate;SQL Server 会根据数据特征选择哈希匹配还是排序去重。它们选的路径不一样,但都逃不开“额外存储 + 额外比对”。
这个差异也提醒我们:不要拿某一种数据库的执行计划去推断另一种数据库的完全相同表现,需要具体分析。但结论方向是一致的:UNION 的额外开销是实打实的。
3. 语法与语义雷区:列数、类型、ORDER BY 和 LIMIT 的归属问题
3.1 列数必须一致,但列名以左边为准
UNION 要求两边的查询返回相同数量的列,这是语法级硬性规定。我在面试别人的时候经常问:如果左边列名是user_id,右边列名是uid,结果集的列名是什么?
答案是user_id。最终结果集使用第一个查询的列名,后面查询的列名会被忽略。这不算问题,但经常让人困惑。更麻烦的是,两边列数量不对齐时,数据库直接报错,而不是帮你补 NULL。比如有一个查询想要把“用户基本信息”和“用户扩展信息”拼起来,一边 SELECT 了 5 列,另一边 SELECT 了 6 列,直接报SELECT statements have different number of columns。遇到这种情况,我的习惯是把少的这边显式补上常量列:
SELECT id, name, dept, NULL AS extra FROM staff_a UNION ALL SELECT id, name, dept, remark FROM staff_b;补NULL的意图很明确:让列数对齐,同时不影响业务读取。
3.2 隐式类型转换是隐形杀手
UNION 各分支对应位置的数据类型并不要求严格一致,数据库会做隐式转换。但这个“方便”背后藏着坑。
比如左边第一个字段是整数id,右边对应位置是字符串code,数据库可能会把右边转成数值,也可能把左边转成字符串,具体取决于方言和数据类型优先级。如果一边是DECIMAL,另一边是VARCHAR,排序去重时比较规则可能跟你预期不一样。更危险的情况是:一边是日期字符串'2024-01-01',另一边是真正的 DATE 类型,合并之后的比较结果可能不是你想要的时间顺序。
我一般建议,在 SELECT 列表里显式 CAST 成目标类型:
SELECT CAST(id AS CHAR) AS id, name FROM staff_a UNION ALL SELECT CAST(code AS CHAR) AS id, name FROM staff_b;这样至少在类型层面可控,不至于让优化器替你决定。
3.3 ORDER BY 和 LIMIT:对整个结果集生效还是对单个分支生效?
这是 UNION 对新手的另一个高频误区。看下面这条 SQL:
SELECT id FROM staff_a ORDER BY id UNION ALL SELECT id FROM staff_b ORDER BY id;你以为这是两个分支各自排序,最后拼在一起整体有序吗?不是。根据 SQL 标准,ORDER BY出现在 UNION 的最后一段时,作用范围是整个 UNION 的结果。上面这段语法在很多数据库里会直接报错,或者出现未定义行为。正确写法是把 ORDER BY 放在整个合并结果的末尾:
SELECT id FROM staff_a UNION ALL SELECT id FROM staff_b ORDER BY id;类似地,LIMIT 也只对整个 UNION 结果生效,不能单独限制某个分支。如果需要限制单个分支,必须用括号包裹:
(SELECT id FROM staff_a ORDER BY id LIMIT 10) UNION ALL (SELECT id FROM staff_b ORDER BY created_at LIMIT 20);注意,MySQL 支持给单个 SELECT 加括号,但 PostgreSQL、SQL Server 对括号内查询的支持也有差异,而且即便语法通过,也不代表每个分支的排序能在合并后保留。宁可把“每个分支都要排序并截断”这个诉求拆成两条 SQL,在业务代码里合并,也别跟 SQL 标准较劲。
3.4 子查询包装能否保住分支顺序
有人会这样写:
SELECT * FROM ( SELECT id FROM staff_a ORDER BY id ) UNION ALL SELECT id FROM staff_b;这个写法在部分数据库里会语法报错。即便在 MySQL 里能跑,子查询的 ORDER BY 也经常被优化器忽略,因为对外层 UNION 来说,子查询只是一个“行集合”,顺序没有意义。真正要保住顺序,标准做法是最后统一排序,或者用带标记列的方式:
SELECT id, grp, created_at FROM ( SELECT id, 1 AS grp, created_at FROM staff_a UNION ALL SELECT id, 2 AS grp, created_at FROM staff_b ) t ORDER BY grp, id;这样我们就能控制“分组排序”了。但要注意:这个例子是演示思路,实际项目里如果两个分支各自截断的数据量很大,把排序放到外层一次搞定远比多个子查询排序划算。
4. 性能实测:从 1 万行到 1000 万行,你的速度差异到底来自哪
4.1 测试环境与造数方式
为了把差距讲清楚,我搭了一个测试环境:一台 8C16G 的虚拟机,MySQL 8.0.32,InnoDB,两张结构完全一样的表t_a、t_b,字段为id BIGINT PRIMARY KEY、code VARCHAR(32)、created_at DATETIME。造数时让两张表各有 50% 的重复 id,模拟最常见的“两边有重叠”的场景。
分别测试 1 万、10 万、100 万、500 万、1000 万行级别,对比:
SELECT id, code, created_at FROM t_a UNION SELECT id, code, created_at FROM t_b; SELECT id, code, created_at FROM t_a UNION ALL SELECT id, code, created_at FROM t_b;4.2 耗时对比数据
简单汇总我记录的实测结果(去掉了热缓存冷启动的噪声,大概是这个量级):
| 单表数据量 | UNION | UNION ALL | 倍数 |
|---|---|---|---|
| 1 万行 | 约 40ms | 约 15ms | 2.7x |
| 10 万行 | 约 220ms | 约 45ms | 4.9x |
| 100 万行 | 约 2.1s | 约 0.3s | 7x |
| 500 万行 | 约 11s | 约 1.3s | 8.5x |
| 1000 万行 | 约 25s | 约 2.6s | 9.6x |
数据量越大,UNION 的额外开销占比越高。原因很简单:UNION ALL 的耗时基本等于两张表的扫描时间 + 结果传输时间,几乎是线性的;而 UNION 还要在排序/哈希阶段叠加一个非线性增长的因子。当数据量跨过内存排序缓冲区的临界点,临时落盘之后差距会被进一步拉大。上面 1000 万行的测试里,UNION 的临时表已经明显依赖磁盘 I/O,耗时已经不能只用 CPU 来解释。
4.3 不只是耗时,还有临时空间和锁
耗时只是表象,另一个容易忽视的点是临时空间。UNION 需要把去重结果物化到临时表(或者排序文件)再输出,这会在临时目录里占用大量磁盘空间。生产环境如果临时目录设置得不够大,UNION 大表时甚至可能直接报No space left on device。UNION ALL 大多数情况下只要边扫描边输出,几乎不产生临时文件。
此外,MySQL 的 SELECT 需要读一致性视图,但临时表物化阶段也会消耗额外的内存缓冲池资源。高并发场景下,这种额外资源消耗会传导到整个实例,影响其他查询。我见过一个二线系统因为一个报表查询用 UNION 连接两张千万级表,把整个实例的临时表空间打满的案例。最终把表结构落成快照表,改成先各自聚合再用 UNION ALL,问题才彻底消失。
所以,性能对比真正想说的是:UNION 不是一个“只要能跑就行”的操作,它背后是一个完整去重子流程。当你要合并的数据量达到百万以上时,这个子流程的成本会非常突出,值得提前设计。
5. 业务决策框架:什么时候必须 UNION,什么时候最好别用
5.1 必须用 UNION 的场景:字典合并、会员归集、唯一编码校验
去重是业务需要的场景,UNION 就是正确选择:
- 两张字典表存放的是同一套编码体系的历史快照,现在要合并出一个最新唯一字典,需要 UNION。
- 两个会员系统导出名单,合并成一份种子数据时要保证一个人只出现一次,需要 UNION。
- 要校验某一批 id 在两张表中是否存在重复,直接用 UNION 看看总行数和两张表行数和的关系,就能快速发现问题。
这类场景的核心特征是:结果集的重复行在业务上没有意义,甚至会造成错误。
5.2 必须用 UNION ALL 的场景:日志追加、流水列表、指标累加
反过来,如果重复行有业务含义,或者你只关心“加起来”而不管重复,那么 UNION ALL 不仅是更快,更是语义正确的选择:
- 业务流水按季度拆分到表里,现在要导出全年明细。同一条记录不会同时存在于两个季度表,但你要的是完整流水,不是去重后的流水。
- 两个来源的埋点日志拼在一起做分析。即便某条日志在两个源里都出现,先原样保留,等分析阶段做标签去重,比在 SQL 层直接去重更合理。
- 统计订单总额,两张表分别统计后相加。哪怕想顺便算总件数,也绝不能让两条“一模一样”的订单变成一条,否则金额直接少了一半。
在这些场景里,用 UNION 反而会引入严重的数据错误。业务上的“正确”,比执行速度更重要。
5.3 业务重复 vs 物理行重复:别把两个概念混了
最容易被忽略的是“重复”这个词在不同语境下的含义。UNION去重,去的是整行所有列完全相同的物理重复;而业务上说的“重复”,往往是某一个或某几个业务键相同,比如同一个 user_id 就算重复。
举个例子:某个用户昨天和今天各产生一条订单,订单号不同,但 user_id 相同。业务上你希望“每个用户只保留一条记录”,UNION 帮不了你,因为整行并不相同。你必须自己定义业务键并用 GROUP BY 或窗口函数处理:
SELECT user_id, MAX(order_id) AS latest_order FROM ( SELECT user_id, order_id FROM orders_a UNION ALL SELECT user_id, order_id FROM orders_b ) t GROUP BY user_id;这个例子同时说明了另一个原则:先用业务键压缩数据,再决定是否 UNION ALL,往往比合并后做全行去重更高效,因为合并前的数据已经被压缩过。
6. 进阶玩法:聚合前置、FULL OUTER JOIN 模拟和 ROW_NUMBER 去重改写
6.1 先 GROUP BY 再 UNION,还是先 UNION 再 GROUP BY?
遇到“两张表合并后按用户汇总”的需求,不少人直接写:
SELECT user_id, SUM(amount) AS total FROM ( SELECT user_id, amount FROM t_a UNION ALL SELECT user_id, amount FROM t_b ) x GROUP BY user_id;这个写法没有错,但效率通常不是最优的。更高效的方式是先各自聚合,再 UNION ALL 合并,最后再聚合一层:
SELECT user_id, SUM(total) AS total FROM ( SELECT user_id, SUM(amount) AS total FROM t_a GROUP BY user_id UNION ALL SELECT user_id, SUM(amount) AS total FROM t_b GROUP BY user_id ) x GROUP BY user_id;为什么这样更快?因为两张表各自的 GROUP BY 可以在索引扫描阶段完成局部压缩,把几十万行压成几千个分组;UNION ALL 之后的外层 GROUP BY 只处理几千行,几乎感觉不到开销。而如果把 UNION ALL 放前面,外层 GROUP BY 就要处理几百万行原始明细。这个优化思路在报表类场景里特别常见。
6.2 用 UNION 拼出 FULL OUTER JOIN 的完整逻辑
MySQL 直到 8.0 都没有原生FULL OUTER JOIN,但外连接需求在实际项目中并不少见。一种经典替代写法就是用 UNION 把 LEFT JOIN 和 RIGHT JOIN 的结果拼起来:
SELECT a.id, a.name, b.order_id FROM users a LEFT JOIN orders b ON a.id = b.user_id UNION SELECT a.id, a.name, b.order_id FROM users a RIGHT JOIN orders b ON a.id = b.user_id;第一次执行 LEFT JOIN 拿到所有用户(包括没有订单的用户),第二次 RIGHT JOIN 拿到所有订单(包括找不到用户的孤儿订单)。UNION 负责把两边结果合并成一个完整集合,同时把可能在两边都出现的连接行去重。严格说这种写法需要结果集列结构一致,使用场景通常局限于连接结果行重复不严重的情况。
如果数据量很大,我一般倾向用LEFT JOIN + IS NULL找孤儿数据,再分别处理,而不是直接拼两个大 JOIN 结果。因为两个大表的 JOIN 本身就是高成本操作,UNION 又叠加去重,双重成本叠加,响应时间容易失控。
6.3 ROW_NUMBER 去重改写:比 UNION DISTINCT 更可控
UNION 的全行去重很“粗暴”,如果业务去重键只占部分列,UNION 就不适用。这时候用窗口函数更灵活:
SELECT id, name, dept FROM ( SELECT id, name, dept, ROW_NUMBER() OVER (PARTITION BY id ORDER BY name DESC) AS rn FROM ( SELECT id, name, dept FROM staff_a UNION ALL SELECT id, name, dept FROM staff_b ) t ) x WHERE rn = 1;这个写法的核心价值在于:PARTITION BY 可以指定一个业务主键,而不是整行。重复行的保留顺序也由 ORDER BY 控制,能精确选择“保留最新”或“保留最早的”。这项能力是 UNION 的全行去重不具备的。需要提醒的是,窗口函数对内存的占用也可能很高,大数据量下需要关注是否触发磁盘排序。
7. 集合家族其余成员:INTERSECT、EXCEPT 与方言差异
7.1 INTERSECT 与 EXCEPT 的语义
UNION 只是 SQL 集合操作家族中的一员。与它并列的还有:
INTERSECT:取两个结果集的交集,重复行也会被去重。EXCEPT(Oracle 里叫MINUS):取第一个结果集里有、第二个结果集里没有的行,同样去重。
比如员工表和离职员工表,想要“在职且曾被评为优秀员工”的名单,可以直接 INTERSECT;想要“在职但不在离职名单里”的人,可以用 EXCEPT。它们和 UNION 共享同一个去重机制,所以性能特点也类似:都需要排序/哈希,都应该是“最后选择”。
7.2 MySQL 没有原生的 INTERSECT 和 EXCEPT?
MySQL 8.0.31 开始支持INTERSECT和EXCEPT,但生产环境里大量实例还停留在 8.0 早期版本、5.7,甚至有一些云托管实例的语法支持不太一致。为了兼容,常见的替代方案是:
- INTERSECT 替代:用 EXISTS 或 IN 判断。
SELECT id FROM staff_a WHERE EXISTS ( SELECT 1 FROM orders WHERE orders.user_id = staff_a.id );- EXCEPT 替代:用 LEFT JOIN + IS NULL。
SELECT a.id FROM staff_a a LEFT JOIN orders o ON a.id = o.user_id WHERE o.user_id IS NULL;这两种替代写法不涉及全量排序去重,在某些场景下反而比原生 INTERSECT/EXCEPT 更快,尤其是能利用到索引的时候。
7.3 方言差异汇总
| 操作 | PostgreSQL | MySQL | SQL Server | Oracle |
|---|---|---|---|---|
| UNION / UNION ALL | 支持 | 支持 | 支持 | 支持 |
| INTERSECT | 支持 | 8.0.31+ | 支持 | 支持 |
| EXCEPT | 支持 | 8.0.31+ | 支持 | 使用 MINUS |
不同数据库的语法基本都以标准 SQL 为基础,但细节仍有差异:Oracle 的MINUS不能直接换成EXCEPT;MySQL 早期版本需要 Workaround;SQL Server 要求每个分支列数一致且类型兼容性严格。我的建议是:在项目初始化阶段就明确当前数据库支持哪些集合运算符,避免把一套 SQL 直接搬到另一种数据库上才发现语法不支持。
8. 我的一点实际习惯:如何把 UNION 系列写出既正确又高效
8.1 默认从 UNION ALL 出发
我现在写合并查询时,默认都是UNION ALL,只有当业务明确要求去重并且去重键是整行时,才会切换成UNION。这个习惯帮我避免了很多“为了去重而去重”的性能问题。你可以这样自查:合并后的结果集如果允许同一行出现两次,结果是否还有意义?如果允许,那就用 UNION ALL。绝大多数日志、流水、明细查询都允许重复,所以 UNION ALL 的使用频率远高于 UNION。
8.2 用 EXPLAIN 验证去重路径
每次写完 UNION 查询,我都会顺手看一眼执行计划。重点看两个东西:结果集合并后有没有 Sort / Unique / HashAggregate 节点;有没有Using temporary提示。如果出现这几种节点,我就要想清楚:这个去重真的有必要吗?有没有可能通过对单个分支先分组再合并来去掉全局去重?
另外,如果 UNION 结果需要进一步排序或分页,我会特别留意 ORDER BY 和 LIMIT 是否在整个 UNION 的结果集上执行,而不是被优化器提前下推到某个分支。这可以通过执行计划里排序节点出现的位置判断。
8.3 排查 UNION 慢查询的常用套路
最后分享一个我常用的排查流程。如果你有一条 UNION 查询变慢了,按这个顺序看:
- 先用 EXPLAIN ANALYZE(MySQL 8.0.18+、PostgreSQL 都有)看耗时分布,确认瓶颈在扫描、去重还是排序。
- 把 UNION 临时改成 UNION ALL,重跑一次,对比耗时。如果对比后差异巨大,说明瓶颈就在去重,不是单表扫描。
- 检查两边 SELECT 的列数是否有多余字段。我见过有人为了取一列而把整行字段都带出来,导致去重工作量成倍增加。
- 如果确实必须去重,评估能否把去重提前到单表阶段:先 GROUP BY 或 DISTINCT 压缩每个分支,再 UNION ALL。
- 最后考虑索引:两边查询字段如果都能覆盖索引,扫描阶段会大幅提速;在临时表上的去重则没法走索引,只能靠排序或哈希本身。
按照这个流程,大部分 UNION 慢查询都能在两三轮内定位到根因,而且往往最后都会落到一个结论:不是 UNION 本身不能用,而是它被用在了本来不需要去重的场景上。
在项目里花几分钟想清楚“这次合并到底要不要去重”,比事后排查几小时要划算得多。这笔时间账,任何做数据开发的人都应该算得明白。