1. 什么时候真正需要UNION:从单表拆分的实际场景说起
我最早对UNION产生强烈印象,是在处理一套订单数据的时候。业务那边给了个需求:要把VIP用户的历史订单和普通用户的订单放在同一个报表里统计。当时第一反应是用JOIN,折腾了半天发现JOIN是把两张表的列横向拼起来,而需求是要把两个数据集上下堆叠,逻辑完全是两回事。后来才意识到,UNION才是这类场景的正解。
UNION的本质是把多个SELECT语句的查询结果纵向合并成一个结果集,每个SELECT返回的列数必须一致,列的数据类型要兼容,列的顺序也要对齐。听起来简单,但实际使用中,很多人连“什么时候该用UNION”都没想清楚就直接写,最后要么结果错了,要么性能很糟糕。
我梳理过几种典型场景,基本都是UNION的“甜点区”:
- 历史数据归档:比如订单主表只保留最近三个月的数据,超过三个月的归档到历史表。但报表又必须统计全量,这时候把主表的结果和历史表的结果用UNION合并,比改业务代码更干净。
- 同构多源数据合并:多个分库的表结构一致,想把各分库的数据汇总到一起分析,UNION是天然的选择。
- 同一张表内不同维度的汇总:比如按“今日新增用户”和“今日活跃用户”分别统计,再用UNION拼成一个结果集,放在一张报表里展示。
- 字典值补全或类型映射:某个字段在主表中没有对应记录,但业务上希望空值也能显示成一行“未分类”,这时可以用UNION追加一个固定值的SELECT。
需要特别提醒的是:能在一个SELECT里用CASE WHEN或条件聚合解决的,就不要用UNION。UNION并不是越多越好,它意味着你要对多张表或多段逻辑分别执行扫描,然后合并,成本是叠加的。如果只是为了加一列值,用条件判断往往更高效。
我见过不少人在应该用UNION ALL的时候写了UNION,导致全表数据被拉去内存做去重,结果数据量一大直接爆内存或慢查询超时。这个去重成本是很隐蔽的,下一节我会专门展开讲。
简单总结一下:UNION解决的是“多个结果集纵向拼接”的问题,使用前提是列数一致、类型兼容、顺序对齐。判断标准就一句话——你需要的是一张“变长的表”,而不是“变宽的列”。搞清楚这一点,后续的细节才有意义。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. UNION与UNION ALL:性能取舍的账其实不难算
很多新手分不清UNION和UNION ALL的区别,其实就一个关键点:UNION会去除重复行,UNION ALL不会。就是这个“去重”,会让两者在性能和语义上产生天壤之别。
打个比方:UNION相当于先把两份名单贴在一起,然后拿笔把所有重复的名字划掉,只留一个;UNION ALL则是直接用订书机把两份名单钉在一起,有多少算多少。显然,前者多了一道“核对去重”的工序,这道工序就是性能开销的主要来源。
我在一个实际项目里验证过这个差距。有一张用户行为表,量级在千万级别,我用UNION把两个分段查询拼起来,执行时间大概在4秒左右;换成UNION ALL之后,执行时间一下子降到了1秒出头。数据集越大、重复率越低,这个差别就越明显。原因很简单:UNION要去重,就必须把两个结果集都拉到临时表里,做排序或哈希去重,这个操作是额外的内存和CPU消耗。
所以在选择时,我的判断逻辑是这样的:
- 如果两个查询结果从业务逻辑上不可能重复,比如一个是A渠道的订单,一个是B渠道的订单,且每条订单都有唯一ID不会跨界,直接用UNION ALL。
- 如果允许重复出现,或者重复对业务统计没有影响,用UNION ALL。
- 只有当业务上确实要求“合并后每个记录只出现一次”时,才用UNION。
有人可能会说:“用UNION保险一点,万一重复了呢?”这句话在小型项目里没毛病,但在数据量大一点的线上环境,这个“保险”可能直接让查询变慢一个数量级。与其用UNION兜底,不如先在业务上想清楚两个子查询的数据是否天然互斥。
另外还有一个常见的误用场景:在UNION后面的每个子查询里明明已经用DISTINCT了,外层再用UNION,这就等于做两次去重,纯属浪费。正确的做法是:每个子查询内不要写DISTINCT,把去重交给外层的UNION一次完成;如果用了UNION ALL,那子查询内的去重就有必要。
还有个细节容易被忽略:UNION结果集的列名是以第一个SELECT的列名为准。这意味着第二个SELECT可以给列起别名,但不会影响最终结果集的列名。如果应用层代码是按列名取值的,就要以第一个SELECT的列名为准来映射。
我见过有人为了让报表显示的表头好看,在最后一个SELECT里改了别名,结果发现前端根本不认。这个不算大坑,但调试的时候容易让人懵。记住一点:第一段SELECT决定列名和数据类型,后面的SELECT只需要保证列数和类型兼容就行。
3. 类型不一致、ORDER BY和LIMIT的执行顺序坑
UNION用多了就会发现,坑往往不在UNION本身,而在与其它子句组合时产生的“化学反应”。我挑几个踩得最多、也最隐蔽的来说。
3.1 列类型不匹配:隐式转换在悄悄改变你的数据
UNION要求各SELECT结果集的列类型兼容,注意是“兼容”不是“完全相同”。比如第一段返回的是字符串,第二段返回的是数字,数据库会做隐式类型转换。这个转换在不同数据库里表现还不一样,有的会把数字转成字符串,有的反过来,甚至可能直接报错。
我在MySQL里遇到过最难受的情况:一张表的字段是VARCHAR,另一张是INT,UNION之后,数字列被全部转换成了字符串。如果不小心用这个结果去做排序,排序规则就变成了字符串排序,结果就是10排在9前面,看起来完全乱掉。
处理方法也很直白:要么在设计表结构时就让相同语义的字段用相同类型,要么在SELECT里显式用CAST或CONVERT把类型统一。显式转换虽然多写几个字,但语义清晰,也不会让数据库的隐式规则给你一个“意料之外的结果”。
还有一个类似的问题:排序规则(collation)不一致。这个坑比较深,涉及字符集和排序规则,很多老手都栽过,我在下一节单独讲。
3.2 ORDER BY到底是对谁排序?
UNION配合ORDER BY的时候,最容易掉坑。下面这个写法,在MySQL和大多数数据库里是报错或者不符合预期的:
sql复制SELECT name FROM users_a ORDER BY created_at
UNION ALL
SELECT name FROM users_b ORDER BY created_at;
注意,ORDER BY出现在了子查询内部。在多数数据库的语法规则里,UNION后面的ORDER BY只能出现在整个语句的最后,用来对合并后的最终结果排序。如果你试图在每个子查询里分别排序,数据库要么报语法错误,要么直接忽略掉这个排序。
实际上,很多业务需求是要“每个分段各自排序后再合并”,这时候直接写子查询内的ORDER BY是行不通的。我在实践中用的办法是给每行加一个“分组排序键”,也就是先按分组的顺序排,再在组内排:
sql复制SELECT name, 1 AS group_id, created_at
FROM users_a
UNION ALL
SELECT name, 2 AS group_id, created_at
FROM users_b
ORDER BY group_id, created_at;
这样每个组内部就能按照各自的排序规则排列。虽然多了一列辅助字段,但逻辑清楚,完全可控。
3.3 LIMIT放错位置:只限制了一个子查询
LIMIT的坑和ORDER BY类似。有人想在两个子查询里分别取前10条,再合并成20条:
sql复制SELECT name FROM users_a LIMIT 10
UNION ALL
SELECT name FROM users_b LIMIT 10;
这个写法在MySQL里,LIMIT的默认作用范围是整个UNION之后的最终结果集,也就是只取10条,而不是你想象的各取10条。这会导致结果直接少一半,而且你还未必能第一时间察觉到。
正确的写法是给每个子查询加上括号:
sql复制(SELECT name FROM users_a LIMIT 10)
UNION ALL
(SELECT name FROM users_b LIMIT 10);
加了括号之后,LIMIT的作用域就被限制在子查询内部了。这个括号的用法同样适用于带ORDER BY的子查询。其实MySQL在部分版本里对括号内的LIMIT和ORDER BY支持得并不算太一致,但加上括号永远是最稳妥的写法,因为这样对数据库的解读是最明确的。
3.4 括号与优先级:别把UNION和JOIN混在一起
还有一个我见过不少次的问题:一个查询里既有JOIN又有UNION,结果发现JOIN的优先级把结果集搞错了。
UNION的优先级是低于JOIN的。也就是说,FROM a JOIN b UNION SELECT ...会被解析成(a JOIN b) UNION (...)。如果你的本意是要把两个JOIN的完整结果做UNION,那就必须在每个JOIN子句外面加括号:
sql复制(SELECT a.id, b.name FROM a JOIN b ON a.id = b.aid)
UNION ALL
(SELECT c.id, d.name FROM c JOIN d ON c.id = d.cid);
这个坑不致命,但一旦踩到,排查起来会非常费劲,因为结果集往往“看起来好像对”,仔细一数又少了或多了一些行。
4. illegal mix of collations:这个报错的根因不是猜出来的
如果你的查询写了UNION,然后在执行时看到了这样一段报错:
code复制Illegal mix of collations for operation 'UNION'
先说结论:引发这个报错的原因是参与UNION的两个字段,字符集或排序规则不同。数据库在合并结果时发现两边数据的“字符排序规则”对不上,无法确定怎么比较或去重,索性直接拒绝执行。这个报错在线上非常常见,尤其是在多表关联或拼接不同来源的数据时。
4.1 从报错到定位:一次完整的排查思路
我第一次遇到这个报错时,第一反应是字段类型不对,检查了半天类型完全一致,后来查看了两边的表结构,才发现问题不在字段类型,而在字段的COLLATE属性。
排错的正确思路,我认为应该是这样的:
- 第一步:分别单独执行UNION两侧的SELECT语句,确认两边单独执行都没有问题。
- 第二步:对比两侧结果集字段的类型和排序规则。可以用
SHOW FULL COLUMNS FROM 表名(MySQL)查看每个字段的Collation。 - 第三步:如果两边的Collation不一致,比如一侧是
utf8mb4_unicode_ci,另一侧是utf8mb4_general_ci,那就是它了。
我当时的实际情况是:users表建表时的字符集是utf8mb4_unicode_ci,而另一张历史表users_history的字符集是utf8mb4_general_ci。两边单独查询都没问题,一UNION就报错。原因就在于UNION需要比较两边的字段来做去重(即使是UNION ALL,某些数据库也会在内部做必要的字段处理,依然可能触发检查),数据库要求两边的排序规则必须兼容,否则它不知道“同一个字符串”应该按哪个规则去判断相等。
4.2 collation到底是什么,又是怎么影响结果的
说到这里,不如把collation的概念一次性讲清楚。字符集(charset)决定了你能存哪些字符,比如utf8mb4能存emoji和绝大多数语言的字符;而排序规则(collation)决定了字符之间怎么比较大小、怎么排序。
utf8mb4_unicode_ci和utf8mb4_general_ci就是两套不同的排序规则。它们的区别主要在Unicode排序的精确度上。unicode_ci更符合Unicode标准,对很多特殊语言的支持更准确;general_ci的排序速度略快,但某些边界字符的排序结果可能不符合预期。
这些差异平时不显著,但在UNION这种需要“跨规则比较”的操作里,数据库会直接报错而不是擅自选一套规则。这是数据库的一种自我保护机制,避免在排序规则无法确定的时候给出错误的结果。
4.3 解决方案:三个方向,按优先级来
解决这个报错有三个方向,我的建议是按顺序尝试:
方案一:显式指定COLLATE,快速但不推荐长期使用
sql复制SELECT name FROM users
UNION ALL
SELECT name FROM users_history COLLATE utf8mb4_unicode_ci;
这种写法给字段临时指定排序规则,可以在不改表结构的前提下让两边保持一致。但它有一个隐患:如果以后表结构调整或字段增多,这种临时的COLLATE很容易被漏掉,而且它会对该字段的索引使用产生负面影响。
方案二:用CONVERT统一字符集
sql复制SELECT CONVERT(name USING utf8mb4) AS name FROM users
UNION ALL
SELECT CONVERT(name USING utf8mb4) AS name FROM users_history;
这比COLLATE更彻底一点。它把两边字段都转成统一的字符集,然后再做合并。这种写法的好处是显式、可控,缺点是如果字段很多,SELECT写起来会很长。
方案三:改表结构,统一底层的字符集和排序规则
sql复制ALTER TABLE users_history
MODIFY name VARCHAR(255)
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
这治标又治本。如果你的库是新建的,或者可以接受短时间锁表,最彻底的办法就是让同名同义字段的字符集和排序规则保持一致。我自己的建议是,新建表时统一指定数据库默认字符集和排序规则,不要依赖数据库自动选的旧默认值。MySQL 8.0之前的默认排序规则是utf8mb4_general_ci,8.0之后改成了utf8mb4_0900_ai_ci,如果同一套系统里有的表建在5.7、有的建在8.0,就很容易埋下这个坑。
4.4 连接层也会“传染”collation
还有一个隐藏的传染源:数据库连接层的字符集设置。即使表字段的Collation一致,如果两个连接分别用了不同的character_set_client或character_set_results,UNION仍然可能报错。
我在排查历史上一次生产问题时,两张表的Collation明明一样,但问题出在读写分离架构中两个从库的collation_connection设置不同。后来统一了连接参数,问题才彻底消失。这提醒我们:排查这类问题,不要只盯着表结构,连接参数也要一起查。
5. 排序字段的拆解与UNION内部的执行顺序问题
UNION看起来简单,但它的执行顺序对结果影响非常大,尤其当一个查询里同时有GROUP BY、ORDER BY、LIMIT时。我在这一节把UNION在数据库内部的执行逻辑拆细一点,帮大家建立直觉。
一个典型的UNION查询,数据库的处理步骤大致是:
- 分别执行UNION两侧的SELECT子查询,得到两个中间结果集。
- 将两个中间结果集纵向合并。
- 如果是UNION,对合并后的结果集去重;如果是UNION ALL,跳过这一步。
- 对最终结果执行外层的ORDER BY和LIMIT。
从这个过程能看出两个关键点:
- 去重发生在合并之后、排序之前,所以UNION的去重判断是针对合并后所有列组合的,而不是只看某个主键。
- 排序和分页都发生在合并之后,这意味着LIMIT的偏移量是全局的,不是每个子查询单独算的。
我之前维护过一个报表接口,需求是“各渠道分别取前10条数据合并展示”。一开始我直接在UNION的最外层写ORDER BY和LIMIT,结果发现某个渠道的数据全被另一个渠道挤掉了。最后改成给每个子查询加上括号,并在括号内单独写LIMIT,才得到正确的分页结果。
正确写法是这样的:
sql复制(SELECT channel_id, order_amount FROM orders WHERE channel_id = 'A' ORDER BY order_amount DESC LIMIT 10)
UNION ALL
(SELECT channel_id, order_amount FROM orders WHERE channel_id = 'B' ORDER BY order_amount DESC LIMIT 10);
这种写法在MySQL、PostgreSQL里都是支持的。需要注意,有些数据库对括号内ORDER BY + LIMIT的组合解析有历史问题,但现代版本基本都修掉了,大家用的时候最好先跑一下EXPLAIN确认执行计划。
5.1 在UNION外部排序时如何保留“来源标记”
有一种很常见的需求:合并结果里要能区分每行数据来自哪个表或哪个查询,这时候用一个来源字段做“硬编码列”非常有效:
sql复制SELECT name, '当前用户' AS source, created_at FROM users
UNION ALL
SELECT name, '历史用户' AS source, created_at FROM users_history
ORDER BY FIELD(source, '当前用户', '历史用户'), created_at DESC;
这个写法里,source列可以作为排序键、分组键、甚至前端展示的徽标文案。我在做用户画像标签合并时经常用这个技巧,省掉了后端再根据ID回查来源的额外逻辑。
5.2 UNION与GROUP BY组合:先分组还是先去重
UNION和GROUP BY组合时,很多人分不清先去重还是先分组。从上面提到的执行顺序来看,UNION的去重发生在合并之后;而GROUP BY是在每个子查询内部完成的。
也就是说,如果你先写了一个带GROUP BY的子查询,再用UNION去合并,分组是在合并之前发生的;反过来,如果你在UNION的最外层套了一个GROUP BY,那是对合并后的全部数据再做一次分组。
这两种写法的结果可能差别很大。比如你要统计“每个渠道的订单总数”,正确的做法是在子查询内部GROUP BY;如果你在UNION外层GROUP BY,那就变成了对合并结果再按某个维度汇总,逻辑上完全是两回事。
我建议在写复杂查询时,先在心里过一遍“每层操作作用于什么范围”。子查询内部的GROUP BY、ORDER BY、LIMIT作用在子查询的结果集上;UNION外层的这些操作作用在整个合并结果集上。把这条规则想清楚,80%的UNION坑都能避开。
5.3 UNION ALL + 临时表:复杂场景的可行替代
有一种场景我不太推荐用UNION硬扛:当多个子查询里都有复杂的JOIN时,UNION会让整个语句变得很长、很难维护,而且查询优化器可能无法为这种多段逻辑生成足够好的执行计划。
这时我通常会考虑用临时表分步处理:先把每个子查询的结果分别写入临时表,最后再合并。这个方案的可读性和可维护性都更好,而且更容易定位问题。
sql复制CREATE TEMPORARY TABLE tmp_result AS
SELECT name, order_amount FROM orders WHERE channel_id = 'A';
INSERT INTO tmp_result
SELECT name, order_amount FROM orders WHERE channel_id = 'B';
这种方式适合那些“一次跑完、结果复用”的场景,比如数据迁移、清洗脚本、报表预计算。缺点是比单条UNION语句多了几步IO,但对于复杂的OLAP场景,可维护性带来的价值往往比这点性能损失更大。
6. 从实际案例看UNION的优化与使用边界
UNION写对了只是第一步,写得好不好还要看执行计划。这一节我分享几个我实际优化过的UNION查询案例,以及一些使用边界上的判断。
6.1 用EXPLAIN看UNION的执行计划
在MySQL里,EXPLAIN一个UNION查询,会返回多行执行计划,每一行对应一个子查询。观察这些行能发现不少有价值的信息:
- 如果某一行出现了
Using temporary或Using filesort,说明这一步正在做临时表或文件排序,数据量大时就是性能瓶颈。 - 如果union result一行有
Using temporary,那就意味着UNION(去重)产生的临时表开销已经发生了。
我自己在优化一条慢查询时,就是用EXPLAIN发现两个子查询各扫描了大表,而合并后还要做一次文件排序。后来我给每个子查询加上了更精确的WHERE条件,把数据量压缩到原来的十分之一,查询时间从5秒降到了0.5秒以下。
这个优化思路的本质是:UNION的性能取决于最“重”的那个子查询,而不是所有子查询的平均值。所以优化重点永远是找到最重的那个子查询,大幅度削减它的扫描范围。
6.2 实例:用UNION ALL合并多月的月度表
现在很多系统按月分表,比如orders_202501、orders_202502。要统计1月到3月的总订单量,最容易想到的写法是:
sql复制SELECT COUNT(*) FROM orders_202501
UNION ALL
SELECT COUNT(*) FROM orders_202502
UNION ALL
SELECT COUNT(*) FROM orders_202503;
这个写法本身没问题,但结果返回三行,每行一个月的数量。如果你需要总数量,还得在应用层相加。更聪明的做法是嵌套一层:
sql复制SELECT SUM(cnt) FROM (
SELECT COUNT(*) AS cnt FROM orders_202501
UNION ALL
SELECT COUNT(*) FROM orders_202502
UNION ALL
SELECT COUNT(*) FROM orders_202503
) t;
把UNION的结果作为一个派生表,外层再聚合,逻辑清晰,而且少一次应用层的数据往返。这种“UNION + 派生表”的组合在日常开发中非常实用。
6.3 什么时候别用UNION
UNION不是万能的。以下几种情况我会直接放弃UNION,改用更优的方案:
- 可以一次性用JOIN解决的列方向合并:如果要纵向拼接的是同一种主体,只是要展示多个属性,JOIN是正确选择,UNION会生成大量重复行。
- 可以用条件聚合解决的统计:比如按不同状态统计数量,用
SUM(CASE WHEN status = 'A' THEN 1 ELSE 0 END)写在同一个SELECT里,性能和可读性都完胜UNION。 - 数据量极大且需要深分页:UNION把多个结果集合并后再排序分页,意味着所有子查询都要先跑出全量数据,再做全局排序。如果每个子查询都是千万级,这个开销可能直接打爆内存。这种情况下,我建议在应用层分多次查询,再用代码合并,或者用分区表替代。
6.4 关于索引利用的注意事项
UNION对索引的利用情况和普通查询不同。每个子查询会独立走自己的索引,合并后外层的去重、排序无法直接利用子查询里的索引。
这意味着:
- 子查询内的WHERE条件要尽量利用索引,让每个子查询返回的数据量尽可能小。
- 外层的ORDER BY字段如果不在索引里,大概率会触发
Using filesort,数据量大时很难受。 - 如果必须做全局排序,考虑在应用层排序而不是数据库排序,尤其是排序字段很复杂、或者需要多级排序时。
6.5 一个综合的业务场景复盘
最后用一个我实际做过的场景把整个内容串起来。
当时有一个“全渠道客户标签报表”的需求:结果集由三部分组成——线下渠道客户、线上渠道客户、历史已注销客户,三者的客户表结构不同,部分字段来自不同的库,而且每个来源的客户需要带上各自的来源标识,最终结果要按照最近活跃时间倒序分页展示。
这个需求我用UNION ALL分三步拼装,每个子查询里先做必要的JOIN和字段映射,统一列名和类型,外层再加ORDER BY和LIMIT。写完之后遇到两个问题:一是历史表的字符集不一致,出现了一开始提到的collation报错,我用CONVERT统一了字符集;二是各渠道“分别取前100条”的分页需求,我给每个子查询加了括号和内部LIMIT才解决。
最后的结果是:接口响应时间从原来的3秒多降到了0.6秒左右,原因是避免了在应用层多次查询后手动合并,也通过子查询内部条件过滤掉了大量不必要的数据参与合并。
这个案例里UNION的价值不在于“能跑通”,而在于一次查询完成关联、映射、合并、排序、分页全套动作,让应用层代码变得极其简单。只要把本文里提到的字符集、类型、ORDER BY/LIMIT作用域、去重成本这几个环节处理好,UNION完全能扛住复杂业务场景。
