☰
SQL性能优化:UNION ALL实战与踩坑指南
2026/9/28 13:07:44 网站建设 项目流程

写这篇东西的起因,是上周帮同事排查一条慢 SQL。那条查询逻辑不复杂,就是把两张表的数据合并起来做统计,结果跑了二十多秒,DBA 群里直接被点名。我拉出执行计划一看,问题清清楚楚:他写的是 UNION,不是 UNION ALL。两张表的数据本来就没有交集,去重这个动作纯粹是白干,还额外触发了一次排序。改成 UNION ALL 之后,查询时间直接掉到两秒以内。

类似的情况我在不同团队里见过太多次了。UNION ALL 是 SQL 里最常见、也最容易被忽视性能的关键字之一,很多人对它的理解停留在"跟 UNION 差不多,就是不去重",但实际用起来,场景远不止合并两张表那么简单。这篇文章我就把自己这些年实际用 UNION ALL 的场景、踩过的坑、以及怎么用它解决真实业务问题,一次性整理明白。

先说清楚适合谁看:刚入门 SQL 的开发、每天跟报表打交道的数据分析师、以及被慢查询折磨的 DBA 和数据开发,都能从这里拿到点东西。老手可以直接跳到第 3 节以后,那些执行计划和踩坑记录是常规文档里不会写的东西。

1. 先搞清楚 UNION ALL 到底是个什么东西

1.1 它和 JOIN 是完全不同的两种操作

很多刚写 SQL 的朋友会把 UNION ALL 和 JOIN 弄混,因为看起来都像"把两个结果集拼在一起"。但这两者的逻辑完全不同,理解错了,后面的查询怎么写都是歪的。

JOIN 是横向合并:它把两张表中满足 ON 条件的行拼接成更宽的行。比如订单表和用户表 JOIN,得到的是"订单信息 + 用户信息"这种列变多的结果,行数则会因为一对多关系而放大。UNION ALL 是纵向合并:它把两个查询的结果按行堆叠在一起,列数保持不变,行数增加。比如一月份订单表和二月份订单表 UNION ALL,就是把一月的每一行跟二月的每一行首尾相接。

这个区别在我给新人培训的时候用过一句话:JOIN 在横向拓宽数据,UNION ALL 在纵向堆叠数据。如果你发现你要合并的两个查询列数是不同的,那大概率你不该用 UNION ALL,而是要去检查业务逻辑是不是理解错了。

从数据库引擎的角度看,UNION ALL 的实现其实非常"笨":第一个查询出结果,放到内存或者临时结构里,然后第二个查询的结果直接往后面追加,全程没有排序、没有去重、没有额外的比较操作。这也是它性能好的根本原因——引擎不需要为它做任何额外的事情。

1.2 UNION 和 UNION ALL 的那一字之差,值多少性能

这是被讨论最多的话题,但我还是想用自己的实测数据说一下。UNION 底层做的事情,是在 UNION ALL 的基础上,外加一次去重。去重在数据库里可不是免费的,它通常意味着对结果集做一次排序来去重,或者构建一张哈希表记录已经出现过的行。不管是哪种,都意味着额外的 CPU 计算,以及可能的内存或临时表磁盘空间消耗。结果集越大,这个代价越明显。

我去年在一张 5000 万行级别的日志表上做过对比测试:同一个查询,UNION 版本跑了 8.3 秒,UNION ALL 版本跑了 1.1 秒。在结果集没有重复这个前提成立时,用 UNION 的每一秒都是在为去重买单。

对比维度UNIONUNION ALL
是否去重是否
底层动作堆叠 + 排序/哈希去重仅堆叠
性能慢(数据量大时差距明显)快
适合场景业务上必须去重业务上允许重复或本无重复

关键点在于:去重本身是一个业务需求,不是 SQL 自带的保险丝。如果你不能确定结果集里没有重复,你的第一反应应该是检查业务,而不是用 UNION 来兜底。两个查询的中间结果集如果有重叠但你有意保留全部记录,那 UNION 反而成了错误答案——它会把本该出现的重复数据吃掉。

