80.89 GB 的 CSV 文件,对 S3 上的数据跑一遍 TPC-H Q6,DuckDB v1.5.5 需要 877.563 秒;换到 v2.0.0-dev 的异步 I/O 路径,同样的查询只要 45.264 秒,接近 20 倍的差距。这是 DuckDB 团队工程师 Pedro Holanda 在 2026 年 7 月 31 日发布的工程博客《Asynchronous I/O in DuckDB: Work, Thread, Work》里给出的最极端一组数字。Parquet 场景的提升同样可观:22 GB 单文件 Q6 从 8.230 秒降到 2.844 秒,调优后进一步压到 2.227 秒。这套异步 I/O 管线已在 v2.0.0-dev 预览版可用,将在 2026 年秋季发布的 v2.0 正式版中默认启用。

为什么 DuckDB 以前不做异步 I/O
数据库引擎的查询算子再快,数据拉不进来也是空转。DuckDB 过去十年里基本绕开了这个问题:通过过滤下推和投影剪枝,它尽量只读真正需要的数据。这套策略成立的前提是数据就在本地 SSD 上,分区成 Parquet row group 或 CSV 固定大小缓冲区后,读取延迟低、带宽高,同步访问完全够用,瓶颈从来都在子查询、join、聚合这些环节。
情况在最近两年发生了变化。DuckDB 的架构天然适合查询远端大规模数据集,数据湖方案 DuckLake 是官方主打方向之一;2026 年 5 月起,DuckDB 还能通过 Quack 协议以服务器形态运行。数据文件在本地 SSD 的假设不再总是成立。典型部署变成:数据放在 S3 这类对象存储,计算发生在同区域的 EC2 实例。此时延迟和带宽成为主导因素,如果发出的并发请求数量不足以吃满可用网络带宽,工作线程会把大量时间耗在等待远端读取上,而不是处理数据。
同步 I/O 在这个环境下的代价可以直接量化:v1.5.5 跑 S3 查询时网络吞吐只有 5 Gbit/s 左右,而测试机 r7i.16xlarge 的网卡上限是 25 Gbit/s。同步读取维持的在飞请求数量太少,四分之三的带宽被白白闲置。
双线程池:把阻塞的等待隔离出去
异步 I/O 的概念并不复杂:发起 I/O 操作时,申请它的工作线程不被阻塞。DuckDB 的实现拆成两个独立线程池:
- REGULAR 池:真正干活的工作线程,默认每个可用 CPU 线程一个,负责解码、join、聚合等计算任务。空闲时也可以顺手做 I/O 任务。
- ASYNC 池:专门承担阻塞式 I/O 的线程池。远端读取的线程几乎全部时间都在等 HTTP 响应,CPU 占用极低,因此 ASYNC 线程数量远多于系统线程,默认值是系统线程数的 4 倍,总量上限 256。

时序图里可以看到协作方式:两个 ASYNC 线程持续保持 fetch 任务在飞(取 Parquet 的 row group 字节范围),工作线程同步解码数据。预热阶段扫描任务先挂起,工作线程腾出来跑其他流水线任务;第一个 job 就绪后,取数和解码开始重叠执行。线程挂起后可以在任意 REGULAR 线程上恢复,不绑定原线程。
保持 ASYNC 线程满负荷是关键。为此 DuckDB 采用 read-ahead(预读)策略,提前调度工作线程当前还不需要的 fetch 任务,而不是等需要时才发起读取。预读用内存换吞吐:如果解码慢而网络快,预取的数据会堆积并可能触发内存溢出。缓解手段是配套的异步内存治理。
Read-ahead 队列与内存治理
任务调度的基本单位是 job。Parquet 文件的一个 job 是一个文件的一个 row group;CSV 的一个 job 是一个扫描边界,通常覆盖文件内固定字节范围。一个 Parquet job 可能按投影列、过滤下推、列物理位置以及相邻字节范围合并情况拆成多个 fetch 任务,CSV job 的 fetch 任务负责加载起始缓冲区,扫描边界到达缓冲区末尾时再取下一个(处理跨缓冲区的行)。

