写SQL的人,大概率都背过一句口诀:UNION 会去重,UNION ALL 不去重。但真到了线上环境,面对一个跑了十几秒的合并查询,你光会背口诀是不够的。UNION 和 UNION ALL 的区别,本质上是一套完整的执行逻辑、性能模型和业务取舍。合并操作看着简单,里面藏着的排序、哈希、隐式转换、NULL 处理这些细节,随便拎一个出来都能让慢查询雪上加霜。
这篇文章我不打算只给结论,而是把这两个操作符从底层执行计划到真实业务场景完整拆一遍,包括字段类型不匹配会出什么错、为什么有些查询用 UNION 会触发额外的排序、什么时候必须用 UNION 而不能用 UNION ALL。同时附上我在 MySQL 和 SQL Server 上实测过的性能数据,以及几个典型的优化案例。无论你是刚接触 SQL 的新手,还是被慢查询折磨过的老手,这篇文章都能帮你把合并操作彻底吃透。
1. UNION 与 UNION ALL 的核心区别:从执行逻辑说起
1.1 先搞清楚一个前提:什么是结果集合并
在进入 UNION 和 UNION ALL 的细节之前,得先明确一个基础概念——这两个操作符干的都是“垂直合并”。
假设你有两张表,一张存的是 2023 年的订单数据,另一张存的是 2024 年的订单数据。你想统计这两年所有订单的总量,最直接的做法就是把两张表的订单数据纵向拼在一起,形成一个新的结果集。这个过程在 SQL 里就叫集合合并(Set Operation)。
这里有个关键点经常被新手忽略:JOIN 是横向合并,把两张表的列拼在一起;而 UNION 是纵向合并,把两张表的数据行拼在一起。JOIN 增加的是列数,UNION 增加的是行数。比如 JOIN 会把订单表和客户表通过 customer_id 关联起来,得到一条同时包含订单信息和客户信息的记录,而 UNION 是把订单表和另一张订单表的数据上下堆叠,每一行的结构必须一致,都是同样的列。
理解了这一点,后面讨论字段数量一致性、类型匹配这些问题就有了基础。因为纵向合并要求上下两个结果集必须“长得一样”,否则数据堆叠之后就无法正确对应到每一列上。
1.2 UNION 的去重机制是怎么运作的
UNION 的核心行为是合并加去重。它的执行过程在数据库内部大约分三步:
第一步,分别执行 UNION 两侧的 SELECT 查询,得到两个中间结果集。第二步,把这两个结果集纵向合并到一起,形成一个临时结果集。第三步,对合并后的临时结果集做去重检查,消除完全相同的行,然后把去重后的结果返回给用户。
关键在于第三步。数据库引擎需要对合并后的所有行进行一次完整的去重操作,这意味着它必须比较每一行和另一行是否在所有列上都完全相等。为了完成这个比较,数据库通常会选择两种策略之一:排序去重(Sort + Distinct)或者哈希去重(Hash Distinct)。
排序去重的工作方式类似于你先给所有数据按顺序排好队,然后从头到尾扫描一遍,发现相邻两行完全一样就删掉一行。排序本身的时间复杂度是 O(n log n),当数据量达到百万级以上时,这个开销相当可观。
哈希去重的工作方式则是把每一行转换成哈希值,然后通过哈希表判断重复。哈希去重在数据量较大时通常比排序快,但会占用额外的内存来维持哈希表结构。
无论哪种策略,UNION 都意味着额外的 CPU 计算和内存开销。这就是它和 UNION ALL 最根本的差异所在。
1.3 UNION ALL 为什么不做任何额外操作
UNION ALL 的执行逻辑就简单粗暴了——只做合并,不做去重。
两侧的查询分别执行完毕后,数据库引擎会把结果集直接首尾相接,堆叠到同一个结果集里,然后立刻返回。整个过程没有排序,没有哈希,没有比较,没有去重检查。从执行路径上看,UNION ALL 比 UNION 少了一个关键步骤,而这个步骤恰恰是最耗资源的。
用生活化一点的类比来说:UNION 像是你把两堆文件合并到一起,然后逐份检查有没有重复的复印件,发现有一样的就抽掉一份。UNION ALL 则是直接把两堆文件倒进同一个箱子里,不管有没有重复,原样混在一起。
所以在语义上有一个很重要的前提:使用 UNION ALL 意味着你需要承担“可能出现重复行”的结果。如果你的业务逻辑本身就不允许出现重复数据,或者你确定两侧查询的结果集在逻辑上不会有任何重叠,那么使用 UNION ALL 是安全的,而且性能明显更好。但如果两侧的结果集存在重复可能性,而你的业务又要求返回唯一值,那就不能因为贪性能而使用 UNION ALL,否则查出来的数据就是错的。
2. 性能差异:为什么 UNION 总是更慢
2.1 去重依赖排序或哈希,这不是免费的
在数据库执行计划里,UNION 的去重操作通常体现为一个 SORT 操作符或一个 DISTINCT SORT 操作符。Oracle 里常见的是 SORT UNIQUE,SQL Server 里常见的是 DISTINCT SORT,MySQL 里则可能看到 Using temporary 和 Using filesort 的标志。这些操作符背后都是实打实的资源消耗。
简单做个数学估算。假设两个 SELECT 各返回 50 万行数据,合并后总行数是 100 万行。使用 UNION ALL 时,这 100 万行数据直接就输出了,数据库只需要把它们从底层扫描结果逐行搬到最终结果集,几乎不产生额外的计算开销。使用 UNION 时,数据库必须对这 100 万行做完整去重。如果使用排序去重,光是排序 100 万行数据就需要相当长的时间,更别提排序过程中临时文件落盘带来的 I/O 消耗。
这里有个很重要的细节:当去重数据量超过数据库分配的内存阈值时,排序过程会从内存排序退化为磁盘排序。一旦发生磁盘排序,性能会断崖式下跌,因为磁盘 I/O 的速度比内存访问慢几个数量级。这也就是为什么在实际工作中,一个看似简单的 UNION 查询在数据量激增后突然变慢的原因。
2.2 执行计划对比:SORT 与 Concatenation
用实际的执行计划来说话更有说服力。在 MySQL 中执行一条 UNION 查询,通过 EXPLAIN 查看执行计划,通常会看到类似这样的信息:
EXPLAIN SELECT order_id FROM orders_2023 UNION SELECT order_id FROM orders_2024;执行计划中会出现Using temporary和Using filesort的标记,这说明 MySQL 创建了临时表来存放合并后的数据,并且做了排序以便去重。
而同样的语句改成 UNION ALL:
EXPLAIN SELECT order_id FROM orders_2023 UNION ALL SELECT order_id FROM orders_2024;执行计划中就不会再出现Using temporary,取而代之的是一个类似 Append 的操作。在 MySQL 8.0 里,UNION ALL 的执行计划可以被优化合并为一次扫描,或者简单地把两个子查询的结果拼接返回。
在 SQL Server 中看执行计划更直观。UNION 会显示一个Distinct Sort操作符,它在内存中对数据行排序并去重。UNION ALL 则是Concatenation操作符,单纯把两个数据流拼接在一起。两者的执行计划图形差异非常明显,一眼就能看出来。
2.3 一个真实场景下的性能测试记录
我之前在一张包含 800 万行数据的订单表上做过一次对比测试。场景是把两个月份的订单数据合并,每个月份约 120 万行,合并前数据有约 3% 的重复订单号。
使用 UNION 完成合并去重,耗时约 4.2 秒,执行计划显示使用了临时表和排序。使用 UNION ALL 完成合并,耗时约 0.8 秒,执行计划里没有临时表也没有排序操作。
5 倍左右的性能差距在数据量更大的场景下还会进一步拉大。当单表数据量达到亿级,UNION 的排序代价会让查询直接超时,而 UNION ALL 依然可以秒级返回。当然,这个测试中 3% 的重复率比较低,如果重复率很高,UNION 去重后的结果集更小,后续处理也许更快,但在合并这一步,它的开销始终是高于 UNION ALL 的。
关于索引有一个值得注意的点:如果两侧查询的 WHERE 条件都能走索引,那么扫描阶段的 I/O 成本可以降下来。但 UNION 的去重排序发生在扫描完成之后,索引对去重阶段的加速作用有限。唯一能对去重排序产生帮助的是,如果你对合并后的字段建了合适索引,数据库在某些情况下可以避免额外的排序操作,但这种优化有限且依赖具体执行计划。
3. 字段数量、类型与隐式转换:合并前的必修课
3.1 字段数量不一致的报错与处理
UNION 操作有一个硬性规则:两侧 SELECT 的字段数量必须完全一致。如果你写:
SELECT order_id, customer_id, amount FROM orders_2023 UNION SELECT order_id, customer_id FROM orders_2024;数据库会直接报错。MySQL 会提示The used SELECT statements have a different number of columns,SQL Server 会报类似All queries combined using a UNION, INTERSECT or EXCEPT operator must have an equal number of expressions in their target lists的错误。
解决办法是给字段较少的那一侧补上缺失的列,通常补一个常量占位符。比如上面的例子可以改成:
SELECT order_id, customer_id, amount FROM orders_2023 UNION SELECT order_id, customer_id, 0 AS amount FROM orders_2024;这个 0 只是占位,代表 2024 年数据的 amount 字段没有对应值。在业务语义上,你要清楚这个占位符的含义——它既不是真实数据,也不是 NULL 的替身,而是一个默认值。如果后续有人对这个字段做 SUM 聚合,这个 0 不会影响求和结果;如果做 AVG 聚合,0 反而会拉低平均值,所以占位常量的选择要谨慎。
3.2 字段类型不匹配时的隐式转换陷阱
字段数量一致还不够,两侧对应位置的字段类型也需要兼容。这里的“兼容”不是必须完全相同,而是数据库能将两侧的数据统一到同一个类型下。
最常见的例子是一个 SELECT 返回字符类型,另一个 SELECT 返回整数类型。数据库会按照隐式转换规则将整数转换成字符,或者反过来把字符转换成整数。这背后藏着两个风险:转换方向的确定性,以及转换造成的精度损失。
比如SELECT 100 AS val和SELECT 'abc' AS val做 UNION,数据库尝试将 'abc' 转换为数字时会直接报错。而SELECT 100 AS val和SELECT '100abc' AS val做 UNION,虽然数据库能够把 '100abc' 隐式转换为 100(部分数据库支持前缀数字字符串的转换),但这个转换规则在不同数据库中的行为并不一致,极易埋坑。
更隐蔽的问题是浮点数精度。如果一侧是 DECIMAL(10, 2),另一侧是 DOUBLE,合并后数据库可能将结果统一为 DOUBLE,导致精度发生变化。类似的坑我在实际项目中踩过,最后排查出问题的时候,数据已经错了好几天。
3.3 空值 NULL 在不同数据库中的表现
NULL 在 UNION 去重中有一个容易混淆的行为:在大多数关系型数据库中,两条记录如果除了某些字段为 NULL,其他字段都相同,那么这两条记录在 UNION 去重时会被认为是重复的。也就是说,NULL 值在去重比较时视作相等。
这个行为其实遵循了 SQL 标准中的集合语义——两个结果集的并集是按行值是否完全相等来判断的。NULL 作为行的一部分,在比较时被视为相同。
但要注意另一个场景:如果你在 SELECT 的列中直接使用 NULL 常量,比如:
SELECT NULL AS name, amount FROM orders_2023 UNION SELECT NULL AS name, amount FROM orders_2024;如果 amount 相同,这两行会被去重,合并后的结果只剩一行,因为两行的所有列都相等。这在业务上可能不是你期望的结果,如果每一行都应该保留的话,就得考虑用 UNION ALL 或者给 NULL 字段附加其他区分信息。
4. 什么时候选 UNION,什么时候选 UNION ALL
4.1 去重是业务需求时,选 UNION 没悬念
最典型的场景是跨表去重统计。比如从日志表和历史归档表中合并查询去重后的用户 ID 列表。日志表记录的是今天的活跃用户,归档表记录的是以前的活跃用户,你想知道总共有多少活跃用户,显然同一个用户不应该被统计两次,这时候 UNION 就是正解。
再比如标签系统。一张表存的是 VIP 用户,另一张表存的是高消费用户,你想找到所有“只要满足任一条件就算”的用户集合,并且最终结果里每个用户只出现一次,用 UNION 就能直接满足需求。
这些场景下,UNION 不仅在做合并,还在做集合运算。它定义的就是集合论里的“并集”概念,天然包含去重特性。用 UNION ALL 反而不对,因为可能出现重复用户导致统计虚高。
4.2 数据本身保证不重复,选 UNION ALL 更明智
如果两侧查询的结果集在业务语义上不可能重复,那用 UNION 就是纯粹的浪费。典型的例子是分区表合并查询,比如按日期分别查询不同分区的数据,因为分区条件互斥,一个订单只会出现在一个分区中,合并后的结果自然不会有重复行。
还有一种是字段拆分,比如一个系统里有新旧两套编码体系,你需要把两张表的编码汇总到一起生成下拉选项,这两张表分别维护不同编码段,天然互斥。此时使用 UNION 不仅多了一次无意义的去重排序,还可能因为去重而丢掉业务上应该保留的重复标识,所以应该用 UNION ALL。
判断的核心就一句话:问自己“两边查出来的数据,有没有可能出现一模一样的一整行?”如果答案是否定的,就用 UNION ALL。
4.3 从 UNION 改写为 UNION ALL 的经典优化案例
我服务过一个报表系统,当时的查询大概长这样:
SELECT customer_id, SUM(amount) FROM orders_2023 GROUP BY customer_id UNION SELECT customer_id, SUM(amount) FROM orders_2024 GROUP BY customer_id;这条 SQL 的问题是它对两个分组汇总结果做了 UNION 去重。因为同一个客户可能两年都有订单,去重后数据量变小了,初看没什么问题。但仔细分析业务后发现,这条 SQL 的本意是分年度统计每个客户的销售金额,根本不需要去重,更不应该把同一客户两年的金额合并成一行。正确的写法应该是:
SELECT customer_id, SUM(amount) FROM orders_2023 GROUP BY customer_id UNION ALL SELECT customer_id, SUM(amount) FROM orders_2024 GROUP BY customer_id;查询时间从 6.3 秒降到了 1.1 秒,而且数据结果更合理。类似这种因为不懂业务语义、盲目使用 UNION 导致的全表排序,是实际工作中最常见的性能浪费之一。
还有一个常见的优化技巧:当你要对一个 UNION 的整体结果做 GROUP BY 或 ORDER BY 时,可以考虑把 UNION ALL 的结果作为一个子查询再聚合。因为外层的聚合操作会统一处理重复行,内层的 UNION ALL 就只是负责快速堆叠数据,没必要提前做去重。
5. UNION 的边界用法与常见陷阱
5.1 带 ORDER BY / LIMIT 时容易踩的坑
UNION 的排序和分页是一个经典误区。许多人以为这样写是对的:
SELECT name FROM table_a ORDER BY name UNION SELECT name FROM table_b ORDER BY name;实际上在大多数数据库中,这个语法要么报错,要么只有最后一个 SELECT 的 ORDER BY 生效。ORDER BY 真正要对整个合并结果集排序,需要把整个 UNION 包成子查询,或者把 ORDER BY 放在整个语句的末尾:
SELECT name FROM table_a UNION SELECT name FROM table_b ORDER BY name;这个语法中 ORDER BY 作用于整个 UNION 结果集,是合法的。注意不要写成每个 SELECT 各自带 ORDER BY,那不是对整个结果排序。
LIMIT 同理。如果你只想从合并结果中取前 10 条,需要:
SELECT * FROM ( SELECT name FROM table_a UNION ALL SELECT name FROM table_b ) AS t LIMIT 10;直接在每个 SELECT 中加 LIMIT 只会先截断各自的结果再合并,通常不是你想要的效果。
另外要特别注意括号和子查询的优先级。在 MySQL 中,SELECT * FROM table_a UNION SELECT * FROM table_b LIMIT 10这个写法里,LIMIT 只作用于最后的 SELECT,而不是整个 UNION 结果。要限制整个合并结果的返回行数,必须像上面那样套一层子查询。
5.2 UNION 与 JOIN、IN、EXISTS 的取舍
操作符之间的选择问题也经常让人纠结。UNION 处理的是“纵向合并”,IN / EXISTS / JOIN 处理的是“横向关联”和“存在性判断”,它们解决的问题不同,但在某些写法上,可以用不同方式达到类似目的。
想查“购买了 A 产品或者 B 产品的所有用户”,你可以用 UNION 把两组用户合并去重:
SELECT user_id FROM orders WHERE product_id = 'A' UNION SELECT user_id FROM orders WHERE product_id = 'B';也可以用 OR 条件加 DISTINCT 达到同样效果:
SELECT DISTINCT user_id FROM orders WHERE product_id IN ('A', 'B');两种写法结果相同,但执行方式可能差异巨大。UNION 会分别扫描两次再合并去重;IN 加 DISTINCT 通常只需要一次扫描。数据量大的时候,后者的效率往往更高。
反过来,如果你想查“购买了 A 产品但没有购买 B 产品的用户”,UNION 就无能为力了,应该用 NOT EXISTS 或 LEFT JOIN 加 IS NULL 来做差集。每种操作符都有自己的适用范围,不能一遇到多条件查询就无脑上 UNION。
5.3 不同数据库的实现差异(MySQL / SQL Server / Oracle)
虽然 UNION / UNION ALL 是 SQL 标准语法,但各数据库在具体执行细节上还是有差异的。
MySQL 对 UNION 的一个限制是你不能直接在单个查询里无限堆叠 UNION,嵌套层数太多会导致语句难以维护。MySQL 8.0 之前的版本对派生表的优化不够好,你用 UNION ALL 作为子查询时可能产生derived_merge相关的问题,MySQL 8.0 之后优化器会尝试把派生表合并到外层查询,性能改善明显。
SQL Server 在 UNION 和 UNION ALL 之间有一个值得留意的特性:UNION 的排序去重操作可以利用查询计划中的内存授予。如果内存授予估计不足,会触发 TempDB 的磁盘溢出,表现为查询变慢且 TempDB 文件增长明显。遇到这种问题可以通过定期更新统计信息,或者添加合适的索引来优化。
Oracle 的优化器对 UNION 的处理比较成熟,但有一个经典问题是 UNION 在有些版本中可能导致 CBO(基于成本的优化器)对行数估计失真,进而影响整个查询计划。使用 UNION 时经常需要手动收集统计信息或者加 hint 来保证执行计划稳定。
PostgreSQL 中 UNION 和 UNION ALL 的执行计划通常会有明确差异,PostgreSQL 支持并行扫描,UNION ALL 在并行度上的表现往往更好。另外 PostgreSQL 对每个 SELECT 的排序是独立的,如果你希望合并后的结果有序,需要显式添加 ORDER BY。
6. 实践中的问题排查速查表
最后把我这些年处理过的与 UNION 相关的实际问题和排查思路整理成速查表,方便你遇到类似情况时快速定位。
| 现象 | 常见原因 | 排查思路 | 建议解法 |
|---|---|---|---|
| UNION 查询很慢,且临时表占用空间大 | 去重触发排序或哈希,数据量超出内存 | 查看执行计划是否出现 SORT、Using temporary | 确认业务场景能否使用 UNION ALL |
| 合并后结果比预期少 | UNION 去重把业务上不该删的行删掉了 | 检查业务语义上两侧结果集是否允许重复 | 按业务需求改用 UNION ALL |
| 报 “different number of columns” 错误 | 两侧 SELECT 字段数量不一致 | 数一下两侧 SELECT 的字段数 | 缺少字段的一侧补常量占位符 |
| 报 “illegal mix of collations” 错误 | 两侧字段字符集或排序规则不一致 | 检查两侧字段的 collation 设置 | 用 COLLATE 统一排序规则 |
| ORDER BY 只对最后一个 SELECT 生效 | 排序位置写错了 | 检查 ORDER BY 是在每个 SELECT 内还是在语句末尾 | 将 ORDER BY 移到整个语句末尾 |
| LIMIT 只截断了一侧结果 | LIMIT 被解析到最后一个 SELECT 的子句中 | 检查语句中 LIMIT 的作用范围 | 将整个 UNION 包成子查询再 LIMIT |
| 合并后出现乱码 | 两侧字符集不一致 | 检查字段或连接的 charset 设置 | 统一字符集或使用 CONVERT 转换 |
| 去重后数值精度变化 | 一侧是 DECIMAL,一侧是 FLOAT/DOUBLE | 检查隐式类型转换规则 | 将字段显式 CAST 为同一精度类型 |
| 内存溢出或磁盘临时文件暴涨 | UNION 去重的排序数据量太大 | 查看数据库临时表空间占用 | 拆分查询,分批处理,改为 UNION ALL + 外层聚合 |
这些坑并不是每次都会遇到,但一旦遇到,排查起来常常比写 SQL 本身更耗时。把这张表存下来,遇到相关报错直接对照定位,能省下不少时间。
最后分享一点个人体会
我在实际业务中见过太多因为 UNION 和 UNION ALL 选错而导致的慢查询,也见过为了优化而把 UNION 改成 UNION ALL 后数据出错的案例。这两个操作符的选择,从来不只是性能问题,而是语义正确性和性能之间的权衡。
一个值得坚持的习惯是:先想清楚业务上需不需要去重,再考虑性能。如果业务上允许重复,或者你已经通过过滤条件保证了不重复,那就放心用 UNION ALL。如果业务要求唯一,那就用 UNION,不要为了省那几秒去承担数据错误的风险。
另外,如果一条 SQL 里出现了多次 UNION,通常说明你的表结构设计可能有问题,或者查询逻辑本可以用 JOIN 实现。合并操作本身不复杂,但滥用 UNION 往往是数据库设计不够规范的表现,优化结构性问题的收益,比死磕一个操作符大得多。