提示:判断该用 UNION 还是 UNION ALL,先问自己一个问题——"我需要两个结果集重叠部分的重复行吗?"需要,就 UNION ALL;明确不需要去重,且重叠可能性确实存在,才用 UNION。

2. 我实际工作中最常用的五个场景

2.1 场景一:结构相同的表,按月拆分后的合并查询

这是 UNION ALL 最经典、使用频次最高的场景。很多业务系统在表设计初期就会做分表,最常见的是按月分表:order_202401、order_202402……一直排下去。分表解决了单表数据量过大的写入和索引压力,但带来了一个必然的问题:跨月查询怎么办?

这时候 UNION ALL 几乎是标准的解法:

SELECT id, user_id, amount, status, create_time FROM order_202401 UNION ALL SELECT id, user_id, amount, status, create_time FROM order_202402 UNION ALL SELECT id, user_id, amount, status, create_time FROM order_202403

这里必须用 UNION ALL 而不是 UNION,原因有两层。第一,订单数据本身有唯一主键,物理上就不可能跨表重复,UNION 的去重是无用功。第二,分表的目的是为了性能,结果在合并环节因为一个 UNION 把去重和排序的代价加回来,等于把分表省下的资源又还回去了。

我曾经接手过一个分表多达 36 个月的数据平台,原来的代码里每个季度汇总都写 UNION,跑一次要十分钟。后来把所有 UNION 改成 UNION ALL,再配合后面要讲的"把拼接结果当子查询再聚合"的写法,直接把耗时压到两分半。跨库的类似场景也经常出现:订单库和订单归档库是两个独立的库,或者数据落在不同实例上,你要同时查线上和归档数据,UNION ALL 同样适用——只要列结构一致,数据库根本不关心数据来自哪个库。

2.2 场景二:统计报表里的多段数据汇总

报表需求里经常出现这样的情况:同一个指标,要分多个口径、多个时间段分别计算,最后合并展示在一张表里。比如经营日报里,要同时看到"今日订单数""本月累计订单数""本年度累计订单数",这三个数字来自同一张表、但是 WHERE 条件完全不同。

新手遇到这种需求通常会写三个查询,在应用层把它们拼起来。其实用一个 UNION ALL 就能搞定:

SELECT '今日' AS period, COUNT(*) AS order_cnt, SUM(amount) AS total_amount FROM orders WHERE create_time >= CURDATE() UNION ALL SELECT '本月', COUNT(*), SUM(amount) FROM orders WHERE create_time >= DATE_FORMAT(CURDATE(), '%Y-%m-01') UNION ALL SELECT '年度', COUNT(*), SUM(amount) FROM orders WHERE create_time >= DATE_FORMAT(CURDATE(), '%Y-01-01');

看到第一个 SELECT 里那个 '今日' AS period 了吗?这是这类报表查询的核心技巧:用一列常量作为分组标识,让最终结果集的每一行都能说清楚自己属于哪个口径。应用层拿到这个结果后,遍历一次就能直接渲染到报表上,不需要发三次查询,也不需要自己维护三个变量。

这种做法还有一个隐藏好处:三个聚合本身都只扫一遍各自所需的数据,而 UNION ALL 只是把三行结果堆到一起,代价几乎可以忽略。如果改成在应用层循环三次查询,每次还要建立连接、解析 SQL、拉取结果,开销反而更大。

2.3 场景三:冷热数据分层后的联合访问

数据量到了一定规模后,很多团队会把数据分成热数据和冷数据。热数据放在高性能存储或主表里,冷数据移入归档表、或者换成成本更低的存储引擎。但业务查询经常是"既要热也要冷",比如用户想看自己的历史订单,而这些订单一半在热表、一半在归档表。

这种情况下 UNION ALL 就是连接冷热两个世界的桥梁:

