联合查询union核心步骤(四步走)
做数据开发这几年,我见过太多人在union上翻车了。不是查出来的结果莫名其妙少了几行,就是字段对不上报一堆语法错误,更常见的是在union和union all之间选错了导致全表扫描、查询慢到怀疑人生。其实union这个功能本身并不复杂,翻来覆去就那几个核心步骤,但越是基础的东西,越值得把底层逻辑彻底搞透。今天我就把联合查询union的核心用法拆成四步,结合我实际踩过的坑和优化经验,一次性说清楚。
这篇文章适合谁看?刚接触SQL、被各种join和union绕晕的新手,写了好几年SQL但一直在“能用就行”状态、想搞清楚原理的开发者,还有需要在报表汇总、多表合并场景里做数据整合的分析师。不管你处在哪个阶段,把这四步吃透,union相关的需求基本都能拿捏住。
1. 联合查询整体设计思路:union到底是干什么的
先搞清楚一个最基础的问题:union解决的是什么场景?很多初学者会把union和join搞混,这里我用大白话区分一下。join是“横着拼”,把两个表的字段拼到一行里,比如用户表和订单表通过user_id关联,查出来一行里既有用户姓名又有订单金额。union是“竖着堆”,把两个查询的结果集按行堆叠在一起,比如1月份销售表查出来100行,2月份销售表查出来120行,union之后得到220行(去重逻辑后面细说)。
我举个实际工作中的例子。公司有线上商城和线下门店两套销售系统,各存各的订单数据。老板要一份全渠道的月度销售总表,字段需要包含订单编号、渠道、商品名称、销售额、下单时间。这种需求用union就非常合适:分别从两个系统查数据,然后堆在一起。如果你用join去搞,反而会搞出笛卡尔积的灾难现场。
union的官方定义是:通过组合两个或多个select语句的结果集,合并成一个结果集返回。它有几个硬性规则:每个select语句必须拥有相同数量的列,对应位置的列数据类型必须兼容,order by只能放在最后一个select语句后面。这些规则背后的原理,就是union在做结果集合并时,是按照“位置”而不是“名字”来对齐的。这句话建议反复读三遍,很多报错都源于没理解这个位置对齐的逻辑。
从执行引擎的角度看,union背后做的是两件事:把多个查询的结果集拼接起来,然后对合并后的结果做去重。也就是说,union默认会执行distinct操作,对所有列进行逐一比对,完全相同的行只保留一条。这个特性在数据量小的时候感知不强,但数据量一上来,去重带来的排序和比较开销会非常明显。这也是为什么很多性能优化方案里,能用union all的地方坚决不用union。
2. 四步走核心拆解:从语法到实战的完整路径
2.1 第一步:确认列的数量和顺序,这是百分之九十报错的根源
我接手过不少团队的历史SQL,发现union报错大概有九成是列数不一致导致的。比如第一个select查了4列(订单号、渠道、金额、时间),第二个select只查了3列(订单号、渠道、金额),union直接报错“使用的列数目不同”。
为什么会这样?因为union在做结果集合并时,需要把第一个查询的每一列和第二个查询的对应位置列做比对。位置对不上,合并就无从谈起。这里有个隐含要求:不仅列数要一致,列的顺序也要一致。什么意思?假设两个select都查了订单号和金额,但第一个先查订单号再查金额,第二个先查金额再查订单号。从数据结果看,union不会报错,但返回的每一行里,第一个位置是第一个查询的订单号和第二个查询的金额混在一起,逻辑上完全错乱了。这种错乱比报错更可怕,因为光看结果很难发现。
我在实际工作中总结了一套标准操作流程。首先把每个select的字段清单单独列出来,按业务含义排好顺序,比如统一是“日期、渠道、订单号、金额”。然后逐个比对位置,确保每个位置上的字段业务含义一致。最后检查每个字段的数据类型是否兼容。
这里还有个细节:字符串类型的“001”和数值类型的1能不能union?答案是可以的,因为数据库会自动做隐式类型转换。但转换的方向取决于数据库的隐式转换规则,这可能导致一些意想不到的结果。我遇到过金额字段一个查出来是字符串、另一个是数值类型,union之后字符串被转成了数值,原本的精度丢失了。所以我的习惯是:在union之前,把对应字段统一用cast转换成相同的数据类型,哪怕看起来能兼容也建议显式转换一次,省得后面出幺蛾子。
2.2 第二步:搞清楚union和union all的去重逻辑,别再凭感觉选
这是union里最重要的一个分岔路口。union会对合并后的结果集做去重,union all则直接拼接、原样返回。两者的区别用一句话概括:union是“合并后去重”,union all是“全量拼接”。
从底层实现来看,union的去重不是简单的逐行比对,而是需要对结果集做排序或者哈希操作才能找出重复行。这意味着union的内存消耗和计算开销都比union all大很多。数据量小的时候体会不明显,几万行数据union也就多花几十毫秒。但到了几百万行甚至上千万行的级别,union可能直接让查询跑几十秒,而union all基本秒出。
那是不是永远用union all就行?也不是。去重是有业务价值的。我给你两个对比场景。
场景一:两个子查询分别查的是不同月份的订单,业务上订单号不可能跨月重复,这时候用union all就是最合适的。因为你知道不会有重复,用union反而白白浪费一次去重计算。
场景二:两个子查询分别查的是线上和线下的订单,但可能存在同一订单在两个系统里都记录了的情况(比如线上下单门店自提),业务上需要合并成一个订单。这时候如果不做去重,报表数据就会翻倍,必须用union。
我的建议是:先确认业务逻辑上是否存在重复的可能。如果不确定,先用union all跑一遍,看看结果行数;再用union跑一遍,对比两次的行数差异。如果差异为0,说明本来就无重复,后续可以放心用union all提升性能;如果差异很大,说明重复率很高,需要union,同时排查一下为什么会出现重复,是不是源头数据就有问题。
2.3 第三步:order by和limit的位置,union的“语法陷阱”集中区
union相关的语法错误还有一个高发区:order by和limit的摆放位置。这个我见得太多了,几乎每周都能在群里看到有人问。
先说order by。如果你对union的最终结果排序,order by必须放在整个union语句的最后,而且只能出现一次。它作用的是合并去重后的结果集,而不是某一个子查询。比如你想按下单时间倒序排列所有渠道的订单,正确写法是这样:
select order_id, channel, amount, order_time from online_orders union select order_id, channel, amount, order_time from offline_orders order by order_time desc;注意,这个order by里的字段名用的是第一个select的列名。因为union的结果集列名以第一个select为准。如果你在union前面那个select后面写了order by,大多数数据库会报语法错误,或者直接忽略。在MySQL里如果你在第一个子查询里加order by但不加limit,优化器可能会直接把order by优化掉,因为它觉得这个排序没有意义——反正后面还要合并。这也是很多初学者困惑“我明明写了order by为什么不生效”的原因。
再说limit。如果你需要对每个子查询分别做限制,比如“线上取最新10条,线下取最新10条,合并后总共20条”,那么limit要放在各自的子查询里,并且通常要配合order by使用:
(select order_id, channel, amount, order_time from online_orders order by order_time desc limit 10) union (select order_id, channel, amount, order_time from offline_orders order by order_time desc limit 10);这里用括号包住每个子查询,数据库才能正确识别order by和limit的作用范围。如果没有括号,order by和limit到底是修饰谁,数据库的解析会变得很暧昧,不同数据库行为不一样。为了保险起见,只要子查询里涉及order by或limit,我建议一律加括号。
还有一个小技巧:如果你已经对每个子查询做了limit,合并后还想整体再limit一次,可以在union之后的末尾再加一个limit。这个末尾的limit作用于最终结果集,语义是清晰的。
2.4 第四步:列别名与数据类型处理,让结果集整洁可控
union的结果集列名是以第一个select的列名为准的。第二个select里的列名会被忽略,不会出现在最终结果里。这带来一个常见的坑:如果你在第二个select里起了很有意义的别名,最后发现根本不生效。比如:
select order_id as id, amount as amt from online_orders union select order_no as order_id, money as amount from offline_orders;最终结果集的列名是id和amt,不是order_id和amount。所以如果你要写order by或者在外面再包一层查询去引用列名,一定要以第一个select的别名为准。我的习惯是:所有参与union的子查询,第一个select规范写好别名,后面的select保持位置一致就行,别名随便写甚至不写都行,反正不影响结果。
数据类型方面,前面提过隐式转换可能造成精度丢失或者结果异常。这里再展开一个具体案例。有一次我在做金额汇总,两个系统里金额字段一个是decimal(10,2),一个是varchar。union之后我直接在外面套了一层sum(amount),结果发现金额对不上。排查了半天,发现varchar字段里有些脏数据带了货币符号前缀,数据库在做隐式转换的时候,这些带符号的数据被转成了0,导致汇总结果偏小。从那以后,我定了一条规矩:union之前,所有关键字段必须显式cast成目标类型,并顺便做数据清洗。
3. 实操过程:两个经典场景从零到一的完整实现
3.1 场景一:多系统订单数据合并,从需求到SQL的完整推演
假设现在有两个表:order_online(线上订单)和order_offline(线下订单),字段结构如下:
-- 线上订单表 order_online ( order_id varchar(32), channel varchar(16), product_name varchar(64), amount decimal(10,2), order_time datetime ) -- 线下订单表 order_offline ( order_no varchar(32), product_name varchar(64), total_amount decimal(10,2), pay_time datetime )注意,两个表的字段名并不完全一致,线下表用order_no表示订单号、total_amount表示金额、pay_time表示支付时间。这种“两个系统各自维护一套字段命名”的情况在实际工作中太常见了。
需求是:把线上和线下的订单合并成一张全渠道订单明细表,最后按渠道分组统计销售额。
第一步,列对齐。确定最终结果需要哪些列:订单编号、渠道、商品名称、金额、下单时间。然后逐一映射:
| 目标列 | 线上表字段 | 线下表字段 |
|---|---|---|
| 订单编号 | order_id | order_no |
| 渠道 | 'online'(常量) | 'offline'(常量) |
| 商品名称 | product_name | product_name |
| 金额 | amount | total_amount |
| 下单时间 | order_time | pay_time |
第二步,写SQL。因为业务上线上和线下是两套独立的订单系统,订单号不会重复,所以用union all即可:
select order_id as order_id, 'online' as channel, product_name, amount, order_time from order_online union all select order_no as order_id, 'offline' as channel, product_name, total_amount as amount, pay_time as order_time from order_offline;这里有个细节:channel列在两个子查询里都是常量字符串,它的作用是用来区分数据来源。如果没有这个标记列,合并之后你根本分不清某条记录是线上还是线下的。这是多源数据合并时的一个最佳实践:手动加一个“来源标识列”。
第三步,验证结果。分别执行两个子查询,记录各自的行数。合并之后查询总行数,确认等于两个子查询行数之和——这是union all的特征,也是校验数据没有丢失的简单方法。
如果要跨系统去重,比如业务上同一订单可能同时出现在线上和线下表时,就得把union all换成union。但union在去重的时候按所有列逐一比较,如果两个系统记录的payment_time存在毫秒级差异,就会被判为不同行,无法去重。这种情况下,我会先对数据做标准化处理(比如把时间格式化到秒、金额统一精度),再执行union。
3.2 场景二:多月份表合并统计,动态生成union查询的工程实践
还有一类常见需求:分表存储的月度数据,需要合并统计。比如订单表按月拆分成order_202401、order_202402、order_202403,需要统计整个季度的订单情况。如果只有三个月,手写三个select union一下还能接受,但如果是36个月呢?手写36段union会让人怀疑人生。
这种情况下,工程上一般用程序动态生成SQL。以Java和MyBatis为例,可以写一个工具方法,循环生成union片段:
public String buildUnionSql(List<String> tableNames, String startDate, String endDate) { StringBuilder sql = new StringBuilder(); for (int i = 0; i < tableNames.size(); i++) { if (i > 0) { sql.append(" union all "); } sql.append("select order_id, amount, order_time from ") .append(tableNames.get(i)) .append(" where order_time >= '").append(startDate) .append("' and order_time < '").append(endDate).append("'"); } return sql.toString(); }当然,更优雅的做法是直接在SQL层面做分区表,让数据库帮你去管理这些分片。但如果你接手的是历史遗留的分表系统,手写动态union是目前最稳的过渡方案。这里有一个优化点:每个子查询里尽早过滤数据,只查需要的时间范围,减少参与合并的数据量。union all拼接的时候,数据库需要把每个子查询的结果集物化出来再做拼接,子查询返回的数据量越小,整体性能越好。
还有一个实操细节:动态生成的union语句,建议在测试环境先用小数据量验证一遍,确认列顺序、类型匹配都没问题,再上生产。因为这种SQL是动态拼出来的,一个字段顺序写错,线上跑起来才发现问题,排查成本极高。我曾经踩过这个坑:动态SQL里一个表的字段顺序和其他的不一致,结果某个月的金额全部串到了商品名称列,报表出来一堆乱码,最后花了一整天才定位到是拼接SQL的字段顺序问题。
4. 常见问题与排查技巧实录
4.1 为什么union之后行数对不上
这是union使用中最常见的困惑。如果你用的是union,行数比各子查询行数之和小是正常的,因为去重了。但如果你确认业务上不存在重复,行数还是少了,那就要怀疑是不是数据本身存在意外的完全重复。排查方法很简单:改成union all查一次,对比两次行数差异,再对差异部分做group by having count(*) > 1来定位具体重复行。
如果把union all的行数之和也对不上,问题可能出在select语句本身的where条件有交集。比如线上表和线下表都包含“自提订单”,自提订单在两边的order_id是相同的,而且你以为用了union all就不会去重——union all确实不去重,但如果你同时把两个表都过滤出来了相同订单,那结果里就会出现完全相同的两行,这在业务上可能是错误的。这类问题靠SQL本身是发现不了的,必须回到业务层面去确认数据的来源范围是否有重叠。
4.2 order by不生效怎么处理
order by不生效,大概率是order by写在了union的中间子查询里。前面说过,union要求order by只能出现在整个语句的最后,而且要确保它作用于最终结果集而不是某个子查询。解决办法是:把order by挪到union之后。
还有一种情况:你确实把order by写在了最后,但排序结果看起来没有生效。这可能是因为结果集列名以第一个select的列名为准,而你在order by里用了第二个select的列名。比如第一个select的列叫order_time,第二个select的列叫pay_time,你写了order by pay_time,数据库可能直接报错,或者在某些数据库里不报错但排序结果不符合预期。解决方法是:统一用第一个select的列名来排序。
3.3 场景三:FlinkSQL写入Doris时union key模型表的注意事项
这个场景是最近做实时数仓时遇到的,跟热词里的“flinksql写入doris union key模型的表”对上了,值得单独拎出来说。
Doris的unique key模型,在FlinkSQL的写入场景下,很多人会误以为它就是数据库里的union操作。实际上Doris的unique key模型在处理数据写入时,对于相同key的多条记录,会按照版本号或者导入顺序保留最后一条。这个行为和SQL语句里的union去重逻辑不太一样:union去重是“多条记录完全相同时只留一条”,而Doris的unique key是“key相同就覆盖”。比如两条记录,订单号相同,但金额字段一个100一个200,union不会把它们当成重复(因为金额不同),但Doris的unique key会因为订单号相同而把两条记录合并成一条,最后保留的是后写入的那个金额。
这意味着什么?如果业务上需要把多个数据源的记录合并写入Doris的unique key表,你不能依赖Doris的key覆盖机制去帮你实现业务层面的去重逻辑。你需要在FlinkSQL里先用union all把多个流合并,然后再做基于业务主键的去重,显式指定保留哪一条,最后再写入Doris。否则,Doris按key覆盖的默认行为可能不是你想要的。
举个例子。实时订单流里,线上订单和线下订单都可能更新同一个订单号的状态。你希望“同一订单号,状态较新的覆盖旧的”,这个逻辑在Doris的unique key下天然成立。但如果你希望“同一订单号,线上优先于线下”,光靠unique key覆盖做不到,需要在FlinkSQL里用row_number()窗口函数按业务优先级排序,取第一条再写入。
所以,FlinkSQL写入Doris unique key模型时,我的建议是:先明确业务上去重/覆盖的语义,然后在FlinkSQL里用union all把多流合并,再用明确的去重/排序逻辑处理好数据,最后写入Doris。不要试图让Doris为你背业务逻辑的黑锅。
4.3 union查询性能太差怎么优化
性能问题的排查顺序很重要,我一般是按照这个步骤来定位的:
第一步,检查union还是union all。如果没有去重需求,优先用union all,这一步通常能带来数倍的性能提升。第二步,检查每个子查询是否走了索引。如果子查询的where条件里没有命中索引,union整体性能一定差。第三步,检查是否可以对子查询结果先做聚合再union。比如需求是统计每个渠道的销售额,你应该先在每个子查询里group by渠道算好汇总,再union,而不是把明细数据union完再在外层做group by。数据量大的场景下,这个优化可能是几十倍的差距。
还有一个冷门但好用的优化:如果业务上允许,可以尝试把union改成join加group by的等价写法。比如两个表的数据要合并后去重,如果两个表量级差不多,union的性能和join加group by差别不大;但如果一个表很大、一个表很小,有时候改写join加group by反而更快。不过这种改写可读性会变差,建议只在性能瓶颈确实在union上时再考虑。
4.4 union之后的列名与类型错乱问题
列名错乱前面已经提到了,这里再补充一个类型错乱的典型案例。假设第一个select的某列是int类型,第二个select的同一位置列是varchar类型。某些数据库在union时会把结果集列类型提升为varchar,这样原本是int的列也被转成字符串。如果你在外部对这个列做数值计算,会触发隐式转换,性能变差不说,还有可能因为数据格式问题报错。
我在FlinkSQL里就遇到过类似问题。FlinkSQL对于union的类型推断比较严格,如果两个流对应位置的字段类型不一致,直接报错,要求你显式cast。这点反而比传统数据库更安全,因为它逼着你在源头把类型统一。所以我的习惯是,不管是传统SQL还是FlinkSQL,在写union之前先检查一遍每个位置字段的类型,不一致就主动cast成目标类型。
5. 一些补充
5.1 union在聚合汇总场景下的数据倾斜隐患
如果你在做大数据的离线计算,比如Hive或Spark SQL里用union做数据合并,要注意数据倾斜问题。union本身不产生数据倾斜,但union之后如果紧接着一个group by聚合,可能会因为某个key的数据量特别大,导致单个reduce任务处理时间远超其他任务,整个作业卡在那里。
有一次我跑一个全渠道销售汇总,union了12个月的订单数据,然后按品类group by汇总。结果发现某几个爆款品类的数据量是其他品类的几百倍,这几个key对应的reduce任务跑了将近两个小时,其他的十几分钟就结束了。后来我把group by拆成了多个层级,先按月份和品类聚合出小结果集,再union后做最终聚合,把一个两小时的作业优化到了二十分钟以内。
这类问题的排查思路是:看作业日志里各个task的处理时间分布,如果明显有个别task耗时远高于中位数,基本可以判定是数据倾斜。解决办法主要有filter/rand加盐打散、两阶段聚合等,这里不展开,但union之后紧跟的聚合操作,尤其要注意这个风险。
5.2 union与子查询嵌套的写法差异
还有一个细节值得注意:在某些数据库里,union的优先级是低于order by和limit的,所以如果你写select * from a union select * from b order by id,数据库确实按union的最终结果去排序了。但如果你写select * from a union select * from b limit 10,这个limit作用于整个union结果集,返回合并后的前10条。这通常没问题,但如果你本意是“每个表各取前10条再合并”,就必须给子查询加括号,写成(select * from a limit 10) union (select * from b limit 10)。
这两种写法的结果差别很大,但语法上都不报错,特别容易踩坑。我建议把union的两个子查询都用括号包起来,即使不需要limit和order by也加上,这样语义最清晰,也方便后续维护。
6. 总结一下我的使用经验
联合查询的核心就这四步,但真正用好的关键在于对业务数据的理解。union和union all的区别、列对齐、order by摆放、类型统一,这些都是硬规则,背下来就能少踩很多坑。但什么时候该去重、什么时候不该去重、用唯一键去重还是全字段去重,这些必须回到业务场景里来判断。
我在实际工作中还有一个习惯:所有涉及union的SQL,写完第一件事不是直接跑,而是先用explain看执行计划。一是确认每个子查询是否走了索引,二是看执行计划里union的物化方式是否合理。执行计划不会骗人,比你在那里瞎猜性能瓶颈高效得多。
如果union的结果集需要在下游被多次使用,比如报表系统里被多个图表引用,我会建议把union的结果先物化成一张临时表或者视图,而不是每次查询都现算。当一个union涉及的表数量多、数据量大时,重复计算的代价是成倍增长的,物化一次、多次读取,收益非常明显。
最后分享一个小技巧。排查union相关的问题时,我习惯先把union all跑通,确认数据和行数没问题,再改成union,观察行数变化。这样能快速定位是“查询本身有问题”还是“去重逻辑不符合预期”。这个排查顺序帮我节省了大量时间,也推荐给你试试。