1. 为什么我把 UNION ALL 当成基础操作,而不是 UNION 的“替身”
1.1 先搞清楚它俩的本质差异
UNION ALL 和 UNION 看起来只差两个字母,实际行为差异很大。两者都是把两个或者多个 SELECT 的结果纵向拼在一起。UNION 会默认去重,UNION ALL 则原样保留每一行。也就是说,UNION 等价于“先做 UNION ALL,再对最终结果做一次 DISTINCT”。
问题就出在这个“去重”动作上。数据库实现 DISTINCT 不可能不做排序或哈希,在 MySQL 里常见的是 Using temporary 加 filesort;在 SQL Server 里能看到 Distinct Sort 算子;在 Oracle 里是 SORT UNIQUE。数据量小的时候你感觉不到,数据量一旦上来,UNION 的耗时可能是 UNION ALL 的好几倍。我见过最夸张的一个报表,三张订单分表合并,SQL 里写了 UNION,结果每次跑都要一分多钟;改成 UNION ALL 之后直接秒开。为什么?因为那张表按月份拆分,每个订单只在归属月份出现一次,根本不存在跨表重复,UNION 的每轮去重都是白做。
很多教学文章会把 UNION ALL 描述成“性能更好但不去重的版本”,导致新手以为它只是 UNION 的简化版。我的理解正好反过来:当你想合并多个结果集的时候,默认就应该用 UNION ALL,去重应该是你有明确理由才做的事情。这个思维转变,直接影响你后续写 SQL 的习惯和查询性能。
1.2 什么时候才应该使用 UNION
有明确集合语义的时候可以使用 UNION。比如两个来源都记录了“用户编码”,你要拿到完整的用户清单,且同一用户多次出现只算一次,这时候用 UNION 就很顺手。还有做两个集合的并集运算时,数学意义上的并集本来就要求元素唯一。另外,如果你的业务数据本身没有唯一性保证,直接 UNION ALL 可能生成大量重复行,后续再处理反而麻烦。
但即便如此,我也会谨慎。假设每个来源的数据量都很大,而且去重逻辑比较复杂,比如希望把同一个用户的最新一条记录保留下来,那我不会直接依赖 UNION 的“默认去重”,因为 UNION 去重无法指定保留哪一行。这时候我会写成 UNION ALL 的子查询再套一层 ROW_NUMBER(),按业务规则取第一条。这个技巧在后面的场景里会专门展开。
1.3 一个可以复现的性能对比思路
不需要我先给结论,你自己也可以验证。在测试库里建两张几万行的临时表,分别执行两种查询,然后查看执行计划:
- 查询 A:
SELECT * FROM temp1 UNION SELECT * FROM temp2 - 查询 B:
SELECT * FROM temp1 UNION ALL SELECT * FROM temp2
重点观察是否有排序/去重的额外步骤、临时表是否被创建、排序发生在内存还是磁盘。多数数据库的管理工具,比如 MySQL Workbench、SQL Server Management Studio,都可以直接看执行计划。如果两张表结构完全一样且数据没有重叠,UNION 多出来的操作就是纯浪费。这个验证过程只需要几分钟,但能帮你养成正确的第一反应:合并结果集时,默认先考虑 UNION ALL,只有明确需要去重时才切换成 UNION。
2. 场景一:多分表、历史归档表合并,这是 UNION ALL 的主场
2.1 按时间分表后的多表合并
业务上最常见的分表原因有三种:单表数据量太大;按天或月建表方便清理;历史数据归档到独立表。比如订单表 orders_202401、orders_202402、orders_202403,或者日志表 app_log_20240101。要查询连续三个月的数据,最自然的就是把三个表 UNION ALL 起来:
SELECT order_id, user_id, amount, create_time FROM orders_202401 UNION ALL SELECT order_id, user_id, amount, create_time FROM orders_202402 UNION ALL SELECT order_id, user_id, amount, create_time FROM orders_202403;很多公司直接用视图包装这种 SQL,上层应用可以当一张表来查询。使用的时候要注意每个 SELECT 的列数必须一致,列名以第一个 SELECT 为准,其他分支的别名不会影响最终结果。我在项目中经常用脚本自动生成这种视图,把一年 12 个月的分表拼进去,业务方完全无感。
2.2 在线表和归档表字段不一致时怎么拼
归档表经常比在线表少字段,或者字段类型不一样。这时候不要硬撑,少的字段用 NULL 占位,类型尽量统一。比如在线表有 refund_time,归档表没有:
SELECT order_id, amount, refund_time FROM online_orders UNION ALL SELECT order_id, amount, CAST(NULL AS DATETIME) AS refund_time FROM archive_orders;这里的 CAST 不是为了好看,是为了避免数据库在合并时因为类型不一致做隐式转换,隐式转换可能导致外层过滤条件无法使用索引。归档表如果数据量也大,可以给每一段先加过滤条件,不要等到合并完再统一 WHERE,这一点在 2.4 里会细说。
2.3 合并结果的排序和分页必须放在最外层
UNION ALL 的一个经典坑:子查询里的 ORDER BY 会被忽略。例如下面的写法执行后,三个表内部排序其实“没有意义”:
SELECT * FROM orders_202401 ORDER BY create_time UNION ALL SELECT * FROM orders_202402 ORDER BY create_time;大多数数据库要求集合操作的整体结果再排序,正确的做法是把 ORDER BY 放到最后:
SELECT * FROM orders_202401 UNION ALL SELECT * FROM orders_202402 ORDER BY create_time DESC LIMIT 20;如果要做分页,LIMIT/OFFSET 也必须放在外层。否则你会从每个分表里各取 20 条再合并,而不是从合并结果中取 20 条,结果完全不对。另一种更稳妥的方式:在 UNION ALL 的内层用 ROW_NUMBER() 给每个分表的数据编号,外层再按编号过滤,这样能精确控制“每个来源各取多少条”。
2.4 每个分支的过滤条件比外层的 WHERE 更高效
这一点值得反复强调。你写完 UNION ALL 之后,经常会想在外层统一过滤:
SELECT * FROM ( SELECT order_id, amount, create_time FROM orders_202401 UNION ALL SELECT order_id, amount, create_time FROM orders_202402 ) t WHERE t.create_time >= '2024-01-15';这种写法没有错,但大多数数据库的执行方式是先把两个分表的数据都取出来,合并后再过滤 create_time。也就是说,2024 年 1 月整张表都可能被你扫了一遍。更好的做法是把同样的条件塞进每一个子查询:
SELECT order_id, amount, create_time FROM orders_202401 WHERE create_time >= '2024-01-15' UNION ALL SELECT order_id, amount, create_time FROM orders_202402 WHERE create_time >= '2024-01-15';这样每个分表都可以走自己分区字段的索引,扫描的数据量大幅下降。尤其在 OLAP 场景,UNION ALL 分支能下推多少过滤条件,直接决定查询能不能跑完。这条规则我愿称之为“UNION ALL 优化的第一原则”。
3. 场景二:报表统计里的纵向拼接,比一堆 CASE WHEN 更好维护
3.1 把多个指标拆成独立子查询再拼起来
做 BI 报表的时候,经常遇到“一张卡片里有好几个指标,但每个指标来自不同的表或不同维度”。比如经营日报需要今日销售额、今日订单数、今日新增用户、今日退款金额。新手常把它们写成一个超级大的 SELECT,里面嵌套一堆标量子查询和 CASE WHEN。这种 SQL 有两个问题:一是可读性差,二是标量子查询很容易造成大表逐行计算。
我更喜欢先让每个子查询独立统计,再用 UNION ALL 把结果拼成“长表”,最后在报表工具里做透视。举一个例子:
SELECT 'today_sales' AS metric, SUM(amount) AS value FROM orders WHERE dt = CURDATE() UNION ALL SELECT 'today_orders', COUNT(*) FROM orders WHERE dt = CURDATE() UNION ALL SELECT 'new_users', COUNT(*) FROM users WHERE create_date = CURDATE() UNION ALL SELECT 'today_refund', SUM(amount) FROM refunds WHERE dt = CURDATE();结果集是两列:metric 和 value。BI 工具可以直接按 metric 渲染成多行卡片。如果需要把长表转成一行多列,再套一层行转列的写法也不难,而且每一行指标都可以独立加过滤条件、独立走索引。这个模式在日报、周报、经营看板里非常实用。
3.2 多口径统计:同一张表也能用 UNION ALL 拆开
一个常见需求是:“同一张订单表,既想看今天的支付订单,又想看今天的退款订单,还想看历史累计订单”。很多人会写一个 WHERE 里带 OR 的大查询,再配合 SUM(CASE WHEN...) 去算。这样写不算错,但一旦口径多起来,比如“本日支付”“本日待支付”“近 7 日支付”“历史累计”“本月累计”混在一起,一个 SELECT 就会变得很难维护。
建议改成:
SELECT 'today_paid' AS stat_type, COUNT(*) FROM orders WHERE pay_status = 'PAID' AND dt = CURDATE() UNION ALL SELECT 'today_pending', COUNT(*) FROM orders WHERE pay_status = 'PENDING' AND dt = CURDATE() UNION ALL SELECT 'week_paid', COUNT(*) FROM orders WHERE pay_status = 'PAID' AND dt >= DATE_SUB(CURDATE(), INTERVAL 7 DAY) UNION ALL SELECT 'history_total', COUNT(*) FROM orders;每个统计口径都是独立的小查询,中间任何一条口径有问题,单独调试就行,不影响其他行。而且每个小查询都有自己的 WHERE,优化器能分别走合适索引,不会因为一个复杂的 OR 条件把整个索引计划打乱。我在 MySQL 上实测过,同一个需求用 OR 写法可能要全表扫描,拆成 UNION ALL 之后每条分支都用上了二级索引,速度提升明显。
3.3 行转列与列转行,UNION ALL 是“列转行”的利器
数据仓库里经常有宽表和长表的转换。把一行里的多个列变成多行时,SQL 里没有现成的行列转换函数,UNION ALL 是最直接的实现。比如表结构是 product, jan_amount, feb_amount, mar_amount,想变成 product, month, amount 三列:
SELECT product, '2024-01' AS month, jan_amount AS amount FROM monthly_sales UNION ALL SELECT product, '2024-02', feb_amount FROM monthly_sales UNION ALL SELECT product, '2024-03', mar_amount FROM monthly_sales;这种方法的关键是每一行都会生成多行,所以如果业务上还需要保留原始行编号,先把主键也查出来放在结果里。反过来,如果想做真正的行转列,把月份变成列,分组聚合比 UNION ALL 合适。很多新手会在该用 JOIN 转置的时候误用 UNION ALL,我的判断标准很简单:合并后行数变多变少?如果行数变多,大概率是 UNION ALL 的活;如果行数不变但列数变多,那是 JOIN 或聚合的活。
4. 场景三:ETL 数据核对与打标签,UNION ALL 的集合运算妙用
4.1 用 UNION ALL + GROUP BY 找出两表差异
我做数据迁移和数据订正时,最常干的事情就是对比源表和目标表是不是完全一致。最简单粗暴的写法是两个 NOT EXISTS 互相查,SQL 会变得很长,而且性能经常拉胯。UNION ALL 配合 GROUP BY 可以一行核心逻辑搞定:
SELECT id, col1, col2, COUNT(*) AS cnt FROM ( SELECT 'src' AS tag, id, col1, col2 FROM source_table UNION ALL SELECT 'dst' AS tag, id, col1, col2 FROM target_table ) t GROUP BY id, col1, col2 HAVING COUNT(*) = 1;HAVING COUNT(*)=1 表示这一行只在其中一个表里出现,另一个表要么没有、要么值不一样,因为值完全一样的话两行会合并成 COUNT=2。看结果时你会直接拿到差异行的完整内容,非常直观。如果两个表里本身存在重复行,COUNT 会大于 2,那就要先对每个表做去重再对比,否则这个方法会受到干扰。这个技巧我在主数据核对的脚本里用了很多年,比层层嵌套的 EXISTS 省事不少。
4.2 给同一批数据打多个标签
在用户运营和风险控制里,“给用户打标签”是高频需求。比如高活跃、高消费、流失风险三个标签可能来自不同的查询逻辑。如果用一个大 SELECT + CASE WHEN,三个规则相互嵌套,改一个可能影响另外两个。拆成 UNION ALL 之后,每个规则独立成一个分支:
SELECT user_id, 'high_active' AS tag FROM user_behavior WHERE login_days_30 >= 20 UNION ALL SELECT user_id, 'high_spend' FROM user_orders WHERE total_amount_30 >= 5000 UNION ALL SELECT user_id, 'churn_risk' FROM user_behavior WHERE last_login < DATE_SUB(CURDATE(), INTERVAL 30 DAY);最终结果就是 user_id + tag 两列,一个用户可以有多行,正好表示多个标签。如果后续规则变成“高活跃且高消费才算”,你再在子查询里叠加 JOIN 或 EXISTS 就行。注意一点:同一规则内部如果会出现重复 user_id,需要先在子查询里 DISTINCT 或 GROUP BY,避免后面统计标签人数时虚高。
4.3 用 UNION ALL 模拟 FULL OUTER JOIN
MySQL 这类不支持 FULL OUTER JOIN 的数据库,UNION ALL 可以救急。思路是左连接和右连接互补:
SELECT COALESCE(a.id, b.id) AS id, a.col1, b.col2 FROM source_table a LEFT JOIN target_table b ON a.id = b.id UNION ALL SELECT COALESCE(b.id, a.id), a.col1, b.col2 FROM target_table b LEFT JOIN source_table a ON a.id = b.id WHERE a.id IS NULL;后半段只要“只存在于 B 表”的数据,前半段已经包含了“A 表全部”,包括匹配上的行和只在 A 表存在的行。如果关联键会重复,这个写法还需要去重,不然可能产生重复行。所以在用它之前,先确认两边关联键都是唯一的,或者用 ROW_NUMBER 处理。
5. 那些让我翻过车的 UNION ALL 细节
5.1 该去重的地方别迷信“我选了 ALL”
前面说过,去重应该是有明确理由的事。但反过来说,如果一个查询合并后的结果确实需要唯一记录,而你继续用 UNION ALL,结果必然错误。我记得有一次做渠道汇总,把广告、自然搜索、直接访问三个渠道的用户来源拼在一起,为了快就选了不带 ALL 的 UNION。结果人数确实对了,但用户只保留了一条,无法知道他是从哪个渠道来的。后来换了一个思路:先用 UNION ALL 保留可追溯的多渠道明细,再按用户去重时用 ROW_NUMBER() 选择主渠道:
SELECT user_id, channel FROM ( SELECT user_id, channel, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY CASE WHEN channel = 'paid' THEN 1 ELSE 2 END) AS rn FROM ( SELECT user_id, 'paid' AS channel FROM paid_source UNION ALL SELECT user_id, 'organic' FROM organic_source UNION ALL SELECT user_id, 'direct' FROM direct_source ) all_users ) ranked WHERE rn = 1;这样既拿到了每个用户的唯一记录,又能指定“优先保留付费渠道”,比直接 UNION 灵活得多。
5.2 子查询里的 ORDER BY 和 LIMIT 会骗人
我踩过一次很深的坑:MySQL 里写 UNION ALL,每个分表都想取“该月金额最大的前 10 条”,直接在子查询里写 ORDER BY amount DESC LIMIT 10。语法能过,但结果并不总符合预期,因为集合操作会把子查询当成结果集而不是有序流,不同版本、不同优化器下行为不一致。正确的思路是用窗口函数:
SELECT order_id, amount, rn FROM ( SELECT order_id, amount, ROW_NUMBER() OVER (PARTITION BY month_code ORDER BY amount DESC) AS rn FROM ( SELECT order_id, amount, '01' AS month_code FROM orders_202401 UNION ALL SELECT order_id, amount, '02' FROM orders_202402 ) all_orders ) t WHERE rn <= 10;这样“每个月取前 10”的语义非常明确,不会因为优化器的执行计划不同而翻车。窗口函数虽然写起来长一点,但比依赖隐式行为安全得多。
5.3 类型不一致,合并结果的索引可能直接失效
UNION ALL 要求每个 SELECT 的结果列数一致,但没有强制执行完全相同的类型。比如一个分支的 amount 是 DECIMAL,另一个分支是 VARCHAR,里面存的是 '100.00'。合并时数据库会把两者转成公共类型,如果这个公共类型是 VARCHAR,外层做金额排序或比较时就会按字符串来,顺序完全乱掉。因此我建议所有分支在合并前显式统一类型,字符串数字尤其要小心:
SELECT order_id, CAST(amount AS DECIMAL(10,2)) AS amount FROM online_orders UNION ALL SELECT order_id, CAST(amount AS DECIMAL(10,2)) FROM imported_orders;不要嫌啰嗦。宁可每个分支多写几个 CAST,也不要在线上看到“9”排在“10”后面时才来后悔。
5.4 NULL 和空字符串是两回事
归档表没有时间字段,你用空字符串填充,在线表用 NULL 表示没有,UNION ALL 合并后外层如果筛选字段为 NULL,这两个值会被区分开。大多数业务统计中,空字符串和 NULL 应该算作同一类。建议在分支里统一写 COALESCE(field, ''),或者统一用 NULL。另外不同表之间的字符集、排序规则不一致,也会让 ORDER BY 和去重行为出现差异,新建表时尽量统一字符集;老表迁移时最好先确认,否则线上查出来的顺序会让人摸不着头脑。
5.5 大结果集先下推,别等外层兜底
前面提到过“UNION ALL 优化的第一原则”,这里再补一个极端例子。如果一张历史大表有 3 亿行,你直接把它和其他表 UNION ALL,然后外层只取 id 为 123 的数据,数据库可能会把两个结果都物化完再过滤,甚至直接把临时表写到磁盘。正确做法是每个分支都把 id=123 作为下推条件。这样每个分支返回的数据量立刻缩小,UNION ALL 的开销几乎可以忽略。这个习惯适用于所有数据库,也是我面试候选人时喜欢问的一个点。
6. UNION ALL、IN、OR、JOIN、FULL JOIN 的选型边界
6.1 一张表搞定的对比
选型时可以问自己:我要的是“更多行”,还是“更多列”,还是“去掉某些行”?
| 需求 | 推荐写法 | 主要理由 |
|---|---|---|
| 把多个独立查询结果上下拼接 | UNION ALL | 行数增加,列数不变,保留全部 |
| 从多个结果中取唯一实体清单 | UNION / DISTINCT | 语义是集合求并集 |
| 同一张表多个条件同时满足 | WHERE 多条件 / JOIN | 逻辑与关系,不是上下拼接 |
| 同一张表多个条件任意满足 | OR / IN / UNION ALL 子查询 | 合适时拆分条件可利用索引 |
| 两张表按 key 关联取不同字段 | JOIN | 行数不变,列数增加 |
| 求两张表的补集、全集差异 | FULL JOIN 或 UNION ALL+GROUP BY | 按整行对比时后者更方便 |
这张表我每次写复杂 SQL 前都会过一遍,能避免一半以上“用错算子”的情况。
6.2 OR 与 UNION ALL 的索引优化
MySQL 的优化器对包含 OR 的查询不一定能好好用索引,特别是一个条件命中索引 A,另一个条件命中索引 B,它还可能要回表合并。我做过一个线上慢查询:WHERE user_id = 123 OR status = 'PAID',表有几百万行,结果是全表扫描。改成:
SELECT * FROM orders WHERE user_id = 123 UNION ALL SELECT * FROM orders WHERE status = 'PAID';两个分支都能走各自的索引,查询从几秒降到几十毫秒。注意这样会产生重复行,如果某行同时满足两个条件,业务上又需要不重复,可以在外层按主键去重,或者先确认两个条件互斥后再放心用 UNION ALL。并不是所有数据库都需要这样手工改写,新版 MySQL 的优化器也会把部分 OR 自动转换成类似 UNION ALL 的执行计划,所以务必先看执行计划再决定。
6.3 JOIN 和 UNION ALL 千万别用反
见过不少半路出家的同学,想把两张表的数据合并成一张表,结果写出了SELECT a.*, b.* FROM a, b这种笛卡尔积,或者用 JOIN 把行数搞多了。判断的办法很简单:如果你是想“横向”增加不同的属性列,比如订单加上用户姓名,用 JOIN;如果你是想“纵向”追加同一类记录,比如一月订单加二月订单,用 UNION ALL。两者混用时要特别小心,比如先 JOIN 再 UNION ALL 和先 UNION ALL 再 JOIN 结果可能完全不同。建议先把每一层想清楚再写,不要指望靠语法碰运气。
6.4 FULL JOIN 与 UNION ALL + GROUP BY 怎么选
完整外连接适合按主键关联,并且你关心两个表的字段差异。UNION ALL + GROUP BY 适合整行对比,字段多的时候不用把每个字段都写进 JOIN 条件。实际做数据核对时我优先用 UNION ALL + GROUP BY,因为写起来短,而且结果里能直接看到哪一行来自哪个源。但如果业务上频繁做两表关联且需要保留所有匹配行,FULL JOIN 会更直接。没有 FULL JOIN 的数据库,用前面 4.3 节的模拟写法也能顶上。
7. 调试与验证:我验证 UNION ALL 结果时常用的几个习惯
7.1 先验证行数恒等式
无论查询多复杂,UNION ALL 不加去重时,结果集总行数应该等于各分支行数之和。我通常先跑一个计数查询对比:
SELECT (SELECT COUNT(*) FROM branch1) + (SELECT COUNT(*) FROM branch2) AS expected_count;然后再跑完整合并查询的 COUNT(*),两边一致基本可以确认没有漏数据、没有多数据。不一致就逐个分支排查。这个方法在做日报核对时帮我省了很多时间。
7.2 给每个分支打标签来追溯
在正式报表里不要加标签,但在调试阶段,我习惯给每个分支临时加一个常量列:
SELECT 't1' AS src, * FROM table1 UNION ALL SELECT 't2', * FROM table2;这样一旦结果里出现异常数据,马上能看出它来自哪个表,不用再猜。如果 SQL 环境不支持 * 与常量列混用,就把需要的列都列出来,顺便还能对列名做统一规范。
7.3 分支很多时先落地成临时表
如果 UNION ALL 有七八个分支,每个分支都是大表,反复修改调试成本很高。我会先把每一个分支的结果写入临时表,最后再SELECT * FROM temp1 UNION ALL temp2 ...。这样一来临时表可以加索引,二来每次修改只重算一个分支,其他分支复用,整个流程快很多。在数仓里,这种思路也对应“先把各分区结果物化,再合并读取”,是性能和可维护性兼顾的方案。
7.4 最后说点个人长期实践下来的体会
用 UNION ALL 最重要的不是背语法,而是建立“每个分支独立、过滤下推、类型统一”这三个习惯。我一般不盲目追求“一定不要用 UNION”,而是会在写之前问一句:这里的重复行到底要不要?如果要去重,是简单去重还是按优先级去重?只要想清楚了,UNION ALL 和 UNION 其实都不难用。另一个习惯是,写完 SQL 先看一眼执行计划,看看有没有 Using temporary、有没有 filesort、每个分支有没有把 WHERE 推到表上。能做到这几点,UNION ALL 几乎就成了你最趁手的 SQL 工具之一。