说实话UNION 和 UNION ALL 这两个关键字几乎每个写 SQL 的人都认识但它们之间的差别绝不只是“去重”和“不去重”这么简单。我在日常做报表汇总、跨表对账、数据清洗的时候这两个操作符几乎是天天见但真正能把它们的执行原理、性能差异、隐藏的坑讲清楚的人并不多。很多人线上慢 SQL 一查发现罪魁祸首就是那个随手写的 UNION或者反过来业务上明明需要去重却为了图快用了 UNION ALL结果金额翻倍、统计失真。这篇文章我就从原理到实战把这两个合并操作彻底拆开揉碎讲一遍。我会先用最简单的话说明它们是什么、能解决什么问题再讲背后的执行逻辑然后给出一组实测数据和优化方案最后把我在项目里踩过的一些坑整理成清单。不管你是刚入门的新手还是经常和数据打交道的开发、分析师看完应该都能少走不少弯路。1. UNION 和 UNION ALL 到底在做什么1.1 核心差异一个去重一个不去重先从一个最直观的例子说起。假设你有一张 2023 年订单表里面有订单号 1、2、3另一张是 2024 年订单表里面有订单号 3、4、5。现在你想把两年的订单合并到一起看于是分别写下两条 SQLSELECT order_no FROM orders_2023 UNION SELECT order_no FROM orders_2024;结果会返回 1、2、3、4、5 共 5 行因为订单号 3 在两年的表里都出现了UNION 会自动帮你把重复的那一条去掉。而如果把 UNION 换成 UNION ALLSELECT order_no FROM orders_2023 UNION ALL SELECT order_no FROM orders_2024;结果会返回 1、2、3、3、4、5 共 6 行订单号 3 出现了两次因为 UNION ALL 什么都不管就是把两块数据上下拼在一起。一句话总结UNION 会对最终结果集做一次去重UNION ALL 原样拼接。这里说的“重复”指的是整行数据在每个字段上的取值完全一致只要有一个字段不同就不会被当作重复行去掉。这个区别看起来很简单但很多人不知道的是为了做“去重”这个动作数据库在背后要付出相当大的代价。我见过太多性能问题根源就是在一张几千万行的表上毫不犹豫地用了 UNION结果排序缓存被打爆临时文件写满磁盘一个本来 1 秒能出的查询硬生生拖到几十秒。1.2 为什么“去重”会让性能差别这么大要理解 UNIUN 为什么慢得先明白数据库是怎么实现“去重”的。标准 SQL 里对结果集做去重最常见的手段有两种一种是排序去重一种是哈希去重。排序去重的思路大致是这样的先把合并后的所有数据放进一个临时结果集然后按照所有列的值排个序。排完序之后相同的数据自然就挤在一起了数据库只需要从头到尾扫一遍把相邻的重复行丢掉就行。这样做的好处是逻辑简单、实现稳定坏处是排序本身非常昂贵。这里我就不堆时间复杂度公式了大家只需要记住一句直觉当数据量从一万涨到一千万排序的开销不是涨一千倍而是涨得更多因为数据太多时内存放不下就得往磁盘写临时文件来回读写磁盘比内存操作慢几个数量级。哈希去重好一些在内存充足的情况下它只需要把每行数据算出一个哈希值放进哈希表里遇到重复的哈希值再做精确比较。理想情况下是线性增长的开销比排序快很多。但哈希表同样吃内存数据量大到一定程度依然逃不过落盘的命运。你可以用数据库的执行计划来验证这一点。在 MySQL 里对一个大查询做 EXPLAIN如果看到了 Using temporary 或者 Using filesort基本可以断定这里发生了隐式的排序去重。在 SQL Server 的执行计划里UNION 对应的则是 Distinct Sort 这个操作符名字起得非常直白先合并再去重排序。而 UNION ALL 在执行计划里通常只有一个 Concatenation 节点中文叫串联就是把两个数据流直接接起来没有任何额外处理。这就是为什么 UNION ALL 通常比 UNION 快好几倍。它快不是因为它用了什么黑魔法而是它真的什么额外的事都没做。1.3 从业务角度判断该用哪一个既然 UNION ALL 快这么多那是不是可以无脑用 UNION ALL千万别。我见过不少新手这么干然后被测试数据狠狠教育了一顿。选哪个操作符第一优先级不应该是性能而是业务语义。我自己的判断流程很简单先问自己三个问题第一这两个结果集在业务上有没有可能重叠比如一个是 2023 年的订单一个是 2024 年的订单两者用时间字段天然隔离主键不会重合那基本可以放心用 UNION ALL。第二如果存在重复行重复计入会不会影响最终的业务结论举个例子查询“每个销售顾问的成单量”同一个订单如果既出现在 A 表又出现在 B 表UNION ALL 会把单子数算两遍这种就必须用 UNION或者先做一步去重。第三为了去重付出的性能代价是否能换来正确性如果答案是肯定的那就老实使用 UNION别为了省那几秒钟把数据搞错。还有一种比较折中的情况两个结果集可能会重叠但重叠率很低。比如只有 1% 的数据重复你却用 UNION 对全量数据做一次大排序这其实是一种浪费。后面我会专门讲怎么处理这种场景。2. 合并操作的语法细节与易错点2.1 列数、列顺序与列名的对齐规则UNION 和 UNION ALL 的基本语法很简单就是把多个 SELECT 语句用关键字连接起来。但有一条规定是硬性的每个 SELECT 返回的列数必须完全一致。比如第一个 SELECT 查了三列第二个 SELECT 也得查三列否则数据库直接报错这没得商量。这里有一个特别容易犯的错列数一样就够了不够还要注意列的顺序。UNION 是按位置对齐的也就是说第二个 SELECT 的第一列会跟第一个 SELECT 的第一列对应第二列跟第二列对应跟列名完全无关。举个例子第一个 SELECT 返回的是 order_id, user_id, amount第二个 SELECT 返回的是 user_id, amount, order_id虽然列名和数据类型看起来都能对得上但如果你直接 UNION数据库会把第二个 SELECT 的 user_id 放到第一个 SELECT 的 order_id 这一列去比较和拼接。这种情况下轻则隐式转换造成性能问题重则结果完全错乱业务上出现“订单号变成了用户ID”的诡异数据。至于合并后的结果集列名规则也很明确以第一个 SELECT 里的列名或别名为准。也就是说第二个 SELECT 哪怕是写出了别名也不会出现在最终结果里。你想给结果集的列起一个像样的名字直接改第一个 SELECT 的别名就行。2.2 数据类型不匹配时数据库会怎么做列数对齐了下一个问题就是数据类型。UNION 要求对应列的数据类型是兼容的。什么叫兼容数字可以和数字合并字符可以和字符合并日期可以跟日期合并这些都不难理解。难的是那些看似能合并实际上有隐性转换的情况。比如第一个 SELECT 查的是整数类型的 order_id第二个 SELECT 查的是字符类型的 order_no两边长度、格式可能都不太一样。数据库通常会根据所有分支的列类型推断出一个兼容的类型然后对其中一边做隐式转换。在 MySQL 里合并后的列类型会优先参考第一个 SELECT 的类型但同时也会尽量选一个能容纳所有分支数据的类型。一个常见的结果是INT 和 VARCHAR 合并之后列类型变成了 VARCHAR原本是数字的 order_id 也变成了字符串。这种隐式转换带来的问题最典型的就是索引失效。假如你在 WHERE 条件或 JOIN 条件里使用这个合并后的列去跟数字比较数据库可能无法直接使用列上的索引被迫做全表扫描性能一下就崩了。所以我的习惯是只要发现两个分支的列类型不完全一致就主动用 CAST 函数把它们统一成同一个类型哪怕多写几行代码也值。另外还要提醒一下 NULL 的问题。UNION 去重时会把多个 NULL 当作同一个值处理这个语义和 DISTINCT 保持一致。举个例子两张表里各有一条记录的备注字段是 NULL用 UNION 合并后会只剩一条而用 UNION ALL 会保留两条。很多人写 SQL 时没注意这个细节结果发现行数对不上账排查半天才意识到是 NULL 被合并了。2.3 ORDER BY 和 LIMIT 的位置有讲究UNION 和 ORDER BY、LIMIT 的组合是另一个高频翻车点。不少新手会想当然地认为每个 SELECT 后面都可以跟自己的 ORDER BY 和 LIMIT但实际上整个 UNION 语句只允许在最后面统一放一个 ORDER BY这个 ORDER BY 是对合并后的最终结果集排序的不能只对其中某一个分支排序。比如下面这条 SQL 在大多数数据库里并不会像你想的那样工作SELECT order_id FROM orders_2023 ORDER BY created_at DESC UNION ALL SELECT order_id FROM orders_2024 ORDER BY created_at DESC;这句话的本意可能是“分别取两年订单各自按时间倒序”但数据库并不会按你的意图去理解。它不仅可能报语法错误就算能执行这个 ORDER BY 也会被忽略或者被当成最终排序的一部分结果完全不是你以为的那样。正确的做法是如果确实需要“先取每个分支的前 N 条再合并”就得用子查询把每个分支包起来。以 MySQL 为例可以写成这样SELECT * FROM ( SELECT order_id, created_at FROM orders_2023 ORDER BY created_at DESC LIMIT 10 ) t1 UNION ALL SELECT * FROM ( SELECT order_id, created_at FROM orders_2024 ORDER BY created_at DESC LIMIT 10 ) t2;这里有个细节需要特别注意在 MySQL 8.0.19 之前把带 LIMIT 的 SELECT 加上括号直接做 UNION语法上会报错必须像上面这样包一层子查询。8.0.19 之后才放宽了这个限制。如果是 SQL Server可以直接在每个分支的 SELECT 里用 TOP写成 SELECT TOP 10 order_id FROM orders_2023 UNION ALL SELECT TOP 10 order_id FROM orders_2024这个语法更直观。还有一个点如果 UNION 语句末尾带了 ORDER BY这个 ORDER BY 会作用于整个合并结果集。假如你要对合并后的结果做分页比如只取前 20 条直接在语句末尾加 LIMIT 20 是对的。但要注意这和“每个表各取 20 条”是完全不同的两件事业务方提需求时如果你不追问清楚很容易在分页这里栽跟头。这个坑我后面会专门展开讲。3. 一组实测数据与执行计划分析3.1 测试环境与对比 SQL光说不练假把式我特意在一台普通配置的测试机上跑了一组对比。环境大概是这样的MySQL 8.0机器是普通的 SSD内存 16G两个表的数据量分别是 800 万行和 600 万行两表之间有接近 200 万行的数据是重复的。表结构很简单核心字段就几个order_id、user_id、order_amount、created_at。对比 SQL 就两句话唯一区别是 UNION 和 UNION ALL-- 去重合并 SELECT order_id, user_id, order_amount, created_at FROM orders_2023 UNION SELECT order_id, user_id, order_amount, created_at FROM orders_archive; -- 不去重合并 SELECT order_id, user_id, order_amount, created_at FROM orders_2023 UNION ALL SELECT order_id, user_id, order_amount, created_at FROM orders_archive;两个 SQL 返回的数据量差距很明显。UNION 返回约 1200 万行UNION ALL 返回约 1400 万行多出来的这 200 万行就是两表重复的那部分。但更让我在意的是耗时UNION 用了 9.8 秒UNION ALL 只用了 1.7 秒差了将近 6 倍。这里我要强调一下具体数字会因为机器配置、数据分布、索引情况而有差异别把这个 6 倍当成放之四海而皆准的结论。但趋势是确定的去重操作带来的额外开销会随着数据量的增长被放大得越来越明显。在数据量只有几百条的临时表上你可能完全感受不到差别一旦上了生产环境的大表这个差距就是线上事故和正常请求之间的区别。3.2 执行计划里到底多了什么如果你在 MySQL 里对上面的 UNION 查询做 EXPLAIN会看到最终结果来自一个名为union1,2的派生表Extra 列里大概率会写着 Using temporary 和 Using filesort。这两行英文翻译过来就是数据库创建了一张临时表并且对这张临时表做了排序。排序的目的自然是为了把相邻的重复行找出来并去掉。我把关键部分贴出来给大家一个直观感受id select_type table type rows Extra 1 PRIMARY union1,2 ALL NULL Using temporary; Using filesort 2 UNION orders_2023 ALL ... ... 3 UNION orders_archive ALL ... ...也就是说整个执行过程大概是先分别把 orders_2023 和 orders_archive 的数据读出来拼成一个结果集然后再对结果集进行统一的排序和去重。如果排序数据量超过了 MySQL 的 sort_buffer_sizeMySQL 会把中间结果分批写到磁盘上的临时文件里这也是为什么数据量一大之后UNION 的耗时会出现断崖式上升。对比之下UNION ALL 的执行计划里不会有 Using temporary 和 Using filesort只有简单的表扫描和拼接。SQL Server 里也会看到同样的逻辑UNION 对应的执行计划中有一个 Distinct Sort 操作符UNION ALL 则只有 Concatenation。如果你想亲眼确认可以试试在 SQL Server 里执行 SET STATISTICS TIME ON然后分别跑两条同样的 SQL看消息栏里输出的 CPU 时间和逻辑读次数差异会非常直白。3.3 UNION ALL 替代 UNION 的优化思路既然 UNION 的去重开销这么大一个自然的想法就是能不能先用 UNION ALL再在业务层或者外层查询里做去重这个思路在很多场景下是可行的而且效果相当好。最典型的替代方案是 UNION ALL 加 GROUP BY。比如上面的例子我们只需要按 order_id 去重其他字段随便取一个就可以这样写SELECT order_id, MAX(user_id) AS user_id, MAX(order_amount) AS order_amount FROM ( SELECT order_id, user_id, order_amount FROM orders_2023 UNION ALL SELECT order_id, user_id, order_amount FROM orders_archive ) t GROUP BY order_id;这个写法的好处是去重逻辑从“对所有列整体排序去重”变成了“按键分组聚合”。如果 order_id 上本身有索引分组效率会比全量排序高不少更重要的是它给了你主动权你可以选择哪些列参与去重哪些列用聚合函数取一个代表值这在业务上往往更贴合需求。但我要提醒一句这个方法不一定总是更快。如果两个表的数据本身几乎不重复UNION 的排序开销其实也不会太大反而 GROUP BY 还会多做一次聚合。所以在做这种优化前最好先确认一下数据的重复率。你可以用一条 COUNT 对比 SQL 快速估算重复量我一般会这样查SELECT COUNT(*) AS total_cnt, COUNT(DISTINCT order_id) AS distinct_cnt FROM ( SELECT order_id FROM orders_2023 UNION ALL SELECT order_id FROM orders_archive ) t;如果 total_cnt 和 distinct_cnt 差距很小说明重复数据很少UNION 的浪费主要在于“为了去重而排全量序”。这时候用 UNION ALL 加 GROUP BY 或者干脆直接用 UNION差别可能没那么大优化空间也有限。反过来如果重复率很高GROUP BY 方案的收益就会非常可观。还有一种情况是业务上明确要求保留“重复中的某一条”比如两个来源的会员数据有重叠需要按手机号合并保留注册时间最早的记录。这种场景用 UNION 根本做不了因为 UNION 只能整行去重无法告诉你“重复时该信谁”。正确做法是用窗口函数后面我会给具体示例。4. 高频故障与排查清单4.1 结果顺序不稳定怎么办运行同一条 SQL两次返回的结果顺序不一样这是 UNION 使用中非常常见的问题。很多人第一次遇到的时候会怀疑是不是数据库出 bug 了其实不是。原因很简单UNION 的去重逻辑依赖排序而排序的结果受数据分布、内存大小、并发因素影响除非你显式指定了 ORDER BY否则数据库返回什么样的顺序都是合理范围。尤其当排序数据量超过了内存阈值需要走磁盘临时文件时顺序就更不稳定了。UNION ALL 虽然没有排序步骤但它的输出顺序只代表“这次执行时数据库拼接的先后顺序”同样不是数据库给你的契约。所以记住一个铁律任何依赖查询结果顺序的业务逻辑都是错误的。你需要排序就在语句最后显式加 ORDER BY别指望隐式顺序。这条原则适用于所有 SQL 查询UNION 场景只是更容易暴露这个问题而已。4.2 怎样快速定位“多出来的重复行”用了 UNION ALL 之后发现结果集比预期多了不少行这是另一种高频问题。要快速定位是不是有重复最简单的方法是对比两种写法的行数-- 先看不去重的行数 SELECT COUNT(*) FROM (SELECT ... UNION ALL SELECT ...) t; -- 再看去重的行数 SELECT COUNT(*) FROM (SELECT ... UNION SELECT ...) t;如果两个 COUNT 的值一样说明两个结果集之间没有整行重复的数据你可以放心用 UNION ALL。如果差值很大说明确实存在大量的整行重复。这时候再进一步查一查到底是哪些字段组合重复了可以用 GROUP BY 加 HAVINGSELECT order_id, COUNT(*) FROM ( SELECT order_id, user_id, order_amount, created_at FROM orders_2023 UNION ALL SELECT order_id, user_id, order_amount, created_at FROM orders_archive ) t GROUP BY order_id HAVING COUNT(*) 1;如果查出来重复行很多先别急着去重而是要搞清楚业务上这些重复是否应该存在。比如两个表存的是不同年份的订单order_id 理论上不会重复如果查出来重复那很可能是数据本身有问题比如历史数据被重复导入了。只凭 UNION 去重等于把这个数据质量问题掩盖了。这一点我觉得比性能还重要因为数据库不会帮你判断数据对错它只会按你写的逻辑执行。4.3 分页取数时最容易写错的场景分页和 UNION 的组合堪称经典翻车现场。我遇到过好几次这样的需求要从 A 表和 B 表各取最新的 10 条记录合并后返回给前端。新手最常见的写法是这样SELECT ... FROM table_a UNION ALL SELECT ... FROM table_b ORDER BY created_at DESC LIMIT 10;这个写法的意思是先把两表数据全部合并再按时间倒序排序最后只取前 10 条。如果 A 表最新数据的时间整体比 B 表新很可能这 10 条全部来自 A 表B 表一条都没有。这和“各取 10 条再合并”的需求差了十万八千里。正确的写法在前面已经提过需要把“取前 10 条”的动作放在每个分支内部完成再对合并后的结果排序SELECT * FROM ( SELECT order_id, created_at FROM table_a ORDER BY created_at DESC LIMIT 10 ) t1 UNION ALL SELECT * FROM ( SELECT order_id, created_at FROM table_b ORDER BY created_at DESC LIMIT 10 ) t2 ORDER BY created_at DESC;在 SQL Server 里对应写法是每个分支用 TOP语法更紧凑。在 Oracle 里则是 FETCH FIRST 10 ROWS ONLY。这个问题的本质是先聚合再筛选还是先筛选再聚合。很多人一上来就写 UNION完全没想过筛选顺序会彻底改变结果。建议大家在写这类 SQL 前先把需求里的“每张表”和“合计”这两个关键词抓出来分清到底是全局 TOP N 还是分组 TOP N。4.4 不同数据库的行为差异备忘UNION 和 UNION ALL 是标准 SQL 语法主流数据库都支持但在细节行为上还是有不少差异。我把一些我踩过或看过的差异整理成了一张表供参考数据库去重实现常见注意点MySQL排序/哈希去重大结果集会出现 Using temporary; Using filesort8.0.19 之前括号 LIMIT 语法受限结果列类型按兼容性推断SQL ServerDistinct Sort 操作符内存不足会写 tempdb分支内可用 TOP类型不匹配可能报错或隐式转换PostgreSQL排序去重为主对类型要求更严格对应列的排序规则不一致时容易报错UNION 结果不支持直接当子查询的某些旧版本写法Oracle同样会做排序去重UNION ALL 只拼接类型不匹配时可能报 ORA-01790NULL 排序规则与国际排序设置有关这里提醒一点如果你负责的系统需要同时兼容多种数据库SQL 里尽量不要依赖某个数据库特有的语法。比如分支内的 TOP 就是 SQL Server 专用写法MySQL 用的是 LIMITOracle 最早用 ROWNUM后来才有 FETCH FIRST。为了可移植性最好统一用子查询加标准 SQL 的方式实现。5. 实战经验我在项目中用它踩过的坑5.1 先问业务再谈性能有一次我做数据对账需要从两个系统里各导出一份订单明细然后找出两边的差异。当时图省事直接在 SQL 里写了 UNION让数据库帮我去重。结果跑出来之后两边的总金额对不上排查了整整一个下午最后才发现问题出在一个非常隐蔽的地方两个系统的订单号规则不一样A 系统的订单号是纯数字B 系统的订单号带字母前缀。UNION 时数据库做了隐式类型转换把 B 系统的订单号截断或者转成了奇怪的东西导致大量本来不同的记录被当成了重复项直接被去重去掉了。从那以后我养成了一个习惯写 UNION 之前一定要先弄清楚字段在两个系统里的真实含义和格式别假设同名同类型就是同一个东西。这听起来像是常识但在实际项目里大家为了赶进度真的很少有人会停下来确认这些细节。等测试数据一切正常上线后才发现生产环境里全是坑。业务语义永远排在性能前面。一个查询慢一点还有优化的空间一个查询结果错了轻则返工重则直接影响业务决策这个代价不是几秒钟能补回来的。5.2 只按部分列去重时UNION 解决不了业务上经常会有这种需求合并两个来源的会员名单同一个手机号只保留一条记录优先保留注册时间最新的那条。这种“按部分列去重”的逻辑UNION 是做不到的因为 UNION 的“重复”是指整行所有列都相同而你希望的是只看手机号这一个维度。这种场景下我通常会用 UNION ALL 把数据先合并再借助窗口函数打标然后过滤。以 SQL Server 为例大致是这样的写法WITH combined AS ( SELECT member_id, mobile, register_time, source FROM member_2023 UNION ALL SELECT member_id, mobile, register_time, source FROM member_archive ), ranked AS ( SELECT member_id, mobile, register_time, source, ROW_NUMBER() OVER (PARTITION BY mobile ORDER BY register_time DESC) AS rn FROM combined ) SELECT member_id, mobile, register_time, source FROM ranked WHERE rn 1;这里用 ROW_NUMBER 按照手机号分组按注册时间倒序编号然后取每组第一行。好处是逻辑透明你可以清楚地告诉业务方“同一个手机号我保留的是注册时间最新的记录”。如果以后规则变了想保留来源优先级更高的记录只需要改一下 ORDER BY 里面的字段非常灵活。MySQL 8.0 及之后也支持窗口函数可以直接照搬。MySQL 5.7 这种老版本没有窗口函数那就需要用 GROUP BY 加子查询来模拟或者用变量实现。实现方式上会复杂一些但核心思路是一样的先把数据完整合并再按业务键取一条。5.3 NULL、空串和排序规则隐藏的边界最后说一个容易被忽视的细节NULL、空字符串和排序规则对去重结果的影响。前面提过UNION 去重时会把多个 NULL 当作同一个值。这在大多数情况下是符合预期的但如果你有两张表每张表里都有一些记录的某个字段是 NULL而这个字段恰好参与了整行比较那么这些记录可能会被错误地合并成一条。这种问题在数据质量比较差的系统里特别常见因为你根本不知道哪条记录是“更完整”的那条也确实没有任何规则可以判断。空字符串和 NULL 又是两种完全不同的值。在 MySQL 里空字符串和 NULL 不相等所以去重时它们会保留两条。但要注意这里的行为和排序规则有关。比如在 utf8_general_ci 这种大小写不敏感的排序规则下字符串 abc 和 ABC 会被认为相等UNION 会把它们合并成一条如果换成 utf8_bin 这种二进制排序规则那就完全不会合并。这意味着什么意味着同样的数据换一个数据库实例或者改一下表级别的 COLLATEUNION 返回的行数都可能发生变化。我做数据迁移的时候遇到过这种“隐形差异”源库跑 UNION 得到 100 行导入目标库之后跑同样的 SQL 变成 105 行一查才发现是两边的排序规则不一样导致去重判断标准变了。所以我最后的建议是凡是涉及 UNION 去重的场景上线前一定要有“行数对比”这一步。不是跑一遍看结果对不对就行而是专门跑不带排序、不带去重的 COUNT和带 UNION 去重后的 COUNT两个数对不上时一定要追查差异行到底长什么样。这个习惯帮我擋掉了好几个潜在的线上事故也推荐大家把它写进自己的测试用例里。
