SQL UNION与UNION ALL:去重原理、性能对比与实战避坑指南
发布时间:2026/9/13 1:44:06来源:尧图网络
1. 先把最基础的差异说透UNION 到底联合了什么很多刚接触 SQL 的人都会背一道面试题UNION 会去重UNION ALL 不去重。答案本身不能算错但如果你只理解到这个层面写生产 SQL 的时候迟早要出事。我在业务线上见过太多因为这两个关键字选错导致的慢查询、错数据甚至直接把临时表空间撑爆的事故。所以这篇文章想从原理、场景、性能到实战把这两个关键字的用法和坑一次讲清楚。1.1 一个去重一个不去重但这只是表象先从最直观的差异说起。UNION 和 UNION ALL 都是把两个或多个 SELECT 的结果集纵向拼接在一起注意是纵向——第一个查询的所有行在前第二个查询的所有行在后以此类推。差别就在于-- 有重复行会合并 SELECT name FROM student_a UNION SELECT name FROM student_b; -- 有重复行原样保留 SELECT name FROM student_a UNION ALL SELECT name FROM student_b;如果 student_a 表里有张三student_b 表里也有张三UNION 的结果里只出现一次而 UNION ALL 的结果里会出现两次。这个差异在数据量小的时候几乎感觉不出来一旦数据量上了百万级两者在资源消耗上的差距会非常明显。原因很简单UNION 要做去重就必然要把两个结果集都装进内存或临时表里进行比较UNION ALL 只是单纯的拼接理论上可以流式输出第一条数据出来就能开始传输给客户端了。所以从能不能边算边返回这个角度看两者根本不是一个量级的操作。1.2 数据库到底是怎么完成去重的很多人以为 UNION 的去重是在最后检查一遍有没有重复其实不是。数据库的执行计划通常是这样的分别执行两个 SELECT得到两个结果集。把结果集写入一个哈希表或排序结构取决于优化器的选择。在写入过程中每来一行就检查哈希表里有没有相同的行有就丢弃没有就插入。全部处理完后把哈希表或排序后的结果输出。所以 UNION 的开销不只是多一次比较而是必须物化——也就是说它没法像 UNION ALL 那样边算边返回必须等所有行都处理完之后才能开始输出。这个物化过程在数据量大时会带来明显的内存压力内存不够就会溢写到磁盘性能直接掉一个量级。我用 MySQL 8.0 做过一个不太严谨但很有代表性的测试两张各 100 万行的表取 10 个字段UNION ALL 大概 1.2 秒返回完UNION 跑了 15 秒还没结束最后查执行计划发现它在用临时表加 filesort 做去重。这就是压死骆驼的最后一根稻草——排序。哈希去重还好一点一旦优化器选择了排序去重时间复杂度就是 O(n log n)而不是 O(n)。一个小提醒UNION 的去重比较的是整行不是主键。哪怕两条数据主键不同只要 SELECT 出来的所有列一模一样UNION 就会把它们当成重复行合并掉。这个特性很多人一开始没意识到后面会详细讲。2. 真实的业务场景什么时候该用 UNION什么时候该用 UNION ALL理论说完了聊聊我在实际工作中遇到的选型场景。这是我觉得比语法更重要的事情语法错了会直接报错选型错了往往是线上跑得慢、数据不对但你又很难第一时间察觉。2.1 多表合并报表用 UNION ALL 更符合业务语义前几年我维护过一个经营报表系统有一个功能是查一个月的订单明细但订单按月份存在不同的分区表里历史遗留设计现在不建议这么搞。当时的需求是查 1 月到 3 月的订单我的第一版 SQL 是这样写的SELECT order_id, user_id, amount, create_time FROM orders_202401 UNION SELECT order_id, user_id, amount, create_time FROM orders_202402 UNION SELECT order_id, user_id, amount, create_time FROM orders_202403;跑出来数据量明显不对1 月、2 月、3 月加起来应该是 30 多万行结果只有 27 万行。排查后发现是有几个用户在两三个月里下了完全相同的订单订单号肯定不同但订单号没有出现在 SELECT 的列里——我只查了 order_id、user_id、amount、create_time这几个字段完全相同的行确实存在。问题就出在这里UNION 的去重是按整行去重不是按主键去重。只要 SELECT 出来的所有列一模一样它就认为是重复行。这是 UNION 最容易踩的坑之一。正确的做法是 UNION ALL因为不同月份的订单本来就是不同的业务记录根本不存在重复的概念。即使真的要防重复也应该是去重业务数据本身而不是靠 UNION 这个关键字。后来我给自己定了一条规矩凡是多个结果集拼接且业务上不要求整行去重的一律用 UNION ALL。2.2 数据清洗与去重用 UNION 完成一次顺便去重有没有必须用 UNION 的场景有。比如两个数据源的数据对不齐你想合并出一个干净的清单。我做过一个会员数据合并的需求线上注册表 member_online 和线下导入的 member_offline 可能存在同一个用户用手机号判断现在要导出一份不重复的用户名单给运营。这种情况下用 UNION 就很合适SELECT phone, name, online AS source FROM member_online UNION SELECT phone, name, offline AS source FROM member_offline;只要手机号和姓名一样就认为是同一个人合并后自动去重。这里的 key 是你刚好希望按照整行的逻辑去重而且每行数据的字段完全一致用 UNION 就是最省事的写法。你要是用 UNION ALL 还得自己再套一层 GROUP BY多绕一步。当然这个写法有个前提——你要先确认业务上手机号 姓名相同确实代表同一个人。如果不确定宁可多加一个唯一标识字段再去重也别轻易相信整行相等就代表业务重复。2.3 一个典型误用业务上不需要去重却用了 UNION再分享一个我在评审同事代码时看到的问题。他写了一个标签统计的 SQL统计每个用户命中的标签数量SELECT user_id, COUNT(*) AS tag_cnt FROM ( SELECT user_id, tag_a AS tag FROM user_tag_a UNION SELECT user_id, tag_b AS tag FROM user_tag_b UNION SELECT user_id, tag_c AS tag FROM user_tag_c ) t GROUP BY user_id;看起来没毛病对吧但问题在于如果 user_tag_a 表里同一个 user_id 有多条记录比如用户重复领取了标签UNION 会把这些重复行合并掉COUNT(*) 统计出来的数量就不准确了。他本意是统计用户一共打了多少个标签标签数量应该按标签维度去重而不是把同一标签下的多条记录合并。这里正确的写法应该是 UNION ALL然后在外层用 COUNT(DISTINCT tag) 或者先按 user_id tag 去重。这是个非常隐蔽的逻辑错误UNION 的去重改变了业务口径但只看结果数字好像也对只是少了那么几条对比不了几次根本发现不了。所以我的建议是除非你明确知道整行重复就是你要的业务含义否则一律用 UNION ALL。用 UNION 去重应该是刻意为之而不是顺手写的。3. 写 UNION 查询前必须知道的硬性规则UNION 的语法看起来很简单就是两个 SELECT 中间加一个关键字但真正写起来数据库会教你做人。下面这几个规则是我在实际工作中反复踩过的每一个都对应过一个线上事故或者返工需求。3.1 列数必须一致列序决定最终结果这是 UNION 最基本的要求两个 SELECT 的列数必须相同否则直接报错-- 报错The used SELECT statements have a different number of columns SELECT id, name FROM users UNION ALL SELECT id FROM orders;这个错误很直白一般不会有人犯。但列序的问题就很隐蔽了。UNION 的合并是按位置来的不是按列名来的。第一个 SELECT 的第一列和第二个 SELECT 的第一列合并第二列和第二列合并就算列名不一样只要位置对上了就能跑通但结果可能完全不是你想要的SELECT user_name AS name, user_id AS id FROM users_a UNION ALL SELECT order_id AS id, user_name AS name FROM users_b;这个语句能跑通但第一个结果集的 name 会和第二个结果集的 id 混在一起数据类型如果恰好都是字符串连错误都不会报。我见过有人在 UNION 里因为列序不同把手机号和姓名拼在了一列里导出 Excel 后业务方直接懵了。所以我的建议是写 UNION 之前先把每个 SELECT 的列逐一列出来对齐特别是涉及多表、多字段的时候别怕麻烦。用注释把列的含义标出来会省掉很多排查时间。3.2 类型要兼容隐式转换有惊喜也有惊吓列序之外大部分数据库还要求对应列的数据类型兼容。不兼容的话会报错兼容的话会做隐式转换。这里的坑在于隐式转换的方向和结果集里显示的类型取决于第一个 SELECT 的类型。SELECT 1 AS num UNION ALL SELECT abc AS num;在 MySQL 里第一次查询是数字类型第二次查询是字符串结果呢MySQL 会把 abc 转成 0 再合并最终得到 1 和 0而不是 1 和 abc。这个行为在不同数据库里的规则还不完全一样真碰到了非常头大。我现在的习惯是在 UNION 的每个 SELECT 里都对对应的列做显式转换比如统一 CAST 成 VARCHAR 或者 DECIMAL不要依赖数据库的隐式转换。虽然多写几行但至少结果是可以预期的不会因为换了数据库版本就出现诡异的数据变化。3.3 ORDER BY、LIMIT 与 UNION 组合的奇怪行为这是 UNION 系列里最常被问到的问题ORDER BY 到底对整个结果集生效还是只对最后一个 SELECT 生效正确答案是如果 ORDER BY 放在整个 UNION 语句的最后面那么它是对整个合并后的结果集排序如果放在某个子查询的括号里那么只对那一个子查询生效。-- 对整个结果集排序最常见写法 SELECT id FROM t1 UNION ALL SELECT id FROM t2 ORDER BY id DESC; -- 只对子查询排序必须配合 LIMIT SELECT id FROM t1 UNION ALL (SELECT id FROM t2 ORDER BY id DESC LIMIT 10);但更隐蔽的是在 MySQL 里对括号内的子查询加 ORDER BY 必须配合 LIMIT否则优化器会直接忽略这个排序。也就是说你写了(SELECT id FROM t2 ORDER BY id DESC)但后面没有 LIMIT这个 ORDER BY 就是白写数据库压根不会执行排序。LIMIT 也有类似的坑。如果你想让每个子查询分别取前 10 条再合并必须写成(SELECT id FROM t1 ORDER BY id LIMIT 10) UNION ALL (SELECT id FROM t2 ORDER BY id LIMIT 10);如果漏掉括号LIMIT 就会作用在整个合并后的结果集上。很多人在写分页合并的时候在这里翻车——本来想每个表各取 10 条结果变成两个表合并后再取 10 条数据直接少了一半。4. 性能实测UNION 去重带来的额外成本有多大前面说了 UNION 要物化、要排序或哈希但光说不练假把式。我把我之前在 MySQL 8.0.28 上做的一组对比数据贴出来大家感受一下数量级差异。4.1 一个简单的对比测试环境MySQL 8.0.28InnoDB 引擎两张表各 50 万行表结构和数据完全一样只是 id 有 10 万行是相同的。查询字段是 5 个普通字段加主键结果如下操作耗时返回行数额外说明UNION ALL0.38s100万流式输出无临时表UNION2.15s90万使用临时表 哈希去重UNION未命中索引5.8s90万临时表 filesort数据量翻到 200 万后UNION 的耗时到了 12 秒以上而 UNION ALL 还在 1 秒左右徘徊。而且最要命的是临时表200 万行乘以 5 个字段的临时表光内存就不一定能扛住扛不住就会去写磁盘临时表性能直接雪崩。4.2 一次真实的线上慢查询复盘除了数据量的影响UNION 还有个容易被忽略的问题——它经常会破坏索引下推和覆盖索引的优化。我去年排查过一个线上慢查询SQL 长这样SELECT id, order_no, amount FROM order_2023 UNION ALL SELECT id, order_no, amount FROM order_2024;两个表都建了idx_order_no(order_no)索引单表查询都是毫秒级的但 UNION ALL 之后居然要 3 秒多。查执行计划发现两个子查询都走了全表扫描。原因是 UNION ALL 的结果集被上层当成一个派生表derived table优化器评估之后认为直接全表扫描比先走索引再合并更划算。解决办法是给每个子查询加LIMIT或者使用/* NO_MERGE() */之类的优化器提示强制物化。当然这里的选择要结合实际情况不是无脑加。如果只是查几万行全表扫描也无所谓但如果是千万级的表这个问题就非常致命了。更常见的情况是 UNION 的左右两边是同一张表只是过滤条件不同。这种场景完全可以改写成WHERE加OR或使用条件聚合性能往往能提升好几倍。我后面会展开讲这个替换思路。4.3 别拿 UNION 当万能拼接器还有一个我在面试候选人的时候喜欢问的点UNION 能不能替代 JOIN这两个东西看起来都是把多个表组合在一起但方向完全不同。JOIN 是横向组合把两边的列拼成一行UNION 是纵向组合把两边的行拼成一列。业务上如果只是想要A 表的记录加上 B 表的记录用 UNION如果想要A 表的字段和 B 表的字段对应起来用 JOIN。两者混用是新手最容易搞混的地方。举个例子一张用户表和一张订单表想查每个用户的用户名和订单号这是典型的需要 JOIN 的场景因为要把两边的列拼在一行里。但如果想查所有用户和所有订单的编号这个就是 UNION 的场景。搞清楚方向才不会在 JOIN 里写出一堆笛卡尔积或者在 UNION 里报列数不一致的错。5. 进阶UNION ALL 的几个高频实战姿势最后分享几个我在日常开发中经常用到的 UNION ALL 写法每一个都是踩过坑后才记住的。这些技巧说白了都不复杂但不知道的话写出来的 SQL 就是又慢又难维护。5.1 多表分页先分别取数再合并业务上经常遇到搜索电商平台的商品要同时展示自营和商家的商品按时间倒序每页 20 条。如果直接写 UNION ALL 再 ORDER BY LIMIT数据库会先把所有数据合并、排序完再取 20 条。数据量大时这不是最优解。更好的做法是先用子查询把两边各取 20 条再合并排序取 20 条SELECT * FROM ( SELECT * FROM self_products WHERE status 1 ORDER BY created_at DESC LIMIT 20 ) a UNION ALL SELECT * FROM ( SELECT * FROM merchant_products WHERE status 1 ORDER BY created_at DESC LIMIT 20 ) b ORDER BY created_at DESC LIMIT 20;这样每一侧只需要各扫描少量数据避免了全量排序。不过要提醒的是这种方式只适用于两边各取前 N 条再合并的语义。如果排序条件非常复杂或者两边的数据量差异很大可能还是要走全量合并这个需要结合实际执行计划来判断。5.2 用 UNION ALL 做行转列的补充行转列一般用 CASE WHEN GROUP BY 搞定但在某些场景下UNION ALL 更直观。比如统计每个用户在不同渠道的消费总额SELECT user_id, SUM(amount) AS total FROM ( SELECT user_id, amount FROM order_app UNION ALL SELECT user_id, amount FROM order_web UNION ALL SELECT user_id, amount FROM order_miniapp ) t GROUP BY user_id;这个写法本质上就是先把多张表的数据纵向合并成一张大表再聚合比多表 JOIN 的写法更清晰也更容易维护。而且因为用了 UNION ALL同一用户在不同渠道的消费都会被保留不会误去重。只要渠道表的表结构一致这种写法基本无脑可靠。我特别喜欢这种写法的原因是它把合并和聚合拆成了两步逻辑层次很清楚。以后要加一个新渠道只需要在 UNION ALL 后面多接一段 SELECT不用动外层逻辑。要是用 JOIN加一个渠道就要改一整套关联条件维护成本高得多。5.3 用 UNION ALL 拼常量补维度还有一个比较小的技巧用 UNION ALL 拼接常量行来补全缺失的维度。比如你要做一份每周几的订单分布但周六周日在某段时间没有订单如果用 GROUP BY 统计那两行就是空的图表上看起来就是断的。可以提前拼一个基础维度表SELECT 1 AS weekday UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7;再左连接统计数据就能保证七天都出现在结果里。这个技巧在做报表和图表时非常实用属于那种如果你不知道就会在业务方那里挨骂的操作。补维度的思路也不限于星期几月份、小时、地区等维度都能用同样的方式处理。5.4 UNION 与 OR、IN 的替换关系最后聊一个优化层面的替换思路。当 UNION 的左右两边都是对同一张表的查询时很多时候可以用 OR 或者 IN 替代。比如SELECT * FROM orders WHERE status 1 UNION ALL SELECT * FROM orders WHERE status 2;等价于SELECT * FROM orders WHERE status IN (1, 2);后者不仅能用到索引还不产生临时表通常性能更好。但有一个例外如果两个条件各自能用到不同的索引而且 MySQL 优化器选择了 index mergeUNION ALL 或者 OR 的写法可能比 IN 更好。这个就要用 EXPLAIN 看执行计划了没有银弹。另外UNION 两边的查询如果都涉及 ORDER BY 和 LIMIT是不能简单用 OR 替换的因为 LIMIT 的语义完全不同。这也是为什么前面说没有银弹——你得先搞清楚业务到底想要什么再决定用哪种写法。6. 写在最后我的默认选择写到这里我想强调一句我现在的习惯写 SQL 时默认用 UNION ALL只有当业务上明确要求整行去重时才用 UNION。理由很简单UNION ALL 的行为是可预期的——它只是拼接不会偷偷帮你把重复行吃掉。而一旦用了 UNION你就要开始关心去重逻辑是不是符合业务语义、临时表会不会撑爆、排序会不会拖慢整体查询。这些问题的排查成本远比写的时候多敲三个字符ALL要高得多。从入门到现在我用 UNION 踩过的坑基本都集中在误以为它会按主键去重和误以为它很便宜这两件事上。反过来说UNION ALL 几乎没有给我带来过意外——它不会改变数据语义不会引入隐式的性能陷阱唯一的缺点就是如果你真的需要去重它帮不上忙。这篇文章没有覆盖 UNION 在每个数据库里的特殊行为差异比如 Oracle 的 MINUS、SQL Server 的 EXCEPT以及不同数据库对 UNION 去重实现的不同。但核心原则是通用的先想清楚要不要去重再决定用哪个关键字。如果你在项目里也遇到过 UNION 相关的奇葩问题欢迎在评论区聊聊没准你的经验能帮别人少踩一个坑。
网站建设高端定制企业官网