news 2026/9/24 19:49:59

SQL合并查询优化:UNION与UNION ALL的底层原理与性能差异

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SQL合并查询优化:UNION与UNION ALL的底层原理与性能差异

写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 temporaryUsing 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 valSELECT 'abc' AS val做 UNION,数据库尝试将 'abc' 转换为数字时会直接报错。而SELECT 100 AS valSELECT '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 往往是数据库设计不够规范的表现,优化结构性问题的收益,比死磕一个操作符大得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/24 19:49:39

MySQL与MongoDB选型对比与实战避坑指南

做开发这些年,我遇到过无数朋友问同一个问题:“数据到底存MySQL还是存MongoDB?”尤其是刚入行没多久的同事,经常两套数据库都装好了,却不知道生产环境里哪个场景该用哪个。MySQL是老牌关系型数据库,稳了二十…

作者头像 李华
网站建设 2026/9/24 19:49:29

.NET 3.5 + SQL Server 2005 HR系统源码复现指南

简介:这是一套基于.NET 3.5开发的人力资源管理系统(HRM)完整源码,面向初学者与中小型项目开发者,适用于学习C#企业级应用开发、数据库交互及三层架构实践。系统采用SQL Server 2005作为后端数据库,涵盖员工…

作者头像 李华
网站建设 2026/9/24 19:49:04

Perforce QAC 2025.4深度解析:更懂现代C++的静态分析工具

做嵌入式C/C开发的同行应该都有过这种经历:编译器开了-Wall -Wextra告警全清零、单元测试也过了,结果设备一上电跑起来,定位半天发现是某个指针悬空、缓冲区边界算错,或者一个全局变量被意想不到的地方改掉了。这类深层次问题编译…

作者头像 李华
网站建设 2026/9/24 19:48:47

基于Python深度学习的阿尔茨海默症早期MRI诊断系统

简介:本资源是一套基于Python深度学习技术实现的阿尔茨海默病(AD)早期辅助诊断系统,专为计算机、医学信息工程或人工智能方向的本科生毕业设计、课程设计及项目开发实践打造。系统融合医学影像分析与深度学习建模,支持…

作者头像 李华
网站建设 2026/9/24 19:48:36

主要跨境电商企业和国内电商企业有什么区别?四个维度说清

摘要:主要跨境电商企业和国内电商企业有什么区别?本文从市场环境、平台规则、物流资金、数据管理四个维度拆解,帮打算出海的国内卖家看清两者本质差异与门槛。 很多做国内电商的老板问:国内做得不错,出海是不是直接把…

作者头像 李华