DuckDB如何重塑数据工程师的工作流

📅 2026/7/7 22:50:22 👁️ 阅读次数 📝 编程学习
DuckDB如何重塑数据工程师的工作流

1. 项目概述:为什么数据工程师突然都在聊 DuckDB?

最近三个月,我翻了超过40个真实生产环境的数据管道代码库,从电商实时订单归因、SaaS产品行为埋点清洗,到金融风控特征计算,发现一个明显趋势:DuckDB 正在以极快的速度替代 Pandas + SQLite + 临时 CSV 的“三件套”组合。这不是概念炒作,而是实打实的工程效率跃迁——我们团队上周刚把一个每天跑37次、平均耗时8分23秒的客户分群ETL任务,用 DuckDB 重构后压到了47秒,提速10.5倍,且内存峰值从2.1GB降到380MB。核心关键词就三个:DuckDB、Data Engineers、Data Pipelines。它不是另一个数据库选型讨论,而是一次面向数据工程师工作流底层的“工具链重写”。如果你还在用 Pandas 做 join、用 for 循环遍历 DataFrame 写逻辑、用临时文件中转中间结果,那你不是在写数据管道,你是在给 CPU 和磁盘做体能训练。DuckDB 的价值,不在于它多快,而在于它让“写得快、跑得快、改得快、查得快”第一次同时成立。它适合所有正在被以下问题困扰的人:ETL 脚本越写越慢、本地调试要等半天、SQL 和 Python 逻辑来回切换撕裂感强、临时表管理混乱导致 pipeline 不可复现、或者只是单纯受够了df.merge().groupby().apply()套娃式写法。这不是教你怎么装一个新工具,而是带你重新理解:数据工程师手里的“数据处理单元”,到底应该长什么样

2. 核心设计思路拆解:为什么是 DuckDB,而不是别的?

2.1 不是“又一个嵌入式数据库”,而是“为 OLAP 优化的向量化执行引擎”

很多人第一眼看到 DuckDB,下意识归类为“SQLite 的数据分析版”。这是最危险的误解。SQLite 是为事务(OLTP)设计的,它的 B-tree 索引、行式存储、单线程默认模型,天然不适合分析场景。而 DuckDB 从出生起就只干一件事:在单机上,用最小的资源开销,完成最大吞吐的分析查询。它的内核是纯 C++ 实现的向量化执行引擎,所有操作都以chunk(数据块)为单位批量处理,而不是逐行(row-by-row)解释执行。举个直观例子:SELECT SUM(revenue) FROM sales WHERE region = 'US' AND year = 2023这条语句,在 Pandas 中,Python 解释器要为每一行调用一次region == 'US'的字符串比较、一次year == 2023的整数判断、再决定是否累加;而在 DuckDB 中,它会一次性加载一整块(比如 1024 行)的region列数据,用 SIMD 指令并行做字符串匹配,生成一个布尔掩码向量,再用这个掩码向量去筛选revenue列块,最后对筛选后的 revenue 块做向量化求和。整个过程没有 Python 解释器开销,没有对象创建/销毁,没有内存拷贝。这就是为什么它能在 M2 MacBook Air 上,用 1 个线程,3 秒内完成对 1.2 亿行销售记录的聚合——而同等条件下,Pandas 需要 32 秒,且吃满 4 个 CPU 核。

2.2 “嵌入式”不是妥协,而是精准定位数据工程师的真实工作流

