做经营报表、数据看板的同学几乎都遇到过同一个问题:数据分散在多个源头,订单表在A库,退款表在B库,某个业务线的数据甚至单独放一张结构几乎一样的表。想统计每天的整体营收、订单量、退款金额,如果分别查再在Java或Python里拼数据,不仅代码啰嗦,还容易因为时区或过滤条件不一致导致口径对不上。其实在SQL层面用UNION ALL把多源头数据先纵向合并成一个结果集,再做聚合统计,就能一步到位拿到最终数字。

UNION ALL与UNION到底该怎么选
很多人写SQL时随手就敲了一个UNION,觉得反正都能把两张表的数据拼起来。这两者在语义上的差别非常关键:UNION会对合并后的结果集做去重,相当于隐含了一次DISTINCT操作;而UNION ALL只是单纯地把数据按顺序拼在一起,不做任何去重。这意味着UNION在执行时通常需要对数据做排序或哈希去重,数据量大时这个开销相当可观。
在多源头业务数据汇总的场景中,我们要的是全量数据的累加统计,去重反而是错误行为。比如订单表有50万行,退款表有20万行,合并后就应该有70万行参与统计,用UNION会悄悄把重复行剔除掉,最后算出来的金额可能偏小。所以汇总统计场景下,99%的情况都应该用UNION ALL。只有当你的业务目标就是去重(比如统计出现过的用户数)时,才考虑UNION或者显式写DISTINCT。
从执行计划的角度看,UNION ALL不需要去重节点,数据库可以采用更轻量的append方式直接拼接数据,在MySQL、PostgreSQL、SQL Server中都能明显观察到差距。简单做个对比:
-- 写法一:UNION,会去重,性能差 SELECT order_no, amount FROM t_order UNION SELECT refund_no, amount FROM t_refund; -- 写法二:UNION ALL,不去重,汇总首选 SELECT order_no, amount FROM t_order UNION ALL SELECT refund_no, amount FROM t_refund;
UNION ALL后聚合统计的完整实战
接下来用一个典型场景演示完整思路:订单表记录正向交易,退款表记录逆向退款,现在需要统计每天的订单量、销售额、退款金额和净营收。核心技巧是在合并前给每个来源打上一个来源标记字段,合并后再用条件聚合(CASE WHEN配合SUM)分别计算不同来源的指标,一张报表的数字就能一次查出来。
先看表结构,两张表字段命名不完全一致,金额字段一个叫pay_amount,一个叫refund_amount,这正是多源头数据的常态。处理办法是在每个子查询里用别名把字段对齐,缺的字段用NULL或0补位:
SELECT
DATE_FORMAT(stat_date, '%Y-%m-%d') AS stat_date,
SUM(CASE WHEN source = 'order' THEN 1 ELSE 0 END) AS order_cnt,
SUM(CASE WHEN source = 'order' THEN pay_amount ELSE 0 END) AS total_sales,
SUM(CASE WHEN source = 'refund' THEN refund_amount ELSE 0 END) AS total_refund,
SUM(CASE WHEN source = 'order' THEN pay_amount ELSE 0 END)
- SUM(CASE WHEN source = 'refund' THEN refund_amount ELSE 0 END) AS net_revenue
FROM (
-- 订单来源:正向交易,补齐退款字段为0
SELECT
create_time AS stat_date,
'order' AS source,
pay_amount,
0 AS refund_amount
FROM t_order
WHERE create_time >= '2024-01-01'
UNION ALL
-- 退款来源:逆向交易,补齐支付字段为0
SELECT
refund_time AS stat_date,
'refund' AS source,
0 AS pay_amount,
refund_amount
FROM t_refund
WHERE refund_time >= '2024-01-01'
) AS all_data
GROUP BY DATE_FORMAT(stat_date, '%Y-%m-%d')
ORDER BY stat_date;这段SQL有几个值得注意的细节。第一,来源标记字段source是整个方案的核心,有了它才能在外层用条件聚合区分不同来源的指标,避免退款金额错误地加到销售额里。第二,两张表缺失的字段分别用0补位,保证每个子查询的列数和类型一一对应,这是UNION ALL的硬性要求。第三,过滤条件要写在每个子查询内部而不是外层,让数据库在扫描各表时就能提前过滤,减少进入合并阶段的数据量。
如果业务进一步要求按月或按周统计,只需要改外层的日期函数。MySQL里换成DATE_FORMAT(stat_date, '%Y-%m'),PostgreSQL可以用date_trunc('month', stat_date),思路完全一致。这种先合并再分组聚合的结构,本质上就是把多张表规整成一张宽的明细表,之后的任何维度统计都只是外层查询的变化。
跨库数据源对接与常见坑点
当数据真的分散在不同数据库实例甚至不同类型的数据库时,纯SQL的UNION ALL需要借助外部能力。MySQL可以在同一实例内用库名前缀直接跨库查询,例如db_a.t_order和db_b.t_refund直接UNION ALL;跨实例的场景则要依赖FEDERATED引擎、DBLink或者ETL工具先把数据同步到同一个库。更常见的工程实践是用数据仓库或OLAP引擎(如ClickHouse、Doris)把多源头数据抽取到统一的分析层,再执行UNION ALL加聚合,性能远好于在多个业务库之间做分布式查询。
这种方案有几个高频踩坑点需要留意。一是数据类型不一致的问题,比如A表金额是DECIMAL,B表是VARCHAR,直接合并会隐式转换甚至报错,务必在子查询里显式CAST统一类型。二是时间字段口径要统一,订单表可能存的是UTC时间,退款表存的是本地时间,不做处理直接按日期分组会出现同一天数据错位。三是NULL值处理,SUM会自动忽略NULL,但如果补位时用了NULL而不是0,CASE WHEN里的分支写法不当就可能漏算,建议统一用0补位更稳妥。
-- 坑点示例:类型不一致导致隐式转换
SELECT 'order' AS source, CAST(pay_amount AS DECIMAL(12,2)) AS amt FROM t_order
UNION ALL
SELECT 'refund' AS source, refund_amt FROM t_refund; -- 已是DECIMAL
-- 优化:过滤条件下推到子查询内部
SELECT source, SUM(amt) FROM (
SELECT source, amt FROM t_order WHERE dt >= '2024-01-01'
UNION ALL
SELECT source, amt FROM t_refund WHERE dt >= '2024-01-01'
) t GROUP BY source;性能方面还有两个建议。其一是给每个子查询中用到的过滤字段和分组字段建立合适的索引,UNION ALL本身的拼接成本很低,真正耗时往往在子查询的全表扫描上。其二如果数据量到了千万级以上,考虑给明细表按日期做分区,查询时利用分区裁剪减少扫描范围。整体来看,UNION ALL加聚合统计这套组合拳,写法简单、口径统一、一次查询出结果,是处理多源头业务数据汇总最实用的SQL模式之一。