DuckDB引入异步I/O,适应云端远程数据读取
DataHot 速览
DuckDB计划从v2.0开始支持Parquet和CSV异步读取,解决EC2/S3等计算存储分离环境中工作线程等待网络I/O的问题。随着DuckDB从本地SSD查询扩展到远程数据湖和服务化场景,异步请求有望更充分利用带宽。
为什么值得关注:异步I/O是DuckDB从嵌入式本地分析走向远程数据湖负载的重要架构变化,值得湖仓与查询引擎团队关注。
译文
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 预览版。从下一个主要 DuckDB 版本 v2.0(秋季发布)开始,异步 I/O 将成为默认行为。
异步 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 Query 6 at SF100,数据位于 S3 上,并将结果与 DuckDB v1.5.5 进行了比较,这是我们的最新稳定版本。SF100 数据集对于 Parquet 和 CSV 基准测试都写为每个表一个文件,其中 lineitem 表包含 600,037,902 行。
对于计算,我们使用了 EC2 r7i.16xlarge 机器(64 个 vCPU 和 512 GB 内存),机器和存储数据的 S3 存储桶位于同一区域。我们执行了五次查询并报告平均执行时间。文件从未被缓存(即 SET enable_external_file_cache = false;),这意味着每次执行都直接从 S3 读取数据。
Parquet
Parquet 文件大约为 22 GB,大约有 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 毫秒对网卡接收字节计数器进行采样,并根据采样之间的字节变化计算吞吐量。我们独立确认,该机器可以通过 DuckDB 全文件读取和 s5cmd 工具以 25 Gbit/s 的速度访问网络。 本地磁盘
远程存储是异步 I/O 的主要目标,但冷本地读取为我们提供了有用的对比。为了进行测量,我们在 SF100 Parquet 文件上运行了 TPC-H Q6,这次文件位于 MacBook Pro(Apple M4 Max,14 核,36 GB RAM)的本地磁盘上。由于本地磁盘上异步 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。下表显示了每个版本的运行时间。
| 每行组行数 | 行组数 | 近似行组大小(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 核机器上,具有 64 个行组的版本恰好提供了这一点,并在 2.27 秒内完成,而最快的运行来自具有 306 个行组的版本,为 2.11 秒。
然而,当行组数少于线程数时,我们会失去并行性,无法再饱和网络。对于 Q6,投影和列的物理位置导致每个行组产生两个获取请求。因此,四个行组仅暴露约八个并发 S3 流,使运行时间增加到 8.01 秒。对于最大的配置,文件包含单个行组,其 I/O 实际上减少为两个巨型流,将运行时间推至 25.26 秒。即使更好的压缩使文件大小略大于具有 4,886 个行组的版本的一半,也会发生这种情况。在这种情况下,较小行组所需的额外带宽比极大行组导致的并行性丧失更便宜。
并发查询
当多个查询同时运行时,效果变得更加明显。对于此实验,我们针对 S3 上相同的 SF100 Parquet 数据集并发运行了 TPC-H 查询 1、6、9 和 18,使用单个 DuckDB 实例。我们选择这些查询是因为它们涵盖扫描、聚合和连接的混合,具有不同的 CPU 和内存要求。我们使用默认内存配置以及 16 GB 和 8 GB 的内存限制重复了实验。总运行时间是所有四个查询完成前的墙钟时间。我们报告了平均和峰值 CPU 利用率(使用的核心数)、峰值带宽和峰值常驻集大小。
| 版本 | 内存限制 | 运行时间 | 平均 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。
补充来源
1 个信源 · 1 篇报道这篇内容对你有用吗?
反馈只用于改善内容筛选,不等同于收藏