← 文章 / 数据与数据库
Hacker News 2小时前 · 2026-08-17 01:46:11 · 0 阅读

DuckDB 异步 I/O:工作、线程、再工作

DuckDB 的异步 I/O:工作、线程、再工作

Author Avatar Pedro Holanda 2026-07-31 · 21 分钟

TL;DR:从预计 2026 年秋季发布的 v2.0 起,DuckDB 将支持 Parquet 和 CSV 文件的异步读取。在同步 I/O 无法充分利用带宽的场景下(例如 EC2/S3 分离的计算与存储架构),这一特性可以显著提升查询速度。

数据库的查询算子再快,如果数据进不来也无济于事。不过在 DuckDB 的大部分历史中,这个问题并不突出,因为我们很早就把数据裁掉了——通过下推过滤条件和投影,确保只读取真正需要的内容。

这种策略之所以有效,是因为 DuckDB 主要在本地运行,核心场景是从本机 SSD 直接查询数据,属于"快取快用"型引擎。我们可以把数据切成多个分区(比如 Parquet 的行组、CSV 的定长缓冲),以低延迟、高带宽地加载进来。于是主要的瓶颈都集中在别处:子查询、连接、聚合等。数据访问路径本身反而没受到太多关注——毕竟在本地 SSD 场景下,同步读取完全够用。

然而情况还是变了。我们发现 DuckDB 的架构非常适合查询存储在远程的大规模数据集,比如数据湖(例如 DuckLake)。而且从 今年 5 月 起,我们甚至可以通过 Quack 协议 把 DuckDB 作为服务端运行。也就是说,"数据文件就放在本地 SSD 上"这个最初假设,已经不再是普遍成立的前提了。

这些变化带来的实际影响是:很多当前的 DuckDB 部署都需要把文件从远程存储拉到实际计算的机器上。以数据湖为例,常见的做法是把数据存放在 S3 这样的对象存储中,再在同区域的 EC2 机器上做计算。在这种架构下,延迟和带宽的影响要大得多。如果发起的并发请求不足以填满可用带宽,性能就会急剧下降——线程把大量时间耗在等待远程读取上,而不是真正处理数据。

举个例子,我们考虑一个对远程 Parquet 文件的简单查询。为简便起见,假设只有一个线程在执行。

FROM read_parquet('s3://bucket/file.parquet');

Parquet 扫描按行组划分为若干作业,每个作业包含一个或多个发起字节范围请求的 fetch 任务。在同步 I/O 下,工作线程会被阻塞,一直等待数据传送到本机后才能执行真正的计算,比如解码、聚合等。下图展示了这一过程:线程在等待读取完成期间无法做任何工作。

Synchronous read Synchronous read 同步读取

为了解决这个问题,我们一直在 DuckDB 中实现异步 I/O 流水线。目前已支持 Parquet 和未压缩、可随机定位的 UTF-8 CSV 文件,其他格式(如 DuckDB 原生格式和 JSON)仍在开发中。在本文余下部分,我们将简要介绍 DuckDB 中异步 I/O 的实现方式,并给出 Parquet 和 CSV 文件的基准测试结果。

如果你想现在就体验异步 I/O,可以通过 DuckDB v2.0.0-dev 预览版 进行试用。从下一个 DuckDB 主版本 v2.0 开始,异步 I/O 将成为默认选项,该版本将于今年秋季发布。

异步 I/O

异步 I/O 的概念其实很简单:发起一个 I/O 操作时不应阻塞请求它的工作线程。套用到上面的 Parquet 例子,同一场景会如下图所示:

Asynchronous read Asynchronous read 异步读取

在这个示例中,我们有两个 `ASYNC` 线程和一个常规工作线程。`ASYNC` 线程会持续处理“获取任务”,而工作线程则负责解码数据。在预热阶段,扫描任务会挂起,让工作线程得以执行其他管道任务。一旦首个任务就绪,获取和解码即可并行进行。 在 DuckDB 中,我们实现了类似的机制,包含两个独立的线程池:
  • `REGULAR` – 包含工作线程(默认为每个可用 CPU 线程一个)。这些线程负责执行实际工作,如解码、连接和聚合。它们优先处理常规任务,但在空闲时也会执行 I/O 任务。
  • `ASYNC` – 专为异步任务设计的线程池,主要用于阻塞式 I/O。
我们设置这两个不同池的主要原因在于,对于远程 I/O,这些线程几乎会一直处于阻塞状态(例如等待 HTTP 响应),因此 CPU 利用率极低。正因如此,我们配置了比系统线程多得多的 `ASYNC` 工作线程,默认设置为 `4 * system threads`,总数上限为 256。 确保尽可能多的 `ASYNC` 线程保持忙碌至关重要。为此,我们采用预读策略,而非按需读取。这意味着在常规工作线程当前所需数据之前,就调度好获取任务。 需要注意的是,预读通过占用内存来换取吞吐量。如果解码速度慢而网络速度快,预取的数据可能会积压,导致内存溢出问题。为了缓解这种情况,我们还实现了异步内存管理。预读和内存管理的具体细节将在下一节详细说明。