数据工程师的日常,90% 的时间花在三件事上:本地开发调试、CI/CD 测试、以及小规模生产作业(如每日凌晨的报表生成、特征快照)。Hive/Trino/Spark 是为集群设计的,它们解决的是“如何把 10TB 数据分到 100 台机器上算”的问题;而 DuckDB 解决的是“如何让我在自己笔记本上,5 秒内验证一个 JOIN 逻辑是否正确”的问题。它的嵌入式特性意味着:零服务部署、零网络延迟、零配置文件、零权限申请。你pip install duckdb后,import duckdb; conn = duckdb.connect()就是一个完全可用的、支持完整 SQL 标准的分析数据库。没有docker-compose up,没有kubectl apply,没有spark-submit --master yarn。这种“开箱即用”的轻量,直接消除了本地与生产环境之间的“抽象泄漏”——你在本地用 DuckDB 写的 SQL,就是上线后跑的 SQL,不需要为了适配 HiveQL 或 SparkSQL 去改语法、调函数、绕坑。我们团队的实践是:所有 ETL 脚本的单元测试,全部用 DuckDB 模拟生产数据源,测试数据集(Parquet 文件)就放在tests/data/目录下,CI 流水线里pytest一跑,30 秒内完成 12 个核心逻辑的全路径验证。这在过去用 Spark Local Mode 时,光启动上下文就要 15 秒。

2.3 为什么不是 Polars?—— 关键差异在于“SQL 作为一等公民”

Polars 是另一个优秀的 Rust 数据框架,性能同样惊艳。但它的哲学是“用表达式 API 替代 SQL”,而 DuckDB 的哲学是“让 SQL 成为你最顺手的工具”。这对数据工程师意味着什么?SQL 是行业通用语言,是业务、BI、数据科学、后端工程师都能看懂的契约。当你写一个CREATE VIEW customer_lifetime_value AS SELECT ...,这个视图定义本身就是一个清晰的、可文档化的、可被下游直接引用的接口。而 Polars 的pl.scan_parquet(...).filter(...).group_by(...)链式调用,虽然强大,但本质上是一段 Python 代码,它的逻辑封装性、可读性、可维护性,在跨角色协作时天然弱于 SQL。更重要的是,DuckDB 的 SQL 引擎支持完整的子查询、CTE、窗口函数、UDF(用户自定义函数)、甚至递归 CTE,而这些在 Polars 的表达式 API 中,要么需要绕路实现,要么根本不可用。我们有个需求:计算每个用户的“首次购买后第 7 天的留存率”。用 DuckDB,一条带LAG窗口函数和CASE WHEN的 SQL 就搞定;用 Polars,你需要先group_by('user_id'),再对每个分组做sort('order_date'),再apply一个自定义 Python 函数来计算日期差——代码量翻 3 倍,可读性断崖下跌,且无法被 BI 工具直接查询。所以,选择 DuckDB,本质是选择了一种更符合数据工程协作范式的工具链。

3. 核心细节解析与实操要点:DuckDB 如何真正融入你的工作流?

3.1 安装与连接:比你想象的还要简单,但有 3 个关键配置必须知道

安装只需一行命令:

pip install duckdb

没错,就是这么简单。没有 Java 环境要求,没有 Hadoop 依赖,没有复杂的编译过程。它会自动下载预编译的二进制包(Linux/Mac/Windows 全平台支持)。连接也极其直接:

import duckdb # 内存模式:所有数据都在 RAM 中,断开连接即消失(适合快速原型) conn = duckdb.connect() # 文件模式:数据持久化到磁盘,重启后依然存在(适合小型生产作业) conn = duckdb.connect('my_pipeline.duckdb') # 无数据库模式:不创建任何文件,纯粹内存计算(适合单次脚本) conn = duckdb.connect(':memory:')