SELECT id, user_id, amount, status, create_time FROM orders_active WHERE user_id = 12345 UNION ALL SELECT id, user_id, amount, status, create_time FROM orders_archive WHERE user_id = 12345 ORDER BY create_time DESC;

注意这里的 WHERE 条件分别在两个子查询里各自执行。数据库对 UNION ALL 的优化策略是"先各自查询,再拼接结果",所以每个子查询都能独立利用自己表上的索引。执行顺序上讲,两个子查询过滤完之后,剩下的行数通常很少,UNION ALL 拼接的代价也就很小。

这个场景里容易被忽略的是归档策略的边界条件。我见过因为归档任务跑失败、导致一部分数据同时存在于热表和归档表的情况,用户在前台看到订单重复了。这时候你需要的不是把 UNION ALL 改成 UNION(那会掩盖数据问题),而是去修归档任务、修数据同步逻辑。UNION ALL 的一个隐性价值就在这:它会把数据问题原原本本地暴露出来,而不是替你悄悄抹掉。

2.4 场景四:数据校验和差异比对

做数据仓库或者数据迁移的同学对这类场景应该很有共鸣:你要验证两张表的数据是否一致,或者找出差异数据。UNION ALL 配合 GROUP BY 是业界很经典的一套"求差异"组合拳。

核心思路是:给两张表的每一行打上"来源表标记",然后按所有字段分组,看看哪些组里只有一边的标记:

SELECT id, name, amount, MAX(src_flag) AS flags, COUNT(*) cnt FROM ( SELECT id, name, amount, 'a' AS src_flag FROM table_a UNION ALL SELECT id, name, amount, 'b' AS src_flag FROM table_b ) t GROUP BY id, name, amount HAVING COUNT(*) = 1;

如果某一行只出现在 table_a 里,那么它的标记只有 'a',COUNT() 就是 1,说明这是 A 表独有的数据;B 表独有的也是同理。如果两边都有且内容完全一致,COUNT() 就是 2。用这个查询一次就能把"只有 A 有""只有 B 有""两边都有"全部分出来。

这里为什么必须用 UNION ALL?想想就明白了:如果某一行在两张表里恰好完全一样,用 UNION 去重后它只剩一行,你根本分不清它到底是一边有还是两边都有。UNION ALL 保留所有行,分组数量才能真实反映数据分布。用 UNION 做数据校验,校验出来的结果本身就是错的。

我做过一次千万级数据的迁移核对,就是靠这种写法在几分钟内定位出了几百条差异数据。当然,如果字段特别多,GROUP BY 写起来会很长,这是这个方案的痛点。实际执行时也可以先用一些校验函数把多列压缩成指纹列再比对,效率会更高。

2.5 场景五:补全缺失日期,让报表连续

这个场景估计很多做报表的人踩过坑。表里只有有数据的日子才有记录,但报表要求每天一行,没数据的日期要显示 0 或者空。直接 GROUP BY 出来的结果总有空洞。

标准解法是生成一个完整的日期序列,然后 LEFT JOIN 实际数据。这个"日期序列"从哪来?如果没有数字辅助表,最灵活的方式就是把 UNION ALL 和递归查询结合起来,各种数据库都有对应的写法,比如 MySQL 8.0 里这样生成最近 30 天的日期序列:

WITH RECURSIVE seq AS ( SELECT 1 AS n UNION ALL SELECT n + 1 FROM seq WHERE n < 30 ) SELECT DATE_SUB(CURDATE(), INTERVAL n - 1 DAY) AS day FROM seq;

注意这里的递归 CTE 内部就是靠 UNION ALL 在迭代:每次从上一个 n 加 1,直到不满足 WHERE 条件为止。可以说 UNION ALL 是很多"生成序列"类操作的地基。用这招生成日期序列后,再和统计数据 LEFT JOIN,报表上每一天就都有行了。

3. 实操中的关键细节:从列匹配到执行计划

3.1 列数、列顺序和数据类型匹配规则

UNION ALL 看起来简单,但它对参与拼接的各个 SELECT 是有严格要求的,这也是初学者最容易报错的地方。

