1. 先搞清楚一件事:联合查询到底解决什么问题
1.1 从两张订单表合并的场景说起
上个月有个做电商运营的朋友找我,说他们的报表项目卡了半个下午。公司同时运营线上小程序商城和线下门店两套系统,订单数据分别存在 online_orders 和 offline_orders 两张表里,老板要一份按日期汇总的总订单报表。他一开始想着用 JOIN,结果左连右连怎么都对不上——因为两张表之间压根没有能建立关联关系的主键,线上订单和线下订单本来就是彼此独立的两批数据。
这种场景我太熟悉了。做数据的同学几乎每周都会遇到类似需求:把不同来源的数据"摞"在一起,按时间线统一展示。比如把昨天的销售额和今天的销售额放同一列里出来,比如把全国各个分区的月度业绩拼成一张总表,再比如从用户表和订单表里分别查出匹配条件的数据后合并呈现。这类需求在 SQL 里对应的就是联合查询(UNION)系列操作。
联合查询解决的是"纵向堆叠"问题,也就是把多个查询结果的记录行拼接到一起。而 JOIN 系列解决的是"横向扩展"问题,是把多张表的字段拼到同一行里。这两者的定位完全不同,我见过很多人把二者混为一谈,结果写出来的 SQL 要么报错要么结果完全不是预期。
1.2 联合查询和 JOIN 的本质差异
先拿一个生活中的例子说明白。假设你有两个箱子,一个装苹果一个装梨。JOIN 做的事情是把苹果和梨成对装进同一个礼品盒,盒子的规格由你指定(比如内连接、左连接都行);UNION 做的事情则是把两个箱子的水果倒进同一个大筐,筐里既有苹果也有梨,最后你看到的是"一筐水果",而不是"一对水果"。
用表结构来解释的话:
| 操作 | 方向 | 本质 | 卡片数 | 字段列数 | 典型场景 |
|---|---|---|---|---|---|
| JOIN | 水平 | 扩展列 | 取决于关联匹配逻辑 | 增加(左右合并) | 订单表关联用户表显示出用户姓名 |
| UNION | 垂直 | 增加行 | 累加(重叠时去重) | 不变(列数一致) | 线上线下订单合并成统一列表 |
在面试里这几乎是必问题目:"UNION 和 JOIN 的区别是什么?"很多应届生一上来就说"UNION 是并集,JOIN 是交集",这句话不完全对,JOIN 其实更多是"匹配后的笛卡尔积过滤结果",而不是纯数学意义上的交集。理解到这一层,后续写 SQL 时才不会被各种奇怪的关联条件绕进去。
联合查询背后解决的真实痛点是:业务的数据分散在不同表里,但展示层需要的是一个统一的结果集。这个"统一"不只是结构上列对齐就行,经常还要求排序规则一致、去重逻辑明确、分页准确。本文后面所有内容都会围绕这些真实问题展开。
2. UNION 语法拆解:字段对应规则、类型转换与去重逻辑
2.1 基础语法长什么样
UNION 的基础语法非常简单,就是把两个 SELECT 语句用 UNION 关键字连起来:
SELECT 列1, 列2, 列3 FROM 表A UNION SELECT 列1, 列2, 列3 FROM 表B;这里要强调一个很多人忽略的点:两个 SELECT 语句的列数必须完全一致。注意是列数,不是列名。比如第一个 SELECT 查了两列,第二个 SELECT 也必须查两列,否则 MySQL 直接报错——错误信息通常是 "The used SELECT statements have a different number of columns"。
列名不一致倒没关系,最终结果集里的列名以第一个 SELECT 为准。这个特性的影响在排序时会体现得很明显,我稍后在排序部分会展开。
实际开发中我第一次写 UNION 的时候就犯过傻,觉得"我只要查两列,第二段多查一列列出来不用不就行了",结果 MySQL 立刻打脸。这是新手最容易踩的坑——没有之一。
2.2 UNION 和 UNION ALL 的选择逻辑
UNION 和 UNION ALL 的区别一句话就能说清楚:UNION 会去重,UNION ALL 不去重。
-- 不去重,保留全部记录 SELECT id, name FROM user_a UNION ALL SELECT id, name FROM user_b; -- 去重,相同的整行记录只会保留一条 SELECT id, name FROM user_a UNION SELECT id, name FROM user_b;但问题来了,这个"去重"到底按什么判断?是按 id 还是按 name?答案是按整行所有字段判断。也就是说,两条记录只要在 SELECT 列表里的每个字段值都相同,才会被当成重复行处理。只要有一个字段不同,两条都保留。
这个特性我在实际项目里遇到过很隐蔽的 Bug。当时是从两个库里各选一批用户,期望结果里出现"补全"的效果——假设用户 A 在两个库里都有,我们认为它们是同一个人,应该只留一条。但两个库对同一个用户记录的字段长度不一样,一个存了手机号,一个没存,所以整行并不完全相等,UNION 去重根本没生效,最后报表里同一人出现在两行。
所以如果你想让 UNION 按特定字段去重,不能偷懒指望它默认行为。要么先 SELECT 需要的字段统一格式,要么在子查询里先做一次 GROUP BY 或窗口函数去重,再 UNION。
2.3 MySQL 执行 UNION 的内部逻辑
理解 UNIOIN 的性能,就要先知道 MySQL 是怎么执行它的。简单来说分三步:
- 分别执行各个 SELECT,得到各自的临时结果;
- 把各结果合并成一个结果集;
- 如果是 UNION(不带 ALL),会对合并结果做去重。这一步在实现上可能涉及排序或哈希操作,代价不低。
关键点在于第三步。如果是 UNION ALL,第三步就免了,直接拼接。这就解释了为什么 UNION ALL 的查询通常比 UNION 快——不是因为它更智能,而是因为它省掉了一个可能很昂贵的去重步骤。
MySQL 文档里管这种合并叫作"临时表法"或者"物化"。当数据量大的时候,MySQL 可能还会把 UNION 结果写入磁盘临时表,这就会带来额外的 I/O 开销。一个我亲身经历的例子是:某次统计跨了 12 个月的流水,每个月的数据量都不小,我用 UNION 拼了 12 段查询,结果跑了将近 40 秒。换成 UNION ALL 之后,直接降到 6 秒左右。为什么?因为那 12 段查询的数据里根本不存在完全相同的行,UNION 白白花了几十秒去检查"有没有重复"。
从这个例子得到的经验是:当你确定各段结果之间没有重复行,或者重复不重要时,直接上 UNION ALL。这个不是一个偷懒的建议,而是一条很务实的性能优化准则。
2.4 类型转换的边界问题
两个 SELECT 语句的对应字段,不仅列数要一致,类型还得尽量兼容。MySQL 遇到类型不一致时不会直接报错,而是做隐式转换。问题出在"隐式"这两个字上——转换规则未必符合你的预期。
举例来说:
SELECT order_id, amount FROM order_2024 -- amount 是 DECIMAL(10,2) UNION ALL SELECT order_id, amount FROM order_2025 -- amount 是 VARCHAR(20)这种情况下 MySQL 一般会把 VARCHAR 转成 DECIMAL 参与比较或合并,但如果 VARCHAR 里有非数字内容,比如"待退款"这种状态标记,转换时就会出幺蛾子。轻则值变成 0,重则直接报 "Truncated incorrect DECIMAL value" 警告,最后结果里出现一堆 0 值,你根本不知道是哪一段数据出的问题。
还有一种更隐晦的情况是字符串转数值时的精度丢失。DECIMAL 转成 VARCHAR 一般还好,反过来 VARCHAR 存了超长数字再转 DECIMAL,如果超出 DECIMAL 的精度范围,会被截断或者变成近似值。这在做金额汇总时是灾难性的——汇总结果差个几分钱,财务那边会直接找上门。
我的习惯是,在写 UNION 查询之前先核对两边的字段类型,类型不一致就先 CAST 成同一个目标类型,把转换主动权掌握在自己手里:
SELECT order_id, CAST(amount AS DECIMAL(10, 2)) AS amount FROM order_2024 UNION ALL SELECT order_id, CAST(amount AS DECIMAL(10, 2)) FROM order_2025遇到日期字段更麻烦。一边是 DATE 类型,一边是 VARCHAR 存了"2024-01-15 10:20:33"这种格式,直接合并没问题,但你要是拿这个字段去排序或者分组,顺序就会出现诡异的结果。正确姿势是统一用 STR_TO_DATE 或 DATE_FORMAT 处理后再合并。
3. 排序与分页:联合查询里最容易翻车的两个环节
3.1 ORDER BY 到底认哪张表的列名
这是我在真实排查中遇到最多的问题。直接看这段 SQL:
SELECT id, name, create_time FROM user_a UNION ALL SELECT id, name, create_time FROM user_b ORDER BY create_time DESC;很多人以为 ORDER BY 会按 user_b 里的 create_time 排序,毕竟最后一段子查询嘛。但 MySQL 的实际行为是:整个 UNION 的结果集被当成了一个临时表,这个临时表的列名以第一个 SELECT 为准。也就是说,ORDER BY 里能用的列名是 user_a 的列名,即第一个 SELECT 中定义的列名。哪怕第二段 SELECT 用的列名完全不同,排序仍然按第一个 SELECT 的列名解析。
举个例子说明问题。假设 user_a 的列名叫 user_id,user_b 的列名叫 member_id:
SELECT user_id, username FROM user_a UNION ALL SELECT member_id, username FROM user_b ORDER BY member_id;这行 SQL 很可能会报错:"Unknown column 'member_id' in 'ORDER BY'"。因为你拿第二段查询的列名去排序,MySQL 根本认不出来。正确写法是:
SELECT user_id, username FROM user_a UNION ALL SELECT member_id, username FROM user_b ORDER BY user_id; -- 列名以第一个 SELECT 为准更稳妥的做法是干脆给第一个 SELECT 里的列名取一个语义明确的别名,后面排序直接用别名:
SELECT user_id AS sid, username AS name FROM user_a UNION ALL SELECT member_id, username FROM user_b ORDER BY sid;MySQL 还支持一种进阶写法:ORDER BY 数字位序,比如ORDER BY 1 DESC表示按第一列排序。这个写法在 UNION 场景下反而特别稳,因为它完全绕开了列名归属问题。代价是代码可读性变差,建议加注释说明。
3.2 分页查询的正确姿势
联合查询搭配分页,是另一个高发事故区。最典型的错误是下面这种:
SELECT id, name FROM user_a LIMIT 10 UNION ALL SELECT id, name FROM user_b LIMIT 10;很多人以为这样会"每个表取 10 条,最后得到 20 条"。实际 MySQL 的执行结果确实可能是 20 条,但要注意,如果某个表的数据不足 10 条,结果就不足 20 条。这还不是最坑的,最坑的是当业务上需要"从所有结果里取第 11 到第 20 条"时,这种写法就完全失真了——因为 LIMIT 被应用到了每个子查询内部,而不是整个结果集。
正确做法是先把 UNION 的结果包一层派生表,再在外层分页:
SELECT id, name FROM ( SELECT id, name FROM user_a UNION ALL SELECT id, name FROM user_b ) AS combined ORDER BY id LIMIT 10, 10;还要注意:分页排序的场景下,外层 ORDER BY 必须放在 LIMIT 之前,而且 ORDER BY 的字段最好在子查询里都返回过。如果你只想着"排序字段不需要被展示,所以不查出来",那外层 SELECT 就没法按它排序了。
补充一个细节:派生表(FROM 括号里的那段)必须要有别名,哪怕你根本不引用它,MySQL 也强制要求写AS combined这种别名,否则直接语法报错。这个约束很多人第一次写都会遇到。
3.3 排序字段没在 SELECT 列表里怎么办
业务上有个经典需求:按最后活跃时间排序展示用户,但只需要返回用户 id 和昵称。常规思路是在外层加 ORDER BY 引用最后活跃时间,但如果你没在子查询里把时间字段查出来,外层根本无法引用。
我的处理方案有两种。
第一种:子查询里把排序字段也查出来,外层再决定要不要展示:
SELECT id, name FROM ( SELECT id, name, last_active_time FROM user_a UNION ALL SELECT id, name, last_active_time FROM user_b ) AS combined ORDER BY last_active_time DESC LIMIT 20;第二种:如果排序逻辑很复杂,比如要按照"线上用户排在前面,线下用户排在后面,各自再按时间倒序"这种双优先级排序,可以在子查询里构造排序字段:
SELECT id, name FROM ( SELECT id, name, 1 AS sort_priority, last_active_time FROM online_user UNION ALL SELECT id, name, 2 AS sort_priority, last_active_time FROM offline_user ) AS combined ORDER BY sort_priority ASC, last_active_time DESC LIMIT 20;这种方法在报表场景下非常好用,等于把"业务排序规则"显式地做成了一个字段,后面不管是换排序规则还是调试,都能看得清清楚楚。
4. 性能优化:什么时候该用 UNION,什么时候别硬凑
4.1 每一段 SELECT 尽量先缩小数据范围
很多人写 UNION 的时候有个坏习惯:先把整张表的数据全查出来,再在外层 WHERE 过滤。这是性能杀手。
-- 反面教材 SELECT * FROM ( SELECT id, order_no FROM order_2024 UNION ALL SELECT id, order_no FROM order_2025 ) AS u WHERE order_no = 'SX20250115';这个写法的问题在于,MySQL 会先把两张表的所有数据都读出来合并成一个中间结果,再做 WHERE 过滤。如果两张表都是千万级的大表,这个临时结果集的大小会很恐怖,内存可能直接爆掉。正确的写法是让每段 SELECT 自己先过滤:
SELECT id, order_no FROM order_2024 WHERE order_no = 'SX20250115' UNION ALL SELECT id, order_no FROM order_2025 WHERE order_no = 'SX20250115';为什么第二种会更快?道理很简单:每条 SELECT 都可以独立走它自己的索引,扫描的数据量急剧减小,最终合并的数据量也小得多。
我在做报表优化的时候,常常发现一些 SQL 慢不是因为逻辑写错了,而是过滤条件放错了层级。记住一条铁律:能在子查询里过滤的条件,绝不放在外层过滤。这条规则对 UNION 尤其重要,因为 UNION 的每一段子查询在合并前都是单表查询,单表查询能享受索引,合并后再过滤就只能全表扫临时表。
4.2 UNION 去重带来的额外成本
UNION 与 UNION ALL 的性能差异,前面已经说过一次,但这里值得再展开一点。UNION 的去重动作本质上是在一个结果集上执行去重,MySQL 通常会用排序(filesort)或哈希的方式来完成。数据量小看不出差别,数据量一大,去重部分消耗的时间甚至可能超过各段子查询本身的执行时间。
比如有一个线上商品表和线下商品表,各 10 万条记录,业务上确信两边不会出现完全相同的商品,那 UNION 就是纯粹的浪费。我做过一个实操对照:50 万行的 UNION 去重耗时约 8 秒,换成 UNION ALL 后是 2.3 秒。如果业务又有分页又有二次聚合,UNION 还会把这个去重后的结果物化成更多中间临时表,越传越慢。
这里给出一个决策规则:
| 场景 | 推荐写法 | 原因 |
|---|---|---|
| 两段结果一定没有重复 | UNION ALL | 省去去重开销 |
| 可能有重复但业务可接受重复 | UNION ALL | 对账时按数量核对更清晰 |
| 必须严格去重 | UNION | 整行去重,但注意字段范围 |
| 希望按主键去重 | 先用 GROUP BY/窗口函数 再去 UNION ALL | 控制去重逻辑 |
4.3 同一张表上用 OR 还是 UNION
很多 MySQL 优化器的资料都会提到:WHERE 条件里的 OR 有可能会被优化器改写成 UNION。比如:
SELECT * FROM orders WHERE status = 1 OR status = 5;MySQL 的优化器在某些版本里会把这种 OR 改写为两次索引扫描,然后合并结果。但是这里有个前提——OR 涉及的列需要在同一个索引里,或者优化器认为 UNIOM 成本更低,否则它很可能改成全表扫。
这里有个实践结论:如果 OR 两侧的条件分别命中了不同的索引,用 UNION ALL 手写效果往往比 OR 更稳定。因为优化器不一定每次都能聪明地走索引合并,但手写 UNION ALL 相当于强制指定了执行路径。
举个例子,表里有 idx_area(地区索引)和 idx_status(状态索引),查"北京或上海的已支付订单":
-- 方式一: OR SELECT * FROM orders WHERE area = '北京' OR area = '上海'; -- 方式二: UNION ALL SELECT * FROM orders WHERE area = '北京' UNION ALL SELECT * FROM orders WHERE area = '上海' EXCEPT? 不, 无重复时可直接 UNION ALL两个写法结果相同,但方式二可以确保 area 上的索引被充分利用。另外提醒一下:UNION 与 UNION ALL 在优化器眼中并不是等价的,UNION 自带去重属性,优化器可能为此引入额外的排序,而 UNION ALL 就是简单的追加合并。所以在写性能敏感的 SQL 时,建议多用 UNION ALL,把去重需求用其他手段精确控制。
5. 完整案例:把两张结构不同的订单表合并成日级汇总报表
5.1 表结构与目标定义
前面提到学员的场景,这里给出一个完整可执行的案例。
先定义两张表:
-- 线下订单表 CREATE TABLE offline_orders ( order_id VARCHAR(32) PRIMARY KEY, order_date DATETIME, amount DECIMAL(10,2), channel VARCHAR(20) DEFAULT '线下' ); -- 线上订单表 CREATE TABLE online_orders ( order_id VARCHAR(32) PRIMARY KEY, created_at DATETIME, pay_amount DECIMAL(10,2), status TINYINT, -- 0: 未支付, 1: 已支付 channel VARCHAR(20) DEFAULT '线上' );业务目标:输出一份按日期(自然日)统计的报表,包含三列——业务日期、订单数、订单总金额。其中线上订单只统计"已支付"状态。
可以看到两张表的字段并不完全一致:线下表叫 order_date 和 amount,线上表叫 created_at 和 pay_amount;线上表还多了个 status 字段需要用 WHERE 过滤。这正是 UNION 的用武之地:每段 SELECT 各取所需字段,自行过滤。
5.2 第一步:两段独立查询的写法
分别写好两段查询:
-- 线下订单统计 SELECT DATE(order_date) AS biz_date, COUNT(*) AS order_cnt, SUM(amount) AS total_amount FROM offline_orders GROUP BY DATE(order_date); -- 线上订单统计(只统计已支付) SELECT DATE(created_at) AS biz_date, COUNT(*) AS order_cnt, SUM(pay_amount) AS total_amount FROM online_orders WHERE status = 1 GROUP BY DATE(created_at);注意这两段 SELECT 的列数都是 3 列,列的数据类型分别是 DATE、BIGINT、DECIMAL,完全对齐。类型对齐这一步很重要,前面讲过,别忽视。
5.3 第二步:合并后二次聚合
如果只是简单把两段结果拼接,你会发现问题:同一个日期在线上和线下都有记录,UNION ALL 结果是同一个日期对应两行。但报表需要的是"每个日期一行,订单数相加,金额相加"。
所以正确做法是:先 UNION ALL 合并,再作为派生表做一层 GROUP BY:
SELECT biz_date, SUM(order_cnt) AS total_orders, SUM(total_amount) AS total_amount FROM ( SELECT DATE(order_date) AS biz_date, COUNT(*) AS order_cnt, SUM(amount) AS total_amount FROM offline_orders GROUP BY DATE(order_date) UNION ALL SELECT DATE(created_at) AS biz_date, COUNT(*) AS order_cnt, SUM(pay_amount) AS total_amount FROM online_orders WHERE status = 1 GROUP BY DATE(created_at) ) AS daily_orders GROUP BY biz_date ORDER BY biz_date DESC;这里有个经验点:嵌套的第二层 GROUP BY 不能省。有些人图省事,直接在 UNION ALL 外面加 ORDER BY biz_date 就交差了,结果同一天出现两行数据,业务方第一眼看不出来,等做图表的时候才发现同一天的柱子被叠加成了两条数据,处理起来特别麻烦。
5.4 这个案例延伸出的几个注意点
第一,COUNT(*) 和 SUM(amount) 在里层已经做了初步聚合,但外层不能直接 SELECT biz_date, order_cnt, total_amount 完事,因为同一日期在两段里各有独立的 COUNT 和 SUM,必须二次聚合。
第二,如果业务要求同时统计"线上未支付订单数",可以在第二段 SELECT 里去掉 status = 1 的 WHERE 条件,改为用 SUM(CASE WHEN status = 1 THEN 1 ELSE 0 END) 这种写法。UNION 完全支持这种字段级条件统计。
第三也是最重要的,别在 UNION 的每一段里都写 ORDER BY。这样写不仅没有业务意义,还会让 MySQL 对每个子查询都做一次排序,性能白浪费。排序只需要在最外层写一次。
6. 面试常考的联合查询问题与背后考察点
6.1 高频问题:UNION 和 UNION ALL 的区别
这个问题的标准答案我前面已经说透了,但面试时建议补充一个"执行成本"维度:UNION 需要对合并结果做去重,可能触发排序或哈希操作;UNION ALL 只是直接拼接。在表现上,UNION ALL 通常更快。加分项是提到"如果明确知道不会重复,优先用 UNION ALL"。
6.2 高频问题:UNION 和 JOIN 的区别
回答这个问题的核心思路是先分清"方向":JOIN 是横向扩展列(把 A 表和 B 表的字段拼接成新行),UNION 是纵向扩展行(把 A 表和 B 表的查询结果上下拼接)。然后补一句"JOIN 的结构通常不要求两表列数一致,而 UNION 必须保证列数一致",这样就能覆盖大部分面试考官的考点。
6.3 高频问题:UNION 去重的粒度
如果面试官追问"UNION 去重是按什么去重",你要能说出来:按整行所有 SELECT 字段的值的组合去重,而不是单纯按主键去重。如果能更进一步说明"所以表结构不同、字段含义不同的两个查询,UNION 去重往往达不到业务预期",这就是一个很好的加分项。
6.4 高频问题:UNION 里 ORDER BY 和 LIMIT 的坑
面试官很可能给你一段有问题的 SQL 让你挑错。最常见的题目就是:
SELECT id, name FROM table1 LIMIT 5 UNION ALL SELECT id, name FROM table2 LIMIT 5;要指出两点问题:一是 LIMIT 分别作用于两个子查询,不能实现"整体取前 10 条";二是如果要整体排序并取前 10 条,应该在外面包一层派生表后再 ORDER BY + LIMIT。再进阶一点,面试官还会问 ORDER BY 的列名归属,要答出"以第一个 SELECT 的列名为准"。
6.5 实战中还要注意的边界场景
面试题只是基础,真正工作里还有几个不那么常被提到但很重要的细节。
一是字段字符集不一致的问题。两张表如果一张是 utf8mb4 一张是 gbk,合并出来的结果可能在某条特殊字符上出现乱码或者字符无法转换的报错。最好在建表或查询时就统一 COLLATE。
二是 UNION 中涉及 NULL 的处理。UNION 去重时,NULL 和 NULL 会被视为相同值;但业务上 NULL 与空字符串是两码事,会导致去重判断和业务预期不一致。举个例子,线上表没有填写用户备注,线下表用户备注为空字符串,两行备注字段在展示层看起来都是"没填",但 UNION 不认为这两行重复,会在结果里保留两行。
三是列的顺序。UNION 合并时是按 SELECT 列表的顺序一对一对应的,不是按列名匹配。也就是说,第一个 SELECT 的第一列与第二个 SELECT 的第一列对应,哪怕你把列名写反了,MySQL 也只是把两边第一列按位置合并,不会报错,但结果含义可能是错的。这种错误非常隐蔽,因为不爆语法错误,只能靠核对字段语义来发现。
我在实际工作中就把这类坑记住了:每次写完 UNION 查询,都习惯性检查一下每个子查询的列顺序是否一一对应。尤其是重构老 SQL 时,前后字段挪了位置,很容易因为列数不变而漏掉问题。
写到最后:一点个人小建议
从最早只会写单表查,到后来带团队做报表、做数仓宽表,我对 UNION 最深的体会是:它看起来简单,真正用好的关键在于"想清楚子查询之间的边界"和"控制好每段子查询的负担"。很多慢查询不是你 MySQL 配置不对,也不是服务器性能不行,就是写法上让优化器扛了太多不必要的活。
如果你也在做多表结果合并的报表,不妨从今天起养成两个习惯:第一,默认写 UNION ALL,明确需要去重时才写 UNION;第二,写完 SQL 后用 EXPLAIN 看一下执行计划,确认过滤是否在子查询完成、是否出现意外的 filesort 或者临时表。这两个习惯能帮你提前避开我在前面列举的大部分坑,报表上线后找你对数的概率会直线下降。