SQL UNION联合查询全解析:从基础语法到大数据引擎实践
2026/9/16 1:52:01 网站建设 项目流程

1. 联合查询UNION到底解决什么问题

1.1 什么时候会用到UNION

我最早接触UNION,是在做报表数据汇总的时候。当时业务方提了一个需求:要在一个页面里同时展示“线上订单数据”和“线下门店销售数据”,两边的字段结构一模一样,但数据存储在两张不同的表里——一张在MySQL主库,一张在数仓的Hive表。如果不用UNION,我只有两个选择:要么在应用层用代码做两次查询然后手动拼接,要么把两张表先物理合并成一张中间表。前者代码啰嗦且容易出错,后者浪费存储还增加维护成本。

UNION就是专门干这个的。它的核心作用只有一个:把两个或多个SELECT查询的结果集,纵向拼接成一个完整的结果集。注意“纵向”这个词——UNION是上下拼,不是左右拼。左右拼是JOIN的活,这个区别我后面会细说,但至少你要先记住:看到UNION,先想到“上下合并行”

在实际工作中,UNION的出现频率远比很多人想象的高,我列几个最常见的场景:

  • 多张结构相同的分表合并查询,比如订单表按月分表,查半年数据就得UNION六张表;
  • 不同来源的数据汇聚,比如自营数据和第三方数据格式一致但分表存储;
  • 同一张表里做复杂筛选条件的“或”逻辑,OR条件太多导致索引失效时,用UNION拆成多个简单查询再合并;
  • 补数据,比如某天的数据因为上游延迟没跑到,手动补一段数据并到正式结果里。

很多人把UNION当成“进阶语法”,其实它是SQL里最基础也最常用的集合操作之一。掌握它的核心步骤,不是为了炫技,而是为了在面对“多条SQL结果需要合并”这种高频需求时,能写出正确、高效、可维护的代码。

1.2 UNION和JOIN是两回事

这是我被问到最多的问题之一。很多新手一看到“联合查询”四个字,第一反应是“是不是就是多表关联”。还真不是。

JOIN是横向扩展——把A表的列和B表的列,通过关联条件拼在同一行里,列数变多。UNION是纵向扩展——把A表的行和B表的行堆叠在同一列结构下,行数变多。

我打个比方。JOIN像是把两张名单按姓名匹配,然后把每个人的电话和地址并排写在一行;UNION像是把两个班的点名册直接摞在一起,一张纸在上一张纸在下,列头完全一致。

还有一个关键差异:JOIN通常需要指定关联条件(ON),没有条件就是笛卡尔积,极度危险;UNION则不需要任何关联条件,它只要求两边的“列结构”对齐。所以判断该用JOIN还是UNION,你只需要问自己一个问题:我要的是“宽”还是“长”?要宽,JOIN;要长,UNION。

这个区分想明白了,后面四步走就有意义了——因为UNION的核心难点,恰恰就在“列结构对齐”这件事上。

2. 四步走:UNION标准操作流程

2.1 第一步:确认列的数量和顺序

UNION最底层的规则,也最容易翻车的规则是:每个SELECT查询返回的列数必须完全一致。这里没有任何商量余地,A查询返回3列,B查询返回4列,直接报错。

很多初学者会犯一个隐蔽的错误:用SELECT *。A表有5列,B表有6列,两边的SELECT *一写,数据库根本不给你运行的机会。就算两边列数一样,SELECT *也很危险,因为只要有一方表结构变更,整个查询立刻崩掉。

我个人的习惯是:永远显式列出列名,不写星号。这不是洁癖,是工程素养。显式列名至少带来三个好处:

  1. 编译期就能发现列数不匹配;
  2. 后续看代码维护的人,不用去查表结构就知道查询返回哪些字段;
  3. 表结构变更时,SQL的报错信息能精准定位到具体列。

列顺序也很关键。UNION是按位置对齐的,不是按列名对齐的。也就是说,第一个查询的第一列会和第二个查询的第一列做匹配,跟这两列叫什么名字没有关系。如果你第一个查询写的是SELECT user_id, user_name,第二个查询写的是SELECT user_name, user_id,数据不会报错,但结果就是乱的——用户的ID会被塞进名字列里,反过来也一样。

所以我在写UNION的SQL时,会刻意把每个子查询的SELECT字段排列顺序写成一模一样,并且加上对应的注释。如果列数太多,我会先把公共字段列表复制到每个子查询里,再逐个调整,避免手打出错。这属于“笨办法”,但能省下大量排查问题的时间。