最基础的规则:每个 SELECT 返回的列数必须一致。第一个 SELECT 决定了结果集的列结构,后面的 SELECT 列数不同就直接报错。以 MySQL 为例,报错信息一般是 "The used SELECT statements have a different number of columns"。

第二个规则是列顺序必须对应。UNION ALL 按位置匹配列,它只认"第几列",不认列名。也就是说,第一个 SELECT 的第三列和第二个 SELECT 的第三列会被拼在同一列上,不管它们叫什么名字。很多人在这里栽过跟头:两个查询的列名不同,但内容顺序其实对应,结果没问题;反过来,如果顺序写反了,数据就错位了,而 SQL 不会给你任何警告。

解决办法很简单:每个 SELECT 里都显式写列名,并且保持相同的排列顺序。不要用 SELECT *,除非你能确保两张表的列定义完全一致。用 SELECT * 在开发环境跑没问题,一旦源表做了加列操作,两边列数不一致,线上直接挂。这个我见过太多次了。

第三个规则是数据类型要兼容。不同数据库的容忍度不一样。MySQL 里如果用 UNION ALL 连接一个整型列和一个字符串列,会发生隐式类型转换,把整型转成字符串或者反过来,具体看语境。这种转换意味着额外的计算,而且可能带来精度问题,比如浮点数和 decimal 混拼时出现尾差。经验之谈:拼接前先统一类型,该 CAST 就 CAST。

3.2 ORDER BY 和 LIMIT 的正确打开方式

这里有个高频坑,几乎每个写 UNION ALL 的人都会踩一次:想在每个子查询里排序,直接写在子查询的 ORDER BY,结果往往被引擎忽略。

为什么?因为在 UNION ALL 的语义里,各个子查询是"集合"的一部分,集合本身没有顺序概念。数据库优化器看到子查询里的 ORDER BY 时,如果这个排序对外层结果没有影响,就可能直接把它优化掉。比如:

-- 这种写法,子查询里的 ORDER BY 基本没用 SELECT id, amount FROM order_202401 ORDER BY amount DESC UNION ALL SELECT id, amount FROM order_202402;

你本来想让第一个子查询按金额降序输出,但引擎大概率会忽略这个排序。什么时候子查询里的 ORDER BY 会生效?通常是配合 LIMIT 的时候,因为 LIMIT 必须先确定取哪几行,排序才有意义:

-- 每个表取金额最大的前 10 条,再合并 SELECT id, amount FROM order_202401 ORDER BY amount DESC LIMIT 10 UNION ALL SELECT id, amount FROM order_202402 ORDER BY amount DESC LIMIT 10 ORDER BY amount DESC;

这种"先各自取 Top N 再合并"的写法在分页、排行榜场景里非常实用,它能让每个子查询各自走索引,避免把所有数据都捞出来再排序。如果要对整个合并结果排序,ORDER BY 必须放在最后一个 SELECT 之后。要注意的是,整体排序时引用的列名,最好取自第一个 SELECT 的列名,因为结果集列名默认由第一个 SELECT 决定。

LIMIT 同理。结果集层面的 LIMIT 写在最外层:

SELECT ... FROM table_a UNION ALL SELECT ... FROM table_b ORDER BY create_time DESC LIMIT 20;

这个查询是先把两边数据拼起来再排序再取前 20 行,和"各自 LIMIT 20 再拼"是完全不同的语义,用之前想清楚你要哪种。

3.3 索引和 UNION ALL 的执行计划怎么看

UNION ALL 本身不排序不去重,所以它对索引没有特殊要求。它性能好不好,取决于每个子查询能不能用好各自的索引。换句话说,问题不在 UNION ALL,而在子查询的 WHERE 条件和 SELECT 列上。

看执行计划的方式,各种数据库大同小异。MySQL 里用 EXPLAIN,你会发现 UNION ALL 的结果集里会出现多行记录,每一行对应一个子查询的执行路径。我常用的排查套路是:先单独跑每个子查询,确认各自执行计划里 type 是 range 或 ref(说明用上了索引),再拼起来跑整体。如果整体变慢,基本可以断定是某个子查询全表扫了。

