SQL性能优化:UNION与UNION ALL的区别、使用场景及慢查询排查实战
发布时间:2026/9/28 6:35:11来源:尧图网络
做了这么多年数据开发和报表优化我几乎每天都要和UNION ALL打交道。但说实话真正能把UNION ALL讲清楚、用得明白的人并不多。大多数初学者要么不敢用要么乱用最常见的情况是把UNION和UNION ALL混为一谈等到线上慢查询报警了才回头排查。先给结论UNION ALL就是把多个查询结果直接纵向堆叠在一起不做任何去重也不排序。它解决的核心问题是“如何把多段结构相同的查询结果合并成一张表”。听起来很简单但这个操作符背后牵扯到性能、语义、索引、分页、排序等一系列问题任何一个环节没想清楚都可能埋下性能隐患。这篇文章我打算从实际工作场景出发掰开揉碎讲清楚UNION ALL的使用场景、和UNION的本质区别、动态 SQL 拼接技巧以及我在一线排查中踩过的坑。无论你是刚接触 SQL 的新手还是已经在写复杂报表的老手这篇文章都能给你一些可直接落地的参考。1. 深入理解UNION和UNION ALL不只是少一个 DISTINCT 那么简单先从热词里大家最关心的区别说起。网上铺天盖地的答案都说“UNION 会去重UNION ALL 不去重”这句话对但远不够。如果你只记住这个结论你根本理解不了为什么线上有人用UNION ALL替换UNION之后查询速度能提升好几倍。1.1 比“去重 vs 不去重”更重要的性能差异UNION的执行逻辑是先把所有子查询的结果合并然后对整个结果集做一次去重操作。去重听起来简单但数据库实现去重的方式通常有两种一种是基于排序Sort一种是基于哈希Hash。无论哪种如果数据量达到几十万甚至上百万行这个操作的 CPU 和内存消耗都是非常恐怖的。我举个例子你写了一条这样的查询SELECT user_id, order_id FROM orders_2023 UNION SELECT user_id, order_id FROM orders_2024;如果两张表各有 50 万行数据数据库需要先把这 100 万行全部读出来然后对所有列做一次完整的排序或哈希计算才能去掉重复行。即使两张表里根本没有重复数据——比如orders_2023和orders_2024的订单号天然不会冲突——数据库依然会老老实实地做这次毫无意义的全量去重。而UNION ALL就简单粗暴得多它把两个结果集直接拼接完了。没有排序没有哈希没有去重判断每条数据只读一次、写一次成本几乎是线性的。注意UNION的去重是对结果集所有列的组合进行去重不是只针对某一列。你必须在 SELECT 子句中列出所有需要判断的列才能得到预期的去重效果。1.2 什么时候UNION的去重真的有用我这样说不代表UNION就该被废弃。恰恰相反有些业务场景必须用UNION。比如你从两个不同的表中查询用户信息这两个表可能因为数据清洗不彻底而存在重复记录比如一个用户既在会员表里被导入了两次又在活动表中出现了一次此时用UNION去重是符合业务语义的。再比如查询某用户“参与的活动中是讲师还是学员”你把讲师表的结果和学员表的结果合并如果同一个人在两场活动中身份都是“讲师”但你只希望他出现一次那UNION就成了必要的选择。关键在于判断你的业务到底允不允许重复如果允许或者你确定不会有重复就用UNION ALL。这不仅仅是性能问题更是语义准确性问题和代码可维护性问题。一个经验丰富的 DBA 看到你在大数据量合并时用了UNION第一反应一定是问这里真的需要去重吗还是你没想清楚性能上我做过一个简单测试两张各 10 万行的表做全字段合并UNION耗时约 1.8 秒UNION ALL只需要 0.3 秒左右。数据量翻到 100 万行后UNION的耗时增长是指数级的而UNION ALL基本还是线性增长。你说这差距值不值得关注1.3 关于 UNION ALL 输出的列名和数据类型这里有个容易忽略的小知识点UNION ALL合并后的结果集列名由第一个 SELECT决定后续查询的列名会被忽略。比如SELECT user_id AS id, user_name FROM table_a UNION ALL SELECT uid AS user_id, name FROM table_b;最终结果集的表头是id和user_name而不是user_id和name。这个特性在动态拼接 SQL 或者用 ORM 映射结果时特别容易踩坑你对着table_b的字段名去取结果发现取不到排查半天才反应过来是列名对齐的问题。数据类型的规则也值得注意。合并时数据库会对相应位置的列做隐式类型转换。如果第一个 SELECT 的列是字符串类型第二个 SELECT 该位置是数字类型数据库会把数字转成字符串。反过来如果第一个是数字第二个是字符串数据库通常会尝试把字符串转成数字一旦字符串里有非数字字符查询会直接报错。这也是一个高频报错点后面我会专门说。2.UNION ALL的五大核心使用场景理解了UNION ALL的原理和性能优势接下来重点说说实际工作中到底在哪些场景下用它。我根据自己带项目和排查线上问题的经验整理了五类最常见的场景基本覆盖了日常工作的大多数需求。2.1 分表查询结果的合并这是UNION ALL最经典、最高频的使用场景。很多业务系统随着数据量增长会把单表拆成多张结构相同的子表比如订单表按年份拆成orders_2022、orders_2023、orders_2024或者按业务线拆成orders_east、orders_west、orders_south。查询时要跨多张子表汇总最简单的办法就是UNION ALL。SELECT order_id, user_id, amount, order_time FROM orders_2022 UNION ALL SELECT order_id, user_id, amount, order_time FROM orders_2023 UNION ALL SELECT order_id, user_id, amount, order_time FROM orders_2024;这种写法最大的优势是简单、直观可读性强而且每张子表都可以独立利用自己的索引。单表查询的过滤条件不需要做任何改变数据库可以分别对orders_2022走order_time索引、对orders_2023走user_id索引最后再把结果合并。有人可能会问为什么不直接建一张分区表如果你用的是 MySQL 8.0、PostgreSQL 或者 OceanBase 这类支持分区表的数据库确实更推荐分区表方案但很多历史系统因为分表策略已经固化或者分表逻辑由中间件控制SQL 层无法直接使用分区表。这时候UNION ALL就成了最稳妥的兜底方案。2.2 多系统、多数据源的数据聚合在一线做数据的同行应该很有感触很多报表数据不是来自同一个系统。比如你要统计一个用户的完整消费行为订单数据可能在 CRM 系统的库售后数据在客服系统的库访问日志在行为分析平台的库。虽然理论上可以用跨库查询或者数据仓库统一汇总但日常临时分析时最快捷的方式就是把各系统查出来的结果用UNION ALL拼接在一起。这种场景有一个天然的优势不同系统之间的数据逻辑上就不可能重复。用户订单、售后工单、访问日志本就不是同一张表的东西谈何去重所以这里只能用UNION ALL用UNION反而多余还平白增加一次全量去重浪费性能和时间。需要注意的是这种跨系统聚合往往还会用到排序和分组。常见写法是把UNION ALL的结果包一层子查询再在外面做GROUP BY或ORDER BY而不是在每个子查询里各自排序。因为UNION ALL不具备全局排序能力它只是把两个有序结果集生硬地拼接在一起整体顺序毫无规律。我在后面动态 SQL 部分会给出具体例子。2.3 报表统计里的行转列与多指标汇总我在做报表时经常遇到这种需求页面左侧是时间维度右侧需要展示多个不同口径的指标比如“本月新增用户数”“本月活跃用户数”“本月流失用户数”这些指标的来源表各不相同过滤条件也完全没有交集。一种写法是三个子查询各自求出一个值然后横向 JOINSELECT a.new_user_count, b.active_user_count, c.churn_user_count FROM (SELECT COUNT(*) AS new_user_count FROM users WHERE created_at 2024-01-01) a CROSS JOIN (SELECT COUNT(*) AS active_user_count FROM user_logins WHERE login_time 2024-01-01) b CROSS JOIN (SELECT COUNT(*) AS churn_user_count FROM users WHERE last_login 2024-01-01) c;但另一种更灵活、更贴合“明细列表”场景的写法是用UNION ALL把不同指标的解码结果摞起来再用条件聚合透视。比如SELECT metric_name, COUNT(*) AS metric_value FROM ( SELECT new_user AS metric_name, id AS biz_id FROM users WHERE created_at 2024-01-01 UNION ALL SELECT active_user AS metric_name, user_id AS biz_id FROM user_logins WHERE login_time 2024-01-01 UNION ALL SELECT churn_user AS metric_name, id AS biz_id FROM users WHERE last_login 2024-01-01 ) t GROUP BY metric_name;这种写法在需要“一列是维度名称、另一列是数值”的宽表报表中尤其好用而且可以继续在外面扩展ROLLUP或者CUBE做多级汇总比横向 JOIN 的写法灵活得多。缺点是子查询里如果有大量重复biz_id最终GROUP BY会去重统计这时要注意业务口径是否允许。注意这种“拼行”的写法一定要确保每个子查询的列数和列顺序完全一致。很多报表 SQL 看起来很长但本质上就是一个大号的UNION ALL一旦某个子查询多写或少写一个字段整个查询直接报错。2.4 日志表与临时表的分区合并日志类数据是UNION ALL的另一个主战场。很多系统把日志按天或按小时拆成表比如access_log_20240101、access_log_20240102。做月度分析时需要把 30 张表的日志全部合并起来用UNION ALL一把梭。在这种场景下UNION ALL的正确使用姿势往往是配合一个“查询条件下推”的技巧。你要分析某个月的所有日志但你可能只需要某几个 IP 的记录。如果直接对整月 30 张表做全量UNION ALL再过滤数据库会把每张表的全部数据读出来浪费巨大。正确做法是在每个子查询里都把过滤条件写进去SELECT ip, url, response_time, visit_time FROM access_log_20240101 WHERE ip IN (10.0.1.1, 10.0.1.2) UNION ALL SELECT ip, url, response_time, visit_time FROM access_log_20240102 WHERE ip IN (10.0.1.1, 10.0.1.2) ...这样做的好处是每张日志表都能走ip上的索引数据量从全表扫描级别降到索引查找级别。很多开发者习惯把过滤条件只写在外层子查询中这是日志分表场景最常见的性能杀手。2.5NOT IN和关联子查询的替代方案还有一个使用频率不低的场景用UNION ALL替代复杂的NOT IN或者EXISTS判断把“要排除的集合”和“要保留的集合”分别查出来再拼接。这种写法在业务上通常对应“白名单 黑名单 默认全量”的组合逻辑。比如你要查一个商品的可用优惠券列表规则是“某些用户不可用某券但另外一批用户可用全量券”。这种复杂逻辑拆成两个查询再UNION ALL思路比写一长串CASE WHEN清晰得多-- 第一批满足条件的用户可用 SELECT coupon_id, user_id FROM coupon_whitelist WHERE coupon_id IN (...) AND status 1 UNION ALL -- 第二批所有用户都可用的默认券 SELECT coupon_id, user_id FROM coupon_default WHERE status 1;这种写法本质上是用可读性换性能让数据库在每一段内分别走各自的索引。业务逻辑后期调整时只需要增删某个SELECT段不需要大改主查询结构维护成本很低。3. 动态 SQL 与UNION ALL拼接时的关键细节实际开发中很多UNION ALL并不是手写死在 SQL 文件里的而是由代码动态生成的。尤其在做筛选条件多变的报表后台、多租户数据查询平台时需要根据前端传入的条件动态决定查询哪几张表、拼接哪些UNION ALL段。这比你想象的更容易出错。3.1 保证每一段 SELECT 的列结构和类型只差在值上动态拼接时的第一原则是**每一段子查询的列数必须一模一样列的类型也不能相差太多。**我之前见过一个动态查询前几个子查询输出的第二列是INT最后一个子查询用了个VARCHAR状态的字符串数据库直接抛了个隐式转换的警告甚至在某些数据库里直接报错。我建议的做法是在动态拼接之前先定义好一个统一的列模板比如(id BIGINT, name VARCHAR(64), biz_type TINYINT, create_time DATETIME)然后每个子查询都严格按这个模板来生成。如果某个来源的数据确实没有某一列就用NULL或者0占位不要自作主张省略列。columns [id, name, biz_type, create_time] parts [] for table_name in table_list: select_part f SELECT id, name, {biz_type_value} AS biz_type, create_time FROM {table_name} WHERE create_time {start_date} AND create_time {end_date} parts.append(select_part) sql \nUNION ALL\n.join(parts)这段代码里biz_type_value是你在代码里计算好的枚举值而不是从源表里读出来的保证每一段输出的biz_type类型一致避免隐式转换带来的性能损耗。3.2 动态ORDER BY和LIMIT的处理动态拼接时最坑的问题是排序和分页。直接在每个子查询后面加ORDER BY、然后UNION ALL你会发现整个结果的排序在合并后变得毫无意义。因为每个子查询只是内部有序合并后数据库不保证整体顺序。正确的做法有两种一是把UNION ALL的结果包一层再在外层做排序分页SELECT t.id, t.name, t.biz_type, t.create_time FROM ( SELECT id, name, 1 AS biz_type, create_time FROM table_a WHERE ... UNION ALL SELECT id, name, 2 AS biz_type, create_time FROM table_b WHERE ... ) t ORDER BY t.create_time DESC LIMIT 20;另一种是如果分页必须要在每个子查询内先做一部分那就要加上排序字段一起传出来。比如每个子查询先取前 100 条外层再统一排序取前 20 条但这就涉及全局正确性问题容易出错。我的经验是除非你能确认每个子查询之间有天然的互斥切分边界否则不要试图用子查询内分页 外层合并来做全局分页大概率会翻车。3.3 避免不用SELECT *的习惯性坑还有一个动态拼接容易犯的毛病为了省事直接写SELECT *。这在单表查询时问题不大但一旦和UNION ALL组合列顺序稍有变动就会导致数据错位。我遇到过一次线上问题订单表加了两个字段后一个报表接口的数据突然全乱了。排查后发现问题出在UNION ALL的第二段子查询用了SELECT *而两张表虽然结构相同但物理列顺序不一样合并后同一位置列的含义完全不同。从那以后我给自己定了一条规矩**凡是用UNION ALL的查询一律显式列出所有字段禁止用SELECT *。**这不是洁癖是保命。4. 实战案例一次订单分表慢查询的优化全过程前面说了这么多理论下面用一个我实际处理过的案例带你完整走一遍从发现慢查询到优化落地的流程这样你更容易理解理论怎么落到实操。4.1 问题现场一个 3 秒以上的分表汇总接口当时有一个订单查询接口功能是查询某个用户在最近两年内的订单列表。表结构是按年份拆分的orders_2023、orders_2024。业务方反馈接口时常超时最慢的一次跑到 4 秒多DBA 拉出来的慢日志显示这个查询耗时主要在一条UNION语句上。原始 SQL 长这样SELECT order_id, user_id, amount, order_time, status FROM orders_2023 WHERE user_id 10086 UNION SELECT order_id, user_id, amount, order_time, status FROM orders_2024 WHERE user_id 10086 ORDER BY order_time DESC LIMIT 20;你看出问题了吗第一UNION会对两年的订单做一次全量去重尽管用户的订单 ID 在业务上根本不会重复第二外面的ORDER BY ... LIMIT 20导致数据库需要先把两个结果集全部去重排序后再取前 20 条白白做了一堆全量操作。4.2 优化方案与效果我的修改方案很简单把UNION改成UNION ALL同时把排序下推到外层。SQL 变成SELECT t.order_id, t.user_id, t.amount, t.order_time, t.status FROM ( SELECT order_id, user_id, amount, order_time, status FROM orders_2023 WHERE user_id 10086 UNION ALL SELECT order_id, user_id, amount, order_time, status FROM orders_2024 WHERE user_id 10086 ) t ORDER BY t.order_time DESC LIMIT 20;改完后接口耗时从 3.5 秒降到了 0.3 秒左右。原因很简单去掉了毫无意义的排序去重且每张子表都只读取该用户的数据数据量级别直接从几十万降到了几千行。这个案例的启示在于很多慢查询并不是数据库不行而是 SQL 里藏着一个多余的全量操作而不自知。4.3 优化过程中的取舍说明可能有细心的读者会问你这个优化是不是改变了业务语义万一同一个order_id真的存在两张表里呢这个判断很重要。我在动手优化前专门确认过这两张表的分表逻辑订单号由全局发号器生成按年份归属到对应的物理表同一个订单号不可能出现在两张表中。所以确认重复概率为零之后用UNION ALL是安全的。如果你的分表逻辑是“按用户 ID 取模”那同一个业务主键确实可能出现在不同子表中这时就要考虑业务上是否能接受重复显示了。实操心得**优化前先确认业务语义再决定要不要去掉去重。**去掉去重的性能收益是巨大的但绝不能以数据逻辑错误为代价。5.UNION ALL高频问题和排查技巧最后把工作里最常见的几个关于UNION ALL的问题一次性列出来附上排查思路省得你到时候搜半天。5.1 列数量不一致导致的报错这是新手最常遇到的问题。UNION ALL要求每个 SELECT 子句的列数量完全一致否则数据库会直接报错The used SELECT statements have a different number of columns。排查思路很简单逐一数每个子查询的 SELECT 字段个数。但实际项目里复杂的动态 SQL 可能每个子查询都是一大长串数起来很痛苦。我的建议是用格式化的方式把每个子查询的 SELECT 字段单独分行写SELECT id, name FROM table_a UNION ALL SELECT id, name, age -- 这里多了一个字段问题一下就看出来了 FROM table_b;5.2 类型不匹配带来的隐式转换即使列数量一致类型不一致也会引发问题。最常见的场景是第一个 SELECT 的某列是DECIMAL第二个 SELECT 对应位置却是VARCHAR且里面有N/A这样的值。数据库尝试把N/A转成DECIMAL时直接报错。排查方法是在每个子查询对应位置用CAST()显式统一类型SELECT id, CAST(amount AS DECIMAL(10, 2)) AS amount FROM table_a UNION ALL SELECT id, CAST(amount AS DECIMAL(10, 2)) AS amount FROM table_b;不要嫌麻烦显式CAST能帮你避开绝大部分隐式转换坑还能避免因为隐式转换导致索引失效的问题。5.3ORDER BY失效这个问题出现频率极高前面其实已经提到过。典型错误写法SELECT id FROM table_a ORDER BY id DESC UNION ALL SELECT id FROM table_b;很多数据库会直接报语法错误有些数据库能执行但结果顺序完全不对。正确做法是把ORDER BY放在整个UNION ALL的最外层。5.4UNION ALL后索引失效这是个比较隐蔽的问题。有些开发者以为把过滤条件放在外层子查询里照样能利用各子表的索引。实际上UNION ALL并集查询中如果过滤条件定义在外层数据库只能把每个子查询的全部结果读出来再在外层做过滤无法将过滤条件下推到子查询。举个例子-- 错误示范外层过滤无法下推 SELECT * FROM ( SELECT id, name FROM table_a UNION ALL SELECT id, name FROM table_b ) t WHERE t.id 100;正确示范是-- 正确示范每个子查询都写过滤条件 SELECT id, name FROM table_a WHERE id 100 UNION ALL SELECT id, name FROM table_b WHERE id 100;这个调整能让每个子查询都走id索引性能差距在表数据量较大时是数量级的。你在优化UNION ALL相关慢查询时一定先检查过滤条件的位置。5.5 与DISTINCT、GROUP BY组合使用的逻辑陷阱还有一个常见问题是在UNION ALL外层再套GROUP BY去重有时会造成和业务预期不符的结果。比如你把两个来源的用户 ID 拼在一起外层GROUP BY user_id这时重复的用户会被合并但这个“重复”是不是业务允许的需要仔细想。如果你只是想让两个结果集简单合并展示不需要去重那就要避免在外层顺手加GROUP BY的习惯。同样SELECT DISTINCT在UNION ALL的每个子查询中单独使用和在外层使用语义完全不同要根据业务需求判断放在哪一层。个人体会写到最后做了这么多年 SQL 优化我最大的体会是UNION ALL表面人畜无害但它的正确性几乎完全取决于你对自己数据形态的理解。你能不能用它取决于你是否确认重复数据不存在或者在业务上没有影响你能不能让查询变快取决于你的过滤条件写没写在正确的位置上。它不是一个可以无脑套用的语法糖而是一把用得好了效率极高、用不好就会数据错乱的利器。最后分享一个我一直在用的小习惯凡是包含UNION ALL的 SQL我都会在注释里写清楚每个子查询代表什么业务含义、为什么这一段的重复数据不影响最终结果。这样过几个月你自己回来看这段 SQL 时不用靠回忆就能快速读懂当初的取舍逻辑。这也算是在代码可维护性上给自己留的一条退路。
网站建设高端定制企业官网