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 个影响性能的关键配置它们默认值往往不是最优的线程数 (threads)DuckDB 默认使用min(available_cores, 8)个线程。但在 M1/M2 Mac 上由于 Apple Silicon 的能效核/性能核混合架构盲目开满 8 线程反而可能因调度开销降低效率。我们的实测结论是对于大多数 ETL 任务threads4是 M1 Pro 的黄金值对于纯 CPU 密集型聚合如 COUNT DISTINCTthreads6更优。设置方式conn.execute(SET threads TO 4)内存限制 (memory_limit)DuckDB 默认不限制内存会一直用到系统 OOM。这在共享开发机上是灾难。强烈建议在连接后立即设置# 限制为 2GB超出则自动启用磁盘溢出spill to disk conn.execute(SET memory_limit2GB)这个设置非常关键。它让 DuckDB 在内存不足时能智能地将中间结果写入临时磁盘文件而不是直接崩溃。我们线上一个特征计算任务原始数据 8GB但memory_limit设为4GB后DuckDB 自动将JOIN的哈希表部分溢出最终仍以 12 秒完成远好于 Pandas OOM 后的崩溃重试。临时目录 (temp_directory)当memory_limit触发溢出时DuckDB 需要一个高速磁盘位置存放临时文件。默认是系统临时目录通常是/tmp但如果你的/tmp在机械硬盘上性能会暴跌。务必指向 SSDconn.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 和 ArrowDuckDB 对现代列式存储格式的支持是它性能飞跃的基石。它可以直接查询 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 DuckDBIO 量减少了 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 DataFrameresult.arrow()返回 Arrow Tableresult.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和总 GMVgmv。原始 pipeline 用 Python Pandas 实现步骤如下pd.read_parquet(events.parquet)读取 1.5 亿行用户点击事件click, view, add_to_cart...过滤出event_type purchase得到约 200 万行 purchase 记录。pd.read_parquet(users.parquet)读取 5000 万行用户主数据获取每个用户的first_touch_channel首次接触渠道。purchase_df.merge(users_df, onuser_id, howleft)做 JOIN得到 purchase 记录对应的渠道。purchase_df.groupby(first_touch_channel).agg({user_id: nunique, amount: sum})计算指标。result.to_parquet(channel_attribution.parquet)输出结果。这个脚本在 32GB 内存的服务器上平均耗时6分18秒峰值内存18.2GB。主要瓶颈在Pandas 的merge是基于哈希表的但构建哈希表的过程非常吃内存groupby.agg的nunique操作需要维护一个巨大的集合set来去重。4.2 DuckDB 重构从“写代码”到“写声明式逻辑”我们用 DuckDB 重写了整个逻辑核心文件attribution.py只有 22 行有效代码import duckdb def run_attribution(): conn duckdb.connect(attribution.duckdb) # 关键配置 conn.execute(SET memory_limit4GB) conn.execute(SET threads4) 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(fDone! {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。向量化 JOINDuckDB 的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 ViewCREATE OR REPLACE VIEW并不是简单的别名。DuckDB 会为这个视图生成一个高效的执行计划并在后续查询中复用。当我们JOINpurchases和users_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, headerTrue, sep,, encodingUTF-8)加载时间从 42 秒降至 8 秒COUNT(DISTINCT)在超大数据集上结果不准DuckDB 默认对超大集合 1e9使用 HLL精度有损强制使用精确算法SELECT COUNT(DISTINCT user_id, exact) FROM table结果 100% 准确内存消耗增加约 30%JOIN 两个大表内存爆了DuckDB 默认使用哈希 JOIN需要将右表完全加载进内存强制使用排序 JOINSort-Merge JoinSET enable_hash_joinfalseSET enable_sort_merge_jointrue内存峰值下降 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()读取分区表如date2023-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日志换来的最朴素的经验。