1. 面试必答之外:UNION与UNION ALL的差异到底藏在哪里
很多数据库方向的开发者在面试前都会背一套标准答案:UNION会去重,UNION ALL不去重,所以UNION ALL性能更好。这句话确实不算错,但它只是结论的最外层。真正到了生产环境里,因为写错UNION而导致的线上问题,我见过太多次,几乎都出在这个结论没有覆盖到的细节上。与其反复背诵结论,不如先把UNION和UNION ALL在数据库里到底做了什么动作拆开看清楚。
UNION和UNION ALL都属于集合运算中的“垂直合并”操作。SQL里最常见的JOIN,是把一张表的列拼接到另一张表右侧,结果集会变宽,也就是水平方向的合并;而UNION系关键字做的是另一件事——把两个SELECT查询各自返回的行,从上到下拼接成一张结果集,结果集会变高,属于垂直方向的合并。举个例子:表A查出两条记录,表B查出三条记录,用UNION合并后,最多可能得到五条记录,但前提是这三条记录里没有和那两条重复的;而用UNION ALL合并后,必然是五条记录,哪怕有重复也照单全收。这就是两者在结果集形态层面的分歧。
再把语法层面展开一点,UNION和UNION ALL的写法几乎一模一样,要求也很一致:两个SELECT子句返回的列数必须相同,对应列的数据类型必须兼容,最终结果集的列名以第一个SELECT子句为准。但真正的分水岭在于,UNION在执行时会自动对合并后的结果做一次去重,相当于把UNION ALL的结果再套一层SELECT DISTINCT;而UNION ALL不执行任何去重判断,直接把两个子查询的行按顺序拼接返回。这个“差一个DISTINCT”听起来不起眼,实际却会带来一连串你意想不到的连锁反应,接下来我用一次真实压测来说明。
1.1 垂直拼接:很多人对UNION的第一步理解就是错的
我在做技术评审时,经常发现一些工作了好几年的开发,对UNION的认知还停留在“它能把两个查询结果合起来”。这不够,因为一旦遇到需要合并两个结构完全不同的查询,他们就会把JOIN和UNION混着用。JOIN的逻辑单位是“行与行的横向配对”,UNION的逻辑单位是“结果集与结果集的纵向拼接”。前者通常要指定连接条件,后者则完全不需要条件,只要求列数匹配。
理解这个区别,最好的方式是把两个查询结果表想象成货架上的两层货架板。JOIN是把两层的商品在同一个货架上按位置对齐摆放,UNION则直接把两个货架板上下叠起来,上面的商品有没有和下面的重复,它不会主动去管,除非你明确要求它管。这个类比在你后面写分页、排序、统计SQL时会特别有用。
1.2 标准答案背后的三个隐藏副作用
回到那个标准答案,UNION会去重,UNION ALL不去重。这句话背后至少藏着三个平时不一定会被提到、但关键时刻会决定结果的副作用。
第一个副作用是性能:去重必然涉及行与行之间的比较,数据库要么把结果集排序后扫描相邻行去重,要么通过哈希表记录哪些行已经出现过。无论哪种方式,都需要额外的内存和计算资源。第二个副作用是顺序:为了去重,很多数据库会使用排序操作,这会导致UNION的结果顺序看起来像是“被有序化”了,但这个顺序是服务于去重的,不是任何业务希望的排序。第三个副作用是临时空间:当结果集大过内存阈值时,去重过程会把中间结果写入临时表或临时文件,磁盘I/O会让延时急剧上升。
把这三个副作用放在一起,你会发现UNION和UNION ALL的差异,不仅仅是数学意义上的“是否包含重复行”,而是一整套执行计划的差异。真正遇到大数据量时,这种差异会从理论变成灾难。所以我一直觉得,要想不被“背八股”工程化,关键不是记住结论,而是理解去重这个动作的底层行为。
2. 一个真实压测案例:为什么UNION在数据量面前会“失态”
为了把性能差异讲得不那么抽象,我模拟了一张订单流水表,一共存储了200万行数据。这张表有三个关键字段:order_id主键、amount金额、status状态。我用它构造了一个典型场景:一个查询想取“已支付状态”的订单,另一个查询想取“金额大于500”的订单,这两个条件在数据上有大量重叠,重叠量大概是三十万行。然后分别用UNION和UNION ALL写出来,观察执行计划和执行时间。
-- 方式一:使用UNION,自动去重 SELECT order_id, amount, status FROM order_records WHERE status = 'PAID' UNION SELECT order_id, amount, status FROM order_records WHERE amount > 500; -- 方式二:使用UNION ALL,不去重 SELECT order_id, amount, status FROM order_records WHERE status = 'PAID' UNION ALL SELECT order_id, amount, status FROM order_records WHERE amount > 500;两条SQL唯一的不同就是关键字。如果你只把“去重”当成一个简单的数学操作,你会觉得UNION也就是多判断几次相等,至于慢那么多吗?实际测试结果却非常打脸,在同样环境、同样数据量下,UNION ALL执行耗时大约180毫秒,UNION执行耗时超过3秒。十几倍的差距,全部出在去重这个动作上。
2.1 执行计划里的关键算子:SORT还是HASH
我用EXPLAIN打开了两种写法的执行计划,差异非常直观。UNION ALL的执行计划非常简洁:两个子查询的扫描算子树,合并到一个Append或类似节点下,然后直接返回结果,中间没有任何去重算子。UNION的执行计划里多了一个去重节点,在MySQL里叫Using temporary,在Oracle里叫SORT UNIQUE,在PostgreSQL里叫Unique或HashAggregate。就是这个节点,把所有返回行都装进一个临时结构进行重复判断。
去重节点的实现方式,决定了数据量变大以后谁的胜出更明显。UNION核心要处理的问题等价于“找出一个集合中所有不重复的行”。如果数据库采用排序去重,它会把结果集按照所有列的顺序排一遍,然后只保留和前一列不同的行,排序本身需要O(n log n)的比较,数据量翻倍,耗时远不止翻倍。如果采用哈希去重,虽然比较的效率高一些,但哈希表占用的内存很大,一旦超过内存阈值,数据库就必须把哈希表溢写回磁盘,瞬间变成磁盘I/O密集操作。
2.2 临时表与磁盘I/O:性能断崖的根源
在MySQL里,UNION的去重过程会创建临时表。临时表默认优先使用内存,但如果结果集大小超过了tmp_table_size或max_heap_table_size的配置值,MySQL会把临时表从内存转为磁盘临时表。磁盘临时表的读写速度比内存慢一个数量级,这是UNION在百万级数据量下性能断崖的根源之一。Oracle的情况类似,SORT UNIQUE产生的排序数据需要写入临时表空间,如果临时表空间不足,整个SQL会直接报错。
很多人在优化UNION时,总想着去加索引,却忽略了一个关键事实:UNION的去重发生在两个子查询的结果集已经合并之后,索引只能优化子查询里的过滤条件,对去重这个环节几乎帮不上忙。所以当你的SQL写成了UNION,性能瓶颈往往就锁定在排序或哈希上,而不是扫描上。这也是为什么我倾向于在数据量大的场景中,优先用UNION ALL把数据取回来,再在应用层或外层SQL里做去重。
2.3 UNION结果顺序的“薛定谔”效应
除了性能,UNION还藏着一个经常让开发在测试环境乐呵、生产环境崩溃的顺序问题。由于UNION的去重经常依赖排序实现,它的结果顺序在大多数情况下表现为“所有列从小到大有序”。但这种“有序”并不是你指定的,也不是业务需要的,它纯粹是去重的副产品。更麻烦的是,这个排序的稳定性取决于数据库版本、优化器选择和表数据分布,同一套SQL在测试库跑出来的顺序,在生产库不一定复现。
UNION ALL则完全不存在这个困惑,它的输出规则明确:左侧子查询的所有行先返回,接着返回右侧子查询的所有行。只要你不加ORDER BY,这个顺序就是稳定的。所以凡是涉及合并后还要继续处理顺序的业务,尤其分页和榜单类需求,直接用UNION ALL,然后再统一排序,要比依赖UNION的“默认顺序”可靠得多。
3. 合并结果集时的边界规则:类型转换、NULL与列名
性能差异只是UNION和UNION ALL最显眼的一面,真正容易让线上数据出错的反而是那些看似细枝末节的合并规则。因为UNION家族要求两个结果集“兼容”,但兼容不等于完全相同,数据库会在这套兼容机制里做出很多你未必预期到的决定。
3.1 列数与列顺序:第一个隐藏红线
无论UNION还是UNION ALL,都要求两个SELECT子句返回的列数必须一致。这里的“一致”是严格一致,多一点少一点都不行,MySQL会直接报The used SELECT statements have a different number of columns,Oracle会报ORA-01789。很多人以为只要保证列数相同就万事大吉,却忽略了列顺序也必须对应。
列顺序的问题特别隐蔽。举个例子,第一个子查询返回的是user_id, user_name,第二个子查询写成user_name, user_id。两个查询列数相同,类型也恰好都是数值和字符串,UNION能正常执行,但结果集第一列对应的是user_id(来自第一个查询)和user_name(来自第二个查询),语义完全错位,数据汇总之后会变成一团乱麻。好在这种错误通常可以在输出里侥幸看出来,但如果两张表恰好都是字符串ID和字符串姓名的组合,那就真的事故现场了。
3.2 隐式类型转换:数据库悄悄替你决定类型
列数对齐之后,数据库还要处理类型。UNION要求对应列的数据类型兼容,而不是完全一致。兼容意味着数据库有时候会做隐式类型转换。在Oracle里,数值和字符串拼接时通常有一个明确的优先级顺序;在MySQL里,不同排序规则和字符集下,转换规则也会不同。
我踩过一个非常典型的坑:两个子查询分别从两张历史表中提取设备编码,一张表是varchar类型存储,另一张表因为建表时字段设计不当,存储的是int类型。数据本身长得一样,都是'1024'这种格式。用UNION合并后,本应保留两条记录,结果只返回了一条。原因就是数据库在比较时把varchar的'1024'转成了数字1024,和int列的值相等,于是判定为重复行,静默吞掉了其中一条。这种“假重复”在逻辑检查时很难发现,只有对最终行数做精确校验时才会暴露。排查这一类问题的通用方法,是提前把两边对应列CAST成同样的类型,比如CAST(device_code AS CHAR),让比较在可控前提下进行。
3.3 NULL参与去重的特殊语义
再来说NULL。SQL里的NULL不代表一个具体值,它代表“未知”。在常规的等值比较里,NULL = NULL的结果是UNKNOWN,不是TRUE。但去重这个操作有自己的一套逻辑:数据库在做DISTINCT或UNIQUE判断时,会把多个NULL看作是同一个值。换句话说,两行数据其他列完全相同,其中一列都是NULL,那么在UNION去重时,这两行会被判定为重复,只保留一行。
这个行为在业务上有时合理,有时就是灾难。比如合并两个客户来源渠道时,source_code这一列如果允许NULL,那么所有source_code为NULL的客户都会被合并成一行,即使他们在另一张表里是不同的潜在客户记录。处理方式是:如果NULL对你的业务有实际含义,进入UNION之前先用COALESCE把NULL替换成一个占位值,比如COALESCE(source_code, 'UNKNOWN'),再去合并。
3.4 列名以第一个子查询为准:容易被忽视的使用约束
还有一个经常被误解的规则:UNION结果集列名取第一个SELECT子句的列名,第二个及以后子查询的列别名都会失效。比如第一个查询SELECT name FROM a,第二个查询SELECT nickname AS name FROM b,最终结果集列名是name,不会出现nickname这一列。更麻烦的是,如果你在最终结果集外层写ORDER BY nickname,数据库会直接报错或提示找不到列。
解决方式很简单:要么在第一个子查询里给列起别名,要么在UNION外层套一层SELECT,明确定义最终要输出的列名。我这里更推荐后者,尤其在结果集还要继续参与JOIN或者被下游应用解析时,显式的列名定义能避免一堆隐式行为带来的混乱。
4. 跨数据库实践:Oracle、MySQL与PostgreSQL的UNION细节差异
UNION和UNION ALL是SQL标准的一部分,但每个数据库在实现时都会加入自己的性格。平时开发可能只接触一种数据库,切换数据库后一些经验会失效,这一点在UNION上体现得尤其明显。我把几个常见数据库实测后的差异点整理了一下。
4.1 Oracle的SORT UNIQUE与临时表空间
Oracle在相当长的一代版本里,对UNION的实现方式是SORT UNIQUE,也就是先对结果集排序,再去重。这也解释了为什么很多从Oracle出来的人会觉得UNION的结果“默认是排好序的”。但正如前面所说,排序是为了去重,不是业务语义。如果我们依赖Oracle的这个默认行为,把UNION结果当成order by后的结果来消费,一旦优化器改为哈希去重或数据库版本升级,结果顺序就可能变化。
Oracle的另一个独特问题是临时表空间。SORT UNIQUE在数据量大的时候,会把排序数据写入临时表空间。如果临时表空间是个小文件或者剩余空间不足,SQL会直接报ORA-01652错误。所以我建议在Oracle里跑大数据量UNION前,先确认临时表空间的配额,避免半夜调度任务因为一个UNION告警。
4.2 MySQL的临时表策略与子查询排序陷阱
MySQL对UNION的处理有一个非常典型的特征:结果集去重时,会尽可能用内存临时表,一旦超过tmp_table_size和max_heap_table_size的较小值,就会把临时表转成磁盘临时表。MySQL的这两个参数默认值普遍不大,Linux默认服务器上通常只有16MB到64MB,所以稍微大一点的UNION查询都容易把临时表溢出到磁盘,性能问题随之而来。
MySQL还有个特别容易踩的语法坑:如果要在UNION的单个子查询里使用ORDER BY并配合LIMIT,必须用括号把子查询包裹起来,否则语法会报错,或者在某些版本里ORDER BY被直接忽略。正确写法是这样的:
(SELECT order_id, amount FROM order_records WHERE status = 'PAID' ORDER BY create_time DESC LIMIT 10) UNION ALL (SELECT order_id, amount FROM order_records WHERE amount > 500 ORDER BY create_time DESC LIMIT 10);如果不加括号,MySQL会认为ORDER BY作用于整个UNION结果,最终和你的预期完全错位。这个坑在Oracle里没那么明显,因为Oracle对UNION子查询的排序容忍度更高,但在MySQL里几乎每次都能踩到,必须养成加括号的习惯。
4.3 PostgreSQL的Unique节点与哈希聚合
PostgreSQL在计划阶段对UNION的处理同样值得一说。它通常会在Append节点之上叠加一个Unique节点,用于去重。新版PostgreSQL还支持HashAggregate方式的去重,这种方式不需要全局排序,只需要维护一个哈希表,内存命中时性能非常可观。不过一旦数据量超过work_mem,PostgreSQL同样会把哈希表溢写到磁盘,只是它的溢出机制比MySQL更温和一些。
PostgreSQL还有一个很有特色的特性:它允许在UNION之后使用DISTINCT ON这类语法,虽然使用场景比较小众,但如果你在写复杂报表,这会是一个非常有价值的工具。DISTINCT ON允许你指定“按某一列去重,并保留这一组中的特定行”,比如按用户ID去重,同时保留每个用户最新一条订单。它和UNION的去重语义不同,但在某些场景里能替代UNION加窗口函数的组合,值得把它纳入工具箱。
5. 名称相同含义完全不同:数据库UNION与FastAPI的Union
网络热词里出现了一个和UNION相关的条目——FastAPI的Union作用。很多人刚开始接触FastAPI,看到Union这个单词,以为它和数据库的UNION有什么血缘关系,实际上这是两种完全不同领域里的术语,只是因为英文单词相同,造成了认知上的混淆。
5.1 FastAPI里的Union其实是Python类型联合
FastAPI里的Union来自Python的typing模块,它用来描述一个变量可以拥有多种类型中的任意一种。比如一个接口的某个可选参数,既允许传入字符串又允许不传,就可以用Union[str, None]来标注。更常见的写法是直接用Optional,它本质上是Union[X, None]的别名。
from typing import Union from pydantic import BaseModel class SearchRequest(BaseModel): keyword: Union[str, None] = None page: int = 1 page_size: int = 20这段代码里的Union[str, None]表示keyword字段可以是一个字符串,也可以是None。它不会在执行时把多个结果合并,也不会对数据做任何行集层面的运算。它的作用是让类型检查器、IDE和FastAPI的接口文档生成器知道这个字段接受哪些类型,语义上属于静态类型标注的范畴。
5.2 为什么这个概念混淆会造成实际困扰
我在审查代码时遇到过同事把FastAPI的Union和数据库的UNION混为一谈,试图在一个接口里返回多个模型并用Union来“拼接”结果,结果导致响应结构完全不符合预期,而且错误暴露得很晚,等到前端同事对接时才发现问题。这个场景特别典型地说明了一个问题:在跨技术栈协作时,同一个术语在不同技术体系里可能完全是两个含义,如果不搞清楚术语所在的技术域,很容易写出逻辑错误但语法上却“看起来没错”的代码。
区分这两个概念,其实不需要多高深的方法论,只要记住三条:其一,数据库UNION是SQL执行语句,作用于数据行,产生结果集合并;其二,Python的Union是类型标注,作用于类型检查阶段,不产生任何运行时数据变换;其三,前者写在SQL查询中,后者写在Python类型声明中。三者具备其一,就不会再把它们搞混了。面试里如果被问到FastAPI的Union,直接回答类型联合即可,不用硬往数据库方向扯。
5.3 FastAPI里Union真正有用的两个场景
FastAPI中用Union最多的两个场景,一个是请求体字段的可选参数,比如上面那个例子中的keyword;另一个是接口响应的多态类型,比如一个接口可能返回成功对象,也可能返回错误对象,此时可以用:
from typing import Union from fastapi import FastAPI from pydantic import BaseModel class SuccessResp(BaseModel): code: int = 0 data: dict class ErrorResp(BaseModel): code: int = 1 message: str app = FastAPI() @app.get("/search", response_model=Union[SuccessResp, ErrorResp]) def search(): return SuccessResp(data={"result": "ok"})这样写之后,FastAPI生成的OpenAPI文档里会把该接口的响应类型标明为SuccessResp或ErrorResp的联合,客户端和前端可以根据不同的返回结构做类型判断。理解了这个用法,以后看到Union就不会再和SQL UNION联想到一起了。
6. 写进代码里的取舍:我的UNION选型规则与翻车记忆
讲了那么多原理和机制,最后落到工程里最实际的问题:同一个需求,什么时候用UNION,什么时候用UNION ALL?在不清楚数据重叠程度时,是否只能靠猜?我根据自己的经验,总结了一套可以直接上手的决策规则,也算是对这么多年踩坑经历的一次系统归纳。
6.1 明确不会重复时,放心用UNION ALL
最理想的情况是从业务约束上就能确认两个查询结果不会重复。比如两个查询来自不同的表,其中一张表有唯一索引保证业务主键唯一,另一张表的数据范围在业务上不可能与第一张表重合;又或者两张表来自不同业务域的独立数据源,没有任何重叠的可能性。在这些前提下,UNION ALL是唯一正确的选择,既能保证结果行数精确,又能避免去重带来的额外性能开销。
我做过一个典型案例:一个报表系统需要合并“线上订单表”和“线下手工录入订单表”,两张表虽然结构大部分一致,但业务上线下录入时不会插入线上渠道的订单号,而且线上订单表对订单号有唯一索引。明确这个约束后,我把原先的UNION改成了UNION ALL,报表查询耗时从2秒降到300毫秒,数据没有任何偏差。有时候性能优化并不需要高级技巧,只是把不必要的操作移除掉。
6.2 无法确认重叠且数据量不大时,用UNION更安全
如果两个查询的来源比较复杂,比如多表关联后的结果集,无法凭直觉判断是否会有重复,而且数据量相对可控,那我倾向于用UNION。原因很简单:UNION可以保证结果集的行数不超过任一子查询的行数之和中“不重复部分”的最大值,至少不会因为重复行导致下游统计翻倍。在数据量不大的场景下,去重的性能开销可以接受,选择正确性优先是合理的。
6.3 数据量大且必须去重时,把去重从UNION里拆出来
最尴尬的场景是:数据量大,业务又必须去重。这个时候如果直接用UNION,除了要忍受排序或哈希去重的高昂代价,还要面临临时表空间爆掉的风险。我的处理思路是:先分析重复的来源,看看能否在子查询层面就排除掉重复;如果无法在源头排除,那就把UNION ALL的结果先落进一张临时表,在临时表上建立适当的索引,然后再做去重或聚合。
比如这种写法:
CREATE TEMPORARY TABLE tmp_union_result AS SELECT user_id, user_name FROM vip_user UNION ALL SELECT user_id, user_name FROM normal_user; -- 在临时表上以user_id建索引,再做按业务键去重的查询 SELECT user_id, MAX(user_name) AS user_name FROM tmp_union_result GROUP BY user_id;这样做的好处是,把UNION的自动去重换成了明确的GROUP BY或窗口函数,执行路径更可控,也便于在临时表上建立索引加速。如果你用的是MySQL,临时表的引擎、字符集也要注意;如果用的是PostgreSQL,还可以考虑把两个查询改成分区表的一部分,用分区裁剪来避免全量合并。
6.4 我的避坑检查清单
以下几条是我每次写UNION相关SQL时都要在心里过一遍的检查点,分享出来供你参考:
- 两个子查询的列数是否一致,列顺序是否对应,尤其注意别把同类型但不同含义的列放错位置。
- 对应列的类型是否需要显式CAST,避免数据库隐式转换导致假重复。
- 业务上是否允许重复行不清除,允许就上UNION ALL,不允许再考虑UNION。
- 结果集是否需要稳定的顺序,如果需要,别依赖UNION的默认行为,自己写ORDER BY。
- 子查询里使用ORDER BY/LIMIT时,是否已经用括号包裹整个子查询。
- 结果集列名以哪个子查询为准,下游代码取字段名时必须与之对应。
- 大数据量场景是否已经评估过临时表空间或内存参数,考虑用临时表替代。
6.5 最后再分享一个实战细节
在我个人写过的大量报表SQL里,UNION ALL的使用频次远高于UNION。原因并不是我总在允许重复的场景里工作,而是在多数业务报表需求里,“哪些数据可能重复”这件事完全可以提前分析清楚。一旦分析清楚,要么用UNION ALL直接输出,要么在子查询里用DISTINCT或GROUP BY把数据源清理干净。这样一来,合并层就不需要背着去重重担,执行计划和性能都更加可控。八股文告诉我们的结论是静态的,但数据库的执行行为是动态的,能理解这层动态关系,比记住任何一个标准答案都重要。