原文:https://duckdb.org/2026/07/31/asynchronous-io
DuckDB 中的异步 I/O:工作、线程、工作
Pedro Holanda
2026-07-31 · 21 分钟
TL;DR: 从计划于 2026 年秋季发布的 v2.0 版本开始,DuckDB 将支持对 Parquet 和 CSV 文件的异步读取。当同步 I/O 无法饱和可用带宽时(这在 EC2/S3 的计算-存储分离架构中很典型),这可以显著加速查询。
如果无法快速拉取数据,数据库系统中查询运算符的速度再快也无济于事。然而,在 DuckDB 的大部分历史中,这个问题主要通过尽早裁剪数据来避免。通过下推过滤器和投影,我们可以确保只读取实际需要的数据。
这种方法效果特别好,因为 DuckDB 主要本地运行,其主要用例是作为直接从机器 SSD 查询数据的快速数据库引擎。我们可以将数据分成多个分区,例如 Parquet 文件的行组(row group)或 CSV 文件的固定大小缓冲区,并以低延迟和高带宽加载它们。因此,主要瓶颈在其他地方:子查询、连接、聚合等等。实际的数据访问路径受到的关注较少,因为同步访问完全适合这种用例。
像往常一样,情况发生了变化。我们意识到 DuckDB 的架构非常适合查询远程存储的大规模数据集,例如数据湖(如 DuckLake)。从今年五月开始,我们甚至可以使用 Quack 协议将 DuckDB 作为服务器运行。因此,数据文件位于本地 SSD 的原始预期不再总是成立。
这些变化的实际影响是,当前许多 DuckDB 部署需要将文件从远程存储传输到实际处理它们的机器上。例如,对于数据湖,一个典型的设置是将数据存储在 blob 存储(如 S3)中,并在同一区域的 EC2 机器上处理它。在这种设置中,延迟和带宽扮演着更为重要的角色。如果我们不能发出足够多的并发请求来使用可用的网络带宽,性能可能会急剧下降,线程将大量时间花费在等待远程读取上,而不是处理数据。
举个例子,让我们考虑一个对远程 Parquet 文件的简单查询。为简单起见,假设我们只有一个线程在执行。
FROM read_parquet('s3://bucket/file.parquet');
Parquet 扫描被划分为基于行组的作业(job),每个作业包含一个或多个发出字节范围请求的获取任务(fetch task)。使用同步 I/O 时,工作线程将被阻塞,等待数据到达机器,然后才能执行实际工作,如解码、聚合等。你可以在下图中看到可视化描述,线程在等待读取完成时被阻塞,无法执行任何工作。
同步读取
为了解决这个问题,我们一直在 DuckDB 中实现异步 I/O 流水线。目前它们已为 Parquet 和未压缩的、可寻址的 UTF-8 CSV 文件实现,对其他格式(如 DuckDB 的原生格式和 JSON)的支持仍在开发中。在这篇博客的其余部分,我们将简单解释异步 I/O 在 DuckDB 中的实现方式,并提供 Parquet 和 CSV 文件的基准测试。
如果你想现在尝试异步 I/O,可以使用 DuckDB 的 v2.0.0-dev 预览构建。从下一个主要 DuckDB 版本 v2.0(秋季发布)开始,异步 I/O 将默认启用。
异步 I/O
异步 I/O 的概念思路相当简单:我们应该能够在不阻塞请求它的工作线程的情况下启动 I/O 操作。应用到我们的 Parquet 示例中,同样的画面将如下所示:
异步读取
在这个例子中,我们有两个 ASYNC 线程和一个常规工作线程。ASYNC 线程保持获取任务在运行中,而工作线程解码数据。在初始预热期间,扫描任务暂停(park),让工作线程可以自由运行其他流水线任务。一旦第一个作业准备就绪,获取和解码就可以重叠进行。
在 DuckDB 中,我们实现了类似的东西。我们有两个独立的线程池:
- REGULAR – 此池包含我们的工作线程(默认情况下:每个可用 CPU 核心一个)。这些是做实际工作的线程,如解码、连接和聚合。它们优先处理常规工作,但空闲时也可以执行 I/O 任务。
- ASYNC – 一个用于异步任务的线程池,主要是阻塞 I/O。
我们之所以拥有这两个不同的池,主要原因是对于远程 I/O,这些线程几乎可以将所有时间都花在阻塞上,例如等待 HTTP 响应,因此 CPU 利用率非常低。因此,我们拥有比系统线程多得多的 ASYNC 工作线程,默认设置为 4 * 系统线程数,总数上限为 256。
尽可能保持我们的 ASYNC 线程忙碌至关重要。为了确保这一点,我们实现了预读(read-ahead)策略,而不是按需发出读取。这意味着,调度获取任务的时间要早于常规工作线程当前实际需要它们的时间。
我们需要注意的一点是,预读通过占用内存来换取吞吐量。如果解码速度慢而网络速度快,预取的数据可能会累积并导致内存不足问题。为了缓解这一点,我们还实现了异步内存治理。预读和内存治理都将在以下部分更详细地解释。
预读队列
预读的想法也很直接。不是在常规工作线程需要数据的精确时刻开始读取,而是为更靠前的工作调度获取任务。当常规工作线程解码当前作业时,ASYNC 线程已经在为接下来的作业拉取数据。目标是保持足够多的获取任务在运行中,以隐藏远程存储的延迟。
作业是可以独立调度和处理的工作单元,它们可能根据底层文件格式而有所不同。对于 Parquet 文件,一个作业是一个文件的一个行组。对于 CSV 文件,一个作业是一个扫描边界,通常覆盖文件内的一个固定字节范围。
一个 Parquet 作业可能被分解为多个获取任务,具体取决于查询投影、过滤器下推、物理列位置以及哪些相邻字节范围可以合并。下图中的两个获取任务是说明性的,它们的确切分组和大小取决于文件和查询。
对于 CSV 文件,我们没有像 Parquet 文件那样细粒度的信息。一个作业的获取任务会在其起始缓冲区尚未加载到内存时加载它,并且当扫描边界到达该缓冲区的末尾时,还会加载随后的缓冲区(例如,处理跨两个缓冲区拆分的行)。
作业
填充队列不需要专用的生产者线程。任何前来寻找扫描工作的常规工作线程首先会尽可能地填充队列,直到达到允许的限制。该限制要么由用户指定的槽位数给出,要么由内存预算给出。如果有空间,就会创建一个作业及其获取任务。获取任务立即在 ASYNC 池上调度,而作业则按批次顺序进入预读队列。
ASYNC 线程独立于作业队列的领取顺序执行各个获取任务。来自同一作业的获取任务可以并发运行,尽管不保证分配给特定的 ASYNC 线程。一个作业的所有获取任务共享一个倒计数,将其降至零的获取任务完成该作业的 I/O。
一个工作线程领取队列中最旧的作业并检查该倒计数。如果 I/O 完成,工作线程开始解码该作业。如果没有,它会暂停扫描任务,并可以自由运行其他流水线任务。最后一个获取任务随后解除扫描任务的阻塞,扫描任务可以在任何常规工作线程上恢复。
领取作业也会立即释放一个队列槽位,允许任何寻找扫描工作的常规工作线程在队列尾部生成一个替换作业。下图描绘了这个循环:
预读循环
内存管理
保持更多的获取任务在运行中会消耗更多的内存。为了确定预算并避免内存不足问题,我们引入了 read_ahead_depth 配置选项。它可以有三种类型的值:
- -1(默认):无限深度,受内存限制。
- N > 0:最多 N 个作业在前,无内存预算。
- 0:关闭预读,每个扫描任务仅为其自身作业调度 I/O。
要配置它,请使用 SET 子句,例如:
SET read_ahead_depth = 5;
在默认模式下,预算与临时内存管理器协商,该管理器与在并发连接、排序和窗口运算符之间分配内存的是同一个管理器。当内存压力很大时,例如因为一个运算符正在使用大量内存,队列预留可能立即超出预算。实际上,这意味着队列一次只允许一个作业,并且扫描行为将接近同步扫描。
当内存密集型运算符完成时,内存管理器有更多的预算可以分配,队列会重新填满。
基准测试
当同步请求的延迟阻止我们使用可用的远程带宽时,异步 I/O 应该产生最大的效果。为了衡量这种效果,我们在 SF100 规模下运行 TPC-H 查询 6,数据位于 S3 上,并将结果与我们最新的稳定版本 DuckDB v1.5.5 进行比较。SF100 数据集为 Parquet 和 CSV 基准测试都写成了每个表一个文件,其中 lineitem 表包含 600,037,902 行。
对于计算,我们使用了一台 EC2 r7i.16xlarge 机器(64 vCPU 和 512 GB RAM),机器和包含数据的 S3 存储桶位于同一区域。我们执行了五次查询并报告平均执行时间。文件从未被缓存(即 SET enable_external_file_cache = false;),这意味着每次执行都直接从 S3 读取数据。
Parquet
Parquet 文件大约为 22 GB,约有 4,880 个行组,每个行组包含大约 122,880 行。使用异步 I/O,平均运行时间从 8.230 秒下降到 2.844 秒,使查询速度提高了近 3 倍。
| DuckDB v1.5.5(同步) | 8.230 秒 |
| DuckDB v2.0.0-dev(异步 I/O) | 2.844 秒 |
下面我们还展示了查询过程中的网络吞吐量:
网络吞吐量
在这张图中,我们运行了 DuckDB v1.5.5 和 DuckDB v2.0.0-dev 的两个变体。一个由内存治理器决定预读深度,另一个针对此机器进行了调整,我们将预读上限设置为 64 个运行中作业并调整了 I/O 设置(SET async_threads = 48; SET http_retries = 8; SET http_retry_wait_ms = 50; SET http_retry_backoff = 2)。我们可以看到 v2.0.0-dev 更有效地利用了可用带宽,接近网络限制,并在多个点达到该限制。调整后的版本更进一步。通过更少、更热的连接和廉价的重试,吞吐量波动降至最低,25 Gbit/s 的网络几乎始终保持饱和状态。其查询时间为 2.227 秒,比未调整的 v2.0.0-dev 运行时间减少了 21.7%,使其比 DuckDB v1.5.5 快约 3.7 倍。相比之下,v1.5.5 保持在约 5 Gbit/s,因为其同步读取无法保持足够的运行中请求来使网络饱和。
另一个值得注意的细节是,在所有实验中,在网络流量第一次跃升之前经过了大约几百毫秒,然后在主要数据传输开始之前又经过了另外几百毫秒。第一个间隙是打开 DuckDB 连接、执行第一次 TLS 握手和打开文件所需的时间。跃升对应于下载文件页脚(footer),而第二个间隙来自在执行查询之前处理页脚信息。我们相信这是我们在 v2.0 发布之前可以进一步研究和优化的一个领域。
我们每 50 毫秒采样一次网卡的接收字节计数器,并根据样本之间的字节变化计算吞吐量。我们通过 DuckDB 全文件读取和 s5cmd 工具独立确认了该机器可以以 25 Gbit/s 的速度访问网络。
本地磁盘
远程存储是异步 I/O 的主要目标,但冷(cold)本地读取为我们提供了一个有用的对比。为了测量它们,我们在 MacBook Pro(Apple M4 Max,14 核,36 GB RAM)的本地磁盘上针对 SF100 Parquet 文件运行了 TPC-H 查询 6。由于异步 I/O 在本地磁盘上的优势来自冷读取,我们在每次运行之间清除了操作系统缓存(使用 macOS 的 purge 命令),确保每次执行都真正从磁盘读取文件。
| DuckDB v1.5.5(同步) | 1.321 秒 |
| DuckDB v2.0.0-dev(异步 I/O) | 0.883 秒 |
我们可以看到,对于冷运行,异步 I/O 大约快 1.5 倍,运行时间减少了约 33%。性能差异比上述情况小得多,因为 SSD 的延迟远低于 EC2/S3 网络,带宽远高于 EC2/S3 网络。对于热(hot)运行,差异可以忽略不计,因为如果数据被正确缓存,则不会发生磁盘访问。
小文件
分区数据集在这里是一个特别相关的用例,因为分区很容易将数据分散到许多小文件中。为了观察异步 I/O 在此设置中的行为,我们还使用相同的 TPC-H SF100 数据集执行了一次 Parquet 运行。我们没有使用一个文件,而是生成了 976 个文件,每个文件包含五个行组。每个文件大约包含 615,000 行,大小约为 22 MB。
| DuckDB v1.5.5(同步) | 9.344 秒 |
| DuckDB v2.0.0-dev(异步 I/O) | 2.945 秒 |
我们可以看到 v2.0.0-dev 在此处的性能提升与单文件基准测试相似,运行速度约为 3 倍。这表明预读也可以在多个文件之间并行,而不会被打开文件或获取其页脚所阻塞。
大型行组
我们还想看看在另一个极端,当 Parquet 文件只有几个非常大的行组时会发生什么。在这个实验中,我们生成了同一个 TPC-H SF100 lineitem 表的六个版本,每个版本都是一个文件,仅更改了请求的行组大小,并使用 DuckDB v2.0.0-dev 运行了 Q6。下表报告了每个版本的运行时间。
| 122,880 | 4,886 | ~4 MB | ~21,600 MB | 2.74 秒 |
| 1,966,080 | 306 | ~70 MB | ~21,400 MB | 2.11 秒 |
| 9,375,593 | 64 | ~320 MB | ~20,500 MB | 2.27 秒 |
| 62,914,560 | 10 | ~1,500 MB | ~14,700 MB | 3.69 秒 |
| 150,009,476 | 4 | ~3,200 MB | ~12,800 MB | 8.01 秒 |
| 600,037,902 | 1 | ~12,300 MB | ~12,300 MB | 25.26 秒 |
起初,较大的行组减少了查询时间。随着我们增加行组大小,请求延迟被分摊到更大的传输上。然而,超过某个点后,可用的并行度开始下降。行组是 DuckDB 的 Parquet 扫描并行单位,因此理想情况下,扫描应该至少为每个系统线程暴露一个行组。在这台 64-vCPU 机器上,具有 64 个行组的版本正好提供了这一点,并在 2.27 秒内完成,而最快的运行来自具有 306 个行组的版本,为 2.11 秒。
然而,当行组数少于线程数时,我们会失去并行度,并且无法再使网络饱和。对于 Q6,投影和列的物理位置导致每个行组产生两个获取请求。因此,四个行组只暴露了大约八个并发的 S3 流,将运行时间提高到 8.01 秒。对于最大的配置,文件包含单个行组,其 I/O 实际上减少为两个巨大的流,将运行时间推至 25.26 秒。即使更好的压缩使文件大小略大于具有 4,886 个行组版本的一半,这种情况也会发生。在这种情况下,较小行组所需的额外带宽比极大行组失去的并行度更便宜。
并发查询
当多个查询同时运行时,这种效果变得更加明显。在这个实验中,我们使用单个 DuckDB 实例,在 S3 上针对相同的 SF100 Parquet 数据集并发运行 TPC-H 查询 1、6、9 和 18。我们选择这些查询是因为它们涵盖了扫描、聚合和连接的混合,具有不同的 CPU 和内存需求。我们使用默认内存配置以及 16 GB 和 8 GB 的内存限制重复了实验。总运行时间是所有四个查询完成之前的墙上时钟时间(wall-clock time)。
| DuckDB v1.5.5 | 默认 | 35.8 秒 | 5.9 核 | 35.7 核 | 10.7 Gbit/s | 14.5 GB |
| DuckDB v2.0.0-dev | 默认 | 15.6 秒 | 48.1 核 | 64.0 核 | 24.9 Gbit/s | 20.1 GB |
| DuckDB v1.5.5 | 16 GB | 35.6 秒 | 6.1 核 | 25.7 核 | 17.4 Gbit/s | 14.1 GB |
| DuckDB v2.0.0-dev | 16 GB | 22.7 秒 | 35.2 核 | 63.4 核 | 24.8 Gbit/s | 15.7 GB |
| DuckDB v1.5.5 | 8 GB | 35.9 秒 | 6.9 核 | 38.8 核 | 16.8 Gbit/s | 10.4 GB |
| DuckDB v2.0.0-dev | 8 GB | 24.2 秒 | 30.3 核 | 63.7 核 | 25.0 Gbit/s | 11.5 GB |
在默认内存配置下,DuckDB v1.5.5 平均只让 64 个核心中的大约 6 个保持忙碌。换句话说,机器大约 90% 的时间处于空闲状态,等待同步 S3 读取。另一方面,DuckDB v2.0.0-dev 平均有 48 个忙碌核心,峰值时达到全部 64 个,并使 25 Gbit/s 网络饱和。因此,所有四个查询都在不到一半的时间内完成。
内存结果也很有趣。随着我们降低限制,内存治理器减少预读积压,而像 Q18 中那样内存密集型的运算符可能会溢出到磁盘。这降低了 DuckDB 进程使用的峰值物理内存(即 RSS),DuckDB v2.0.0-dev 从默认配置的 20.1 GB 降至 16 GB 限制下的 15.7 GB 和 8 GB 限制下的 11.5 GB。额外的溢出和减少的预读也降低了平均 CPU 利用率并增加了运行时间,但 v2.0.0-dev 继续使网络饱和,并且在两种情况下都仍然比 v1.5.5 快得多。
有人可能会注意到,8 GB 结果峰值 RSS 仍为 11.5 GB。这是因为 jemalloc 会将最近释放的页面保留驻留大约一秒钟,以便它们可以被重用。此内存不再被 DuckDB 的内存管理器计算在内,并且 v1.5.5 显示出相同的分配器行为。
CSV
在 CSV 文件上效果更大。CSV 文件为 80.89 GB,异步 I/O 将平均运行时间从 878 秒减少到仅 45 秒,使查询速度提高了近 20 倍。CSV 是面向行的,因此扫描传输的数据量要大得多,并且执行固定大小的缓冲区读取,使得并发远程读取特别有价值。
| DuckDB v1.5.5(同步) | 877.563 秒 |
| DuckDB v2.0.0-dev(异步 I/O) | 45.264 秒 |
如同其他实验,我们使用了默认的内存治理预读深度,因此这次运行并未针对平均保持 25 Gbit/s 网络饱和进行调整。
结论
在这篇博客文章中,我们介绍了最近在 Parquet 和 CSV 文件的异步 I/O 方面的工作。其大部分好处来自访问远程数据,但冷本地读取也可以受益,尽管较少。接下来,我们计划为 JSON 和 DuckDB 原生文件添加异步读取,因为这是与 DuckDB 核心最相关的另外两种格式。存在于树外扩展(out-of-tree extension)中的格式尚未列入路线图。我们还将研究 io_uring,即 Linux 的异步 I/O 接口,它可以减少系统调用开销和阻塞在 I/O 上的线程数。如果在实践中证明有益,我们将将其集成到 DuckDB 中。需要注意的一个重要事情是,只要底层数据格式是 Parquet(或者 CSV,如果你足够勇敢),DuckDB 中支持的任何数据湖解决方案都已经可以自动受益于异步 I/O。
[文件内容结束]