2.2 第二步:确认列的数据类型兼容

列数对齐只是第一步。第二步要检查的,是每一个对应位置的数据类型是否兼容。

很多数据库对类型不一致的处理是“隐式转换”的,比如MySQL里VARCHAR和INT比较时,INT会被转成VARCHAR再比较。但在UNION的场景里,隐式转换并不总是靠谱。我踩过一个典型的坑:A表的日期字段是DATE类型,B表的对应字段是VARCHAR(10),存的格式是2025-01-01。UNION执行时MySQL做了隐式转换,没有报错,但排序时出现了诡异的结果——按日期排序,排出来却是按字符串排的,2025-01-10排在2025-01-02前面。

因此我的建议是:在写UNION之前,先检查每个对应位置的字段类型差异。有差异的,显式用CAST统一类型。比如:

SELECT user_id, CAST(create_time AS DATE) AS create_time FROM order_online UNION ALL SELECT user_id, create_time FROM order_offline

显式CAST的好处有两个:一是让最终结果集的类型可控,不依赖数据库的隐式转换策略;二是能提前暴露数据质量问题。比如某个字段在B表里混入了非日期字符串,CAST会在查询时报错,你能第一时间发现,而不是等数据流到下游报表里才发现——“诶,这个月的数据怎么少了几天”。

还有一个容易忽略的点:空值类型。如果A查询的这一列是正常字段,B查询的对应位置写了一个字符串字面量''或常量NULL,在某些数据库的隐式转换规则下也可能出问题。稳妥的做法是用CAST(NULL AS VARCHAR(20))这类方式明确声明字面量的类型,做到“对应位置的类型严格一致”。

2.3 第三步:确认排序和过滤条件的作用范围

这一步特别有意思,因为它的坑是“看着没毛病,跑起来要命”。

先说排序。在SQL标准里,UNION的ORDER BY只能出现在整个语句的最后,作用是给最终合并后的结果集排序。你不能在第一个子查询里写ORDER BY,期望它先排好序再合并——大多数数据库会直接报语法错误,就算有些数据库不报错,这个排序也是无效的,不会影响最终结果。

但这里有个容易混淆的地方:如果子查询里同时存在LIMIT和ORDER BY,情况就不一样了。比如:

(SELECT user_id, amount FROM order_online ORDER BY amount DESC LIMIT 10) UNION ALL (SELECT user_id, amount FROM order_offline ORDER BY amount DESC LIMIT 10)

这段SQL里,子查询的ORDER BY配合LIMIT是有效的——它先取出每个表里金额最大的前10条,再合并。这是“先排序截断、再合并”的合法用法。所以你要想清楚:你要的是整体排序,还是每个子集先做处理再合并。整体排序,就写在最外层;子集内处理,就写在子查询里并搭配LIMIT。

再说过滤条件。如果把WHERE条件写在子查询内部和写在整个UNION外面,语义完全不同。写在子查询内部,是“先过滤再合并”;写在外面,是“先合并再过滤”。绝大多数时候,业务想要的都是前者。比如我要查“上个月和这个月的有效订单”,正确写法是每个子查询各自加WHERE条件:

SELECT order_id, amount FROM orders_202501 WHERE status = 'valid' UNION ALL SELECT order_id, amount FROM orders_202502 WHERE status = 'valid'

如果写成:

SELECT order_id, amount FROM orders_202501 UNION ALL SELECT order_id, amount FROM orders_202502 WHERE status = 'valid'

那这个WHERE就只作用在第二张表上,第一张表的无效订单全部会被带出来。这个错误极其隐蔽,因为SQL的逻辑和自然语言的理解不一样,人脑默认“最后面的条件应该作用在全部数据上”,但实际上WHERE只能作用于它所在的SELECT块。

2.4 第四步:根据去重需求选择UNION还是UNION ALL

这是四步里最后一步,也是性能影响最大的一步。UNION默认会对结果去重,UNION ALL则完全不去重,直接拼接。

很多人只把这当成“去重和不去重”的区别,其实背后的性能差异才是关键。UNION的去重不是免费的——数据库必须对最终结果集做一次排序或哈希操作来消除重复行。这意味着它要把所有数据先落到一个临时空间,再逐行比较。数据量小的时候无所谓,数据量一旦上了百万行,UNION的执行时间可能是UNION ALL的好几倍。

