ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SQL性能优化:UNION ALL实战与踩坑指南

SQL性能优化:UNION ALL实战与踩坑指南 写这篇东西的起因是上周帮同事排查一条慢 SQL。那条查询逻辑不复杂就是把两张表的数据合并起来做统计结果跑了二十多秒DBA 群里直接被点名。我拉出执行计划一看问题清清楚楚他写的是 UNION不是 UNION ALL。两张表的数据本来就没有交集去重这个动作纯粹是白干还额外触发了一次排序。改成 UNION ALL 之后查询时间直接掉到两秒以内。类似的情况我在不同团队里见过太多次了。UNION ALL 是 SQL 里最常见、也最容易被忽视性能的关键字之一很多人对它的理解停留在跟 UNION 差不多就是不去重但实际用起来场景远不止合并两张表那么简单。这篇文章我就把自己这些年实际用 UNION ALL 的场景、踩过的坑、以及怎么用它解决真实业务问题一次性整理明白。先说清楚适合谁看刚入门 SQL 的开发、每天跟报表打交道的数据分析师、以及被慢查询折磨的 DBA 和数据开发都能从这里拿到点东西。老手可以直接跳到第 3 节以后那些执行计划和踩坑记录是常规文档里不会写的东西。1. 先搞清楚 UNION ALL 到底是个什么东西1.1 它和 JOIN 是完全不同的两种操作很多刚写 SQL 的朋友会把 UNION ALL 和 JOIN 弄混因为看起来都像把两个结果集拼在一起。但这两者的逻辑完全不同理解错了后面的查询怎么写都是歪的。JOIN 是横向合并它把两张表中满足 ON 条件的行拼接成更宽的行。比如订单表和用户表 JOIN得到的是订单信息 用户信息这种列变多的结果行数则会因为一对多关系而放大。UNION ALL 是纵向合并它把两个查询的结果按行堆叠在一起列数保持不变行数增加。比如一月份订单表和二月份订单表 UNION ALL就是把一月的每一行跟二月的每一行首尾相接。这个区别在我给新人培训的时候用过一句话JOIN 在横向拓宽数据UNION ALL 在纵向堆叠数据。如果你发现你要合并的两个查询列数是不同的那大概率你不该用 UNION ALL而是要去检查业务逻辑是不是理解错了。从数据库引擎的角度看UNION ALL 的实现其实非常笨第一个查询出结果放到内存或者临时结构里然后第二个查询的结果直接往后面追加全程没有排序、没有去重、没有额外的比较操作。这也是它性能好的根本原因——引擎不需要为它做任何额外的事情。1.2 UNION 和 UNION ALL 的那一字之差值多少性能这是被讨论最多的话题但我还是想用自己的实测数据说一下。UNION 底层做的事情是在 UNION ALL 的基础上外加一次去重。去重在数据库里可不是免费的它通常意味着对结果集做一次排序来去重或者构建一张哈希表记录已经出现过的行。不管是哪种都意味着额外的 CPU 计算以及可能的内存或临时表磁盘空间消耗。结果集越大这个代价越明显。我去年在一张 5000 万行级别的日志表上做过对比测试同一个查询UNION 版本跑了 8.3 秒UNION ALL 版本跑了 1.1 秒。在结果集没有重复这个前提成立时用 UNION 的每一秒都是在为去重买单。对比维度UNIONUNION ALL是否去重是否底层动作堆叠 排序/哈希去重仅堆叠性能慢数据量大时差距明显快适合场景业务上必须去重业务上允许重复或本无重复关键点在于去重本身是一个业务需求不是 SQL 自带的保险丝。如果你不能确定结果集里没有重复你的第一反应应该是检查业务而不是用 UNION 来兜底。两个查询的中间结果集如果有重叠但你有意保留全部记录那 UNION 反而成了错误答案——它会把本该出现的重复数据吃掉。提示判断该用 UNION 还是 UNION ALL先问自己一个问题——我需要两个结果集重叠部分的重复行吗需要就 UNION ALL明确不需要去重且重叠可能性确实存在才用 UNION。2. 我实际工作中最常用的五个场景2.1 场景一结构相同的表按月拆分后的合并查询这是 UNION ALL 最经典、使用频次最高的场景。很多业务系统在表设计初期就会做分表最常见的是按月分表order_202401、order_202402……一直排下去。分表解决了单表数据量过大的写入和索引压力但带来了一个必然的问题跨月查询怎么办这时候 UNION ALL 几乎是标准的解法SELECT id, user_id, amount, status, create_time FROM order_202401 UNION ALL SELECT id, user_id, amount, status, create_time FROM order_202402 UNION ALL SELECT id, user_id, amount, status, create_time FROM order_202403这里必须用 UNION ALL 而不是 UNION原因有两层。第一订单数据本身有唯一主键物理上就不可能跨表重复UNION 的去重是无用功。第二分表的目的是为了性能结果在合并环节因为一个 UNION 把去重和排序的代价加回来等于把分表省下的资源又还回去了。我曾经接手过一个分表多达 36 个月的数据平台原来的代码里每个季度汇总都写 UNION跑一次要十分钟。后来把所有 UNION 改成 UNION ALL再配合后面要讲的把拼接结果当子查询再聚合的写法直接把耗时压到两分半。跨库的类似场景也经常出现订单库和订单归档库是两个独立的库或者数据落在不同实例上你要同时查线上和归档数据UNION ALL 同样适用——只要列结构一致数据库根本不关心数据来自哪个库。2.2 场景二统计报表里的多段数据汇总报表需求里经常出现这样的情况同一个指标要分多个口径、多个时间段分别计算最后合并展示在一张表里。比如经营日报里要同时看到今日订单数本月累计订单数本年度累计订单数这三个数字来自同一张表、但是 WHERE 条件完全不同。新手遇到这种需求通常会写三个查询在应用层把它们拼起来。其实用一个 UNION ALL 就能搞定SELECT 今日 AS period, COUNT(*) AS order_cnt, SUM(amount) AS total_amount FROM orders WHERE create_time CURDATE() UNION ALL SELECT 本月, COUNT(*), SUM(amount) FROM orders WHERE create_time DATE_FORMAT(CURDATE(), %Y-%m-01) UNION ALL SELECT 年度, COUNT(*), SUM(amount) FROM orders WHERE create_time DATE_FORMAT(CURDATE(), %Y-01-01);看到第一个 SELECT 里那个 今日 AS period 了吗这是这类报表查询的核心技巧用一列常量作为分组标识让最终结果集的每一行都能说清楚自己属于哪个口径。应用层拿到这个结果后遍历一次就能直接渲染到报表上不需要发三次查询也不需要自己维护三个变量。这种做法还有一个隐藏好处三个聚合本身都只扫一遍各自所需的数据而 UNION ALL 只是把三行结果堆到一起代价几乎可以忽略。如果改成在应用层循环三次查询每次还要建立连接、解析 SQL、拉取结果开销反而更大。2.3 场景三冷热数据分层后的联合访问数据量到了一定规模后很多团队会把数据分成热数据和冷数据。热数据放在高性能存储或主表里冷数据移入归档表、或者换成成本更低的存储引擎。但业务查询经常是既要热也要冷比如用户想看自己的历史订单而这些订单一半在热表、一半在归档表。这种情况下 UNION ALL 就是连接冷热两个世界的桥梁SELECT id, user_id, amount, status, create_time FROM orders_active WHERE user_id 12345 UNION ALL SELECT id, user_id, amount, status, create_time FROM orders_archive WHERE user_id 12345 ORDER BY create_time DESC;注意这里的 WHERE 条件分别在两个子查询里各自执行。数据库对 UNION ALL 的优化策略是先各自查询再拼接结果所以每个子查询都能独立利用自己表上的索引。执行顺序上讲两个子查询过滤完之后剩下的行数通常很少UNION ALL 拼接的代价也就很小。这个场景里容易被忽略的是归档策略的边界条件。我见过因为归档任务跑失败、导致一部分数据同时存在于热表和归档表的情况用户在前台看到订单重复了。这时候你需要的不是把 UNION ALL 改成 UNION那会掩盖数据问题而是去修归档任务、修数据同步逻辑。UNION ALL 的一个隐性价值就在这它会把数据问题原原本本地暴露出来而不是替你悄悄抹掉。2.4 场景四数据校验和差异比对做数据仓库或者数据迁移的同学对这类场景应该很有共鸣你要验证两张表的数据是否一致或者找出差异数据。UNION ALL 配合 GROUP BY 是业界很经典的一套求差异组合拳。核心思路是给两张表的每一行打上来源表标记然后按所有字段分组看看哪些组里只有一边的标记SELECT id, name, amount, MAX(src_flag) AS flags, COUNT(*) cnt FROM ( SELECT id, name, amount, a AS src_flag FROM table_a UNION ALL SELECT id, name, amount, b AS src_flag FROM table_b ) t GROUP BY id, name, amount HAVING COUNT(*) 1;如果某一行只出现在 table_a 里那么它的标记只有 aCOUNT() 就是 1说明这是 A 表独有的数据B 表独有的也是同理。如果两边都有且内容完全一致COUNT() 就是 2。用这个查询一次就能把只有 A 有只有 B 有两边都有全部分出来。这里为什么必须用 UNION ALL想想就明白了如果某一行在两张表里恰好完全一样用 UNION 去重后它只剩一行你根本分不清它到底是一边有还是两边都有。UNION ALL 保留所有行分组数量才能真实反映数据分布。用 UNION 做数据校验校验出来的结果本身就是错的。我做过一次千万级数据的迁移核对就是靠这种写法在几分钟内定位出了几百条差异数据。当然如果字段特别多GROUP BY 写起来会很长这是这个方案的痛点。实际执行时也可以先用一些校验函数把多列压缩成指纹列再比对效率会更高。2.5 场景五补全缺失日期让报表连续这个场景估计很多做报表的人踩过坑。表里只有有数据的日子才有记录但报表要求每天一行没数据的日期要显示 0 或者空。直接 GROUP BY 出来的结果总有空洞。标准解法是生成一个完整的日期序列然后 LEFT JOIN 实际数据。这个日期序列从哪来如果没有数字辅助表最灵活的方式就是把 UNION ALL 和递归查询结合起来各种数据库都有对应的写法比如 MySQL 8.0 里这样生成最近 30 天的日期序列WITH RECURSIVE seq AS ( SELECT 1 AS n UNION ALL SELECT n 1 FROM seq WHERE n 30 ) SELECT DATE_SUB(CURDATE(), INTERVAL n - 1 DAY) AS day FROM seq;注意这里的递归 CTE 内部就是靠 UNION ALL 在迭代每次从上一个 n 加 1直到不满足 WHERE 条件为止。可以说 UNION ALL 是很多生成序列类操作的地基。用这招生成日期序列后再和统计数据 LEFT JOIN报表上每一天就都有行了。3. 实操中的关键细节从列匹配到执行计划3.1 列数、列顺序和数据类型匹配规则UNION ALL 看起来简单但它对参与拼接的各个 SELECT 是有严格要求的这也是初学者最容易报错的地方。最基础的规则每个 SELECT 返回的列数必须一致。第一个 SELECT 决定了结果集的列结构后面的 SELECT 列数不同就直接报错。以 MySQL 为例报错信息一般是 The used SELECT statements have a different number of columns。第二个规则是列顺序必须对应。UNION ALL 按位置匹配列它只认第几列不认列名。也就是说第一个 SELECT 的第三列和第二个 SELECT 的第三列会被拼在同一列上不管它们叫什么名字。很多人在这里栽过跟头两个查询的列名不同但内容顺序其实对应结果没问题反过来如果顺序写反了数据就错位了而 SQL 不会给你任何警告。解决办法很简单每个 SELECT 里都显式写列名并且保持相同的排列顺序。不要用 SELECT *除非你能确保两张表的列定义完全一致。用 SELECT * 在开发环境跑没问题一旦源表做了加列操作两边列数不一致线上直接挂。这个我见过太多次了。第三个规则是数据类型要兼容。不同数据库的容忍度不一样。MySQL 里如果用 UNION ALL 连接一个整型列和一个字符串列会发生隐式类型转换把整型转成字符串或者反过来具体看语境。这种转换意味着额外的计算而且可能带来精度问题比如浮点数和 decimal 混拼时出现尾差。经验之谈拼接前先统一类型该 CAST 就 CAST。3.2 ORDER BY 和 LIMIT 的正确打开方式这里有个高频坑几乎每个写 UNION ALL 的人都会踩一次想在每个子查询里排序直接写在子查询的 ORDER BY结果往往被引擎忽略。为什么因为在 UNION ALL 的语义里各个子查询是集合的一部分集合本身没有顺序概念。数据库优化器看到子查询里的 ORDER BY 时如果这个排序对外层结果没有影响就可能直接把它优化掉。比如-- 这种写法子查询里的 ORDER BY 基本没用 SELECT id, amount FROM order_202401 ORDER BY amount DESC UNION ALL SELECT id, amount FROM order_202402;你本来想让第一个子查询按金额降序输出但引擎大概率会忽略这个排序。什么时候子查询里的 ORDER BY 会生效通常是配合 LIMIT 的时候因为 LIMIT 必须先确定取哪几行排序才有意义-- 每个表取金额最大的前 10 条再合并 SELECT id, amount FROM order_202401 ORDER BY amount DESC LIMIT 10 UNION ALL SELECT id, amount FROM order_202402 ORDER BY amount DESC LIMIT 10 ORDER BY amount DESC;这种先各自取 Top N 再合并的写法在分页、排行榜场景里非常实用它能让每个子查询各自走索引避免把所有数据都捞出来再排序。如果要对整个合并结果排序ORDER BY 必须放在最后一个 SELECT 之后。要注意的是整体排序时引用的列名最好取自第一个 SELECT 的列名因为结果集列名默认由第一个 SELECT 决定。LIMIT 同理。结果集层面的 LIMIT 写在最外层SELECT ... FROM table_a UNION ALL SELECT ... FROM table_b ORDER BY create_time DESC LIMIT 20;这个查询是先把两边数据拼起来再排序再取前 20 行和各自 LIMIT 20 再拼是完全不同的语义用之前想清楚你要哪种。3.3 索引和 UNION ALL 的执行计划怎么看UNION ALL 本身不排序不去重所以它对索引没有特殊要求。它性能好不好取决于每个子查询能不能用好各自的索引。换句话说问题不在 UNION ALL而在子查询的 WHERE 条件和 SELECT 列上。看执行计划的方式各种数据库大同小异。MySQL 里用 EXPLAIN你会发现 UNION ALL 的结果集里会出现多行记录每一行对应一个子查询的执行路径。我常用的排查套路是先单独跑每个子查询确认各自执行计划里 type 是 range 或 ref说明用上了索引再拼起来跑整体。如果整体变慢基本可以断定是某个子查询全表扫了。这里有一个优化原则UNION ALL 的子查询里一定要把过滤条件下推到每个子查询内部不要在外层包一个大 WHERE 再过滤。因为 UNION ALL 是先拼后过滤还是先过滤后拼看着结果一样但性能天差地别-- 反面写法先拼再过滤 SELECT * FROM ( SELECT id, amount FROM table_a UNION ALL SELECT id, amount FROM table_b ) t WHERE t.amount 1000; -- 正面写法先各自过滤再拼 SELECT id, amount FROM table_a WHERE amount 1000 UNION ALL SELECT id, amount FROM table_b WHERE amount 1000;第一种写法里table_a 和 table_b 的全部数据都要参与拼接占用的临时空间和后续过滤的代价都大。第二种写法让每个子查询在索引层面就把不满足条件的行干掉拼出来的东西本来就小。这是 UNION ALL 性能优化的第一原则过滤条件下推能早就早。4. UNION ALL 高发坑位排查实录4.1 结果集莫名其妙的重复使用 UNION ALL 后出现重复数据严格说这不是 bug因为 UNION ALL 本来就不去重。但很多人会把它当 bug 报上来。排查时先问三个问题业务上是否可能产生重复比如同一用户下了两笔一模一样的金额的订单两行数据除了主键不同其他列完全一样。这不能靠去重解决应该从业务层面理解。是不是 JOIN 导致的结果放大如果子查询里带了 JOIN一对多关系会让行数翻倍那重复其实是 JOIN 的锅不是 UNION ALL 的问题。是不是两个子查询的边界条件重叠了比如一个查 create_time 2024-01-01另一个查 create_time 2024-01-31中间有重叠的查询范围数据自然会被取两遍。这是最常见的假重复来源检查 WHERE 条件的边界有没有错位。我的建议是先在脑子里给每个子查询的结果集画一条分界线确认它们互不重叠再放心用 UNION ALL。真出现重复了先用 SELECT DISTINCT 或 GROUP BY 临时压一下然后赶紧查根因而不是换 UNION 掩盖。4.2 隐式转换拖垮性能这是一个比较隐蔽的性能杀手。前面说过UNION ALL 对数据类型要求是兼容但兼容不等于不转换。比如一张表的 id 是 BIGINT另一张表的 id 是 VARCHAR拼接时数据库会对其中一方做隐式转换。麻烦在于如果转换发生在被索引的列上索引就废了。好比一个字符串类型的 id 列因为和整型列 UNION ALL被整体转成数字后再比较原来建立在字符串上的索引根本用不上只能全表扫。数据量一大这个坑能把查询拖到分钟级。排查方法看执行计划里有没有出现全表扫同时留意字段类型定义。规范的做法是在建表或 ETL 阶段就统一字段类型SQL 侧做好 CAST。这里有个取舍CAST 本身也有计算成本但在列数少、数据量可控的情况下明确 CAST 比让引擎猜要可控得多。4.3 子查询里 ORDER BY 失效这个前面已经提到再补充一个实际案例。有次同事写了一个分页接口的 SQL子查询里带了 ORDER BY 和 LIMIT看起来一切都对接口数据却总是乱的。原因就是他把 ORDER BY 放在了 UNION ALL 的中间而这个排序针对的子查询内部的顺序对外层毫无意义被优化器忽略了。针对这类问题的排查经验如果发现排序没生效先把 UNION ALL 拆开看每个子查询单独执行时顺序是否正常。子查询里要排序记住ORDER BY 要和 LIMIT 绑定没有 LIMIT 的 ORDER BY 在 UNION ALL 里就是废操作。如果想对外层结果排序就把 ORDER BY 放到整个语句的最后。4.4 和 NULL 纠缠不清的坑UNION ALL 在去重这个问题上对 NULL 的态度很有意思UNION 去重时会认为两个 NULL 是相等的在排序时它们归为一组所以多行的 NULL 会被去成一行。但 UNION ALL 不管这些NULL 行有多少保留多少。如果某个报表依赖 NULL 去重你要搞清楚自己用的到底是不是 UNION。另一个 NULL 相关的坑是在数据校验场景两张表的同一列一张是 NULL一张是空字符串 用 GROUP BY 比对时它们会被当成不同的值于是校验报告一堆差异。实际业务上可能觉得二者等价也可能确实有区别这需要和业务确认。不要假设数据库会帮你做任何智能归一化。5. 两个值得收藏的扩展用法5.1 用 UNION ALL 做行转列有些数据库没有专门的 PIVOT 功能比如 MySQL 8.0 之前行转列的一个常用办法就是 GROUP BY CASE WHEN。但 UNION ALL 在有些场景下反而是更灵活的手段。假设有多张月份表你想把每个月的销售额并排展示成一行的多列SELECT MAX(CASE WHEN month 2024-01 THEN amount END) AS jan_amount, MAX(CASE WHEN month 2024-02 THEN amount END) AS feb_amount FROM ( SELECT 2024-01 AS month, SUM(amount) AS amount FROM order_202401 UNION ALL SELECT 2024-02, SUM(amount) FROM order_202402 ) t;核心思想还是那个先用 UNION ALL 把多张表堆成一张长表外面再用条件聚合把它拉成宽表。这个套路在生成报表的时候很常用尤其是你不想在应用层写一堆 if-else 的场合。5.2 用 UNION ALL 做多来源合并写入如果你需要在一条 SQL 里从多个表取数据后插入目标表UNION ALL 也能发挥作用INSERT INTO order_summary (order_id, amount, source) SELECT id, amount, realtime FROM order_realtime WHERE create_time CURDATE() UNION ALL SELECT id, amount, history FROM order_archive WHERE create_time CURDATE();这种写法在做增量汇总、数据回灌时非常方便。一次 INSERT 完成多个来源的合并写入目标表里还能通过 source 字段追溯到数据来源。执行计划上是两个查询各自跑完后统一写目标表对目标表的锁开销也只有一次。6. 一点个人体会写到这里回头看 UNION ALL 这个关键字它其实是 SQL 里少有的越简单越需要想清楚的操作。它本身不做任何聪明的事不排序、不去重、不转换所以它的性能和语义完全取决于你怎么组织每个子查询。你对业务数据的边界了解得越清楚用 UNION ALL 就越放心反过来只要你对数据分布心里没底它就会把各种隐藏问题原样摆到你面前。我个人在写所有涉及 UNION ALL 的 SQL 前都会强迫自己先回答三个问题两个子查询的结果集边界是否互斥、类型是否已经对齐、过滤条件是不是已经下推到最内层。这三件事确认完UNION ALL 基本不会出幺蛾子。如果你也想在团队里推广这个习惯建议直接从 Code Review 里卡两个点是否用了 SELECT *、子查询里有没有多余的 ORDER BY。这俩是最常见的低级问题也是最好改的。最后再说一个小技巧当你怀疑某个用 UNION 的慢查询应该换 UNION ALL 时不要凭感觉改。先在两个子查询上分别 SELECT COUNT(*)再用 UNION ALL 拼起来看总行数最后和 UNION 的结果行数对比。如果两者行数一致说明结果集本就无重复放心换成 UNION ALL如果有差异那差异行数就是过去每次查询为去重付出的无效开销也是你向同事解释为什么要改的有力证据。
返回列表