做报表统计的时候,我经常要处理一类需求:把几张结构一模一样的表的数据堆到一起查。比如订单历史库按年份分了表,今年和去年的数据分开存放,但业务方要一份合并后的总报表;又比如内容平台要同时展示文章和视频的推荐列表,两张表字段结构相同,数据却要在一个接口里返回。这种"纵向合并"的操作,就是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再查用户名,第二条先查用户名再查用户ID,SQL不会报错,但最终结果集的列名以第一条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,另一张表是latin1,MySQL需要转换字符集,转换过程中如果有特殊字符(比如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 can't have a default value"或排序长度受限的问题。遇到这种需求,建议先在子查询里把大字段截断或转成VARCHAR,再去重,能有效规避限制。
从实用性上看,联合查询用得好,最直观的收益是少写很多应用层代码,把数据整合的动作下推到数据库里完成。但前提是你得记住它和JOIN的分工、UNION与UNION ALL的取舍、排序分页的位置要求。我个人的经验是:写联合查询之前先想清楚两个问题——结果集里允不允许重复数据?每个分支能不能独立走索引?想明白了,UNION写出来基本不会出大问题。