这里有一个优化原则:UNION ALL 的子查询里,一定要把过滤条件下推到每个子查询内部,不要在外层包一个大 WHERE 再过滤。因为 UNION ALL 是先拼后过滤还是先过滤后拼,看着结果一样,但性能天差地别:

-- 反面写法:先拼再过滤 SELECT * FROM ( SELECT id, amount FROM table_a UNION ALL SELECT id, amount FROM table_b ) t WHERE t.amount > 1000; -- 正面写法:先各自过滤再拼 SELECT id, amount FROM table_a WHERE amount > 1000 UNION ALL SELECT id, amount FROM table_b WHERE amount > 1000;

第一种写法里,table_a 和 table_b 的全部数据都要参与拼接,占用的临时空间和后续过滤的代价都大。第二种写法让每个子查询在索引层面就把不满足条件的行干掉,拼出来的东西本来就小。这是 UNION ALL 性能优化的第一原则:过滤条件下推,能早就早。

4. UNION ALL 高发坑位排查实录

4.1 结果集莫名其妙的重复

使用 UNION ALL 后出现重复数据,严格说这不是 bug,因为 UNION ALL 本来就不去重。但很多人会把它当 bug 报上来。排查时先问三个问题:

  • 业务上是否可能产生重复?比如同一用户下了两笔一模一样的金额的订单,两行数据除了主键不同,其他列完全一样。这不能靠去重解决,应该从业务层面理解。
  • 是不是 JOIN 导致的结果放大?如果子查询里带了 JOIN,一对多关系会让行数翻倍,那"重复"其实是 JOIN 的锅,不是 UNION ALL 的问题。
  • 是不是两个子查询的边界条件重叠了?比如一个查 create_time >= '2024-01-01',另一个查 create_time <= '2024-01-31',中间有重叠的查询范围,数据自然会被取两遍。这是最常见的"假重复"来源,检查 WHERE 条件的边界有没有错位。

我的建议是:先在脑子里给每个子查询的结果集画一条分界线,确认它们互不重叠,再放心用 UNION ALL。真出现重复了,先用 SELECT DISTINCT 或 GROUP BY 临时压一下,然后赶紧查根因,而不是换 UNION 掩盖。

4.2 隐式转换拖垮性能

这是一个比较隐蔽的性能杀手。前面说过,UNION ALL 对数据类型要求是"兼容",但"兼容"不等于"不转换"。比如一张表的 id 是 BIGINT,另一张表的 id 是 VARCHAR,拼接时数据库会对其中一方做隐式转换。

麻烦在于,如果转换发生在被索引的列上,索引就废了。好比一个字符串类型的 id 列,因为和整型列 UNION ALL,被整体转成数字后再比较,原来建立在字符串上的索引根本用不上,只能全表扫。数据量一大,这个坑能把查询拖到分钟级。

排查方法:看执行计划里有没有出现全表扫,同时留意字段类型定义。规范的做法是在建表或 ETL 阶段就统一字段类型,SQL 侧做好 CAST。这里有个取舍:CAST 本身也有计算成本,但在列数少、数据量可控的情况下,明确 CAST 比让引擎猜要可控得多。

4.3 子查询里 ORDER BY 失效

这个前面已经提到,再补充一个实际案例。有次同事写了一个分页接口的 SQL,子查询里带了 ORDER BY 和 LIMIT,看起来一切都对,接口数据却总是乱的。原因就是他把 ORDER BY 放在了 UNION ALL 的中间,而这个排序针对的"子查询内部的顺序"对外层毫无意义,被优化器忽略了。

针对这类问题的排查经验:如果发现排序没生效,先把 UNION ALL 拆开,看每个子查询单独执行时顺序是否正常。子查询里要排序,记住"ORDER BY 要和 LIMIT 绑定",没有 LIMIT 的 ORDER BY 在 UNION ALL 里就是废操作。如果想对外层结果排序,就把 ORDER BY 放到整个语句的最后。