所以我在生产环境里有一条铁律:能用UNION ALL,就不用UNION。只要业务语义上允许重复数据存在,或者你明确知道两个子查询的结果集不可能有交集,就一律用UNION ALL。

那什么时候必须用UNION呢?我总结了两类场景:

  1. 业务上确实需要全局去重,比如统计“有过购买行为的用户数”,用户可能在多个渠道出现,需要去重后再计数;
  2. 你没法保证两个子查询的结果集是否有交集,而且下游逻辑不允许重复,这个时候宁可多花点时间做去重,也不能让脏数据往下流。

还有一个中间技巧:如果数据量很大,又必须去重,可以考虑用UNION ALL配合子查询里的DISTINCT精细化处理。比如A查询里本身不会重复,只有B查询内部可能重复,那就在B子查询里加DISTINCT,然后外层用UNION ALL。这样数据库只需要对B的结果做去重,而不是对合并后的全集做去重,性能要好不少。

3. 写UNION前必须想清楚的三个问题

3.1 各子查询的内部逻辑是否已经收敛

这一步是我在带新人时反复强调的。很多人在写UNION时,只关心“怎么把两个查询拼起来”,却不关心“每个查询自己是不是已经足够收敛”。结果就是每个子查询都返回大量无效数据,UNION只是把脏数据堆在了一起。

举个实际例子。我要统计“全渠道有效客户数”,线上客户的筛选条件是status = 'active' AND last_login >= 90天前,线下客户的筛选条件是member_level >= 3 AND join_date <= 90天前。这两个条件分别写在各子查询内部,是绝对正确的做法。但有些新人的写法是:把最宽松的条件(比如status = 'active')放在子查询里,把另一个条件留在UNION外面的WHERE里——因为他们觉得“反正最后会一起过滤”。

这就是典型的没有理解WHERE作用域。我把这条规则刻在脑门上:每个子查询只负责自己那一摊数据的“收敛”,所有和这张表自身相关的过滤条件,都必须在子查询内部完成。UNION外面只放“对合并结果集的再一次汇总条件”,通常只有一个GROUP BY或者一个全局ORDER BY,其他的基本都不需要。

3.2 结果集的列名以第一个SELECT为准

这是很多人不知道的小细节。UNION返回的结果集,列名是以第一个SELECT语句的列名为准的,后面子查询的列名会被忽略。比如:

SELECT user_id AS uid, user_name AS name FROM table_a UNION ALL SELECT user_id, user_name FROM table_b

最终结果集的列名是uidname,不是user_iduser_name。所以如果你想给最终结果集起别名,只需要在第一个SELECT里AS就行。

但要注意:虽然第二个及后面的查询列名会被忽略,位置必须对上,这就是我在前面反复强调的列顺序问题。有些数据库(比如SQL Server)如果两边列名不一致,也会报错,虽然MySQL和PostgreSQL宽容一些,但为了跨数据库兼容,最好还是保持两边列名一致,或者只在第一个查询指定AS别名,后面的查询保持相同居中语义。

3.3 UNION的括号使用与语义陷阱

刚才第三步里我提到,子查询里的ORDER BY加LIMIT是生效的,但需要给子查询加括号。这个括号极其重要,不写括号在很多数据库引擎里要么直接语法报错,要么被解析成你完全没想到的逻辑。

以MySQL为例:

SELECT id FROM t1 ORDER BY id DESC LIMIT 5 UNION ALL SELECT id FROM t2 ORDER BY id DESC LIMIT 5

这段SQL在MySQL里是语法错误,因为ORDER BY出现在第一个SELECT块里,在没有括号包裹时,MySQL会认为这个排序是最终排序,但UNION后面又跟了第二个SELECT,矛盾了。必须改成:

(SELECT id FROM t1 ORDER BY id DESC LIMIT 5) UNION ALL (SELECT id FROM t2 ORDER BY id DESC LIMIT 5)

我个人的建议是:所有带ORDER BY或LIMIT的子查询,一律加括号。不加括号的写法不仅在MySQL里容易出错,在别的数据库里行为也可能不一致,不要给自己挖坑。

4. 实战场景:UNION和大数据引擎的结合

4.1 FlinkSQL写入Doris时UNION KEY模型带来的启示

项目热词里有“flinksql写入doris union key模型的表”,这其实是UNION概念在OLAP数据库里的另一个延伸。先说清楚,这里的UNION KEY和SQL里的UNION语法不是一回事,Doris里的UNION KEY模型(合并键模型)指的是:在导入数据时,通过指定UNION KEY列,将多条具有相同KEY的数据合并为一条,并且可以对这些记录做聚合运算。

