如果你负责的报表系统有一天突然被一条慢查询拖垮而这条查询只是用UNION把两个子查询拼在一起你会先怀疑什么我在排查线上问题时遇到过一模一样的状况SELECT 没有关联索引也都在结果集加起来也就几十万行但 SQL 硬生生跑了好几秒。后来才发现问题不是字段拼错也不是索引失效而是UNION自带的那套去重逻辑。这个 MySQL 基础知识点一旦业务量上来就会变成真正的瓶颈。这篇文章就把UNION和UNION ALL的区别、底层的临时表机制、实测数据以及优化方案一次说透适合后端开发、数据开发还有正在准备 MySQL 面试的朋友。1. 一条线上慢查询UNION 把简单聚合拖垮了1.1 场景还原这条 SQL 到底干了什么先还原一下当时的问题。业务方要做用户标签合并有两张标签表一张tag_user_a存放30 天内注册且已下单的用户另一张tag_user_b存放高活跃用户现在需要统计这个月两个标签的总人数。因为一个用户可能同时出现在两张表里所以业务方很自然地用了UNIONSELECT 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什么时候别用 UNION3.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.36InnoDB内存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耗时总耗时单独执行子查询10.11s-0.11s单独执行子查询2-0.09s0.09sUNION ALL0.12s0.10s0.24sUNION0.13s0.11s1.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 ALL0.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 ) ENGINEInnoDB; 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验证优化效果。不要因为一个顺手的习惯让数据库白白扛下一次本不需要的去重操作。