十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SQL联合查询Union核心四步:语法原理、去重逻辑与性能优化实战

SQL联合查询Union核心四步:语法原理、去重逻辑与性能优化实战 联合查询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_idorder_no渠道online常量offline常量商品名称product_nameproduct_name金额amounttotal_amount下单时间order_timepay_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(ListString 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一个200union不会把它们当成重复因为金额不同但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观察行数变化。这样能快速定位是“查询本身有问题”还是“去重逻辑不符合预期”。这个排查顺序帮我节省了大量时间也推荐给你试试。
返回列表