举个具体场景。你在Flink里实时消费订单流,每5分钟往Doris里写入一次增量数据。同一笔订单因为状态变更(待支付、已支付、已发货),会多次出现在数据流里。如果你用明细模型,Doris里就会存好几行相同订单ID的纪录,下游报表做汇总时还得去重或者取MAX。而如果用UNION KEY模型,把订单ID设为KEY列,每次写入时指定对金额字段做“取最新值”或“求和”的聚合,Doris会自动把同一KEY的数据合并成一条。

这和SQL UNION的共通点在于:都是“把多条数据合并成结果”的思维,只是合并维度不同。SQL UNION合并的是“不同来源、相同结构”的数据,UNION KEY合并的是“相同KEY、不同版本”的数据。在实际的数据开发中,两者经常配合使用——上游FlinkSQL先把多个流的结果做UNION ALL合并,再SINK到Doris里,由Doris的UNION KEY模型做二次收敛。

我当时的实现思路是:FlinkSQL里先用UNION ALL把三个订单来源(App、小程序、线下POS)的数据拼成一个统一的DataStream,统一字段名和数据类型,再写入Doris表。Doris表设置了UNION KEY(shop_id, order_id),value列里对order_status字段用REPLACE_IF_NOT_NULL聚合。这样即使Flink任务重复跑或者上游重复发送,Doris层面也能保证同一个订单只保留最新状态,不会产生重复行。

4.2 多引擎下UNION语法的兼容性差异

做数据开发的人,免不了在多个引擎之间切换。不同数据库对UNION的语法支持有细微差别,这里我用自己的踩坑经验列一个避坑清单:

  • MySQL:支持UNION和UNION ALL,支持括号包裹子查询,但对UNION子查询里的ORDER BY限制较多,必须搭配LIMIT才有意义。
  • PostgreSQL:UNION的行为和MySQL基本一致,但PostgreSQL对类型匹配更严格,两个对应列类型不一致时错误率更高,推荐提前CAST。
  • Hive/SparkSQL:对UNION的支持非常成熟,但Spark 2.x之前有个坑:UNION默认是UNION ALL语义,UNION DISTINCT才是去重合并。Spark 3.0之后才对齐了SQL标准。如果你们公司还在用Spark 2.x,写去重合并时一定要用UNION DISTINCT
  • Doris:SQL语法兼容MySQL,但多表UNION的优化行为在旧版本上一般,数据量较大的UNION查询尽量在FlinkSQL里先算好,再写入Doris,避免Doris侧用UNION临时合并大结果集。
  • ClickHouse:默认的UNION行为是UNION ALL(和Spark 2.x类似),需要去重得显式写UNION DISTINCT。很多人从MySQL迁移到ClickHouse时在这个点上栽过跟头。
  • SQL Server:强烈要求两边的列名和类型完全一致,否则直接报错,比其他数据库更严格。

这轮对比的核心结论是:写UNION之前,先确认你所在的引擎默认的UNION语义。是去重还是不去重,直接影响最终结果和你对性能的判断。

5. 常见问题与排查技巧实录

5.1 三条高频报错和对应排查思路

我把自己在实战中遇到的、且反复出现的问题整理成一个速查表,方便你遇到报错时快速定位:

报错情况根本原因排查思路解决方式
column count mismatch各SELECT的列数不一致数一下每个查询的列数对齐列数,建议显式写出每一列
cannot cast type对应位置的字段类型不兼容查看两个表的结构差异用CAST显式统一类型
invalid order by clauseORDER BY位置写错检查是否写在子查询内且无括号加括号包裹子查询,或把排序挪到最终结果后
duplicate column name子查询的列名不一致检查每个子查询的列名和对齐顺序在第一个查询指定AS别名,后续查询列名保持一致
row size too large单行数据量超过引擎限制检查是否把长文本字段也UNION进来了只SELECT需要的字段,避免大字段参与UNION

5.2 一个隐蔽的性能陷阱:隐式去重拖垮全链路

有一次我在做数据同步任务,源表有800万行,目标是一个汇总宽表。我用了UNION把三个月的数据合并起来,当时图省事,直接写了UNION,没写UNION ALL。结果这个任务跑了40分钟还没跑完,平时同步只要5分钟。