4.4 和 NULL 纠缠不清的坑

UNION ALL 在去重这个问题上对 NULL 的态度很有意思:UNION 去重时,会认为两个 NULL 是相等的(在排序时它们归为一组),所以多行的 NULL 会被去成一行。但 UNION ALL 不管这些,NULL 行有多少保留多少。如果某个报表依赖 NULL 去重,你要搞清楚自己用的到底是不是 UNION。

另一个 NULL 相关的坑是在数据校验场景:两张表的同一列,一张是 NULL,一张是空字符串 '',用 GROUP BY 比对时它们会被当成不同的值,于是校验报告一堆"差异"。实际业务上可能觉得二者等价,也可能确实有区别,这需要和业务确认。不要假设数据库会帮你做任何智能归一化。

5. 两个值得收藏的扩展用法

5.1 用 UNION ALL 做行转列

有些数据库没有专门的 PIVOT 功能(比如 MySQL 8.0 之前),行转列的一个常用办法就是 GROUP BY + CASE WHEN。但 UNION ALL 在有些场景下反而是更灵活的手段。

假设有多张月份表,你想把每个月的销售额并排展示成一行的多列:

SELECT MAX(CASE WHEN month = '2024-01' THEN amount END) AS jan_amount, MAX(CASE WHEN month = '2024-02' THEN amount END) AS feb_amount FROM ( SELECT '2024-01' AS month, SUM(amount) AS amount FROM order_202401 UNION ALL SELECT '2024-02', SUM(amount) FROM order_202402 ) t;

核心思想还是那个:先用 UNION ALL 把多张表堆成一张"长表",外面再用条件聚合把它拉成"宽表"。这个套路在生成报表的时候很常用,尤其是你不想在应用层写一堆 if-else 的场合。

5.2 用 UNION ALL 做多来源合并写入

如果你需要在一条 SQL 里从多个表取数据后插入目标表,UNION ALL 也能发挥作用:

INSERT INTO order_summary (order_id, amount, source) SELECT id, amount, 'realtime' FROM order_realtime WHERE create_time >= CURDATE() UNION ALL SELECT id, amount, 'history' FROM order_archive WHERE create_time >= CURDATE();

这种写法在做增量汇总、数据回灌时非常方便。一次 INSERT 完成多个来源的合并写入,目标表里还能通过 source 字段追溯到数据来源。执行计划上是两个查询各自跑完后统一写目标表,对目标表的锁开销也只有一次。

6. 一点个人体会

写到这里,回头看 UNION ALL 这个关键字,它其实是 SQL 里少有的"越简单越需要想清楚"的操作。它本身不做任何聪明的事,不排序、不去重、不转换,所以它的性能和语义完全取决于你怎么组织每个子查询。你对业务数据的边界了解得越清楚,用 UNION ALL 就越放心;反过来,只要你对数据分布心里没底,它就会把各种隐藏问题原样摆到你面前。

我个人在写所有涉及 UNION ALL 的 SQL 前,都会强迫自己先回答三个问题:两个子查询的结果集边界是否互斥、类型是否已经对齐、过滤条件是不是已经下推到最内层。这三件事确认完,UNION ALL 基本不会出幺蛾子。如果你也想在团队里推广这个习惯,建议直接从 Code Review 里卡两个点:是否用了 SELECT *、子查询里有没有多余的 ORDER BY。这俩是最常见的低级问题,也是最好改的。

最后再说一个小技巧:当你怀疑某个用 UNION 的慢查询应该换 UNION ALL 时,不要凭感觉改。先在两个子查询上分别 SELECT COUNT(*),再用 UNION ALL 拼起来看总行数,最后和 UNION 的结果行数对比。如果两者行数一致,说明结果集本就无重复,放心换成 UNION ALL;如果有差异,那差异行数就是过去每次查询为去重付出的无效开销,也是你向同事解释为什么要改的有力证据。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询