预读队列

预读机制的核心思路也很简单:并不等到工作线程真正需要数据时才发起读取,而是提前为后续任务调度好取数任务。这样,当工作线程在解码当前任务时,ASYNC 线程已经在拉取下一批数据了。目标就是让足够多的取数任务并行执行,从而掩盖远程存储带来的延迟。 任务的拆分以"作业"为单位,各作业之间可以独立调度和执行,具体粒度取决于底层文件格式。对于 Parquet 文件,一个作业对应一个文件的一个 row group;对于 CSV 文件,一个作业对应一段扫描边界,通常覆盖文件内固定的字节区间。 一个 Parquet 作业可能会根据查询的投影列、谓词下推、物理列位置以及相邻字节区间的可合并性,拆分成多个取数任务。下图中的两个取数任务只是示意,实际的分组方式和大小取决于具体的文件和查询。 CSV 文件不像 Parquet 那样拥有细粒度的元信息。对于 CSV 作业,如果起始缓冲区尚未驻留在内存中,其取数任务会负责加载它;当扫描边界到达该缓冲区末尾时,还会加载下一个缓冲区(用于处理跨缓冲区的行)。 Jobs Jobs Jobs 队列的填充无需专门的生产者线程。任何前来领取扫描任务的工作线程,都会先把队列补充到允许的上限。这个上限要么由用户指定的槽位数决定,要么由内存预算决定。只要还有空间,就会创建一个作业及其取数任务。取数任务立即调度到 ASYNC 线程池执行,而作业则按批次的顺序进入预读队列。 ASYNC 线程独立执行各个取数任务,与作业队列的领取顺序无关。同一作业的取数任务可以并发运行,但不保证以特定方式分配给 ASYNC 线程。一个作业的所有取数任务共享一个倒计时器,当某个取数任务将其归零时,该作业的 I/O 即宣告完成。

工作线程会抢占队列中最旧的作业并检查倒计时。如果 I/O 完成,该线程便开始解码作业;否则,它会挂起扫描任务,转而执行其他流水线任务。最后的获取任务随后会解除扫描任务的阻塞,使其可在任意普通工作线程上恢复执行。

抢占作业的同时会立即释放一个队列槽位,允许任何正在寻找扫描任务工作的普通工作线程在队尾生成一个替代作业。下图展示了这一循环过程:

Read-ahead cycle Read-ahead cycle Read-ahead cycle

内存管理

保持更多获取任务处于运行状态会消耗更多内存。为了确定预算并避免内存溢出问题,我们引入了 read_ahead_depth 配置选项。它支持三种取值:

  • -1(默认):无限制深度,仅受内存限制。
  • N > 0:最多允许 N 个作业处于前置状态,不设内存预算。
  • 0:关闭预读功能,每个扫描任务仅调度自身作业的 I/O。

要配置该选项,请使用 SET 子句,例如:

SET read_ahead_depth = 5;

在默认模式下,预算是与临时内存管理器协商确定的,该管理器同样负责在并发连接、排序和窗口操作之间分配内存。当内存压力较大时,例如某个操作符占用了大量内存,队列预留可能会瞬间超出预算。实际上,这意味着队列将只允许同时运行一个作业,扫描行为将接近同步扫描。

当内存密集型操作完成后,内存管理器拥有更多预算可供分配,队列便会重新填满。

基准测试

异步 I/O 在同步请求的延迟阻碍我们充分利用远程带宽时效果最为显著。为了衡量这一效果,我们在 S3 上存储的数据上运行了 TPC-H Query 6 at SF100,并与最新的稳定版 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 s
v2.0.0-dev(异步 I/O) 2.844 s

下面展示查询过程中网络吞吐量的变化:

Network throughput Network throughput 网络吞吐量

我们运行了 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 的过程,第二段间隔则是处理 footer 信息后才开始执行查询。我们认为这部分仍有优化空间,计划在 v2.0 发布前进一步研究和改进。

我们每 50 ms 采样一次网卡的接收字节计数器,通过相邻两次采样之间的字节差计算吞吐量。我们已通过 DuckDB 全文件读取和 s5cmd 工具独立验证该机器能够以 25 Gbit/s 的速率访问网络。

本地磁盘

远程存储是异步 I/O 的主要目标,但冷启动的本地读取为我们提供了一个有用的对比。为了测量本地读取,我们在一台 MacBook Pro(Apple M4 Max、14 核、36 GB 内存)上对位于本地磁盘的 SF100 Parquet 文件运行 TPC-H Query 6。由于本地磁盘上异步 I/O 的收益主要体现在冷读取上,我们在每次运行之间清理了操作系统缓存(使用 macOS 的 purge 命令),确保每次执行都真正从磁盘读取文件。