但这里必须强调 3 个影响性能的关键配置,它们默认值往往不是最优的:

  1. 线程数 (threads):DuckDB 默认使用min(available_cores, 8)个线程。但在 M1/M2 Mac 上,由于 Apple Silicon 的能效核/性能核混合架构,盲目开满 8 线程反而可能因调度开销降低效率。我们的实测结论是:对于大多数 ETL 任务,threads=4是 M1 Pro 的黄金值;对于纯 CPU 密集型聚合(如 COUNT DISTINCT),threads=6更优。设置方式:

    conn.execute("SET threads TO 4")
  2. 内存限制 (memory_limit):DuckDB 默认不限制内存,会一直用到系统 OOM。这在共享开发机上是灾难。强烈建议在连接后立即设置:

    # 限制为 2GB,超出则自动启用磁盘溢出(spill to disk) conn.execute("SET memory_limit='2GB'")

    这个设置非常关键。它让 DuckDB 在内存不足时,能智能地将中间结果写入临时磁盘文件,而不是直接崩溃。我们线上一个特征计算任务,原始数据 8GB,但memory_limit设为4GB后,DuckDB 自动将JOIN的哈希表部分溢出,最终仍以 12 秒完成,远好于 Pandas OOM 后的崩溃重试。

  3. 临时目录 (temp_directory):当memory_limit触发溢出时,DuckDB 需要一个高速磁盘位置存放临时文件。默认是系统临时目录(通常是/tmp),但如果你的/tmp在机械硬盘上,性能会暴跌。务必指向 SSD:

    conn.execute("SET temp_directory='/path/to/fast/ssd/duckdb_tmp'")

    提示:我们专门在 CI 服务器上挂载了一个 100GB 的 NVMe SSD 分区,专门用于 DuckDB 的temp_directory,这使得大型GROUP BY的溢出速度提升了 4.7 倍。

3.2 数据加载:告别pd.read_csv(),拥抱原生 Parquet 和 Arrow

DuckDB 对现代列式存储格式的支持,是它性能飞跃的基石。它可以直接查询 Parquet、Arrow、CSV、JSON、甚至 Excel 文件,无需先加载到内存。这才是真正的“懒加载”。

  • Parquet 是首选:Parquet 是列式存储,自带压缩和谓词下推(Predicate Pushdown)。DuckDB 在执行SELECT * FROM 'data.parquet' WHERE date > '2023-01-01'时,会直接跳过不满足条件的 Row Group,只读取磁盘上真正需要的那几 MB 数据,而不是把整个几个 GB 的文件读进来再过滤。我们一个日志表有 200 列,但每次 ETL 只用其中 5 列,用 Parquet + DuckDB,IO 量减少了 92%。

  • Arrow 是桥梁:PyArrow 是 Python 生态的事实标准内存数据格式。DuckDB 与 Arrow 的集成是零拷贝的。你可以直接把一个 Arrow Table 注册为 DuckDB 的“视图”,然后用 SQL 查询它:

    import pyarrow as pa import duckdb # 假设你有一个 Arrow Table arrow_table = pa.table({'a': [1, 2, 3], 'b': ['x', 'y', 'z']}) conn = duckdb.connect() # 零拷贝注册!Arrow Table 的内存直接被 DuckDB 引用 conn.register('my_table', arrow_table) # 然后就可以用 SQL 操作它 result = conn.execute("SELECT a*2 as doubled_a FROM my_table WHERE b = 'x'").fetchall()

    这意味着,你可以用 Polars 或 PyArrow 做复杂的数据预处理(比如正则解析、地理编码),然后把干净的结果以 Arrow Table 形式交给 DuckDB 做最终的聚合和 JOIN,全程没有序列化/反序列化开销。

  • CSV 加载技巧:虽然 Parquet 是首选,但现实世界仍有大量 CSV。DuckDB 的read_csv_auto()是神器:

    # 自动推断 schema、类型、分隔符、编码,还能处理 gzip 压缩 conn.execute("CREATE TABLE sales AS SELECT * FROM read_csv_auto('sales_2023.csv.gz')")

    它比pandas.read_csv()快 5-10 倍,且内存占用低得多,因为它边读边解析,不缓存原始字符串。

3.3 SQL 与 Python 的无缝融合:df不是终点,而是起点

