ARTICLE DETAIL

资讯详情

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

MySQL联合查询UNION与UNION ALL用法、性能优化及踩坑指南

MySQL联合查询UNION与UNION ALL用法、性能优化及踩坑指南 做报表统计的时候我经常要处理一类需求把几张结构一模一样的表的数据堆到一起查。比如订单历史库按年份分了表今年和去年的数据分开存放但业务方要一份合并后的总报表又比如内容平台要同时展示文章和视频的推荐列表两张表字段结构相同数据却要在一个接口里返回。这种纵向合并的操作就是MySQL里的联合查询也就是UNION语法。联合查询和连接查询JOIN虽然都叫多表查询但做的事情完全不同。JOIN是把两个表的数据按关联条件横向拼成一行UNION则是把多条SELECT语句的结果上下堆叠成一个结果集。很多初学者在这里绕晕过我最早也把UNION当成JOIN的替代品去用结果查出来的数据完全不是那么回事。这篇笔记我把联合查询的语法规则、使用场景、性能问题、踩坑记录一次讲透重点内容都标出来了适合正在学SQL的初级开发也适合写了不少查询但没系统梳理过UNION用法的同学。1. 联合查询到底解决什么问题1.1 联合查询和连接查询别再傻傻分不清先说一下为什么需要联合查询。业务数据有个常见规律增长得快、历史数据多DBA就会按时间把大表拆成多个物理表比如订单表拆成orders_2023、orders_2024日志表按月份分表。物理上拆开了但业务查询还是要看全量数据这个时候单个SELECT只能查一张表要么写多个查询在应用层合并要么在SQL层面用UNION一次搞定。同理有些系统把不同类型的数据放在不同表里文章表和视频表但前台推荐流需要混合展示这也是UNION的典型场景。JOIN和UNION的本质区别在于拼接方向上JOIN是横向拼接把表A的行和表B的行通过关联键组成更宽的行列数增加了UNION是纵向堆叠把多条SELECT的结果一行接一行排列行数增加了。用一个生活化的例子来说JOIN像是把两张卡片的左侧和右侧粘在一起形成一张更宽的卡片UNION像是把两叠卡片上下叠成一摞卡片宽度不变但更厚了。查询目标不同选型就完全不同要补充字段信息用JOIN要汇总同类数据用UNION。1.2 UNION 与 UNION ALL一字之差结果天壤之别UNION和UNION ALL这两个关键词是所有联合查询的基础它们唯一的语法差异是有没有ALL但行为差异非常关键。UNION会对最终结果集做去重去掉所有字段完全相同的重复行UNION ALL则是纯粹地把所有行堆叠不做任何去重处理。这意味着两个问题第一UNION因为有去重操作需要额外的排序或哈希步骤来找出重复行开销明显大于UNION ALL第二如果业务上明确知道不会出现重复行或者重复数据有特殊统计价值比如订单流水明细那就应该用UNION ALL否则白白损耗性能还可能把业务数据弄丢。我实际测过一张几十万行的表UNION比UNION ALL慢一倍以上数据量越大差距越明显。去重还有一个值得注意的细节UNION的去重是基于结果集中所有列的完整值来做判断的不是按某一列或主键判断。换句话说只要任意一列的数据不相同两行就不会被当成重复行。这个特性在联表查询中很容易被误判——你以为查出来的重复数据会被去掉实际上因为某些列的值不同去重根本不会生效。2. 哪些场景必须用联合查询2.1 多表结构相同数据需要纵向汇总最典型的联合查询场景就是把结构相同的多张表的数据合并统计。比如一个电商平台把订单按年份分表orders_2023和orders_2024的表结构完全一样现在要统计两年的总订单量和总销售额。直接对两张表分别查询再在代码里相加当然可以但一次SQL搞定更优雅而且可以继续在合并结果上做排序、分组和分页。SELECT order_id, user_id, amount, order_date FROM orders_2023 UNION ALL SELECT order_id, user_id, amount, order_date FROM orders_2024;看到没有这里用的是UNION ALL而不是UNION。原因是订单ID是全局唯一的两张表之间不可能有重复数据去重毫无意义反而白白增加排序开销。如果误用UNION相当于让MySQL额外做一次全量去重数据量大的时候执行时间会变得很可观。如果合并后需要做分组统计可以直接把UNION的结果作为子查询包一层SELECT COUNT(*) AS total_orders, SUM(amount) AS total_amount FROM ( SELECT order_id, user_id, amount, order_date FROM orders_2023 UNION ALL SELECT order_id, user_id, amount, order_date FROM orders_2024 ) AS t;2.2 单表复杂条件拆分用UNION替代大面积OR另一个常见用法是在同一张表里用多条SELECT语句分别查询不同条件再用UNION合并。很多人第一反应是用多个OR条件写进一条SELECT但OR一旦多了SQL的可读性和执行计划的稳定性都会变差。尤其当每个分支涉及不同的索引组合时优化器很难选择最优执行路径甚至可能放弃索引。举个例子后台管理系统要查一批特殊用户要么是VIP等级大于3的付费用户要么是近7天下单超过5次的高频用户要么是注册时间超过3年的老用户。三个条件方向差异很大如果揉进一个WHERE里索引选择容易打架。拆成三条SELECT分别执行每条都能走自己的最优索引再用UNION ALL合并性能反而更可控SELECT user_id, user_name FROM users WHERE vip_level 3 UNION ALL SELECT user_id, user_name FROM users WHERE order_count_7d 5 UNION ALL SELECT user_id, user_name FROM users WHERE DATEDIFF(NOW(), reg_time) 1095;这种情况用UNION还是UNION ALL需要看业务需求如果三个条件可能有重叠比如某个用户既是高VIP又高频下单合并结果中同一个用户会出现多次。有重叠并且结果集需要唯一用户列表就改用UNION去重没重叠或者每行数据有独立意义就用UNION ALL。这个决策要结合数据特征来定不能凭感觉。2.3 联合查询与子查询的搭配思路UNION的结果可以从逻辑上看作一张新的虚拟表所以它天然适合作为子查询的数据源。你需要在这张虚拟表上做进一步筛选、排序、分组的时候就把UNION语句包进FROM子句中。上面的统计订单总量的例子就是这种用法。这种嵌套还经常用于先合并、再取交集/差集的复杂需求。举个例子要找出既在活动A报名表里又在活动B报名表里的用户可以先对活动A报名的用户做UNION合并如果报名表也有分表再INNER JOIN另外一张表或者用IN操作。反向的在A但不在B则可以用LEFT JOIN加IS NULL的方式实现。核心思路不变UNION负责把散落的数据先合并成整体后面的查询再对这个整体做各种加工。3. 联合查询的语法细节与实操要点3.1 基本语法与字段对齐规则UNION的基本语法非常简单核心就是把多条SELECT语句用UNION或UNION ALL连接起来。语法上最硬性的要求是每一条SELECT语句返回的列数必须一致而且对应位置的列的数据类型必须能够兼容。可以理解为你想把几摞不同宽度的卡片叠成一摞但每张卡片的宽度不同堆起来就对不齐了。如果第一条SELECT查了3个字段第二条只查2个字段执行会直接报错。如果字段数相同但顺序不对应比如第一条先查用户ID再查用户名第二条先查用户名再查用户IDSQL不会报错但最终结果集的列名以第一条SELECT为准数据内容会错位——用户ID那一列下面混着用户名肉眼很难发现。这种错位是联合查询最隐蔽的一类bug尤其是在字段很多、表结构相似但不完全相同的场景中。结果集的列名规则也需要注意一下整个UNION结果集的列名统一采用第一条SELECT语句中定义的列名或者别名。比如第一条SELECT写的是SELECT user_id AS id那整个结果集的这一列就叫id后面几条SELECT即使写成别的别名也不起作用。这不影响查询结果的正确性但会让代码的观感变奇怪所以我建议所有SELECT分支使用一致的列别名避免阅读维护时产生误解。3.2 ORDER BY 和 LIMIT 的正确写法联合查询里的ORDER BY和LIMIT是一个非常经典的知识点新手几乎都会踩坑。先说ORDER BY。如果你把ORDER BY放在最后一条SELECT语句的末尾MySQL不一定按照你的想法整体排序它可能只对最后一段查询结果排序然后把之前的结果直接拼在前面。这是因为UNION的优先级规则比较特殊为了让排序作用于整个结果集必须把ORDER BY写在所有UNION分支的末尾并在ORDER BY后使用列名而不是表名.列名因为整个结果集已经被视为一个匿名临时表了。SELECT user_id, user_name FROM users_2023 UNION ALL SELECT user_id, user_name FROM users_2024 ORDER BY user_id DESC;上面的写法会对合并后的完整结果集按user_id降序排列。但如果把ORDER BY去掉或者放在中间的某个SELECT内部排序的语义就会变得模棱两可。稳妥的写法是把UNION包成一个子查询在外面排序SELECT * FROM ( SELECT user_id, user_name FROM users_2023 UNION ALL SELECT user_id, user_name FROM users_2024 ) AS t ORDER BY t.user_id DESC LIMIT 20;再说LIMIT。如果你只想对每一段SELECT分别限制行数需要把每个分支用括号包起来并且注意MySQL对带括号的子查询在UNION里的支持情况。但如果要对整个合并结果限制行数就把LIMIT和ORDER BY一起放在所有UNION分支的最后面。注意一个常见误区如果ORDER BY写在最后一个分支内部而LIMIT写在最外层排序同样可能失效最好的做法是先包一层子查询在子查询外面做排序和分页。3.3 数据类型兼容与隐式转换联合查询要求各分支对应位置的字段数据类型兼容但不要求完全一致。MySQL会根据字段的类型和长度进行隐式转换把不同类型的数据统一成兼容类型。比如第一个分支查的是INT类型第二个分支查的是VARCHAR类型MySQL会把整数转成字符串第一个分支是VARCHAR第二个分支是TEXT类型转换规则又会因为字符集和排序规则的影响出现意外行为。这里有个值得注意的实际问题当合并包含不同字符集的字符串字段时比如一张表的字段是utf8mb4另一张表是latin1MySQL需要转换字符集转换过程中如果有特殊字符比如emoji在latin1中根本无法表示查询就可能报错或产生乱码。设计分表时尽量统一字符集和排序规则能省掉很多麻烦。另一个隐式转换的坑是NULL值。如果第一个分支的某列不允许为NULL而第二个分支对应的列完全是NULL合并后结果集中这个字段可能出现NULL值。这不会报错但会影响到后续的应用层逻辑比如前端拿到的字段值为null导致空指针。合并前先确认好每个分支对应字段是否有合理的默认值特别是以后再对这些数据做聚合运算时NULL的传播会让SUM、AVG这些函数的结果变得和预期不一致。4. 性能优化联合查询如何跑得更快4.1 执行计划怎么看联合查询的性能分析同样从执行计划入手。在MySQL命令行或客户端工具里给UNION查询前面加上EXPLAIN可以看到MySQL是如何执行每一步的。一个UNION查询的执行计划里会显示多个行对应参与联合的每个SELECT分支还会出现一个临时表的处理环节尤其当使用UNION去重时。我建议先养成一个习惯凡是涉及联合查询的SQL上线前都跑一次EXPLAIN重点看各分支的type字段是不是ALL全表扫描还是ref/range索引范围扫描以及Extra列里是否出现了Using temporary或Using filesort。全表扫描加上文件排序基本可以断定这条联合查询的数据量大到会拖垮性能。4.2 索引利用与临时表开销联合查询的索引利用规则和普通查询并没有本质不同每条SELECT分支都可以独立使用自己的索引。换句话说想要联合查询跑得快前提是每个分支的WHERE条件都能命中合适的索引。如果有一个分支没有索引可用做全表扫描整个查询的耗时就会被这个分支拖住其他分支再快也没用。所以在分表设计的时候要确保每张表的索引分布保持一致否则联合查询的性能取决于最差的那个分支。UNION去重相比UNION ALL多出的开销本质上来自去重这个动作。MySQL需要把结果集里的每一行进行比较以判断是否存在重复。当结果集很大时这个操作既消耗CPU又消耗内存甚至会让MySQL在磁盘上创建临时表来完成排序去重。实测结论非常一致如果业务允许优先用UNION ALL把去重的需求交给应用层或通过更精准的业务条件规避掉。4.3 大结果集的分页与排序优化当联合查询的结果集很大且需要进行排序和分页时一个隐藏的性能陷阱是MySQL必须先把所有分支的结果合并成临时表再对临时表执行排序最后才取出LIMIT指定的行。这意味着即使你的业务只需要最后10行数据MySQL也可能把几十万行的合并结果全部物化出来排序内存和IO开销非常巨大。优化方向有两个。第一个方向是在各分支内部就先做排序和LIMIT减少进入合并阶段的数据量。比如你要查每张表最新的10条记录可以在每个分支内部用ORDER BY加LIMIT 10再联合起来做最终排序这样临时表的数据量就被压缩到几十行。第二个方向是尽量把排序字段利用上索引让MySQL在读取数据的过程中自然有序省掉最后的filesort。不过MySQL对UNION分支内部LIMIT的支持有版本差异老版本不支持在UNION的子分支里配合ORDER BY加LIMIT需要你根据MySQL版本查一下语法的兼容性。5. 实战中踩过的坑与排查记录5.1 列数不匹配最常见的报错联合查询报错里出现的最高频的一个就是The used SELECT statements have a different number of columns。产生的原因很简单某条分支的SELECT列数和第一条分支不一致。这种情况多发于表结构变更后没有同步修改所有分支或者是复制粘贴SQL时改漏了字段。排查思路很直接逐个分支数一下SELECT的字段个数。我常用的方法是在编辑器里把每个分支的SELECT字段单独一行列出来视觉上做对齐很容易发现问题。另外使用SELECT * 的分支在表结构变化后也会出错所以生产环境SQL里尽量不要用SELECT *配合UNION否则一旦加列所有相关查询都要跟着改。5.2 排序失效的深层原因明明写了ORDER BY结果排序却没生效是另一个高频问题。前面的语法部分已经解释了原因ORDER BY如果写在最后一个SELECT分支的末尾MySQL可能只在最后一段上排序如果包了子查询但在子查询内部排序外层再次排序时也会出问题。正确的写法是把ORDER BY和LIMIT放在整个联合查询的最后面或者包一层子查询再在外面排序。这里有一个容易被忽视的细节UNION去重后排序结果里某些行消失了。你原本预期有10行排序后只看到8行会被误认为排序错误实际上是UNION把两行完全相同的数据合并成了一行。排查时先把UNION临时改成UNION ALL看行数是否恢复就能定位问题。5.3 数据重复与去重失败最后再说一个去重失败的真实场景。业务流程要求合并两个用户标签表输出唯一的用户列表所以我用了UNION。结果发现同一个用户出现在结果集里多次。查了数据才知道一张表里的字段是user_id加user_name另一张表也差不多但第一张表某个用户的user_name做了变更导致两行虽然user_id相同但拼接起来的完整行并不一样。UNION的比较是全列比较只要任何一列不同就不会去重。这种情况下要么在SELECT阶段只保留需要判重的列要么在UNION外部用GROUP BY再做一次聚合。此外对大型字符串字段做UNION去重时MySQL需要用临时表做排序比较如果字段是TEXT或BLOB还可能出现BLOB/TEXT column cant have a default value或排序长度受限的问题。遇到这种需求建议先在子查询里把大字段截断或转成VARCHAR再去重能有效规避限制。从实用性上看联合查询用得好最直观的收益是少写很多应用层代码把数据整合的动作下推到数据库里完成。但前提是你得记住它和JOIN的分工、UNION与UNION ALL的取舍、排序分页的位置要求。我个人的经验是写联合查询之前先想清楚两个问题——结果集里允不允许重复数据每个分支能不能独立走索引想明白了UNION写出来基本不会出大问题。
返回列表