版本 Q6 运行时间
v1.5.5(同步) 1.321 s
v2.0.0-dev(异步 I/O) 0.883 s

可以看到,在冷启动场景下,异步 I/O 大约快 1.5 倍,运行时减少了约 33%。与前面展示的场景相比,性能差异要小得多,这是因为 SSD 的延迟远低于 EC2/S3 网络,带宽也远高于后者。对于热启动场景,差异几乎可以忽略不计——只要数据被正确缓存,就不会发生磁盘访问。

小文件

分区数据集是一个非常相关的使用场景,因为分区很容易把数据分散到大量小文件中。为了观察异步 I/O 在这种场景下的表现,我们还用同一份 TPC-H SF100 数据集进行了一次 Parquet 测试。这次我们没有使用单个文件,而是生成了 976 个文件,每个文件包含 5 个 row group。每个文件大约有 61.5 万行,大小约为 22 MB。

版本 Q6 运行时间
v1.5.5(同步) 9.344 s
v2.0.0-dev(异步 I/O) 2.945 s

可以看到,v2.0.0-dev 在这里带来的性能提升与单文件基准测试中相当,大约快 3 倍。这表明预读(read-ahead)同样可以跨多个文件并行执行,而不会因打开文件或获取文件 footer 而成为瓶颈。

大 row group

我们还想看看另一个极端情况:当一个 Parquet 文件只有少数几个非常大的 row group 时会发生什么。为此,我们把同一份 TPC-H SF100 lineitem 表以单文件形式生成了六个版本,仅改变 row group(RG)的大小,并使用 DuckDB v2.0.0-dev 运行 Q6。下表列出了每个版本的运行时间。

一开始,较大的行组能缩短查询时间。随着行组变大,请求延迟被分摊到更大的传输量上。但超过某个临界点后,可用并行度开始下降。行组是 DuckDB Parquet 扫描的并行单位,因此理想情况下每次扫描应至少为每个系统线程暴露一个行组。在这台 64 vCPU 的机器上,64 个行组的版本恰好满足这一点,2.27 秒完成;而最快的来自 306 个行组的版本,仅需 2.11 秒。

然而,当行组数量少于线程数时,并行度就会下降,无法再充分利用网络带宽。对于 Q6 来说,投影列以及列的物理位置导致每个行组产生两个 fetch 请求。因此四个行组只能暴露大约八个并发的 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)。

行数 / RG RG 数量 RG 大小(MB,约) 文件总大小(MB) 时间
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
版本 内存限制 运行时间 平均 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

在默认内存配置下,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 限制下的 RSS 峰值仍然达到了 11.5 GB。这是因为 jemalloc 会将最近释放的页面保留约一秒钟,以便后续复用。这部分内存已不再计入 DuckDB 内存管理器的统计,v1.5.5 也存在同样的分配器行为。

CSV

在 CSV 文件上效果更为明显。CSV 文件大小为 80.89 GB,启用异步 I/O 后,平均运行时间从 878 秒降至仅 45 秒,查询速度提升近 20 倍。CSV 是按行存储的,扫描时会传输大量数据并进行定长缓冲读取,因此并发远程读取带来的收益尤其显著。

版本 Q6 运行时间
v1.5.5(同步) 877.563 s
v2.0.0-dev(异步 I/O) 45.264 s

与之前的实验一样,我们使用了默认的基于内存管理的预读深度,因此本次运行并未针对平均占满 25 Gbit/s 带宽进行调优。

总结

在这篇博客中,我们介绍了 Parquet 和 CSV 文件异步 I/O 的最新工作。其主要收益体现在访问远程数据时,但本地冷启动读取也能从中获益,只是效果不那么明显。接下来,我们计划为 JSON 和 DuckDB 原生文件加入异步读取,因为它们是 DuckDB 核心中另外两个最相关的格式。位于外部扩展中的格式暂未列入路线图。我们还将研究 Linux 异步 I/O 接口 io_uring,它有望降低系统调用开销以及因 I/O 阻塞的线程数。如果实践证明确有成效,我们会将其集成到 DuckDB 中。需要特别指出的一点是:只要底层数据格式是 Parquet(或 CSV,只要你足够勇敢),DuckDB 支持的所有数据湖方案都能自动从异步 I/O 中受益。

本文目录

近期文章

感谢 GitHub 上 40 000 个 Star

感谢 GitHub 上的 40 000 个 Star

2026-08-05 发布 DuckDB 团队 Announcing DuckDB 1.5.5

发布 DuckDB 1.5.5

2026-07-22 发布 DuckDB 团队 Announcing DuckDB 1.4.5 LTS (Andium)

发布 DuckDB 1.4.5 LTS(Andium)

2026-06-17 发布 DuckDB 团队 所有博客文章
原始来源: Hacker News

评论 (0)