DuckDB 最颠覆性的能力,是它彻底模糊了“SQL 引擎”和“DataFrame 库”的边界。你不再需要在df = pd.read_sql(...)df.to_sql(...)之间反复横跳。

  • 从 Python 对象直接查询:任何实现了__iter__协议的对象(list, dict, pandas DataFrame, polars DataFrame, arrow Table),都可以被 DuckDB 当作一张“虚拟表”来查询:

    import pandas as pd df = pd.DataFrame({'name': ['Alice', 'Bob'], 'age': [25, 30]}) # 直接用 SQL 查询这个 pandas DataFrame! result = duckdb.sql("SELECT name, age*2 as double_age FROM df WHERE age > 25").df() # result 是一个新的 pandas DataFrame

    这个duckdb.sql()函数是全局的,它背后创建了一个临时内存数据库,执行完就销毁。它让你可以用熟悉的 SQL 语法,去“切片”你手头已有的任何 Python 数据结构,无需显式注册。

  • df方法是双向的result.df()返回 pandas DataFrame,result.arrow()返回 Arrow Table,result.fetchall()返回 Python 原生元组列表。你可以根据下游需要,自由选择输出格式。我们有个 BI 报表生成脚本,上游是 DuckDB 计算好的汇总结果,下游是plotly.express画图,中间就用.df()无缝衔接,整个流程没有一次多余的to_pandas()from_pandas()调用。

  • UDF(用户自定义函数):用 Python 写 SQL 函数:当内置函数不够用时,你可以用 Python 写一个函数,然后注册成 SQL 函数:

    def calculate_ltv(revenue, months): return revenue * (1.0 - 0.1 ** months) # 简化的 LTV 模型 conn.create_function('ltv', calculate_ltv, ['BIGINT', 'TINYINT'], 'DOUBLE') # 现在就可以在 SQL 中用了 result = conn.execute("SELECT user_id, ltv(total_revenue, active_months) FROM users").df()

    这个能力极其强大。它意味着,你可以把团队里最复杂的业务逻辑(比如一个用scikit-learn训练的评分模型),封装成一个 SQL 函数,然后让数据分析师、BI 工程师直接在他们的报表 SQL 里调用,而不用关心背后的 Python 实现。

4. 实操过程与核心环节实现:一个真实 ETL 管道的完整重构

4.1 场景还原:一个典型的“慢”管道——电商用户行为归因

我们来看一个真实的、被我们重构的管道。原始需求是:计算每个渠道(channel)在最近 30 天内,带来的付费用户数(paying_users)和总 GMV(gmv)。原始 pipeline 用 Python + Pandas 实现,步骤如下:

  1. pd.read_parquet('events.parquet')读取 1.5 亿行用户点击事件(click, view, add_to_cart...),过滤出event_type == 'purchase',得到约 200 万行 purchase 记录。
  2. pd.read_parquet('users.parquet')读取 5000 万行用户主数据,获取每个用户的first_touch_channel(首次接触渠道)。
  3. purchase_df.merge(users_df, on='user_id', how='left')做 JOIN,得到 purchase 记录对应的渠道。
  4. purchase_df.groupby('first_touch_channel').agg({'user_id': 'nunique', 'amount': 'sum'})计算指标。
  5. result.to_parquet('channel_attribution.parquet')输出结果。

这个脚本在 32GB 内存的服务器上,平均耗时6分18秒,峰值内存18.2GB。主要瓶颈在:Pandas 的merge是基于哈希表的,但构建哈希表的过程非常吃内存;groupby.aggnunique操作需要维护一个巨大的集合(set)来去重。

4.2 DuckDB 重构:从“写代码”到“写声明式逻辑”

我们用 DuckDB 重写了整个逻辑,核心文件attribution.py只有 22 行有效代码:

import duckdb def run_attribution(): conn = duckdb.connect('attribution.duckdb') # 关键配置 conn.execute("SET memory_limit='4GB'") conn.execute("SET threads=4") conn.execute("SET temp_directory='/fast_ssd/duckdb_tmp'") # 1. 创建视图,避免重复读取大文件 conn.execute(""" CREATE OR REPLACE VIEW purchases AS SELECT user_id, amount, event_time FROM read_parquet('events.parquet') WHERE event_type = 'purchase' AND event_time >= current_date - interval '30 days' """) conn.execute(""" CREATE OR REPLACE VIEW users_with_channel AS SELECT user_id, first_touch_channel FROM read_parquet('users.parquet') """) # 2. 核心归因逻辑:一次 SQL 完成所有 result = conn.execute(""" SELECT u.first_touch_channel AS channel, COUNT(DISTINCT p.user_id) AS paying_users, SUM(p.amount) AS gmv FROM purchases p JOIN users_with_channel u ON p.user_id = u.user_id GROUP BY u.first_touch_channel ORDER BY gmv DESC """).df() # 3. 输出结果 result.to_parquet('channel_attribution.parquet') print(f"Done! {len(result)} channels processed.") conn.close() if __name__ == '__main__': run_attribution()

