Featured image of post DuckDB 集合操作完全指南:UNION、INTERSECT、EXCEPT 实战

DuckDB 集合操作完全指南:UNION、INTERSECT、EXCEPT 实战

深入解析 DuckDB 的四种集合操作,包含真实性能测试数据和 UNION BY NAME 特色功能,帮你避开常见的性能陷阱。

处理多表数据合并时,集合操作是绕不开的基础工具。UNION、INTERSECT、EXCEPT 这些 SQL 标准语法,在 DuckDB 里有些特别的表现。

这篇文章会拆解四种集合操作的用法,分享一些真实的性能测试数据,还有 DuckDB 独有的 UNION BY NAME 功能。如果你正在用 DuckDB 做多源数据整合,这些内容应该能帮上忙。

四种集合操作速览

先理清基本概念。SQL 标准定义了四种集合操作,DuckDB 全部支持:

UNION - 合并两个查询结果,自动去重
UNION ALL - 合并两个查询结果,保留重复行
INTERSECT - 取两个查询结果的交集
EXCEPT - 取第一个查询结果减去第二个(差集)

语法都很简单:

1
2
3
SELECT column1, column2 FROM table1
UNION
SELECT column1, column2 FROM table2;

看起来 straightforward,但实际用起来有些细节值得注意。

性能差异:UNION vs UNION ALL

很多人可能没意识到,UNION 和 UNION ALL 的性能差距可以非常大。

UNION 需要去重,DuckDB 会用哈希表来做这件事。数据量小的时候感觉不到,数据量一大,差距就明显了。

我在本地跑了一组测试,数据量从 1 万行到 50 万行,两个表有 50% 的数据重叠:

数据量UNION ALLUNIONINTERSECTEXCEPT
10,0000.0007s0.0031s0.0026s0.0023s
50,0000.0005s0.0088s0.0066s0.0066s
100,0000.0005s0.0154s0.0109s0.0116s
500,0000.0007s0.0546s0.0394s0.0370s

几个关键观察:

UNION ALL 的性能非常稳定,基本不受数据量影响,始终在 0.0005-0.0007 秒这个量级。因为它只是简单拼接,不需要额外处理。

UNION、INTERSECT、EXCEPT 的性能都随数据量线性增长。到 50 万行时,UNION 比 UNION ALL 慢了大约 78 倍(0.0546s vs 0.0007s)。INTERSECT 和 EXCEPT 的性能表现相近。

选择建议:如果你确定两个结果集没有重复数据,或者你不在乎重复,用 UNION ALL。如果需要去重,再考虑 UNION。大数据量场景下,这个选择对性能影响很大。

UNION BY NAME:DuckDB 的特色功能

这是 DuckDB 相比传统数据库的一个实用扩展。

标准 UNION 要求两个查询的列数完全一致,而且按位置对齐。如果列顺序不同,或者列数不一样,就会报错。

UNION BY NAME 改变了这个规则。它按列名对齐,而不是按位置。缺失的列会自动填充 NULL。

1
2
3
4
5
6
7
8
9
-- 两个表结构不完全一致
CREATE TABLE csv_data AS SELECT '2024-01-01' AS date_str, 100 AS quantity, 9.99 AS price;
CREATE TABLE api_data AS SELECT '2024-01-02' AS timestamp, 50 AS qty, 19.99 AS unit_price;

-- 标准 UNION 会报错(列名不同)
-- UNION BY NAME 可以正常工作
SELECT * FROM csv_data
UNION BY NAME
SELECT * FROM api_data;

结果会有 6 列:date_str、quantity、price、timestamp、qty、unit_price。csv_data 的行在 timestamp/qty/unit_price 列是 NULL,api_data 的行在 date_str/quantity/price 列是 NULL。

这个特性在几个场景下特别有用:

场景 1:合并不同来源的数据

比如你要合并 CSV 文件和 API 返回的数据,两边的字段命名可能不一样。UNION BY NAME 可以自动处理这种差异。

场景 2:多文件批量导入

1
2
3
4
-- 读取多个 CSV,自动合并
SELECT * FROM read_csv_auto('sales_2024_*.csv')
UNION BY NAME
SELECT * FROM read_csv_auto('sales_2025_*.csv');

即使不同年份的 CSV 列顺序不同,也能正确合并。

场景 3:增量数据合并

新增的表可能比旧表多几个字段,UNION BY NAME 可以无缝处理。

不过要注意,NULL 填充可能带来后续处理的问题。如果业务逻辑依赖这些字段,最好在合并前先统一列名,或者用 COALESCE 处理 NULL 值。

实战案例

案例 1:找出流失客户

用 EXCEPT 找出上个季度活跃但这个季度没购买的客户:

1
2
3
4
5
-- 上季度活跃客户
SELECT customer_id FROM active_customers WHERE last_active >= '2024-07-01'
EXCEPT
-- 本季度有购买的客户
SELECT customer_id FROM recent_buyers WHERE purchase_date >= '2024-10-01';

这个查询会返回在上季度活跃但本季度没有购买记录的客户 ID。

案例 2:识别 VIP 客户

用 INTERSECT 找出同时满足多个条件的客户:

1
2
3
4
5
-- 消费金额高的客户
SELECT customer_id FROM high_spenders WHERE total_amount > 10000
INTERSECT
-- 购买频次高的客户
SELECT customer_id FROM frequent_buyers WHERE purchase_count > 20;

结果是既消费金额高、又购买频次高的客户,这就是你的核心 VIP 群体。

案例 3:合并多季度销售数据

用 UNION ALL 合并多个季度的数据,保留所有记录:

1
2
3
4
5
6
7
SELECT 'Q1' AS quarter, product_id, sales_amount FROM sales_q1
UNION ALL
SELECT 'Q2' AS quarter, product_id, sales_amount FROM sales_q2
UNION ALL
SELECT 'Q3' AS quarter, product_id, sales_amount FROM sales_q3
UNION ALL
SELECT 'Q4' AS quarter, product_id, sales_amount FROM sales_q4;

这里用 UNION ALL 而不是 UNION,因为我们不需要去重(每个季度的数据本身就是独立的),而且 UNION ALL 更快。

案例 4:合并异构数据源

用 UNION BY NAME 合并结构不完全一致的数据:

1
2
3
4
5
6
7
-- 线下门店数据
SELECT store_id, store_name, city, revenue, staff_count 
FROM offline_stores
UNION BY NAME
-- 线上渠道数据(没有 staff_count 字段)
SELECT channel_id AS store_id, channel_name AS store_name, region AS city, revenue
FROM online_channels;

线上渠道没有 staff_count 字段,UNION BY NAME 会自动为这些行填充 NULL。

常见踩坑点

坑 1:列数不一致报错

标准 UNION/INTERSECT/EXCEPT 要求两个查询的列数完全一致。如果列数不同,会直接报错。

1
2
3
4
-- ❌ 报错:列数不一致
SELECT id, name, email FROM users_2024
UNION
SELECT id, name FROM users_2025;

解决方案有两个:要么补齐列数,要么用 UNION BY NAME。

坑 2:类型不匹配

两个查询的对应列类型不一致时,DuckDB 会尝试隐式转换。有时候能成功,有时候会报错或者产生意外结果。

1
2
3
4
-- 显式转换类型,避免意外
SELECT CAST(id AS VARCHAR) AS id, name FROM table1
UNION
SELECT CAST(id AS VARCHAR) AS id, name FROM table2;

养成显式转换的习惯,可以减少很多奇怪的问题。

坑 3:UNION 的性能陷阱

前面提到过,UNION 比 UNION ALL 慢很多。如果你只是简单合并数据,不需要去重,用 UNION ALL。

如果确实需要去重,可以考虑先 UNION ALL,再用 DISTINCT 或者 GROUP BY 处理,这样逻辑更清晰,有时候性能也更好。

坑 4:UNION BY NAME 的 NULL 填充

UNION BY NAME 会为缺失列填充 NULL。如果后续查询没有处理这些 NULL,可能会出问题。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
-- 用 COALESCE 处理 NULL
SELECT 
    COALESCE(store_id, channel_id) AS id,
    COALESCE(store_name, channel_name) AS name,
    COALESCE(city, region) AS location,
    revenue,
    COALESCE(staff_count, 0) AS staff_count
FROM (
    SELECT * FROM offline_stores
    UNION BY NAME
    SELECT * FROM online_channels
);

选择决策树

面对具体场景时,可以这样选择:

需要合并两个查询结果?

  • 确定没有重复,或者不在乎重复 → UNION ALL(最快)
  • 需要去重 → UNION

需要找共同数据?

  • 两个结果集的交集 → INTERSECT

需要找差异数据?

  • 第一个结果集减去第二个 → EXCEPT

两个查询的列结构不一致?

  • 用 UNION BY NAME(DuckDB 特色)

总结

DuckDB 的集合操作遵循 SQL 标准,但有一些实用的扩展。UNION BY NAME 在处理异构数据源时特别有用,可以省去很多手动对齐列的工作。

性能方面,记住一个原则:能用 UNION ALL 就不用 UNION。去重的开销在大数据量下会很明显。

集合操作经常和 CTE、JOIN 一起使用,组合起来可以处理很复杂的数据整合场景。如果你对这些高级用法感兴趣,可以看看 DuckDB 官方文档里的更多示例。

参考资源

Built with Hugo
Theme Stack designed by Jimmy