DuckDB MERGE INTO 与 Dynamic Filters:ETL 性能翻倍秘籍
在数据工程领域,ETL(Extract-Transform-Load)是最核心的环节之一。而 ETL 中最常见的操作就是增量同步——每天将新数据合并到目标表中,更新已存在的记录,插入新的记录。
传统的做法是先用 SELECT 查询区分新旧数据,然后分别执行 UPDATE 和 INSERT。这种方式不仅繁琐,还容易在并发场景下产生数据不一致。
DuckDB 提供了两个强大的工具来解决这个问题:
- MERGE INTO — 一条 SQL 完成 Upsert
- Dynamic Filters — 自动优化 IN (子查询) 的性能
本文将深入讲解这两个功能,并通过实战案例展示如何将 ETL 效率提升数倍。
一、MERGE INTO:一条 SQL 搞定增量同步
1.1 基础用法:Upsert 一行
MERGE INTO 的基本思想是:将源表和目标表通过某个键进行匹配,匹配成功的行执行 UPDATE,匹配不到的行执行 INSERT。
MERGE INTO dim_products d
USING staging_products s
ON d.product_id = s.product_id
WHEN MATCHED THEN UPDATE SET
product_name = s.product_name,
price = s.price
WHEN NOT MATCHED THEN INSERT VALUES
(s.product_id, s.product_name, s.price);
这个语法非常直观:
dim_products是目标表(维度表)staging_products是源表(临时表或外部数据)ON子句定义了匹配条件WHEN MATCHED定义了更新逻辑WHEN NOT MATCHED定义了插入逻辑
1.2 进阶用法:只更新变化的行
在实际场景中,源表可能有 100 万条数据,但真正发生变化的只有几百条。如果无条件全部 UPDATE,不仅浪费资源,还会产生大量的 WAL(Write-Ahead Log)开销。
DuckDB 允许在 WHEN MATCHED 后面添加额外的 AND 条件:
MERGE INTO dim_products d
USING staging_products s
ON d.product_id = s.product_id
WHEN MATCHED AND d.price != s.price THEN UPDATE SET
product_name = s.product_name,
price = s.price,
updated_at = CURRENT_TIMESTAMP
WHEN NOT MATCHED THEN INSERT VALUES
(s.product_id, s.product_name, s.price, CURRENT_TIMESTAMP);
这里的关键是 AND d.price != s.price——只有当价格真正发生变化时,才执行 UPDATE。未变化的行直接被跳过,大幅减少了不必要的写入操作。
1.3 增量同步配置
配合一张配置表,可以实现精确的增量同步:
-- 配置表结构
CREATE TABLE sync_config (
table_name VARCHAR PRIMARY KEY,
max_id BIGINT
);
-- 初始化配置
INSERT INTO sync_config VALUES ('dim_products', 0);
-- 增量同步
MERGE INTO dim_products d
USING staging_products s
ON d.product_id = s.product_id
WHEN NOT MATCHED AND s.product_id > (
SELECT max_id FROM sync_config WHERE table_name = 'dim_products'
) THEN INSERT VALUES
(s.product_id, s.product_name, s.price, CURRENT_TIMESTAMP);
-- 更新配置
UPDATE sync_config
SET max_id = (SELECT MAX(product_id) FROM dim_products)
WHERE table_name = 'dim_products';
1.4 SCD Type 2:缓慢变化维
在数据仓库中,经常需要追踪历史变更。SCD Type 2 是一种经典方案:每当记录发生变化时,保留旧版本并创建新版本,通过 valid_from 和 valid_to 字段管理生命周期。
-- 目标表结构
CREATE TABLE dim_customers (
customer_id INTEGER,
name VARCHAR,
email VARCHAR,
is_active BOOLEAN,
valid_from DATE,
valid_to DATE
);
-- MERGE INTO 实现 SCD Type 2
MERGE INTO dim_customers dc
USING staging_customers sc
ON dc.customer_id = sc.customer_id
WHEN MATCHED AND dc.email != sc.email THEN UPDATE SET
is_active = false,
valid_to = CURRENT_DATE
WHEN NOT MATCHED THEN INSERT
(customer_id, name, email, is_active, valid_from, valid_to)
VALUES
(sc.customer_id, sc.name, sc.email, true, CURRENT_DATE, NULL);
执行后:
- 旧记录的
valid_to被设为当前日期,标记为失效 - 新记录带有
valid_to = NULL,表示当前有效 - 查询当前有效数据只需
WHERE valid_to IS NULL
二、Dynamic Filters:DuckDB 1.5.x 的隐藏利器
MERGE INTO 解决了"怎么合并"的问题,但当你处理大规模数据时,还需要考虑"怎么查询更快"。这就是 DuckDB Dynamic Filters 的用武之地。
2.1 什么是 Dynamic Filters?
Dynamic Filters 是 DuckDB 查询优化器的一个特性,它能在执行过程中自动生成过滤器,推送到数据扫描阶段,从而大幅减少需要处理的数据量。
最常见的应用场景是 IN (子查询) 模式:
SELECT source, COUNT(*), SUM(value)
FROM events
WHERE user_id IN (
SELECT DISTINCT user_id
FROM events
WHERE event_date >= date '2024-06-01'
AND event_date < date '2024-07-01'
)
GROUP BY source;
在传统的数据库引擎中,这个查询可能会先执行子查询得到所有 user_id,然后再扫描 events 表逐行比对。如果子查询返回 4 万条记录,而 events 表有 50 万条,这种全表扫描+逐行比对的效率非常低。
2.2 Bloom Filter 自动优化
DuckDB 1.5.x 的 Dynamic Filters 会自动将子查询的结果压缩成一个 Bloom Filter(布隆过滤器),然后在扫描外层表时先过一遍这个过滤器。
布隆过滤器是一种空间效率极高的概率型数据结构。对于一个包含 4 万个元素的集合,Bloom Filter 可能只需要几百 KB 的存储空间,就能以极高的准确率判断一个元素是否在集合中(唯一的代价是有极低的误判率——即可能把不在集合中的元素误判为在集合中,但不会漏掉在集合中的元素)。
查看 EXPLAIN ANALYZE 的输出,你可以看到 Dynamic Filters 的详细信息:
TABLE_SCAN events
Dynamic Filters:
optional: user_id >= 'user_0'
AND optional: user_id <= 'user_999'
AND optional: user_id IN BF(#0)
这里的 BF(#0) 就是自动生成的 Bloom Filter。它会将 4 万多条 user_id 压缩成一个位图,外层扫描时先过一遍,过滤掉大量不匹配的行。
2.3 手动控制 Dynamic Filters
虽然 DuckDB 会自动启用 Dynamic Filters,但你也可以通过 SET 命令进行手动控制:
-- 开启缓存算子(DuckDB 1.5.3+ 推荐)
SET enable_caching_operators = true;
-- 关闭动态过滤器(调试用)
SET dynamic_filters = false;
-- 查看当前设置
SHOW ALL LIKE '%filter%';
enable_caching_operators 是一个重要的性能开关,它允许 DuckDB 缓存中间计算结果(如子查询的结果),避免重复计算。在大多数 ETL 场景中,建议保持开启状态。
三、实战:完整的 ETL 管道
让我们把 MERGE INTO 和 Dynamic Filters 结合起来,构建一个完整的 ETL 管道。
3.1 场景描述
假设你在运营一个电商数据平台,每天需要从多个来源收集订单数据,并汇总到一张总表中。
3.2 数据准备
import duckdb
con = duckdb.connect("etl_pipeline.duckdb")
# 创建目标汇总表
con.execute("""
CREATE TABLE IF NOT EXISTS daily_orders (
order_id VARCHAR PRIMARY KEY,
customer_id VARCHAR,
product_name VARCHAR,
amount DECIMAL(10, 2),
order_date DATE,
status VARCHAR,
last_updated TIMESTAMP
)
""")
# 创建每日增量数据(模拟从外部 API 获取)
con.execute("""
CREATE TABLE IF NOT EXISTS daily_staging AS
SELECT
'ORD-' || i AS order_id,
'CUST-' || (i % 1000) AS customer_id,
'Product-' || (i % 200) AS product_name,
ROUND(UNIFORM(10, 500)::DECIMAL(10, 2), 2) AS amount,
CURRENT_DATE - INTERVAL (i % 30) DAY AS order_date,
CASE
WHEN i % 5 = 0 THEN 'cancelled'
WHEN i % 3 = 0 THEN 'refunded'
ELSE 'completed'
END AS status,
CURRENT_TIMESTAMP AS last_updated
FROM GENERATE_SERIES(1, 10000) AS t(i)
""")
3.3 执行 MERGE INTO
-- 增量合并:更新已存在的订单,插入新订单
MERGE INTO daily_orders d
USING daily_staging s
ON d.order_id = s.order_id
WHEN MATCHED AND d.amount != s.amount OR d.status != s.status THEN UPDATE SET
customer_id = s.customer_id,
product_name = s.product_name,
amount = s.amount,
status = s.status,
last_updated = CURRENT_TIMESTAMP
WHEN NOT MATCHED THEN INSERT VALUES
(s.order_id, s.customer_id, s.product_name, s.amount,
s.order_date, s.status, s.last_updated);
3.4 利用 Dynamic Filters 加速查询
-- 查询今日完成的订单总额(Dynamic Filter 自动优化)
SELECT product_name, SUM(amount) as total_amount
FROM daily_orders
WHERE order_date = CURRENT_DATE
AND status = 'completed'
AND customer_id IN (
SELECT DISTINCT customer_id
FROM daily_orders
WHERE order_date = CURRENT_DATE
)
GROUP BY product_name
ORDER BY total_amount DESC;
在这个查询中,DuckDB 会自动为 customer_id IN (子查询) 生成 Bloom Filter,大幅提升查询性能。
3.5 清理临时数据
-- 清空 staging 表,准备下一批数据
TRUNCATE TABLE daily_staging;
-- 或者使用 COPY 将数据导出为 Parquet
COPY daily_orders TO 'output/daily_orders.parquet' (COMPRESSION ZSTD);
四、与传统方案的性能对比
| 方案 | 增量同步方式 | 大数据查询优化 | 适用场景 |
|---|---|---|---|
| DuckDB MERGE INTO | 一条 SQL 完成 Upsert | Dynamic Filters 自动 Bloom Filter | 单机/中小规模 ETL |
| PostgreSQL UPSERT | INSERT … ON CONFLICT | 需手动创建索引/物化视图 | 需要事务保证的场景 |
| MySQL REPLACE | REPLACE INTO | 需手动优化 | 简单场景,无历史追踪 |
| Spark Structured Streaming | DataFrame API merge | Catalyst 优化器 | 超大规模分布式场景 |
| 传统 SELECT + UPDATE + INSERT | 多条 SQL 分段执行 | 依赖外部索引 | 兼容老旧系统 |
DuckDB 的优势在于:
- 简洁性:MERGE INTO 一条语句替代多步操作
- 自动化:Dynamic Filters 无需手动配置,开箱即用
- 性能:列式存储 + 向量化执行,百万级数据秒级处理
- 零运维:无需部署集群,Python 库直接调用
五、变现建议:把 ETL 技能变成收入
掌握了 DuckDB 的 MERGE INTO 和 Dynamic Filters 之后,你可以在以下几个方向创造商业价值:
5.1 数据管道即服务
为中小企业搭建定制化的 ETL 管道。很多公司每天手动整理 Excel 报表,耗时耗力。你用 DuckDB 可以在几分钟内自动化整个过程,按项目收费 $500-2000/套。
5.2 实时数据监控
基于 MERGE INTO 的增量更新能力,搭建实时数据监控仪表盘。客户数据变化时自动触发告警,适合电商、金融等行业。
5.3 数据仓库迁移咨询
帮助传统公司从 MySQL/PostgreSQL 迁移到 DuckDB 作为分析引擎。MERGE INTO 和 Dynamic Filters 可以显著简化他们的 ETL 逻辑,降低维护成本。
5.4 自动化报表 SaaS
将 DuckDB 嵌入到报表生成工具中,支持增量更新和自动过滤。定价 $9.99-49.99/月,面向中小企业的自动化报表需求。
六、总结
DuckDB 的 MERGE INTO 和 Dynamic Filters 是两个经常被低估的功能:
- MERGE INTO 让增量同步变得简洁优雅,一条 SQL 替代多步操作,支持条件更新和 SCD Type 2
- Dynamic Filters 自动优化 IN (子查询) 的性能,通过 Bloom Filter 减少扫描行数,无需手动配置
- 两者结合 可以构建高效的 ETL 管道,处理百万级数据的增量更新和复杂查询
掌握这些技能,不仅能提升你的数据处理效率,还能转化为实实在在的商业价值。
📖 更多 DuckDB 实战技巧与教程 → duckdblab.org