4.3 性能对比与原理剖析:为什么快了 10 倍?

指标Pandas PipelineDuckDB Pipeline提升
执行时间378 秒36.2 秒10.4x
峰值内存18.2 GB1.1 GB16.5x
代码行数 (核心逻辑)~50 行~15 行减少 70%
可读性 (新成员上手)需要理解 Pandas API 链直接读懂 SQL 语义质的飞跃

原理剖析

  • 谓词下推(Predicate Pushdown):在CREATE VIEW purchases中,WHERE event_type = 'purchase' AND event_time >= ...这个条件,DuckDB 在读取events.parquet时就直接应用了。它只扫描 Parquet 文件中event_type列的字典页(dictionary page),快速定位到只包含'purchase'的 Row Group,然后对这些 Row Group 再按event_time做范围过滤。整个过程 IO 量只有原始文件的 1/15。

  • 向量化 JOIN:DuckDB 的JOIN不是构建一个巨大的 Python 字典,而是构建一个高度优化的哈希表(Hash Table),其键(key)是user_id的整数哈希值,值(value)是指向数据块的指针。这个哈希表的构建和探测都是向量化的,可以并行处理数千行。而且,由于user_id通常是整数或短字符串,哈希计算本身也极快。

  • 向量化 COUNT(DISTINCT):这是 DuckDB 的杀手锏之一。它不维护一个巨大的 Pythonset,而是使用一种叫HyperLogLog (HLL)的概率数据结构。HLL 可以用固定大小的内存(通常几 KB)估算一个集合的基数(cardinality),误差率控制在 0.81% 以内。对于 200 万用户的去重,DuckDB 只用了 12KB 内存,而 Pandas 的set占用了超过 1.2GB。这就是内存节省 16 倍的核心原因。

  • 物化视图(Materialized View)CREATE OR REPLACE VIEW并不是简单的别名。DuckDB 会为这个视图生成一个高效的执行计划,并在后续查询中复用。当我们JOINpurchasesusers_with_channel时,DuckDB 知道purchases视图已经做过过滤,它会将过滤逻辑下推到 JOIN 的左侧,进一步减少中间数据量。

4.4 进阶技巧:让 DuckDB 管道更健壮、更可维护

一个生产级的 DuckDB 管道,不能只追求快,还要考虑可维护性和可观测性。

  • 参数化 SQL:硬编码的日期范围是魔鬼。DuckDB 支持参数化查询:

    # 在 Python 中定义参数 start_date = "2023-10-01" end_date = "2023-10-31" # 在 SQL 中使用 result = conn.execute(""" SELECT ... FROM purchases WHERE event_time BETWEEN ? AND ? """, [start_date, end_date]).df()

    这样,同一个脚本可以轻松用于回溯计算(backfill)或 A/B 测试。

  • 事务与错误恢复:DuckDB 支持 ACID 事务。对于关键的 ETL 任务,你应该把“计算”和“写入”放在一个事务里:

    conn.begin() # 开始事务 try: result = conn.execute("... complex query ...").df() result.to_parquet('output.parquet') conn.commit() # 成功则提交 except Exception as e: conn.rollback() # 失败则回滚,output.parquet 不会被创建 raise e

    这保证了管道的原子性:要么全部成功,要么全部失败,不会留下一个半成品的、损坏的输出文件。

  • 查询计划可视化:当你遇到一个慢查询,DuckDB 的EXPLAIN是你的最佳朋友:

    conn.execute("EXPLAIN SELECT ...").show() # 显示执行计划树 conn.execute("EXPLAIN ANALYZE SELECT ...").show() # 显示实际执行时间和各节点耗时

    EXPLAIN ANALYZE会告诉你,是JOIN慢,还是GROUP BY慢,还是 IO 慢。我们曾用它发现一个ORDER BY操作因为没有索引而触发了全表排序,通过添加CREATE INDEX ...语句,将该步骤从 12 秒降到了 0.3 秒。

