如果你负责的报表系统有一天突然被一条慢查询拖垮,而这条查询只是用UNION把两个子查询拼在一起,你会先怀疑什么?我在排查线上问题时遇到过一模一样的状况:SELECT 没有关联,索引也都在,结果集加起来也就几十万行,但 SQL 硬生生跑了好几秒。后来才发现,问题不是字段拼错,也不是索引失效,而是UNION自带的那套"去重"逻辑。这个 MySQL 基础知识点,一旦业务量上来,就会变成真正的瓶颈。这篇文章就把UNION和UNION ALL的区别、底层的临时表机制、实测数据以及优化方案一次说透,适合后端开发、数据开发还有正在准备 MySQL 面试的朋友。
1. 一条线上慢查询:UNION 把简单聚合拖垮了
1.1 场景还原:这条 SQL 到底干了什么
先还原一下当时的问题。业务方要做用户标签合并,有两张标签表,一张tag_user_a存放"30 天内注册且已下单"的用户,另一张tag_user_b存放"高活跃用户",现在需要统计这个月两个标签的总人数。因为一个用户可能同时出现在两张表里,所以业务方很自然地用了UNION:
SELECT user_id FROM tag_user_a WHERE create_time >= '2025-01-01 00:00:00' AND order_cnt > 0 UNION SELECT user_id FROM tag_user_b WHERE last_login >= '2025-01-01 00:00:00';这个 SQL 的逻辑初衷没错:把两批 user_id 合并,同时去掉重复用户。数据量也不大,每张表符合条件的行数分别大约 80 万和 60 万,两张表在 user_id 上都建了主键索引和普通索引。但这条查询在凌晨跑批时耗时接近 4 秒,比单独执行两个子查询的耗时之和还要多出 3 倍。
当时我第一反应是检查索引是否生效,单独跑了一下两个子查询,都在 0.1 秒左右返回。问题显然出在把他们"拼起来"这一步。
1.2 执行计划第一眼:临时表成了最大瓶颈
用EXPLAIN看这条 SQL 的执行计划,输出里最扎眼的是最后一行:
1 PRIMARY tag_user_a index PRIMARY ... Using index 2 UNION tag_user_b index PRIMARY ... Using index 3 UNION RESULT <union1,2> ALL ... Using temporary前两个子查询都走了覆盖索引,问题不大。真正耗时的是第三行UNION RESULT,它代表 MySQL 需要把前两步的查询结果放到一张临时表里,然后做去重。在 MySQL 8.0 中,这里还会看到Using temporary,如果结果集大到一定规模,临时表会从内存转到磁盘,性能直接掉一个量级。
很多人以为慢是因为两个子查询各自扫描了全表,其实不是。真正的问题是UNION强制要求 MySQL 合并结果集并去掉完全重复的行,这个去重动作是需要额外时间和空间成本的。而线上这条 SQL 慢就慢在临时表上。
2. UNION 和 UNION ALL:语义差一点,性能差多少
2.1 MySQL 为什么需要为 UNION 单独建临时表
UNION的语义是"两个查询结果的并集,并去除重复行"。MySQL 处理它的标准方式是:先执行每个 SELECT,把各自的结果集放入临时表,再对临时表做去重操作。去重的方式通常有两种:一种是通过创建唯一索引来强制去重,另一种是对结果做排序后扫描相邻行去重。
UNION ALL就简单粗暴得多:它只是把两个查询的结果直接拼接在一起返回,不做任何去重。你可以把它理解成concat文件,而UNION是sort -u。
这个差别体现在执行计划上非常明显。同样是上面两个查询,把UNION换成UNION ALL后,执行计划变成:
1 PRIMARY tag_user_a index PRIMARY ... Using index 2 UNION tag_user_b index PRIMARY ... Using index少了一个UNION RESULT节点,也没有Using temporary。执行时间直接从 4 秒降到了 0.3 秒以内。这不是某个优化器开关的问题,而是算法层面的差距。
2.2 去重的真实成本:不是多一次扫描那么简单
UNION的去重成本,并不仅仅是"多了一次内存比较"。当两个结果集很大时,MySQL 需要把所有需要去重的行都放到临时表里。这个临时表首先会尝试存在内存中,由tmp_table_size和max_heap_table_size控制上限。一旦超过上限,MySQL 就会把临时表从 MEMORY 引擎转换成 InnoDB 或 MyISAM 磁盘临时表,期间还伴随filesort排序。
filesort这个词很迷惑人,它不代表"一定用磁盘文件排序",但排序动作本身一定会产生额外的 CPU 和内存消耗。并且临时表上的去重若走唯一索引,插入每条数据前还要检查唯一性约束,类似索引插入的成本。数据量一上来,这部分就是实打实的开销。
我见过一个极端的例子:两个各 200 万行的结果集做UNION,临时表落盘后,临时表文件占据了几个 GB 的磁盘空间,查询跑了几分钟。换成UNION ALL后,几秒就返回了。所以说,UNION比UNION ALL慢不是慢在谁写了更好的 SQL,而是慢在它底层多了一个完整的"建临时表 + 去重"流程。
2.3 去重比较的粒度:不是主键,而是整行
还有一个非常容易踩的语义坑:UNION判断重复的粒度是"整行完全相同",不是"主键相同"或者"某个唯一键相同"。
比如下面这个例子:
SELECT user_id, user_name FROM tag_user_a UNION SELECT user_id, phone AS user_name FROM tag_user_b;假设同一个 user_id 同时出现在两张表里,但第一张表里 user_name 是"张三",第二张表因为字段来源不同,把 phone 字段映射成了"13800138000"。对于 MySQL 来说,这两行并不完全相等,所以UNION会把它们都保留下来,不会按 user_id 去重。
这就是很多人用UNION做"去重"却发现结果里还是有重复用户的原因。如果你的业务目标是"按 user_id 去重",正确做法是在 SELECT 列表中只保留 user_id,或者使用GROUP BY user_id显式声明。否则,UNION的"去重"很可能和你理解的"去重"根本不是一回事。
3. 什么时候该用 UNION,什么时候别用 UNION
3.1 必须用 UNION 的三种业务场景
虽然UNION慢,但它并非一无是处。有些场景下,它在语义上比UNION ALL更安全:
- 需要把多个查询结果合并成一个"不重复集合",且这个去重规则恰好是"整行一致",例如多个来源的字典表合并、多个标签表合并取并集。
- 结果集本身很小(比如几百行),即使使用
UNION也不会产生性能问题,这时候用UNION可以保证结果干净。 - 查询逻辑无法通过改写避免重复,比如两个子查询来自不同的物理表,数据源头没有统一去重标识,但又必须一次性给出并集。
在这些场景下,UNION不是可选项,而是业务语义的一部分。为了省性能而强行去掉去重,可能会导致数据错误。
3.2 用 UNION ALL 更合适的场景
更多时候,UNION ALL才是正确选择:
- 数据本身不可能重复,例如两个状态互斥的订单明细,一张是已支付订单,一张是已退款订单,同一物理行不可能同时满足两个条件。
- 需要保留重复明细,例如统计各渠道访问日志,用户可能在多个渠道出现,我们要的是覆盖整个时间窗口的全部记录。
- 数据量很大,且后续还要做复杂计算,这时候应该尽量减少中间临时表的压力。
这里有个很容易搞混的点:很多人看到"两个子查询条件互斥",就认为用UNION ALL绝对安全。确实,如果两张表的数据源完全独立、不会有相同业务主键,那没问题。但如果是同一张物理表,只是 WHERE 条件不同,要特别注意条件之间是否有"同时满足"的情况,比如status = 'refunded'和amount < 0可能在同一条记录上同时存在,这样UNION ALL会导致同一行被统计两次。
3.3 用 UNION ALL 代替 UNION 的业务前提
如果实在想用UNION ALL替代UNION,有一个必须执行的动作:确认两个子查询结果集没有重复。怎么确认?先跑一遍"探针查询":
SELECT COUNT(*) AS total FROM ( SELECT user_id FROM tag_user_a UNION ALL SELECT user_id FROM tag_user_b ) t;再对比:
SELECT COUNT(DISTINCT user_id) AS distinct_total FROM ( SELECT user_id FROM tag_user_a UNION ALL SELECT user_id FROM tag_user_b ) t;如果两个 count 相等,说明业务上不存在重复,用UNION ALL完全没问题;如果不相等,要看这是因为业务上确实需要去重,还是数据源本身存在脏数据。如果是脏数据,你应该在数据清洗阶段处理,而不是把去重压力全部丢给数据库。
4. 实测一组数据:UNION 比 UNION ALL 慢了多少
4.1 测试环境与样本数据
为了让结论更有说服力,我在本地做了一个简单的压力验证。环境如下:
- MySQL 版本:8.0.36,InnoDB
- 内存:16GB
- 表:
tag_user_a、tag_user_b - 表结构:
id BIGINT PRIMARY KEY,user_id BIGINT,tag_type VARCHAR(20),create_time DATETIME - 数据量:每张表约 100 万行,其中 user_id 按顺序生成,两张表有约 50% 的重复数据
为了避免写几十万字的数据插入 SQL,我直接用存储过程生成,核心逻辑类似:
INSERT INTO tag_user_a (user_id, tag_type, create_time) SELECT n, 'a', NOW() - INTERVAL n SECOND FROM ( SELECT seq AS n FROM seq_1_to_1000000 ) t;这里的seq_1_to_1000000是 MySQL 8.0 里的递归 CTE 生成的序列,具体写法就不展开了。关键点是要保证两张表有可控的重叠率。
4.2 执行计划对比
测试查询如下:
SELECT user_id FROM tag_user_a WHERE create_time IS NOT NULL UNION SELECT user_id FROM tag_user_b WHERE create_time IS NOT NULL;以及对应的UNION ALL版本。查看执行计划时,我发现不仅是少了UNION RESULT节点,UNION ALL在两张表上的访问方式也略有不同。在UNION中,MySQL 可能会为了去重而选择某些排序或索引访问策略;而在UNION ALL中,它更倾向于直接按主键顺序或索引扫描。
按照官方文档和源码行为,UNION的去重实现会尝试利用索引来避免排序,但在该测试场景中,由于需要从两个结果集中合并,优化器最终还是选择了临时表方案。所以执行计划里出现了Using temporary,并且临时表的行数是前面两个子查询结果集行数之和。
4.3 运行时间对比
我用SET profiling = 1采集了耗时,结果如下:
| 查询方式 | 子查询1耗时 | 子查询2耗时 | 总耗时 |
|---|---|---|---|
| 单独执行子查询1 | 0.11s | - | 0.11s |
| 单独执行子查询2 | - | 0.09s | 0.09s |
| UNION ALL | 0.12s | 0.10s | 0.24s |
| UNION | 0.13s | 0.11s | 1.87s |
可以看到,两个子查询单独执行都在 0.1 秒左右,UNION ALL基本是两者相加,非常线性;而UNION则接近 2 秒,额外多了 1.6 秒。这 1.6 秒就是在临时表上做去重和排序的时间。
SHOW PROFILE里能更清楚地看到时间分布:
| Creating tmp table | 0.012s | | Copying to tmp table | 0.874s | | Sorting result | 0.423s | | Sending data | 0.312s |Copying to tmp table和Sorting result占据了绝大部分时间。如果你在做性能分析时看到这两项,基本可以直接锁定UNION或DISTINCT这类去重操作。
4.4 数据重叠率对耗时的影响
我又调整了两张表的数据重叠率,分别测试 0%、50%、100% 三种情况。结果很有意思:
- 重叠率为 0% 时,
UNION耗时约 1.2 秒,依然比UNION ALL(0.2 秒)慢得多。 - 重叠率为 50% 时,
UNION耗时约 1.9 秒,因为临时表里需要比较的行数变多,去除重复后其实行数少了一半,但去重比较的成本反而更高。 - 重叠率为 100% 时,
UNION耗时约 2.1 秒,最终结果集只有 100 万行,但已经排除了 100 万行重复数据,成本是最大的。
这个实验说明,UNION的性能瓶颈不在最终返回多少行,而在于"合并后比较所有行"的整体处理量。即使最终结果集很小,只要中间结果大,一次完整的UNION就快不了。
5. 优化 UNION 的实践:从执行计划到 SQL 改写
5.1 尽量把 WHERE 条件下推到每个子查询
一个简单但很容易被忽略的优化点:UNION需要处理的是每个子查询的完整结果集,所以子查询返回的行数越少,临时表的压力就越小。
比如原 SQL 中,如果业务只需要统计最近 7 天的数据,就一定要在子查询里先把 time 条件加进去,而不是在外面套一层 WHERE 再对UNION结果过滤。下面这种写法是反面例子:
SELECT user_id FROM ( SELECT user_id FROM tag_user_a UNION SELECT user_id FROM tag_user_b ) t WHERE t.create_time >= '2025-04-01 00:00:00';虽然 MySQL 优化器在特定版本中可能做到条件下推,但不是所有场景都可靠。你不要赌优化器,直接在子查询里写清楚:
SELECT user_id FROM tag_user_a WHERE create_time >= '2025-04-01 00:00:00' UNION SELECT user_id FROM tag_user_b WHERE create_time >= '2025-04-01 00:00:00';这样做之后,临时表里的行数会小很多,耗时自然下降。
5.2 只 SELECT 去重需要的列,避免无谓列
很多人写UNION时习惯把查询需要用到的列都 SELECT 出来,比如:
SELECT user_id, user_name, phone FROM tag_user_a UNION SELECT user_id, user_name, phone FROM tag_user_b;但业务其实只需要拿到去重后的 user_id。多余列会让临时表宽度变大,占用更多内存,排序和比较的成本也更高。正确做法是只保留必要列,等拿到去重后的 user_id 再回表补充其他字段。
还有一个隐藏问题:如果这些列的字符集或排序规则不一样,MySQL 可能无法高效比较,甚至需要额外的转换,进一步拖慢UNION。所以尽量让列类型、长度、字符集一致。
5.3 用"UNION ALL + 临时表"分步替代大 UNION
如果数据集非常大,十几万行甚至百万行以上,仅仅改写条件可能还不够。我自己在跑数据同步任务时常用一个思路:先把所有结果用UNION ALL写入临时表,然后对临时表做去重。这样做的优势是每一步都可以单独控制,也方便排查数据问题。
大致过程如下:
CREATE TEMPORARY TABLE tmp_union_data ( user_id BIGINT PRIMARY KEY ) ENGINE=InnoDB; INSERT INTO tmp_union_data (user_id) SELECT user_id FROM tag_user_a WHERE create_time >= '2025-04-01 00:00:00'; INSERT IGNORE INTO tmp_union_data (user_id) SELECT user_id FROM tag_user_b WHERE create_time >= '2025-04-01 00:00:00';利用INSERT IGNORE+ 主键/唯一键,让数据库在插入时静默去重。相比直接UNION,这种方式的优点是:
- 每一步的耗时清晰可见,不会一个事务扛到底。
- 临时表可以建索引,后续其他查询也可以复用。
- 如果数据量再大,你可以分批插入,降低锁和临时表压力。
缺点是写起来麻烦一点,且临时表在当前会话结束后自动消失,不适合跨会话复用。但临时表演示数据流思路是非常合适的。
5.4 用 EXISTS 或 JOIN 改写 UNION 去重
还有一种思路是绕开UNION的临时表,比如要获取"出现在 tag_user_a 或 tag_user_b 中且没有在 tag_user_c 中出现过"的用户,可以用LEFT JOIN ... IS NULL或NOT EXISTS改写。不过改写时要非常小心,不同业务语义对应不同写法,不能一概而论。原则上,如果UNION只是作为大查询的一部分,优先考虑改写;如果它本身就是复杂集合操作之一,用临时表更直观。
6. 我的默认策略:用语义换性能,但先验证
6.1 我自己的判断清单
写了这么多年 SQL,我现在遇到UNION和UNION ALL的选择时,基本按下面这套逻辑走:
| 条件 | 选择 | 原因 |
|---|---|---|
| 业务明确要求"去重后的并集",且数据量小(万行以内) | UNION | 简洁、语义清晰,性能影响可忽略 |
| 数据量较大,但业务上确认两个结果集不会重复 | UNION ALL | 避免临时表开销 |
| 数据量较大,且不确定是否重复 | 先跑聚合探针,确认为空再用 UNION ALL | 不盲目冒险 |
| 数据量巨大(千万级以上) | 改写为临时表 + INSERT IGNORE / GROUP BY | 让去重步骤可控,避免大临时表落盘 |
这个清单不是死的。实际线上环境,我会优先从业务语义出发,再结合执行计划确认成本。如果看到EXPLAIN中出现Using temporary和Using filesort,就要警惕是否可能被慢查询日志盯上。
6.2 线上快速验证"能否用 UNION ALL"的方法
在不改代码的情况下,快速判断一个查询能不能优化成UNION ALL,我会用这样的方式:
EXPLAIN SELECT ...;同时跑:
SELECT COUNT(*) FROM ( SELECT user_id FROM tag_user_a UNION ALL SELECT user_id FROM tag_user_b ) t;如果正常业务预期这个数字应该等于"去重后的集合行数",但实际数字明显偏大,说明有重复,这时候直接替换成UNION ALL是危险的。如果数字等于期望值,那UNION的额外开销就纯粹是浪费,可以大胆替换。
6.3 几个容易被忽视的细节
最后补充几个我在实战中踩过的小坑:
UNION子查询里的ORDER BY基本无效,除非配合LIMIT。很多人写SELECT ... ORDER BY create_time LIMIT 10 UNION ...还希望全局排序,结果发现排序被忽略了。正确的全局排序应该把UNION结果包一层再ORDER BY。UNION各子查询的列名以第一个子查询为准,如果你在第二个子查询里使用别名,要注意顺序和类型。拿UNION结果做嵌套查询时,很容易踩"找不到字段"的坑。- 如果两张表的字符集不一致,
UNION可能会因为 collation 不同导致无法使用索引,甚至报错。建议统一使用utf8mb4,并在设计阶段就保持一致。 - 在 MySQL 8.0.10 之后,
EXPLAIN ANALYZE可以给出实际执行时间和迭代信息,对定位UNION临时表开销非常有帮助。遇到复杂慢查询我一般会先跑一遍:
EXPLAIN ANALYZE SELECT user_id FROM tag_user_a UNION SELECT user_id FROM tag_user_b;它会直接显示每一步返回的行数和实际耗时,比单纯看EXPLAIN直观得多。
回到文章开头那个把报表拖垮的UNION。最后我把线上 SQL 改成了UNION ALL,然后在应用层通过Set去重,因为当时业务并不需要数据库内存放一个几十万行的临时去重结果。改造后,接口耗时从 4 秒压到了 0.2 秒,数据库负载也降下来了。
如果你也想在项目中优化类似的 SQL,我的建议很简单:先搞清楚业务到底需不需要去重,需要去重时优先想清楚去重的粒度,不需要时果断用UNION ALL,然后用EXPLAIN ANALYZE验证优化效果。不要因为一个"顺手"的习惯,让数据库白白扛下一次本不需要的去重操作。