DuckDB引入异步I/O,适应云端远程数据读取
译文 AI 逐段翻译
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 文件的行组或 CSV 文件的固定大小缓冲区,并以低延迟和高带宽加载它们。因此,主要瓶颈在其他地方:子查询、连接、聚合等。实际的数据访问路径受到的关注较少,因为同步访问非常适合这种用例。
像往常一样,情况发生了变化。我们意识到 DuckDB 的架构非常适合查询远程存储的大规模数据集,例如数据湖(例如 DuckLake)。自 今年五月起,我们甚至可以使用 Quack 协议将 DuckDB 作为服务器运行。因此,数据文件位于本地 SSD 的原始预期不再总是成立。
这些变化的实际影响是,许多当前的 DuckDB 部署需要将文件从远程存储传输到实际处理它们的机器。例如,对于数据湖,典型的设置是将数据存储在 blob 存储(如 S3)中,并在同一区域的 EC2 机器上处理。在这种设置中,延迟和带宽起着更重要的作用。如果我们无法发出足够的并发请求来利用可用的网络带宽,性能可能会急剧下降,线程将花费大量时间等待远程读取而不是处理数据。
作为示例,让我们考虑一个对远程 Parquet 文件的简单查询。为简单起见,假设我们只有一个线程在执行。
FROMread_parquet('s3://bucket/file.parquet');Parquet 扫描被分区为基于行组的作业,每个作业包含一个或多个发出字节范围请求的获取任务。使用同步 I/O,工作线程将被阻塞,等待数据到达机器,然后才能执行实际工作,例如解码、聚合等。你可以在下图中看到直观的描绘,其中线程在等待读取完成时被阻塞,无法进行任何工作。
同步读取
为了解决这个问题,我们一直在 DuckDB 中实现异步 I/O 管道。目前已为 Parquet 和未压缩、可寻址的 UTF-8 CSV 文件实现,对其他格式(如 DuckDB 的原生格式和 JSON)的支持仍在进行中。在本文的其余部分,我们将简单解释异步 I/O 在 DuckDB 中是如何实现的,并为 Parquet 和 CSV 文件提供基准测试。
如果您现在想尝试异步 I/O,可以使用 DuckDB 的 v2.0.0-dev 预览版。异步 I/O 将从下一个主要 DuckDB 版本 v2.0 开始默认使用,该版本将于 今年秋季发布。
异步 I/O
异步 I/O 的概念相当简单:我们应该能够启动一个 I/O 操作,而不会阻塞请求它的工作线程。应用于我们的 Parquet 示例,同一幅图将如下所示:
异步读取
在此示例中,我们有两个 ASYNC 线程和一个常规工作线程。ASYNC 线程保持 获取任务 在进行中,而工作线程解码数据。在初始预热期间,扫描任务暂停,让工作线程空闲以运行其他管道任务。一旦第一个作业就绪,获取和解码就可以重叠。
在 DuckDB 中,我们实现了类似的东西。我们有 两个独立的线程池:
REGULAR– 此池包含我们的工作线程(默认情况下:每个可用 CPU 线程一个)。这些是执行实际工作的线程,如解码、连接和聚合。它们优先处理常规工作,但在空闲时也可以执行 I/O 任务。ASYNC– 一个用于异步任务的线程池,主要是阻塞 I/O。
我们有这两个不同池的主要原因是,对于远程 I/O,这些线程几乎可以花所有时间被阻塞,等待 HTTP 响应,因此 CPU 利用率很低。因此,我们的 ASYNC 工作线程比系统线程多得多,默认设置为 4 * 系统线程数,总数上限为 256。
最重要的是尽可能保持我们的 ASYNC 线程繁忙。为了确保这一点,我们实现了预读策略,而不是按需发出读取。这意味着调度获取任务要提前于我们的常规工作线程当前需要的任务。
我们需要注意的一点是,预读通过占用内存来换取吞吐量。如果解码很慢而网络很快,预取的数据可能会积累并导致内存不足问题。为了缓解这种情况,我们还实现了异步内存治理。预读和内存治理都将在以下部分中更详细地解释。
预读队列
预读的想法也很简单。不是在常规工作线程确切需要数据的那一刻才开始读取,而是为更靠后的工作调度获取任务。当常规工作线程解码当前作业时,ASYNC 线程已经在为接下来的作业拉取数据。目标是保持足够的获取任务在进行中,以隐藏远程存储的延迟。
作业是可以独立调度和处理的工作单元,它们根据底层文件格式的不同而不同。对于Parquet文件,一个作业是一个文件的一个行组。对于CSV文件,一个作业是一个扫描边界,通常覆盖文件内的固定字节范围。
根据查询投影、过滤下推、物理列位置以及附近字节范围的合并情况,Parquet作业可能会被拆分为多个抓取任务。下图中的两个抓取任务仅作示意,因为它们的确切分组和大小取决于文件和查询。
对于CSV文件,我们没有像Parquet文件那样细粒度的信息。作业的抓取任务会在扫描边界到达缓冲区末尾时加载起始缓冲区(如果尚未在内存中)以及后续缓冲区(例如,处理跨两个缓冲区分行的行)。
作业
填充队列不需要专门的生产者线程。任何寻找扫描工作的常规工作线程首先会尽量填满队列。限制要么由用户指定的槽位数量给出,要么由内存预算给出。如果有空间,就会创建一个作业及其抓取任务。抓取任务会立即调度到ASYNC线程池,而作业则按批次顺序进入预读队列。
ASYNC线程独立于作业队列的认领顺序执行各个抓取任务。同一作业的抓取任务可以并发运行,但不保证分配到特定的ASYNC线程。作业的所有抓取任务共享一个倒计时,将其减至零的抓取任务完成该作业的I/O。
工作线程认领队列中最旧的作业并检查倒计时。如果I/O完成,工作线程开始解码该作业。如果未完成,它将扫描任务暂停,并可以自由运行其他管道任务。最后一个抓取任务随后解除扫描任务的阻塞,该任务可以在任何常规工作线程上恢复。
认领作业会立即释放一个队列槽位,允许任何寻找扫描工作的常规工作线程在队列尾部生成替代作业。下图描绘了这一循环:
预读循环
内存管理
保持更多抓取任务在途会消耗更多内存。为了确定预算并避免内存不足问题,我们引入了read_ahead_depth配置选项。它可以有三种类型的值:
-1(默认):无限制深度,受内存约束。N > 0:最多N个作业,没有内存预算。0:预读关闭,每个扫描任务只调度自己的作业的I/O。
要配置它,请使用SET子句,例如:
SETread_ahead_depth=5;在默认模式下,预算由临时内存管理器协商,该管理器同样用于在并发连接、排序和窗口算子之间分配内存。当内存压力较大时,例如因为某个算子使用大量内存,队列预留可能会立即超出预算。实际上,这意味着队列一次只允许一个作业,扫描行为将接近同步扫描。
当内存密集型算子完成后,内存管理器可以分配出更多预算,队列会重新填满。
基准测试
当同步请求的延迟阻止我们利用可用远程带宽时,异步I/O应该产生最大的效果。为了衡量这种效果,我们运行了TPC-H查询6(SF100),数据存储在S3上,并将结果与DuckDB v1.5.5(我们最新的稳定版)进行了比较。SF100数据集在Parquet和CSV基准测试中均以每表一个文件的方式写入,其中lineitem表包含600,037,902行。
对于计算,我们使用了EC2r7i.16xlarge机器(64个vCPU和512GB内存),机器和包含数据的S3存储桶位于同一区域。我们执行了查询五次,并报告平均执行时间。文件从未被缓存(即SET enable_external_file_cache = false;),意味着每次执行都直接从S3读取数据。
Parquet
Parquet文件约为22GB,有大约4,880个行组,每个行组包含大约122,880行。通过异步I/O,平均运行时间从8.230秒降至2.844秒,使查询几乎快了3倍。
| 版本 | Q6运行时 |
|---|---|
| v1.5.5(同步) | 8.230秒 |
| 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 握手以及打开文件所需的时间。凸起对应下载文件页脚,而第二个间隔来自在执行查询之前处理页脚中的信息。我们认为这是在 v2.0 发布之前可以进一步调查和优化的领域。
我们每 50 毫秒采样一次网卡接收字节计数器,并根据样本之间的字节变化计算吞吐量。我们独立确认了机器可以以 25 Gbit/s 的速度访问网络,无论是使用 DuckDB 全文件读取还是使用 s5cmd 工具。 本地磁盘
远程存储是异步 I/O 的主要目标,但冷本地读取提供了一个有用的对比。为了测量它们,我们在 SF100 Parquet 文件上运行了 TPC-H 查询 6,这次文件位于 MacBook Pro(Apple M4 Max,14 核,36 GB 内存)的本地磁盘上。由于本地磁盘上异步 I/O 的好处来自冷读取,我们在每次运行之间清除了操作系统缓存(使用 macOS purge 命令),确保每次执行确实从磁盘读取文件。
| 版本 | Q6 运行时间 |
|---|---|
| v1.5.5(同步) | 1.321 秒 |
| v2.0.0-dev(异步 I/O) | 0.883 秒 |
我们可以看到,对于冷运行,异步 I/O 大约快 1.5 倍,运行时间减少了约 33%。性能差异比上述案例小得多,因为 SSD 的延迟远低于 EC2/S3 网络,带宽也高得多。对于热运行,差异可以忽略不计,因为如果数据被正确缓存,则不会发生磁盘访问。
小文件
分区数据集在这里是一个特别相关的用例,因为分区可以很容易地将数据分散到许多小文件中。为了查看异步 I/O 在这种设置下的表现,我们还对相同的 TPC-H SF100 数据集进行了 Parquet 运行。我们没有使用一个文件,而是生成了 976 个文件,每个文件有五个行组。每个文件包含大约 615,000 行,大小约为 22 MB。
| 版本 | Q6 运行时间 |
|---|---|
| v1.5.5(同步) | 9.344 秒 |
| v2.0.0-dev(异步 I/O) | 2.945 秒 |
我们可以看到 v2.0.0-dev 在这里提供了与单文件基准测试类似的性能提升,运行速度大约快 3 倍。这表明预读也可以跨多个文件并行化,而不会因为打开文件或获取其页脚而成为瓶颈。
大型行组
我们还希望看到另一个极端的情况,即 Parquet 文件只有几个非常大的行组。在这个实验中,我们生成了相同的 TPC-H SF100 lineitem 表的六个版本,作为单个文件,仅更改请求的行组(RG)大小,并使用 DuckDB v2.0.0-dev 运行 Q6。下表报告了每个版本的运行时间。
| 行数 / RG | 行组数 | 近似行组大小(MB) | 总文件大小(MB) | 时间 |
|---|---|---|---|---|
| 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 个行组的版本的一半,这种情况也会发生。在这种情况下,较小行组所需的额外带宽比极大行组失去的并行性要便宜。
并发查询
当多个查询同时运行时,效果变得更加明显。在这个实验中,我们并发运行了 TPC-H 查询 1、6、9 和 18,针对 S3 上相同的 SF100 Parquet 数据集,使用单个 DuckDB 实例。我们选择这些查询是因为它们涵盖了扫描、聚合和连接的混合,具有不同的 CPU 和内存需求。我们使用默认内存配置以及 16 GB 和 8 GB 的内存限制重复了实验。总运行时间是所有四个查询完成前的墙钟时间。我们报告了平均和峰值 CPU 利用率(使用的核心数)、峰值带宽(bw.)和峰值常驻集大小(RSS)。
| 版本 | 内存限制 | 运行时间 | 平均 CPU | 峰值 CPU | 峰值带宽 | 峰值 RSS |
|---|---|---|---|---|---|---|
| v1.5.5 | 默认 | 35.8 秒 | 5.9 | 35.7 | 10.7 Gbit/s | 14.5 GB |
| v2.0.0-dev | 默认 | 15.6 秒 | 48.1 | 64.0 | 24.9 Gbit/s | 20.1 GB |
| v1.5.5 | 16 GB | 35.6 秒 | 6.1 | 25.7 | 17.4 Gbit/s | 14.1 GB |
| v2.0.0-dev | 16 GB | 22.7 秒 | 35.2 | 63.4 | 24.8 Gbit/s | 15.7 GB |
| v1.5.5 | 8 GB | 35.9 秒 | 6.9 | 38.8 | 16.8 Gbit/s | 10.4 GB |
| 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 v2.0.0-dev进程的峰值物理内存(即RSS)从默认配置的20.1 GB降低到16 GB限制下的15.7 GB和8 GB限制下的11.5 GB。额外的溢出和减少的预读也降低了平均CPU利用率并增加了运行时间,但v2.0.0-dev在这两种情况下都继续使网络饱和,并且仍然比v1.5.5快得多。
有人可能注意到8 GB的结果仍然达到11.5 GB的RSS峰值。这是因为jemalloc将最近释放的页面保留在内存中大约一秒钟,以便它们可以被重用。这些内存不再被DuckDB的内存管理器计入,v1.5.5也表现出相同的分配器行为。
CSV
对于CSV文件,效果更大。CSV文件大小为80.89 GB,异步I/O将平均运行时间从878秒减少到仅45秒,使查询速度提高了近20倍。CSV是面向行的,因此扫描传输的数据量要大得多,并且执行固定大小的缓冲区读取,这使得并发远程读取特别有价值。
| 版本 | Q6运行时间 |
|---|---|
| v1.5.5(同步) | 877.563秒 |
| v2.0.0-dev(异步I/O) | 45.264秒 |
与其他实验一样,我们使用了默认的内存管理的预读深度,因此这次运行没有调整以保持25 Gbit/s网络平均饱和。
结论
在这篇博客文章中,我们介绍了最近在Parquet和CSV文件上异步I/O的工作。它的大部分好处来自访问远程数据,但冷本地读取也能受益,尽管较少。接下来,我们计划为JSON和DuckDB原生文件添加异步读取,因为这是与DuckDB核心最相关的另外两种格式。位于树外扩展中的格式尚未列入路线图。我们还将研究io_uring,即Linux的异步I/O接口,它可以减少系统调用开销和阻塞在I/O上的线程数。如果它在实践中证明有益,我们将把它集成到DuckDB中。需要注意的重要一点是,只要底层数据格式是Parquet(或者CSV,如果你够勇敢的话),DuckDB支持的任何数据湖解决方案都已经可以自动受益于异步I/O。
本文内容
近期文章
感谢您在GitHub上获得的40,000颗星
2026-08-05
发布
DuckDB团队
宣布DuckDB 1.5.5
2026-07-22
发布
DuckDB团队
宣布DuckDB 1.4.5 LTS (Andium)
2026-06-17
发布
DuckDB团队