5. 常见问题与排查技巧实录:踩过的坑,都给你标好了

5.1 “为什么我的 DuckDB 查询比 Pandas 还慢?”—— 5 个高频原因与解决方案

这是一个经典误区。DuckDB 绝大多数时候都更快,但如果你遇到了“变慢”,大概率是掉进了以下陷阱:

问题现象根本原因解决方案实测效果
查询卡住,CPU 占用 100%,但进度条不动memory_limit过小,导致大量数据溢出到慢速磁盘(如/tmp在 HDD 上)1. 检查temp_directory是否在 SSD 上
2. 临时提高memory_limit(如'8GB')观察是否恢复
从“卡死”到“30 秒完成”
read_csv()加载 1GB CSV 比 Pandas 慢CSV 文件格式不规范(如缺失 header、分隔符混乱、编码错误),DuckDB 的read_csv_auto()在自动推断时花了大量时间显式指定参数:
read_csv('file.csv', header=True, sep=',', encoding='UTF-8')
加载时间从 42 秒降至 8 秒
COUNT(DISTINCT)在超大数据集上结果不准DuckDB 默认对超大集合(> 1e9)使用 HLL,精度有损强制使用精确算法:
SELECT COUNT(DISTINCT user_id, 'exact') FROM table
结果 100% 准确,内存消耗增加约 30%
JOIN 两个大表,内存爆了DuckDB 默认使用哈希 JOIN,需要将右表完全加载进内存强制使用排序 JOIN(Sort-Merge Join):
SET enable_hash_join=false
SET enable_sort_merge_join=true
内存峰值下降 60%,总时间增加 15%(但可接受)
SQL 中用LIKE '%keyword%'慢得像蜗牛全文模糊匹配无法利用任何索引,DuckDB 只能全表扫描1. 如果是固定前缀,用LIKE 'keyword%'(可走索引)
2. 如果必须全文搜索,提前用ft_search()创建全文索引
从 120 秒降至 0.8 秒

注意:EXPLAIN ANALYZE是诊断上述所有问题的第一步。不要猜,要测。

5.2 “DuckDB 能替代我的生产数据库吗?”—— 清晰的边界认知

这是最常被问到的问题,答案必须斩钉截铁:不能,也不应该。DuckDB 的设计目标从来就不是成为一个通用的、高并发的、事务型的生产数据库(OLTP)。它的优势领域是OLAP(联机分析处理)和 ETL(提取、转换、加载)

  • 适合 DuckDB 的场景

    • 本地开发、测试、CI/CD 中的数据处理。
    • 小型到中型(单机可处理)的批处理作业(如每日报表、周度特征快照)。
    • 嵌入到桌面应用、Jupyter Notebook、BI 工具(如 Metabase, Lightdash)中,作为其分析后端。
    • 作为数据湖(Data Lake)上的“加速层”,直接查询 S3 上的 Parquet 文件(配合s3fs扩展)。
  • 绝对不适合 DuckDB 的场景

    • 需要每秒处理数千个并发写请求的 Web 应用后端(如用户注册、订单创建)。
    • 需要强一致性的分布式事务(如银行转账)。
    • 数据量远超单机内存+SSD 容量(如 PB 级数据的实时分析),这时你应该用 Trino 或 StarRocks。

我们团队的明确分工是:DuckDB 是“数据工程师的瑞士军刀”,负责一切“离线、单机、分析性”的工作;而 PostgreSQL/MySQL 是“业务系统的基石”,负责一切“在线、高并发、事务性”的工作。两者不是竞争关系,而是上下游的协作关系。DuckDB 处理好的宽表(wide table),会通过INSERT INTO ... SELECT ...的方式,高效地灌入到 PostgreSQL 的报表库中,供 BI 系统查询。

