背八股的时候谁都能说一句“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在底层走了一条完全不同的执行路径。我拿一个最朴素的例子说明。假设有一张订单表和一个退款表你要统计所有涉及的用户IDSELECT 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 UniqueUNION 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 OracleSORT UNIQUE与HASH UNIQUEOracle的执行计划中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与SQLiteSQL 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 ALL4.1 用UNION ALL的经典场景最常见的UNION ALL场景是数据合并特别是合并后还要做聚合统计的情况。比如你有12个月的分表table_202401、table_202402……要统计全年的用户订单金额直接用UNION ALL把每个月的数据串起来再包一层SUMSELECT 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 BYUNION 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存储数字新表是BIGINTUNION时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里的Union6.1 不只是UNIONINTERSECT、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天生支持EXCEPTOracle对应的是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的数据量级下多一次排序/哈希是否可接受把这三个问题过一遍选型基本不会出错。这比背诵任何八股都管用。