SQL入门系列讲到这里,单表查询和连接查询的基本功就算打完了。这一讲我们聊UNION联合查询——一个我工作中用得非常频繁、但很多人一开始都会搞混的集合操作。它的使用场景特别直白:你手上有好几张结构相同的表,按年份拆的、按部门拆的、按区域拆的,现在要把这些表的数据合并到一张结果集里统一统计,这个时候就需要用到UNION。
UNION的中文名叫“联合查询”,本质上是把多个SELECT语句的结果纵向拼接成一个完整的结果集。它也是面试里几乎必考的点,因为表面上看只是几个关键字的事,背地里却牵扯到结果集结构、数据类型、字符集排序规则,甚至执行计划,稍不留神就会踩坑。这一讲我会从它和JOIN的本质区别讲起,重点拆解UNION和UNION ALL的区别,再带一个完整的实操案例,最后把常见的报错和我自己踩过的坑一并整理出来。不管你是刚学SQL的初学者,还是写了一段SQL但一直没系统整理过集合操作的开发同学,这篇都适合你。
1. 联合查询的本质:把查询结果“纵向拼接”
1.1 先彻底分清JOIN和UNION
我见过太多没弄懂UNION的人,第一反应都是问:这和JOIN有什么区别?每次遇到这个问题,我都用一句话回答:JOIN是“横向拼接”,UNION是“纵向拼接”。
JOIN做的事情是根据关联条件,把两张表的列拼到一起。拼完之后,列数通常会增加,行数一般不会减少。比如说订单表和客户表,通过客户ID关联,把客户姓名、客户等级这些列追加到订单表右边,这就是典型的横向拼接。
UNION则完全不同,它把两个SELECT查出来的行摞在一起。拼完之后,列数不变,但行数是两个结果集行数的叠加。
我打个比方:JOIN像做拼图,把两块拼图按边缘卡在一起拼成一张更大的图;UNION像整理扑克牌,把两叠牌直接合到一叠。一个是横向扩展,一个是纵向扩展。
这两个操作解决的需求完全不同。JOIN解决的是“多张表的信息需要同时展示在一行里”的问题,UNION解决的是“多段结果需要放到一个结果集里统一处理”的问题。如果你把UNION当JOIN用,或者反过来把JOIN当UNION用,最后拿到的结果一定不是你想要的。
1.2 UNION的基本语法和三条硬性规则
UNION的语法本身简单得不像话:
SELECT 语句1 UNION [ALL] SELECT 语句2;看起来轻松,但实际使用时有三条硬性规则必须守,缺一条就报错。
第一,两个SELECT查出来的列数必须完全一样。这一点新手经常忽略,尤其是查询的列特别多时,从上面拷贝下来漏写一列,数据库会直接给你一个列数不匹配的报错。记住,UNION按位置合并列,不是按列名合并,所以列数不对等于失败。
第二,列的顺序必须对应。UNION是以位置为准的——第一个SELECT的第三列,会和第二个SELECT的第三列拼到同一列里。如果你把两边的列顺序写反了,数据库不报错,但结果全部错位,这种阴间Bug排查起来极其痛苦。
第三,对应列的数据类型要兼容。比如一边是INT,一边是VARCHAR,很多数据库会尝试隐式转换;如果一边是字符串一边是日期,转换不过去,就会报类型转换错误。
另外还有一点容易被忽略:最终结果集的列名以第一个SELECT的列名为准。第二个SELECT里写的别名,哪怕再长再规范,都不会出现在最终结果集里。所以你想让结果集列名变得友好,要在第一个SELECT里起别名。
下面给一个非常典型的例子。两张学生表,分别存放2023级和2024级的学生数据,结构一模一样:
CREATE TABLE student_2023 ( id INT, name VARCHAR(50), class_name VARCHAR(20) ); INSERT INTO student_2023 VALUES (1, '张三', '一班'), (2, '李四', '二班'), (3, '王五', '三班'); CREATE TABLE student_2024 ( id INT, name VARCHAR(50), class_name VARCHAR(20) ); INSERT INTO student_2024 VALUES (1, '张三', '一班'), (4, '赵六', '一班'), (5, '钱七', '二班');注意,张三在两年的时间段里整行完全一致,这一点对理解UNION的去重行为至关重要。现在执行:
SELECT id, name, class_name FROM student_2023 UNION SELECT id, name, class_name FROM student_2024;结果是5行,张三只会出现一次。因为UNION默认会对结果集做去重。而如果换成UNION ALL,结果会是6行,张三会出现两次。这引出了下一节的内容。
2. UNION和UNION ALL:少两个字母,差别很大
2.1 核心区别:去重与不去重
UNION与UNION ALL最本质的区别,就是去重。
UNION等价于“先合并、再对整行做一次DISTINCT”,所以重复行只会保留一条。UNION ALL则是“直接追加,保留所有行”,完全不去重。
回到上面的例子,student_2023和student_2024里都有张三这一条完全相同的数据,UNION会把重复的合并成一个,最终输出5行;UNION ALL不做任何去重,张三出现两次,最终输出6行。
这里要特别强调一个理解误区:UNION的去重是针对“整行的所有列”来判定的,而不是单独针对某一列。如果两张表里都有“张三”,但张三所在班级不同,整行数据不相等,那UNION不会认为它是重复行,也不会把它合并掉。
所以,如果你期望的是“按姓名这一列去重,每个姓名只保留一条”,UNION做不到,得用GROUP BY或者ROW_NUMBER()开窗函数去实现。这是一个很常见的需求错位,我在实际工作中见过不少新同事在这个地方栽跟头。
2.2 性能差异背后的原理
性能方面,UNION ALL几乎总是比UNION快,而且数据量大时差距非常明显。
为什么?UNION要做去重,数据库必须在内存或临时表里对合并后的结果集做排序(SORT)或哈希去重(HASH)。这个操作需要额外的CPU、内存和磁盘IO。如果参与合并的数据集有几百万上千万行,光是去重这一步,就能让查询时间从秒级变成十几秒甚至分钟级。
UNION ALL就轻松多了,它只是把各个子查询的结果一条条顺序追加到一起,没有去重开销,执行计划也更简单直观。
我在一次生产环境调优里遇到过典型情况:两张千万级的表做结果合并,业务上确认两边数据不可能有重复行,但代码里写的却是UNION。那条SQL跑了十几秒才出结果,改成UNION ALL之后,一秒出头就出来了。没有任何索引和SQL结构上的改动,仅仅是换掉一个关键字,查询效率直接提升了一个数量级。
2.3 实际场景里怎么选
我的选择原则从来都是一句话:业务上允许重复,就选UNION ALL;必须消灭重复行,才选UNION。
绝大多数分表场景都适用UNION ALL。比如订单表按月拆表,每个订单ID全局唯一,合并时根本不可能出现重复行,这时候用UNION纯粹是让数据库白白做一次无用功。
只有一种情况我才会毫不犹豫地使用UNION:业务上确实需要把多个结果集合并后去掉完全相同的行。比如从不同历史版本的数据源里合并数据,两边可能存在同一笔记录的多个副本,这时UNION的去重能力就很有价值。
有一点需要说明,虽然不同数据库对UNION的底层实现有差异,但语义完全一致:MySQL、SQL Server、PostgreSQL、Oracle里,UNION默认去重,UNION ALL不去重;Hive、Spark SQL里也是一样。你只需要记住这一条,到任何数据库环境都通用。
3. UNION的高阶写法:排序、常量列、聚合齐上阵
3.1 对整个结果集排序的正确姿势
多个SELECT合并之后经常需要排序。很多新手会习惯性地在每个子查询后面加ORDER BY,这是不对的。ORDER BY必须写在最后一个SELECT的后面,而且它排序依据的是合并之后的结果集列名,不是某个子查询里独有的列。
举个例子,把两年的订单合并后,按年份和金额排序:
SELECT '2023' AS order_year, order_id, amount FROM orders_2023 UNION ALL SELECT '2024', order_id, amount FROM orders_2024 ORDER BY order_year ASC, amount DESC;这里ORDER BY引用的order_year、amount都是合并后结果集里真实存在的列。如果你在ORDER BY里写了一个只在第一个子查询里存在、但没被SELECT输出到结果集的列,比如id,很多数据库会直接报错,因为它根本不知道这个列是什么。
尤其SQL Server对这个限制执行得很严格,它会明确提示你,ORDER BY项必须出现在SELECT列表中。
3.2 用常量列给不同来源打标记
合并多张表时,我强烈建议加一个常量列作为来源标签。这个习惯帮我避免过很多次数据追溯的麻烦。
SELECT '2023' AS order_year, order_id, amount FROM orders_2023 UNION ALL SELECT '2024', order_id, amount FROM orders_2024;这里的'2023'、'2024'就是常量列,合并之后每行数据从哪来一眼可见。后续需要按来源分组统计时,这个字段也能直接用上。
常量列还有一个更实用的价值:当两张表的列语义不完全对等时,可以用常量或NULL来补位,保证列数对齐。比如一张表有备注列,另一张没有,合并时就可以用CAST(NULL AS VARCHAR(50))来占位,保持结构一致。
3.3 UNION ALL + GROUP BY 做多段聚合
有一种常见的需求:合并后再统一分组统计,或者各子查询先聚合再合并。两种写法不能盲目选,要看数据量。
如果两张表数据量都特别大,建议在子查询里先各自GROUP BY,做一次预聚合,再把聚合结果UNION ALL起来。这样可以提前削减数据量,减少合并阶段的开销。
如果合并之后需要跨来源统一分组,就先把明细UNION ALL成一个大结果集,再在外面套GROUP BY。比如统计两年各班级的学生数:
SELECT class_name, COUNT(*) AS cnt FROM ( SELECT id, name, class_name FROM student_2023 UNION ALL SELECT id, name, class_name FROM student_2024 ) t GROUP BY class_name ORDER BY cnt DESC;这种写法的好处是逻辑清晰:先合并,再加工。执行效率也比直接对两张表分别统计再加总要稳定,因为统一分组避免了两次GROUP BY结果合并时的各种类型和边界问题。
3.4 用UNION模拟FULL OUTER JOIN
最后这个进阶技巧非常实用,尤其适合MySQL这类不支持FULL OUTER JOIN的数据库。
FULL OUTER JOIN要的是“左表有就带左表,右表有就带右表,两边都有就都带出来”。没有这个语法时,可以用LEFT JOIN和RIGHT JOIN配合UNION来模拟:
SELECT a.id, a.name, b.order_id FROM customer a LEFT JOIN orders b ON a.id = b.customer_id UNION SELECT a.id, a.name, b.order_id FROM customer a RIGHT JOIN orders b ON a.id = b.customer_id;左连接把左表全部记录带出来,右连接把右表全部记录带出来,UNION再去掉重叠的部分,最后的结果就等价于FULL OUTER JOIN。
这个方法在数据对比场景里特别经典。比如比对两个系统的用户表,找出哪些用户只存在于系统A、哪些只存在于系统B,用这个方法可以一次摸清两边的差异。
4. 完整实操:合并两张结构相同的订单表
4.1 场景说明与建表准备
我一直觉得学SQL不能只看语法,一定要动手建表、插数据、跑查询,才能真正理解一个操作符的行为。这里给一个完整案例。
场景是这样的:公司订单数据按年份拆表,orders_2023和orders_2024结构相同,包含订单ID、客户ID、金额、下单日期。现在需要统计这两年各月份的销售总额。
先建表并插入测试数据(以MySQL为例):
CREATE TABLE orders_2023 ( order_id INT PRIMARY KEY, customer_id INT, amount DECIMAL(10,2), order_date DATE ); INSERT INTO orders_2023 VALUES (1001, 201, 120.50, '2023-01-15'), (1002, 202, 88.00, '2023-02-11'), (1003, 203, 250.00, '2023-12-03'); CREATE TABLE orders_2024 ( order_id INT PRIMARY KEY, customer_id INT, amount DECIMAL(10,2), order_date DATE ); INSERT INTO orders_2024 VALUES (2001, 201, 99.50, '2024-01-20'), (2002, 204, 320.00, '2024-02-05'), (2003, 202, 150.00, '2024-12-25');4.2 分步合并与按月统计
第一步,先把两个子查询单独跑一遍,确认各自没有问题。这是我一直强调的排查习惯,尤其是初学者,两个查询都通了再合并,能少踩很多坑。
SELECT order_id, customer_id, amount, order_date FROM orders_2023; SELECT order_id, customer_id, amount, order_date FROM orders_2024;第二步,用UNION ALL合并:
SELECT order_id, customer_id, amount, order_date FROM orders_2023 UNION ALL SELECT order_id, customer_id, amount, order_date FROM orders_2024;合并后得到6行数据,因为两边的order_id都是主键,完全不可能重复,所以我优先选UNION ALL而不是UNION,避免数据库做无意义的去重。
第三步,按月份统计销售额。最稳的写法是先用UNION ALL合并成子查询,再按月分组:
SELECT DATE_FORMAT(order_date, '%Y-%m') AS month, SUM(amount) AS total_amount FROM ( SELECT order_id, customer_id, amount, order_date FROM orders_2023 UNION ALL SELECT order_id, customer_id, amount, order_date FROM orders_2024 ) t GROUP BY DATE_FORMAT(order_date, '%Y-%m') ORDER BY month;执行结果可以清楚看到每个月的销售总额。如果你希望每行数据能识别来源年份,加一个来源列即可:
SELECT '2023' AS source_year, order_id, amount, order_date FROM orders_2023 UNION ALL SELECT '2024', order_id, amount, order_date FROM orders_2024;这种写法在报表里非常常用,既方便追溯数据,也方便按年分组做后续加工。
4.3 判断UNION和JOIN的时机
实操多了之后,你自然会对“UNION还是JOIN”形成肌肉记忆。我这里提供一个判断标准:需要增加新行就选UNION,需要增加新列就选JOIN。
统计两年数据,年份与年份之间是上下堆叠的关系,用UNION。查询订单对应的客户姓名,订单和客户是左右搭配的关系,用JOIN。
还有一个常见场景值得提一提:同一张表上,如果两个查询条件很难用一条WHERE写成AND/OR,或者OR会导致执行计划选不到索引,可以把条件拆成两个SELECT再用UNION ALL合并。我在优化慢SQL时用过好几次这个套路,效果非常明显,至少比让数据库去执行一个低效的OR条件要快。
5. 常见报错与排查经验
5.1 报错:列数不一致
这个报错最典型。比如:
SELECT id, name FROM student_2023 UNION SELECT class_name FROM student_2024;数据库会直接提示列数不匹配。排查方式很简单:把每个SELECT单独跑一遍,数一数列数,缺什么就补什么。
很多新手在这里犯难的原因是不知道补什么,我的建议是补常量占位。比如第二个SELECT没有id,就写CAST(NULL AS INT)或者直接写0,保证两边结构一致。
5.2 数据类型不一致与隐式转换
UNION要求对应列的数据类型兼容。遇到不兼容的情况,数据库会尝试隐式转换,转换不了就报错。
一边是字符串一边是数字时,MySQL会尽量把字符串转成数字。但这种隐式转换经常带来意外结果,比如字符串里有非数字字符时,转换出来的值可能不是你想要的。
更稳妥的做法是显式用CAST统一类型:
SELECT CAST(id AS CHAR) AS id, name FROM student_2023 UNION ALL SELECT CAST(id AS CHAR) AS id, name FROM student_2024;这样两边类型明确一致,执行结果可控,不会出现隐式转换带来的意外。
5.3 illegal mix of collations:字符集排序规则不一致
这个报错很多人处理中文数据时都见过,完整的报错信息是:illegal mix of collations for operation 'UNION'。我第一次遇到时也是一头雾水,后来才明白问题出在哪里。
原因非常典型:参与UNION的两个字段虽然都是字符串类型,但它们的字符集排序规则(collation)不一样。比如一张表用的是utf8mb4_general_ci,另一张用的是utf8mb4_unicode_ci,两边直接UNION时,数据库不知道应该按哪种规则来做排序和比较,于是直接拒绝执行。
解决方案有三种。
第一种,在查询里手动指定COLLATE,强制两边统一排序规则:
SELECT name FROM t1 COLLATE utf8mb4_unicode_ci UNION SELECT name FROM t2;第二种,修改表或字段的排序规则,让两边一致。但这种方案要评估影响范围,因为改排序规则可能影响索引和已有数据的行为,不能随便动。
第三种,如果字段本身不需要复杂排序,可以在SELECT时用CONVERT或CAST统一字符集类型,绕过collation冲突。
SQL Server遇到不同排序规则的数据源时,报错现象很类似,解决办法同样是COLLATE指定统一规则。所以这个思路各数据库通用。
5.4 排序和分页的边界问题
合并后排序最常见的报错是:ORDER BY引用的列不在结果集里。比如:
SELECT name FROM student_2023 UNION ALL SELECT name FROM student_2024 ORDER BY id;如果id没有出现在SELECT列表中,数据库会直接报错,因为外层结果集根本没有id这一列。
解决办法有两个。一是把id也放进SELECT列表,让它存在于结果集里;二是把ORDER BY改成基于结果集里真实存在的列,比如name。
另外要注意,如果合并后需要分页,LIMIT必须写在整个UNION语句的最后、ORDER BY之后:
SELECT id, name FROM student_2023 UNION ALL SELECT id, name FROM student_2024 ORDER BY id LIMIT 10;顺序错了,分页结果就会不符合预期。
5.5 常见问题速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 报错:列数不匹配 | 各SELECT输出列数不一致 | 单独跑子查询核对列数,补常量占位 |
| 结果行数比预期多 | 使用UNION ALL且数据本身有重复 | 确认业务是否允许重复,必要时换UNION |
| 结果行数比预期少 | UNION去掉了不应去掉的行 | 确认是否整行重复,用UNION ALL保留 |
| 中文数据报collation错误 | 两边字符串排序规则不一致 | 用COLLATE统一规则,或改表结构 |
| 报错:ORDER BY列不存在 | 引用了结果集外的列 | 将该列加入SELECT,或改按结果集列排序 |
| 数据错位但SQL不报错 | 两边SELECT的列顺序不一致 | 逐列比对两边的列顺序,确保位置对应 |
6. 性能优化与生产环境避坑建议
6.1 默认优先使用UNION ALL
这条经验我每次带新人都会强调:没有明确去重需求,一律用UNION ALL。
UNION去重听起来方便,但代价是实打实的。数据库需要把多个结果集合并后做排序或者哈希去重,数据量一上来,执行时间、临时表空间、内存开销全部跟着涨。
我见过太多案例,SQL本身写得没毛病,就是UNION多了导致慢查询。改成UNION ALL后,查询速度立竿见影地提升。关键是业务上本来就不需要去重,纯属白花钱。
6.2 在子查询里先过滤,减少合并数据量
UNION合并的数据量越小越快,所以每个子查询内部尽量把WHERE、GROUP BY、LIMIT这些操作做完,再把结果往外抛。
一个常见的低效写法是三个子查询各自查全表,UNION后再在外面套WHERE过滤。这种写法相当于把大量无用的行都合并了一遍,浪费IO和内存。正确做法是把WHERE条件下沉到每个子查询内部,只让需要的数据进入合并阶段。
6.3 不要混淆“表模型”和“UNION查询”
有一次在讨论Flink SQL写入Doris的Unique Key模型的表时,团队里有人把“Unique Key模型”里的主键去重和“UNION去重”混在一起讨论。这里必须明确一下:Doris的Unique Key模型是表模型层面的主键更新机制,决定的是数据写入时按主键如何合并、覆盖;SQL的UNION是查询时的集合操作,决定的是结果集怎么拼接。两者都有“去重”这个词,但发生在完全不同的环节,一个是写数据,一个是查数据,千万不要混为一谈。
6.4 排查UNION问题的几个实用习惯
第一,任何UNION查询,先在数据库里单独跑一遍每个SELECT,确认没问题再合并。我排查UNION问题,90%的时间都花在单独跑子查询上。
第二,用EXPLAIN看执行计划。EXPLAIN会展示UNION每个子查询是否走索引、是否产生临时表、是否有排序操作。看到Using temporary或者Using filesort时,就要有意识地去优化了。
第三,UNION的各子查询尽量做列裁剪,不要直接SELECT *。列越多,合并的数据量越大,UNION去重时的排序代价也越高。
第四,如果UNION语句特别复杂,可以先把合并结果存到临时表,再对临时表做后续处理。虽然多了一步,但可读性和可维护性会好很多,排查时也更容易定位问题。
写到这里,UNION的核心知识点基本就过完了。最后分享一个我的个人体会。最早学UNION时我犯过一个低级错误:想统计两个部门名单的总人数,用了UNION,结果人数比预期少了几个,查了半天才发现是有重名的人被UNION去重了。从那次之后,我养成了一个习惯:凡是用到UNION,先问自己一句,这个场景里允许重复行吗?允许,就用UNION ALL;不允许,才考虑UNION,而且一定要确认UNION去重的是整行,不是某个字段。把这个习惯刻进脑子里,能帮你省掉很多莫名奇妙的数据对不上问题。