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.28,InnoDB 引擎,两张表各 50 万行,表结构和数据完全一样,只是 id 有 10 万行是相同的。查询字段是 5 个普通字段加主键,结果如下:
| 操作 | 耗时 | 返回行数 | 额外说明 |
|---|---|---|---|
| UNION ALL | 0.38s | 100万 | 流式输出,无临时表 |
| UNION | 2.15s | 90万 | 使用临时表 + 哈希去重 |
| UNION(未命中索引) | 5.8s | 90万 | 临时表 + 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 merge,UNION 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 相关的奇葩问题,欢迎在评论区聊聊,没准你的经验能帮别人少踩一个坑。