在报表查询和多数据源合并的场景中,UNION ALL 是使用频率极高的操作符。它的优点是直接拼接结果集、不去重、速度快,但代价也很明显:一旦两个子查询存在交集,重复记录就会原样出现在最终结果里。不少人在遇到重复数据时的第一反应是把UNION ALL改成UNION,结果查询从毫秒级变成了几十秒,线上直接报警。本文就来系统讲清楚UNION ALL后去除重复记录的几种正确写法,以及每种写法适合的场景。

先弄清楚重复记录是怎么产生的
UNION ALL 的语义是"全部保留",它把左右两个结果集逐行拼接,不做任何比较。重复记录的出现通常有三种原因:一是业务上两个子查询的过滤条件本身存在重叠,比如一个查"本月订单"、另一个查"金额大于1000的订单",同一条订单同时满足两个条件就会被查出来两次;二是数据源本身有重复,比如中间表没有唯一约束;三是两个子查询来自不同的表但业务主键相同,合并时自然出现重复。
所以处理重复之前,第一步应该确认重复的定义:是所有字段完全相同才算重复,还是只要业务主键相同就算重复?这两者的处理方式完全不同。所有字段相同可以直接用DISTINCT解决;而业务主键相同但其他字段可能不同的情况,则需要明确保留哪一条,通常按时间取最新或按某个优先级取第一条。
方案一:外层使用DISTINCT去重
最直接的写法是在UNION ALL外面套一层SELECT DISTINCT,这是兼容性最好、最容易理解的方案:
SELECT DISTINCT * FROM (
SELECT order_id, user_id, amount, create_time
FROM t_order
WHERE create_time >= '2024-01-01'
UNION ALL
SELECT order_id, user_id, amount, create_time
FROM t_order_history
WHERE create_time >= '2024-01-01'
) t;DISTINCT会对所有查询字段做去重,等价于UNION的默认行为,但在很多数据库中,这种写法给了优化器更多调整空间。需要注意两点:第一,DISTINCT认为两个NULL相等,所以如果字段里有NULL,行为与预期一致;第二,如果两个子查询的字段类型或字符集不一致,比如一个是utf8mb4一个是utf8,看起来相同的值可能被判为不同,去重会失效,这也是很多人踩过的坑。
方案二:ROW_NUMBER窗口函数按业务主键去重
当重复记录并不是所有字段都相同时,DISTINCT就无能为力了。比如订单表和订单历史表合并后,同一个order_id出现两次,但create_time不同,此时需要保留最新的一条。窗口函数是处理这类问题的标准做法:
SELECT order_id, user_id, amount, create_time
FROM (
SELECT order_id, user_id, amount, create_time,
ROW_NUMBER() OVER (
PARTITION BY order_id
ORDER BY create_time DESC
) AS rn
FROM (
SELECT order_id, user_id, amount, create_time
FROM t_order
UNION ALL
SELECT order_id, user_id, amount, create_time
FROM t_order_history
) t
) x
WHERE rn = 1;这种写法的优势在于去重规则完全可控:PARTITION BY定义什么是"同一组",ORDER BY定义组内保留哪一条。如果两个表有优先级关系,比如实时表优先于历史表,可以加一个来源字段参与排序。窗口函数需要MySQL 8.0及以上版本支持,老版本可以用相关子查询或自连接模拟,但性能会明显下降。
方案三:分组聚合取代表记录
如果不需要保留明细,只想要每个主键的汇总结果,GROUP BY是更高效的选择。比如统计每个用户在两个表中的总金额:
SELECT user_id, SUM(amount) AS total_amount
FROM (
SELECT user_id, amount
FROM t_order
WHERE status = 1
UNION ALL
SELECT user_id, amount
FROM t_order_history
WHERE status = 1
) t
GROUP BY user_id;这种方式下重复记录反而无所谓,因为聚合操作天然会合并它们。反过来提醒一点:如果你的SQL里已经对主键做了GROUP BY,那么外层根本不需要再去重,重复添加DISTINCT只会白白增加一次排序或哈希操作。
直接用UNION还是先UNION ALL再处理
UNION的内部实现等价于UNION ALL加去重,多数数据库用排序或哈希来完成。在数据量小、或者去重列上有索引可以利用时,UNION的性能损耗并不大,直接改写成UNION是最省事的方案。但当两个子查询结果集都很大时,去重操作可能触发磁盘临时表,此时反而应该思考为什么会有重复:如果重复来自过滤条件重叠,更优的做法是调整条件让两个子查询互斥,例如给第二个子查询加上NOT EXISTS排除已查过的主键,从源头上消除重复。
SELECT order_id, user_id FROM t_order
WHERE create_time >= '2024-01-01'
UNION ALL
SELECT order_id, user_id FROM t_order_history o
WHERE o.create_time >= '2024-01-01'
AND NOT EXISTS (
SELECT 1 FROM t_order t WHERE t.order_id = o.order_id
);总结一下选择思路:数据量小直接用UNION;重复记录字段完全一致用DISTINCT;主键相同但需挑选记录用ROW_NUMBER;需要汇总用GROUP BY;能通过条件改造消除重复则优先改造条件。去重方案没有绝对优劣,关键在于先搞清楚重复产生的原因和去重的业务定义,再结合数据量和索引情况选择写法,这样才能写出既正确又高效的SQL。