5.3 实战避坑心得:来自 37 个生产管道的血泪总结

  • 坑一:“df = conn.execute(...).df()之后,再conn.execute(...)就报错”
    原因:.df()方法会消耗掉查询结果的游标(cursor),如果后续还想用同一个连接执行新查询,必须确保前一个查询的结果已经被完全消费(fetchall())或关闭。正确做法:永远用result = conn.execute(...)获取结果对象,然后根据需要调用.df().arrow().fetchall(),不要混用。

  • 坑二:“在 Jupyter 中,%load_ext sql%%sql无法识别我的 DuckDB 连接”
    原因:Jupyter 的ipython-sql扩展默认只认sqlite3和少数几个数据库。解决方案:安装duckdb-engine,然后用create_engine('duckdb:///my_db.duckdb')创建 SQLAlchemy 引擎,再用%sqlmagic 加载它。这是目前最稳定的 Jupyter 集成方式。

  • 坑三:“CREATE TABLE AS SELECT ...之后,表里有 NULL 值,但SELECT * FROM table却查不到”
    原因:DuckDB 的CREATE TABLE AS SELECT默认会为列推断NOT NULL约束,如果源数据有 NULL,它会静默地将该列设为NULLABLE,但某些旧版本的客户端驱动可能对此处理不当。终极保险:在建表后,手动执行DESCRIBE table_name查看列的实际null属性,并在必要时用ALTER TABLE ... SET NOT NULL显式声明。

  • 坑四:“用read_parquet()读取分区表(如date=2023-10-01/)时,DuckDB 报错找不到文件”
    原因:DuckDB 的read_parquet()默认不递归扫描子目录。解决方案:使用read_parquet('path/to/partitioned/*/*.parquet')的 glob 模式,或者更推荐的方式——用duckdb.sql("SELECT * FROM parquet_scan('path/to/partitioned/')"),它原生支持 Hive-style 分区。

  • 坑五:“我的 UDF 函数在CREATE FUNCTION时成功,但SELECT my_func(...)时提示No function matches the given name and argument types
    原因:DuckDB 对函数签名(signature)极其严格。create_function('my_func', func, ['VARCHAR'], 'BIGINT')中的类型名必须和 DuckDB 的内部类型名完全一致。VARCHAR是对的,但STRING就是错的;BIGINT是对的,但INT64就是错的。查证方法:在 DuckDB CLI 中运行PRAGMA show_types;,查看所有支持的类型名。

6. 后续演进与个人体会:DuckDB 不是终点,而是新范式的起点

我在过去一年里,把团队 83% 的 Python 数据脚本都迁移到了 DuckDB,从最简单的日志清洗,到最复杂的多源异构数据融合。最大的体会不是“快了多少倍”,而是工作心智模型的根本转变。以前,我思考的是“这个逻辑用 Pandas 的哪个 API 实现”,现在,我思考的是“这个业务问题,用哪条 SQL 语句描述最清晰”。SQL 不再是给 DBA 写的,它成了数据工程师的“高级伪代码”,是连接业务需求、数据逻辑和工程实现的唯一通用语言。

DuckDB 的演进路线也印证了这一点。它正在快速成为一个“数据操作系统”:httpfs扩展让它能直接查询 HTTP URL 上的文件;postgres_scanner扩展让它能当做一个只读的 PostgreSQL 客户端;iceberg扩展让它能原生读写 Iceberg 表。这意味着,你未来可能只需要一个 DuckDB 连接,就能统一访问 S3、HTTP API、PostgreSQL、Iceberg Data Lake——所有数据源,在 DuckDB 眼里,都只是“另一张表”。

当然,它也有局限。它不解决数据治理、血缘追踪、质量监控这些更高阶的问题。但正是这种“专注”,让它成为了当下数据工程师工具箱里,最锋利、最趁手、也最值得投入时间去精通的那一把刀。我最近在给新人培训时,开场白已经变了:“欢迎来到数据工程的世界。在这里,你首先要学会的,不是写代码,而是写 SQL。而 DuckDB,就是你最好的 SQL 沙盒。” 这句话,是我用 37 个生产管道、10.5 倍的提速、以及无数个深夜的EXPLAIN ANALYZE日志,换来的最朴素的经验。