我排查的时候,第一反应是“是不是JOIN写错了”,看了半天SQL没发现异常。后来用EXPLAIN一看执行计划,发现数据库对UNION的结果集做了一个全局排序,因为是隐式去重,排序的内存超出了配置阈值,数据落到了磁盘临时文件,大量时间消耗在磁盘IO上。

改法很简单:UNION改成UNION ALL。因为我的业务场景里本来就允许重复数据存在(不同月份的同一个人会出现多次是正常的,下游会再做一次去重汇总),UNION的隐式去重完全是多余操作。

这个案例给我最重要的教训是:SQL里的每个关键字都有成本,UNION里藏着一个你没主动要求的排序/去重步骤。尤其是当你面对大数据量时,哪怕写错一个关键字,整个任务的性能都会断崖式下跌。花十秒钟想清楚自己到底要不要去重,比事后排查省太多时间。

5.3 结果集顺序“不稳定”的真相

还有人跟我反馈过:“我写了UNION,每次跑出来的顺序都不一样。”这个现象其实和UNION本身无关,而是绝大多数数据库在UNION执行时不会保证结果的有序性。就算你不加ORDER BY,第一遍跑出来的顺序刚好是“看起来有序”的,下一次跑可能就变了。

特别是Hive和SparkSQL这类分布式引擎,数据分布在多个节点上,每个节点返回的块顺序不固定,合并后的自然顺序天然就是“随机的”。所以你在依赖顺序时——比如分页查询、导出报表、对比两次查询结果——一定要显式加ORDER BY,除非你只是想快速捞几条数据看看,那无所谓。

6. 写UNION的性能优化心得

老实说,UNION用得好不好,三分靠语法,七分靠对数据的理解。下面几个优化心得,都是我在生产环境里验证过的。

第一个心得:能不UNION就不UNION,能用JOIN+GROUP BY就用这个方案。在一些场景里,两张结构相同的表其实可以通过全外连接加条件合并来实现同样的效果,单条SQL执行计划更可控。但要注意,这个“替代方案”仅适用于两张表之间有明确的关联键的场景,如果两表完全独立、没有可比字段,UNION依然是唯一解。

第二个心得:UNION之前先缩小数据量。每个子查询的WHERE条件必须驱动上对应分区或索引。比如我们按月分表的场景,每个子查询的WHERE里必须带上月份字段,让数据库走分区裁剪,而不是全表扫描之后再合并。有些人图省事,直接用SELECT * FROM orders_all(底层是视图,内部做了UNION),应用层再加WHERE过滤,结果就是每个分表都被全表扫了一遍,性能惨不忍睹。

第三个心得:合并多张表时,考虑临时表或物化视图。如果多张表经常要做UNION合并,且合并后的结果被多个下游任务引用,我倾向于把UNION结果物化成一张物理表,定期刷新。这样每个下游任务不用重复做UNION,节省的是整个数仓的计算资源。联合查询不是银弹,好的数据架构是在“实时计算”和“预计算”之间找到平衡点。

第四个心得:注意union列的类型精度问题。尤其是DECIMAL类型,两张表的DECIMAL精度不一致时,UNION的结果集会以高精度为准,但两边的数值可能会被隐式转换,产生意料之外的精度变化。我遇到过线上金额字段被转成更高精度后,下游比较逻辑出现轻微偏差的问题。处理方式也很简单:统一CAST成固定的DECIMAL精度。

7. 写在最后:UNION这个能力的定位

很多人学SQL时,把UNION定义为“一种连接多张表的语法”,这个定位其实窄了。UNION真正的价值,是让你从“单表思维”升级到“集合思维”——你的数据不再是孤立的某张表,而是分布在多个源头、多个时间片、多个物理位置的数据集合。你需要的不是把表粘在一起,而是把数据流汇聚成同一个口径。

我在实际项目中,见过最漂亮的UNION用法,不是那种把几十张表拼起来的“宏大SQL”,而是一个极简的UNION ALL,把实时数据和离线数据拼在一起,实现了T+0的准实时报表。那个SQL只有20行,但整个数据链路的设计非常清晰:离线部分算历史累计,实时部分算今日增量,两者结构一致,UNION ALL一拼,下游直接查。

这就是UNION的正确打开方式——它不复杂,但你必须理解每一行代码背后的语义边界。列数对齐、类型兼容、过滤作用域、去重语义,这四步走完,你对UNION的掌握基本就到了“不踩坑”的水准。之后遇到多数据源合并、分表聚合、实时离线数据拼接,你都能顺手写出既对又快的SQL。

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

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

立即咨询