背八股的时候谁都能说一句“UNION会去重,UNION ALL不去重”,可真到了线上排查慢SQL、写报表统计的时候,翻车的人一抓一大把。我见过太多人因为想当然地选错操作符,导致几千万行的临时表把磁盘撑爆,也见过很多人明明该用UNION却写了UNION ALL,结果上线后业务数据出现重复。UNION和UNION ALL这对兄弟,表面上是“去重”和“不去重”一句话的事,背后藏的其实是SQL引擎的排序、哈希、临时表、内存溢出这一整条链路。这篇文章不跟你整虚的,直接从原理讲到实践,再讲到不同数据库里的坑,务求让你下次再碰到这两个关键字,脑子里浮现的不只是一句八股,而是完整的执行计划和代价模型。
1. 先搞清楚UNION与UNION ALL各自是干什么的
1.1 基本语法与执行效果
UNION和UNION ALL都是集合操作符,作用是把两个或多个SELECT查询的结果集纵向拼接成一个结果集。语法很简单:
SELECT column1, column2 FROM table_a UNION SELECT column1, column2 FROM table_b; SELECT column1, column2 FROM table_a UNION ALL SELECT column1, column2 FROM table_b;两者的区别从结果上看只有一点:UNION会对最终结果做去重,UNION ALL则会把所有记录原样拼在一起,哪怕两条记录完全一样也照单全收。但就是这“去重”两个字,让UNION在底层走了一条完全不同的执行路径。
我拿一个最朴素的例子说明。假设有一张订单表和一个退款表,你要统计所有涉及的用户ID:
SELECT user_id FROM orders UNION SELECT user_id FROM refunds;如果某个用户既下过单又退过款,UNION的结果里这个user_id只出现一次。而如果改成UNION ALL,这个用户就会在结果里出现两次,一次来自订单表,一次来自退款表。单从结果语义上看,UNION ALL返回的是“所有记录的简单堆叠”,UNION返回的是“去重后的用户集合”。
1.2 别小看“去重”这个动作
很多人在初学阶段觉得UNION多做一个去重,那就永远选UNION好了,反正结果更“干净”。这是最大的误解。去重不是免费的,SQL引擎要判断两条记录是否相同,就必须对每一行做比较。比较的方式无非两种:要么先把数据排序,然后相邻行两两比较;要么建立哈希表,逐行判断是否已经存在。不管哪一种,都需要额外的CPU计算和内存/磁盘空间。
换句话说,UNION自带一个隐式的DISTINCT操作,而DISTINCT从来不是廉价操作。你写下的每个UNION,底层都可能对应着一组排序算子或哈希算子,这些算子的开销在小数据量下毫无感知,但数据量一旦上了百万、千万级别,就是秒回和分钟级的差距。
所以,学习这两个操作符的第一步不是记结论,而是建立代价意识:UNION ALL是纯拼接,UNION是拼接加去重,去重是要花钱的。
2. 为什么UNION会去重:底层执行逻辑拆解
2.1 从执行计划看UNION做了什么
以MySQL为例,当我们用EXPLAIN查看一条UNION查询时,会看到类似这样的输出:
EXPLAIN SELECT id FROM table_a UNION SELECT id FROM table_b;在MySQL 8.0中,执行计划里会出现一个名为“UNION RESULT”的额外步骤,并且伴随一个Using temporary的提示。这说明MySQL会把两个子查询的结果先放进一个临时表里,再对临时表做去重。这个临时表可能建立在内存中(使用MEMORY存储引擎或TempTable存储引擎),也可能因为数据量过大而落盘到磁盘上的临时文件。
如果改成UNION ALL,执行计划里就没有这个临时表去重的步骤,两个子查询的结果直接拼接返回。整个过程类似于把两条流水线出来的产品直接倒进同一个箱子里。
在Oracle中,UNION对应的执行计划里会出现SORT (UNIQUE),意思是先排序再去除相邻重复行;而UNION ALL对应的是UNION-ALL,就是一个简单的串联操作。PostgreSQL的处理方式和Oracle类似,UNION会触发HashAggregate或Sort + Unique,UNION ALL则只是把两个子计划的输出通道合并。
2.2 去重的两种实现方式:排序与哈希
刚才提到,去重本质上是判断“这条记录是否出现过”。SQL引擎有两种主流做法:
第一种是排序去重:先把所有行按照所有列排序,排完序后,重复的行一定相邻,这时只需从左往右扫一遍,遇到与上一行相同的就跳过。这个方法的好处是稳定,不需要额外的大块内存来存哈希表,坏处是排序本身是O(n log n)的复杂度,数据量一大非常耗时。
第二种是哈希去重:遍历每一行,计算哈希值并存入哈希表,如果哈希表中已存在相同哈希且内容一致,就丢弃这一行。哈希去重在理想情况下接近O(n),但哈希表需要占用大量内存,当内存不够时就会触发哈希表落盘,反而可能比排序还慢。
不同数据库、不同版本、不同数据量下,优化器会在这两种方案之间动态选择。这就导致了同一个UNION查询,在数据量小时可能走哈希,数据量大了可能切换成排序,行为并不完全可控。
2.3 什么情况下UNION并不比UNION ALL慢
这里有一个反直觉的点:如果两个子查询本身的结果集就没有重复,UNION的去重操作理论上仍然要执行,但实际开销可能小到可以忽略。更极端的情况是,因为统计信息不准或优化器抽风,UNION选择的执行计划反而更快——这种情况不多,但确实存在。
更实际的一个场景是:UNION的去重操作可以帮忙“截断”数据。假设一个子查询返回了100万行,另一个返回了100万行,但两者的交集有80万行,最终结果集只有120万行。此时UNION虽然多做了一步去重,但最终返回给客户端或写入下游表的数据量减少了,网络传输和下游处理的压力反而更小。某些OLAP场景下,这种“以计算换传输”的取舍是划算的。
所以在真实的性能调优中,不要无脑认为UNION ALL一定更快,应该结合结果集特征、数据分布、网络带宽综合判断。但作为默认原则,在明确不需要去重时,优先UNION ALL。
3. 不同数据库里的UNION行为差异
3.1 MySQL:临时表与内存/磁盘的博弈
MySQL对UNION的处理非常依赖临时表机制。在5.7版本中,临时表的内存引擎是MEMORY,内存表有单行大小的限制(VARCHAR等变长字段会转成定长),当结果集超过tmp_table_size或max_heap_table_size时就会转为磁盘临时表,性能断崖式下跌。8.0引入了TempTable存储引擎,改进了变长字段的支持,但落盘阈值依然存在。
在实际生产中,我最常遇到的情况是:一个看起来不复杂的UNION查询,跑了几分钟还没结束,一查临时表已经落到磁盘上了。排查方法很简单:
SHOW STATUS LIKE 'Created_tmp_disk_tables'; SHOW STATUS LIKE 'Created_tmp_tables';如果Created_tmp_disk_tables数值很大,说明临时表频繁落盘。优化方向要么是改写SQL减少结果集大小,要么去调整临时表的内存阈值参数,但后者治标不治本。
3.2 Oracle:SORT UNIQUE与HASH UNIQUE
Oracle的执行计划中,UNION和UNION ALL的区别非常直观。UNION会多一个SORT (UNIQUE)操作,如果SORT_AREA_SIZE或PGA_AGGREGATE_TARGET不够大,排序会 spill 到临时表空间,产生磁盘I/O。
在Oracle 10g之后,PGA可以自动管理,优化器也会根据数据量选择HASH UNIQUE来代替排序。这里值得注意的一点是,Oracle对UNION的列类型匹配要求比MySQL更严格,例如VARCHAR2和NVARCHAR2混用、NUMBER和INTEGER混用时,Oracle可能直接报ORA-12704,而MySQL通常会自动做隐式转换。
3.3 SQL Server、PostgreSQL与SQLite
SQL Server的UNION默认也是走去重,执行计划里会出现Distinct Sort或Hash Match。如果两个查询结果本身不会重复,可以在查询提示里加OPTION (HASH UNION)或OPTION (MERGE UNION)来指定算法,但一般情况下不需要干预。
PostgreSQL的UNION支持UNION DISTINCT和UNION ALL两种显式写法,其中UNION DISTINCT和UNION等价。PostgreSQL的去重实现偏好HashAggregate,对于大结果集可能切换为Sort + Unique。这里有一个实践技巧:当你需要“拼接后再分组统计”时,在PostgreSQL里可以放心用UNION ALL配合外层GROUP BY,因为聚合本身就会去重,没必要让UNION提前做一次。
SQLite的情况更简单,它内部实现UNION的方式和MySQL类似,也是先创建临时表再排序去重。SQLite的UNION ALL由于少了排序步骤,通常速度优势更明显。
3.4 一个表弄懂各数据库对ORDER BY和LIMIT的处理
UNION结合ORDER BY和LIMIT时有很多隐蔽的坑。核心规则是:如果ORDER BY出现在单个SELECT子句内,它只对该子句生效,且通常没有意义;如果要让整个UNION结果排序,必须把ORDER BY放在最后,并使用LIMIT时还需要注意它会作用于整个UNION结果。
-- 正确写法:整体排序取前10条 SELECT name FROM student_a UNION ALL SELECT name FROM student_b ORDER BY name LIMIT 10;不同数据库对上面这条SQL的解析存在差异。MySQL和PostgreSQL会把ORDER BY和LIMIT应用到整个UNION结果上;Oracle在12c之前不支持这种写法,必须再包一层子查询:
SELECT * FROM ( SELECT name FROM student_a UNION ALL SELECT name FROM student_b ) ORDER BY name FETCH FIRST 10 ROWS ONLY;SQL Server的语法和MySQL类似,但要求ORDER BY的列必须出现在SELECT列表中,且不能使用别名之外的表达式。这些差异不痛不痒,但一旦写错就是语法错误或逻辑错误,属于面试里最喜欢挖坑的点。
4. 实战场景:到底该选UNION还是UNION ALL
4.1 用UNION ALL的经典场景
最常见的UNION ALL场景是数据合并,特别是合并后还要做聚合统计的情况。比如你有12个月的分表(table_202401、table_202402……),要统计全年的用户订单金额,直接用UNION ALL把每个月的数据串起来,再包一层SUM:
SELECT user_id, SUM(amount) AS total_amount FROM ( SELECT user_id, amount FROM orders_202401 UNION ALL SELECT user_id, amount FROM orders_202402 -- ... 后续月份 ) t GROUP BY user_id;这里用UNION ALL的原因很明确:订单本身是流水数据,几乎不存在完全重复的记录,而且外层GROUP BY本来就会去重统计,没必要让UNION多做一次。如果用UNION,不仅白白增加排序开销,还可能因为去重把两条金额相同但属于不同订单的记录合并成一条,导致SUM结果错误。
类似的场景还包括:日志表合并、操作流水合并、多租户数据汇总。规律是:只要源数据是“事实表”或“流水表”,并且最终要做GROUP BY聚合,就优先UNION ALL。
4.2 用UNION的经典场景
UNION的典型场景是“集合归并”,即你需要得到一个不重复的集合。比如统计平台上有过消费行为的所有用户(包括下单用户和退款用户),业务上同一个用户只算一个,这时UNION就是最直接的写法。
另一个典型场景是配置表或字典表的合并去重。比如系统里有默认配置和用户自定义配置两张表,合并时相同配置项以用户自定义优先,且只保留一份,用UNION可以先把两份配置合到一起再去重,后续再接一层窗口函数做优先级的筛选。
权限系统里也有类似需求:一个用户可能拥有多个角色赋予的权限,合并所有角色的权限集合时,重复权限没有任何意义,UNION在这里能保证权限结果的唯一性。
4.3 一个完整的案例:用UNION ALL加条件聚合替代UNION
有些场景下,UNION ALL配合条件聚合能完全替代UNION,而且性能更好。举个例子:统计每个用户的累计下单金额和累计退款金额,输出成一行两列。
常规UNION写法:
SELECT user_id, SUM(amount) AS order_amount, 0 AS refund_amount FROM orders GROUP BY user_id UNION SELECT user_id, 0 AS order_amount, SUM(amount) AS refund_amount FROM refunds GROUP BY user_id;这样写不仅要用UNION去重,还要处理同一用户在两段结果中都出现的合并问题,逻辑很绕。更优雅的做法是UNION ALL加上外层聚合:
SELECT user_id, SUM(order_amount) AS order_amount, SUM(refund_amount) AS refund_amount FROM ( SELECT user_id, SUM(amount) AS order_amount, 0 AS refund_amount FROM orders GROUP BY user_id UNION ALL SELECT user_id, 0 AS order_amount, SUM(amount) AS refund_amount FROM refunds GROUP BY user_id ) t GROUP BY user_id;这个写法既避免了对中间结果做无谓的去重,又把同一用户的订单金额和退款金额自然合并到一行。数据量大时,这种改写带来的性能提升可能非常明显。我在实际业务中多次用这种方式优化报表SQL,效果都很理想。
4.4 判断原则速查
| 场景特征 | 推荐操作符 | 原因 |
|---|---|---|
| 流水/事实表合并,后续做GROUP BY | UNION ALL | 聚合会去重,UNION白白增加开销 |
| 需要唯一集合(用户、标签、权限) | UNION | 业务上天然要求去重 |
| 数据量大且确定无重复 | UNION ALL | 省去排序/哈希开销 |
| 数据量小且不确定是否有重复 | UNION | 结果更安全,开销可忽略 |
| 合并后要ORDER BY/LIMIT | 看具体语法 | 注意各数据库差异 |
5. 常见问题与排查技巧实录
5.1 误区一:以为UNION一定比UNION ALL慢
这里要澄清一个容易被误解的点:UNION多做了去重,通常确实更慢,但不绝对。当UNION的去重能显著压缩结果集时,后续的网络传输、客户端处理、外层JOIN的成本都会下降。数据量大的时候,可能UNION总耗时反而更低。
我遇到过一个真实案例:两张千万级大表做关联拼接,由于业务特性,两表各自产生的中间结果有近一半重复。最初开发为了省事全部用UNION ALL,结果下游统计任务每个小时超时。改成UNION后,虽然查询阶段多花了两秒做去重,但下游处理少了近一半的数据量,整个链路从超时变成了三分钟完成。这就是典型的以局部时间换整体时间。
5.2 误区二:ORDER BY用错导致全表排序
把ORDER BY放在UNION的最后一个SELECT里,是新手最容易犯的错。很多人以为这样写没问题:
SELECT name FROM student_a UNION ALL SELECT name FROM student_b ORDER BY name;这条SQL在MySQL里语法上合法,ORDER BY也确实会作用于整个UNION结果,但如果写成这样:
SELECT name FROM student_a ORDER BY name UNION ALL SELECT name FROM student_b;那就会直接报语法错误。更隐蔽的错误是,如果两个子查询都有排序需求,比如分别取每个表的前5名再合并,不能直接在各自SELECT里写ORDER BY和LIMIT,而要用子查询包一层。我见过有人这样写:
SELECT name FROM student_a ORDER BY score DESC LIMIT 5 UNION ALL SELECT name FROM student_b ORDER BY score DESC LIMIT 5;这在大多数数据库里是语法错误,正确写法是:
SELECT name FROM ( SELECT name, score FROM student_a ORDER BY score DESC LIMIT 5 ) a UNION ALL SELECT name FROM ( SELECT name, score FROM student_b ORDER BY score DESC LIMIT 5 ) b;5.3 误区三:列数据类型隐式转换引发性能问题
UNION要求各SELECT子句的列数一致,且对应列的数据类型需要兼容。MySQL在这方面比较宽松,会自动做隐式转换,但隐式转换可能导致索引失效,也会让UNION在去重时因为类型不统一而无法高效比较。
举个例子:
SELECT id FROM table_a UNION SELECT id_str FROM table_b;如果id是BIGINT类型,id_str是VARCHAR类型,MySQL在比较时会把字符串转成数字再比较。这个转换不仅影响UNION去重的效率,还可能因为字符串无法被完整转换为数字而使结果异常。我在做一次数据迁移时就踩过这个坑,某列在旧表是VARCHAR存储数字,新表是BIGINT,UNION时MySQL自动将全表VARCHAR转成数值,结果某条脏数据(字符串中带字母)被转换成了0,去重判断直接错误,导致少统计了一条数据。
正确做法是在写UNION之前,手动用CAST统一列的类型:
SELECT id FROM table_a UNION SELECT CAST(id_str AS UNSIGNED) FROM table_b;5.4 误区四:去重带来的业务逻辑错误
UNION的去重是按“所有select列完全相同”来判断的。如果你只SELECT了部分列,那么两条在该列上相同但其他列不同的记录,也会被UNION当成重复而丢掉。这就可能造成业务数据丢失。
最典型的场景是:你要合并两个订单来源的数据,但只取了订单号,结果同一订单号在两个表中出现的两条不同明细记录,被UNION去重成一条。如果外层还要关联其他表,就丢数据了。准确的做法是:确认去重维度是什么,SELECT的列里必须包含完整业务唯一键,或者明确用UNION ALL再配合其他去重逻辑。
5.5 线上问题排查速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| UNION查询特别慢 | 临时表落盘、排序溢出 | 检查Created_tmp_disk_tables、执行计划 |
| 结果集重复 | 用了UNION ALL | 确认业务上是否需要去重 |
| 结果集缺失 | 用了UNION | 确认去重维度是否覆盖完整业务键 |
| 报错ORA-12704 / 类型不匹配 | 列类型不兼容 | 用CAST统一类型 |
| ORDER BY报错 | 位置错误/数据库语法差异 | 放到整个UNION末尾或包子查询 |
| 内存暴涨 | 哈希去重内存不足 | 考虑用UNION ALL + 外层GROUP BY替代 |
6. 扩展:UNION家族其他成员与FastAPI里的Union
6.1 不只是UNION:INTERSECT、EXCEPT/MINUS
除了UNION和UNION ALL,标准的集合操作符还有INTERSECT(交集)和EXCEPT(差集,Oracle里叫MINUS)。它们同样是纵向比较两个查询结果集,但含义完全不同。
INTERSECT返回两个结果集中都出现的记录,EXCEPT返回在第一个结果集中出现但在第二个结果集中不出现的记录。这两个操作符用得比UNION少,但在做数据对比、标签圈选、差异核对时非常有用。
我举一个实际场景:对比系统A和系统B中的用户ID列表,找出只在系统A中有、系统B中没有的用户(可能需要做数据迁移或对账)。如果不用EXCEPT,你得用LEFT JOIN加IS NULL的写法,逻辑绕,性能也不一定好。用EXCEPT一行搞定:
SELECT user_id FROM system_a_users EXCEPT SELECT user_id FROM system_b_users;这个操作符在MySQL里到8.0.31版本才支持,之前的版本只能用NOT IN或LEFT JOIN实现。SQL Server和PostgreSQL天生支持EXCEPT,Oracle对应的是MINUS。别记混了,面试的时候这是一个很容易被深挖的扩展考点——但笔试或面试中,只要理解了集合语义,写出来只是时间问题。
6.2 FastAPI里的Union和SQL的UNION有什么关系
搜“union”相关热词时,有一个词条是“fastapi union作用”,很多人会在学习时把这两者搞混。这里顺便说清楚:FastAPI里的Union是Python类型注解里的typing.Union,用来表示一个参数可以接受多种类型,和SQL的UNION没有任何关系。
举一个FastAPI的例子:
from typing import Union from pydantic import BaseModel class Item(BaseModel): id: int name: str tag: Union[str, None] = None这里的Union[str, None]表示tag字段可以是字符串,也可以是None,对应到Python 3.10+的写法就是str | None。FastAPI会基于这个类型信息生成接口文档,并在请求校验时允许该字段为空。这就是“fastapi union作用”的答案:它只是类型系统的一个工具,与SQL里合并结果集的UNION完全是两回事。
顺带一提,有些同学会在FastAPI接口里拼接多个数据库查询结果,这时候才会用到SQL里的UNION。两者的语境差异,只要看上下文就能轻松分辨:如果出现在SELECT语句或者表连接中,就是SQL UNION;如果出现在函数参数或模型字段类型定义中,就是Python Union。
6.3 UNION ALL + GROUP BY:一种被忽视的优化套路
最后再分享一个我经常用的优化技巧:当UNION的去重逻辑与后续的聚合逻辑重复时,把去重责任“上移”到外层GROUP BY,内层统一用UNION ALL。这个方法在多个子查询共享大量公共数据时尤其有效。
举个例子,你要统计过去30天内登录过、下过单、发过帖的用户去重总数。常规UNION写法:
SELECT COUNT(DISTINCT user_id) AS user_cnt FROM ( SELECT user_id FROM login_log WHERE login_time >= NOW() - INTERVAL 30 DAY UNION SELECT user_id FROM orders WHERE order_time >= NOW() - INTERVAL 30 DAY UNION SELECT user_id FROM posts WHERE post_time >= NOW() - INTERVAL 30 DAY ) t;这里UNION做了三路的去重合并,外层又做了一次COUNT DISTINCT,去重操作重复了两遍。优化后:
SELECT COUNT(DISTINCT user_id) AS user_cnt FROM ( SELECT user_id FROM login_log WHERE login_time >= NOW() - INTERVAL 30 DAY UNION ALL SELECT user_id FROM orders WHERE order_time >= NOW() - INTERVAL 30 DAY UNION ALL SELECT user_id FROM posts WHERE post_time >= NOW() - INTERVAL 30 DAY ) t;内层UNION ALL只是简单拼接,所有去重都由外层的COUNT DISTINCT一次性完成。这个写法在数据量中等(百万级以内)时,效果立竿见影,既避免了UNION多次排序/哈希,又不会丢掉业务上“同一用户只算一个”的语义。要注意的是,如果后续不是COUNT DISTINCT而是其他聚合方式,先去重还是后去重需要结合具体业务需求判断,不能一味套用。
踩过这么多次坑之后,我的习惯是:写任何UNION/UNION ALL之前,先问自己三个问题——业务上要不要去重?后续有没有其他操作会顺带去重?当前这版SQL的数据量级下,多一次排序/哈希是否可接受?把这三个问题过一遍,选型基本不会出错。这比背诵任何八股都管用。