DuckDB 2.0 性能拆解:异步 I/O 让 S3 查询快 2–3 倍,递归 CTE 重写引擎
事件概述
MotherDuck 博客作者 Mehdi Ouazza 发布了一篇题为《Why DuckDB 2.0 is faster》的实测文章。据文中描述,DuckDB 2.0 将在今年秋季(原文为 2026 年秋季)正式发布,目前 alpha 版本已经可用。作者在自己的笔记本电脑(M5)上、并以家庭网络访问 S3 的方式,对 alpha 版本进行了性能测试,目标是回答一个工程问题:对于构建数据表和管道的使用者而言,2.0 到底带来了什么变化。
该文章在 Hacker News 首页获得 140 分、49 条评论。
作者的结论很直接:DuckDB 2.0 确实更快,但要想拿到这部分性能提升,需要理解自己的数据是如何组织的,有时甚至需要重新考虑数据建模方式。文中重点介绍了三个他认为最重要的特性,并附上了实测数字。需要注意的是,原始正文在递归 CTE 部分被截断,第三个特性以及作者在 commit log 中发现的“隐藏彩蛋”原文未说明。
一个重要的测试前提:所有数字都来自同一台 M5 笔记本和作者的家庭网络,两个版本受到的网络影响大致相同。作者本人也提醒读者不要直接引用这些数字,应自行复现。
关键技术点
一、异步 I/O:S3 查询默认加速
这是作者最看重的特性,因为它不需要修改任何查询语句。
测试查询读取 S3 上一个 2.2 GB 的 Parquet 文件(Stack Overflow votes 数据,2.28 亿行、2268 个 row group),只读取 4 列中的 1 列,约 230 MB,按 VoteTypeId 分组计数。测试时显式设置 enable_external_file_cache = false,确保每次都真正访问 S3。
实测结果:
| 场景 | DuckDB 1.5.5 | DuckDB 2.0 alpha |
|---|---|---|
| 单个 2.2 GB Parquet,读 1 列 | 18.8 s | 7.7 s |
| 23 个大 Parquet 文件,13.6 GB,读 1 列 | 11.8 s | 3.9 s |
| 单个 1.7 GB 纯 CSV | 116 s | 55 s |
| 30 个约 1 MB 的小 Parquet 文件 | 3.7 s | 3.3 s |
加速的机制并不神秘。Parquet 文件被切成 2268 个约 12.2 万行的 row group,每个 row group 的处理包含两类工作:网络等待和 CPU 解码。
- 1.5.5 的行为:每个 worker 串行地“下载 → 等待 → 解码 → 再下载”。等待时 CPU 空闲,解码时没有下载在途,同时最多只有 18 个下载在飞。
- 2.0 的行为:独立的线程池专门负责下载,可以同时让几十个 row group 处于在途状态,并把字节暂存到缓冲区;worker 只负责解码,随时都有现成的 row group 可处理。网络与 CPU 真正并行起来。
控制这一行为的设置是 read_ahead_depth,即下载池允许预取多少个 row group。它默认值为 -1(自动,根据线程数决定),因此异步 I/O 是开箱即用的;把它设为 0 即可退回 1.5 的行为。
作者也点出了一个负面案例:30 个约 1 MB 的小 Parquet 文件几乎没有任何提升(3.7 s → 3.3 s),因为这部分耗时来自每个文件的往返开销(先读 footer、再读数据),预读无法消除。作者直言,把数据湖存成成千上万个 1 MB 的 Parquet 文件本身就是不好的实践,2.0 也救不了它——基础功夫依然重要。
二、递归 CTE:从“每轮全表扫描”到“读一次 + 查找”
DuckDB 团队重写了递归 CTE 引擎,并声称在图可达性场景下有 40 倍的提升(该数字来自团队声明,作者用更易懂的例子做了解释)。
递归 CTE 本质上是对一张表的循环。以 employees 表为例,它只有 manager 和 employee 两列,查询的目标是回答“谁向谁汇报”。查询从一个初始行(CEO Ana)出发,第一轮找出 manager 为 Ana 的人,第二轮找出 manager 属于上一轮结果的人,以此类推,直到某一轮不再发现新行。组织架构图的每一层就是一轮。
这类“同一张表、两列父子关系”的数据结构非常常见:组织架构、文件夹树、物料清单、回复线程、数据血缘、git 历史都属于此类。区别只在于深度——组织架构可能只有 8 层,git 历史可能有数万层。
1.5 的问题在于:每一轮都要回头重新读取整张表来寻找下一层。8 层就是 8 次全表读,几千层就是几千次对同一张表的全表读。
2.0 的做法是:表只读一次,对 parent 列建立一次查找结构,之后每一轮只查找刚刚发现的那几行。成本因此约等于实际触及的行数,而不是“轮数 × 表大小”。
原文在这一部分的示意图之后被截断,后续内容与第三个特性原文未说明。
对数据科学或 AI Agent 落地的意义
第一,S3 上的数据湖查询延迟显著下降,且无需改代码。对于大量使用 Parquet on S3 / S3 兼容对象存储的团队来说,read_ahead_depth 默认开启意味着升级即收益。原文给出的 2–3 倍提升是在家庭网络条件下测得的,作者也提示在云端计算环境中预期会更快。对于需要反复扫描宽表、只取少数列的典型分析负载,这类延迟下降会直接体现在交互式分析的体感上。
第二,递归 CTE 的重写对图状数据友好。数据血缘、依赖解析、组织架构梳理、供应链追溯这类任务,在数据工程与数据治理中很常见。此前这类查询在深层结构上代价高昂,2.0 把复杂度从“轮数 × 表大小”拉回到“实际触及行数”,让“用 SQL 直接做图遍历”在小中规模场景下变得可行。对于不想为此引入图数据库的团队,这是一个实用的替代路径。
第三,对 AI Agent 的间接价值。Agent 在执行数据分析任务时,常常需要即时查询对象存储上的文件来获取上下文。查询延迟直接影响 Agent 的响应时间和单次任务成本。异步 I/O 带来的吞吐提升意味着 Agent 在同样的时间预算内可以完成更多轮的“取数—推理”循环。需要说明的是,原文并未讨论 Agent 场景,这部分属于基于性能数据的合理推断,而非原文结论。
第四,“小文件无解”是一个重要提醒。无论引擎如何优化,元数据往返和调度开销都无法通过预读消除。对于构建数据管道的团队,文件布局与分区策略仍然是决定性能上限的因素。这一点在原文中被明确强调。
我的技术点评
从工程角度看,DuckDB 2.0 的异步 I/O 是一次典型的职责分离改进:把 I/O 密集与 CPU 密集的工作拆到不同的线程池,用缓冲区解耦。这类模式在数据库和分布式系统里都不新鲜,但在一个嵌入式分析引擎中做到默认开启、零查询改动、且只用一个参数控制,实用价值很高。它解决的不是峰值算力问题,而是此前被掩盖的资源利用率问题——1.5 里 CPU 的等待时间被白白浪费了。
递归 CTE 的改写在算法层面更值得关注。它本质上是从“每轮重新扫描输入关系”转向“一次物化 + 增量查找”,这在数据库文献里是常见的优化方向,但对 DuckDB 这样一个以易用性著称的引擎来说,