队列的填充不需要专门的生产者线程。任何来寻找扫描工作的工作线程,会先把队列补到允许的上限,上限由用户指定的槽位数或内存预算决定。有空间就创建 job 及其 fetch 任务:fetch 任务立刻调度到 ASYNC 池,job 按批次顺序进入 read-ahead 队列。同一个 job 的多个 fetch 任务可以并发执行,全部共享一个倒计时,把倒计时减到零的那个 fetch 任务完成该 job 的 I/O。工作线程认领队列中最老的 job 并检查倒计时:I/O 完成就开始解码;没完成就挂起扫描任务去跑别的流水线任务,最后一个 fetch 任务再解除挂起。认领动作立刻释放一个队列槽位,其他找活干的线程就能在队尾补新 job。
内存治理通过新配置项 read_ahead_depth 控制,取值三类:
-1(默认):深度不限,由内存预算约束。N > 0:最多预读 N 个 job,不设内存预算。0:关闭预读,每个扫描任务只为自己调度 I/O。
默认模式下预算与临时内存管理器协商,也就是在并发 join、排序、窗口算子之间分配内存的同一个管理器。内存压力大时(比如某个算子占用大量内存),队列预留可能立即超预算,实际效果是队列一次只放一个 job,扫描行为退化到接近同步;内存大户结束后,预算回充,队列重新填满。
六组 Benchmark 逐项拆解
所有远端测试用 TPC-H Q6 SF100(lineitem 表 600,037,902 行),数据放 S3,计算用同区域 EC2 r7i.16xlarge(64 vCPU、512 GB 内存),每项跑 5 次取均值,且禁用了外部文件缓存(SET enable_external_file_cache = false;),每次执行都真实从 S3 读数据。
单文件 Parquet:22 GB 文件约 4,880 个 row group,每个约 122,880 行。8.230 秒到 2.844 秒,接近 3 倍。网络层面,v2.0.0-dev 默认配置已经接近 25 Gbit/s 网络上限并在多个时间点触及;把预读上限压到 64 个在飞 job 并调整 I/O 参数(SET async_threads = 48; SET http_retries = 8; SET http_retry_wait_ms = 50; SET http_retry_backoff = 2;)后,吞吐方差降到最低,25 Gbit/s 网络几乎全程饱和,查询时间 2.227 秒,比未调优的 v2.0.0-dev 再快 21.7%,相对 v1.5.5 是 3.7 倍。
本地冷读:把同一个 SF100 Parquet 文件放到 MacBook Pro(M4 Max,14 核,36 GB 内存)本地盘,每次跑之前用 macOS purge 清缓存。1.321 秒到 0.883 秒,约 1.5 倍。差距远小于远端场景,因为 SSD 的延迟和带宽都远优于 EC2/S3 网络。热数据场景差异可忽略,缓存命中后根本没有磁盘访问。
小文件分区:SF100 lineitem 拆成 976 个文件,每个 5 个 row group、约 615,000 行、约 22 MB。9.344 秒到 2.945 秒,同样约 3 倍,说明预读可以跨多文件并行,不会被打开文件和读取 footer 卡住。
大 row group 反例:把同一张表重写成单文件、只改 row group 大小,用 v2.0.0-dev 跑 Q6:
| 每 RG 行数 | RG 数 | 单 RG 大小 | 文件总大小 | 耗时 |
|---|---|---|---|---|
| 122,880 | 4,886 | ~4 MB | ~21,600 MB | 2.74 s |
| 1,966,080 | 306 | ~70 MB | ~21,400 MB | 2.11 s |
| 9,375,593 | 64 | ~320 MB | ~20,500 MB | 2.27 s |
| 62,914,560 | 10 | ~1,500 MB | ~14,700 MB | 3.69 s |
| 150,009,476 | 4 | ~3,200 MB | ~12,800 MB | 8.01 s |
| 600,037,902 | 1 | ~12,300 MB | ~12,300 MB | 25.26 s |
row group 是 DuckDB 的 Parquet 扫描并行单位,理想状态是每个系统线程至少分到一个 row group。这台 64 vCPU 机器上,64 个 row group 的版本恰好达标(2.27 秒),最优是 306 个 row group(2.11 秒)。row group 数量少于线程数时并行度丢失,网络吃不饱:Q6 的投影和列物理布局决定每个 row group 产生两个 fetch 请求,4 个 row group 只暴露约 8 条并发 S3 流,耗时 8.01 秒;单个 row group 的极端情况 I/O 退化成两条巨型流,25.26 秒。此时更大压缩率带来的文件体积优势(12.3 GB 对 21.6 GB)抵不过并行度损失。
并发查询:Q1、Q6、Q9、Q18 同时跑在同一 SF100 Parquet 数据集上,单个 DuckDB 实例:
| 版本 | 内存限制 | 总耗时 | 平均 CPU | 峰值 CPU | 峰值带宽 | 峰值 RSS |
|---|---|---|---|---|---|---|
| v1.5.5 | 默认 | 35.8 s | 5.9 | 35.7 | 10.7 Gbit/s | 14.5 GB |
| v2.0.0-dev | 默认 | 15.6 s | 48.1 | 64.0 | 24.9 Gbit/s | 20.1 GB |
| v1.5.5 | 16 GB | 35.6 s | 6.1 | 25.7 | 17.4 Gbit/s | 14.1 GB |
| v2.0.0-dev | 16 GB | 22.7 s | 35.2 | 63.4 | 24.8 Gbit/s | 15.7 GB |
| v1.5.5 | 8 GB | 35.9 s | 6.9 | 38.8 | 16.8 Gbit/s | 10.4 GB |
| v2.0.0-dev | 8 GB | 24.2 s | 30.3 | 63.7 | 25.0 Gbit/s | 11.5 GB |
默认配置下 v1.5.5 平均只忙 6 个核左右,约九成机器在等同步 S3 读取;v2.0.0-dev 平均 48 核、峰值全部 64 核,25 Gbit/s 网络打满,四条查询总耗时不到一半。内存方面,收紧限制时内存治理器会压缩预读积压,Q18 这类内存大户落盘,v2.0.0-dev 峰值 RSS 从 20.1 GB 降到 15.7 GB(16 GB 限制)和 11.5 GB(8 GB 限制)。落盘和预读收缩会拉低 CPU 均值、拉长耗时,但网络始终饱和,两种限制下都明显快于 v1.5.5。8 GB 限制下峰值 RSS 仍达 11.5 GB 的原因是 jemalloc 会把刚释放的页面保留约一秒供复用,这部分内存不再被 DuckDB 内存管理器计数,v1.5.5 同样表现出该分配器行为。
CSV:80.89 GB 文件,877.563 秒到 45.264 秒,接近 20 倍,是全部实验中提升最大的。CSV 是行存格式,扫描传输的数据量大得多,且采用固定大小缓冲区读取,并发远端读取的收益被放大。该测试使用默认内存治理预读深度,没有针对网络饱和做调优。
现在就能试
v2.0.0-dev 预览构建已经包含异步 I/O,支持 Parquet 和未压缩、可随机访问的 UTF-8 CSV。DuckDB 支持的数据湖方案只要底层数据格式是 Parquet(或者胆子够大用 CSV),无需任何改动即可自动获益。v2.0 正式版定于 2026 年秋季发布,届时异步 I/O 默认开启。
调优空间集中在三个层面:read_ahead_depth 控制预读策略;async_threads 调整 ASYNC 池大小;HTTP 重试参数(http_retries、http_retry_wait_ms、http_retry_backoff)影响连接质量。官方调优案例中,64 个在飞 job 加上更少更热的连接和廉价重试,把未调优版本的耗时再砍掉 21.7%。
路线图上还有两件事:JSON 和 DuckDB 原生格式的异步读取支持,以及对 Linux 异步 I/O 接口 io_uring 的调研,后者有望降低系统调用开销和阻塞在 I/O 上的线程数,实测有收益就会集成。树外扩展(out-of-tree extensions)的格式暂不在计划内。
写在最后
这组改动对"数据在 S3、计算在 EC2"的标准云上分析负载是数量级级别的改善,对本地热数据几乎无感。如果你的查询瓶颈本来就在 join 和聚合,升级后体感变化不大;如果你每天在对象存储上扫大量 Parquet,v2.0 值得提前用预览版验证。另外注意 row group 的设计权衡:追求压缩率把 row group 堆到 GB 级,会直接吃掉扫描并行度,306 个 row group(约 70 MB 每个)在这组测试里跑出了最快成绩。
来源:Asynchronous I/O in DuckDB: Work, Thread, Work(Pedro Holanda, DuckDB 官方博客, 2026-07-31)