DuckDB v2.0 异步 I/O 深度拆解:当 64 核机器有 90% 的时间在等 S3——从双线程池、预读队列到内存治理的架构手术
先给你一个数字,这个数字比任何架构图都更能说明问题。
一台 AWS r7i.16xlarge,64 vCPU、512 GB 内存、25 Gbit/s 网卡,跑 TPC-H SF100 的四条查询(Q1、Q6、Q9、Q18),数据放在同区域的 S3 上。DuckDB v1.5.5 的表现是:平均 CPU 利用率 5.9 核。
不是 59 核,是 5.9 核。
也就是说,这台每小时几美元的机器,有超过 90% 的算力在原地发呆,等着一个同步的 HTTP GET 返回。而 DuckDB 引以为傲的向量化执行引擎、morsel 驱动的并行调度、ALP 浮点压缩、DICT_FSST 字典编码——所有这些在过去七年里被反复打磨的东西,都卡在了一根堵住的水管前面。
2026 年 7 月 31 日,DuckDB 团队的 Pedro Holanda 发了一篇标题很俏皮的博客:《Asynchronous I/O in DuckDB: Work, Thread, Work》。同一组测试,换成 v2.0.0-dev,平均 CPU 利用率从 5.9 干到 48.1,峰值打满 64 核,网络吞吐从 10.7 Gbit/s 拉到 24.9 Gbit/s——基本贴着 25 Gbit/s 的物理上限跑,总耗时从 35.8 秒压到 15.6 秒。
单查询更夸张。TPC-H Q6 读 S3 上 22 GB 的 Parquet,8.230 秒 → 2.844 秒;手工调参之后是 2.227 秒,3.7 倍。CSV 那边更离谱,80.89 GB 的文件,877.563 秒 → 45.264 秒,接近 20 倍。
这篇文章我想把这件事彻底讲透。不是复述 release note,而是回答三个问题:
- 为什么 DuckDB 忍了七年才做异步 I/O? 这不是团队偷懒,而是一次架构假设的失效。
- 这套异步 I/O 到底怎么设计的? 双线程池、无生产者预读队列、fetch task 倒计时闩、内存治理器协商——每一处都有可以借鉴的工程判断。
- 对你的生产环境意味着什么? 尤其是那条 row group 尺寸的 U 型曲线,我认为是这篇博客里被严重低估的部分,它直接决定你的数据湖分区策略该怎么写。
顺带说一句,DuckDB 在 2026 年 8 月 5 日刚过了 GitHub 40,000 star。而 v2.0 计划在今年秋天发布,异步 I/O 会默认开启。所以这不是一个"未来可能有"的特性,是一个你今年就得规划升级的东西。
一、前提失效:DuckDB 为什么曾经不需要异步 I/O
1.1 "少读"哲学
要理解这次改动的分量,得先理解 DuckDB 过去的世界观。
DuckDB 从 2019 年发布开始,核心定位是 in-process 分析引擎——"分析领域的 SQLite"。它的典型场景是:你在 Jupyter Notebook 里,import duckdb,然后直接查本机 SSD 上的一堆 Parquet。
在这个场景里,I/O 有几个非常友好的性质:
- 延迟极低:NVMe SSD 的随机读延迟在 50–100 μs 量级;
- 带宽极高:单盘顺序读轻松 3–7 GB/s;
- 没有连接建立成本:不需要 TLS 握手,不需要 HTTP 头;
- OS page cache 免费兜底:第二次读基本是内存速度。
在这样的前提下,同步 I/O 不但够用,而且是更优解。因为同步 I/O 的代码路径短、没有跨线程状态同步、没有内存驻留问题、调试起来直观。花力气做异步,收益还不如去优化一个 hash join 的探测循环。
更重要的是,DuckDB 走了另一条路来对抗 I/O:不读。
Parquet 是列式格式,DuckDB 把过滤和投影往下推到扫描层:
-- 这条查询在 DuckDB 里的真实 I/O 行为
SELECT sum(l_extendedprice * l_discount) AS revenue
FROM read_parquet('lineitem.parquet')
WHERE l_shipdate >= DATE '1994-01-01'
AND l_shipdate < DATE '1995-01-01'
AND l_discount BETWEEN 0.05 AND 0.07
AND l_quantity < 24;
lineitem 有 16 个列,但这条查询只碰 4 个(l_extendedprice、l_discount、l_shipdate、l_quantity)。DuckDB 只会读这 4 个列的 column chunk,剩下 12 列的字节根本不落网卡/磁盘。再叠加 row group 级别的 min/max 统计裁剪,l_shipdate 不在 1994 年的整个 row group 直接跳过。
这套组合拳的效果是:逻辑数据量 100 GB,物理读可能只有 8 GB。当你能把 I/O 量砍掉一个数量级,同步还是异步的差别就没那么要紧了。
所以过去七年,DuckDB 的性能瓶颈报告基本集中在别处:子查询展开、join 顺序、外部聚合的 radix 分区、window function 的分帧。数据访问路径一直是那个"够用就行"的模块。
1.2 三件事改变了前提
然后世界变了。改变来自三个方向,而且都是 DuckDB 自己主动走过去的。
第一,数据湖。 DuckLake 在 2026 年 4 月 13 日发了 v1.0,宣布 production-ready。这个格式的核心主张是"把 lakehouse 的元数据层直接建在 SQL 数据库上",比 Iceberg/Delta 那套 JSON + Avro manifest 的方案简单得多。同时 DuckDB-Iceberg 在 v1.5.3 里补齐了 MERGE INTO、ALTER TABLE、partition transforms、V3 支持;Delta 扩展也在 5 月 7 日补上了写入、Unity Catalog 和 time travel。
这意味着什么?意味着 DuckDB 用户的数据不在本机 SSD 上了。它在 S3、在 GCS、在 Azure Blob。计算在 EC2,存储在 S3,中间隔着一个数据中心网络。
第二,Quack 协议。 2026 年 5 月 12 日,DuckDB 发布了自己的 client-server 协议 Quack。这是个态度转变——从 2019 年高调宣扬"我们就是要 in-process,没有客户端服务端",到 2026 年承认"很多人确实需要多进程并发写同一个库"。
Quack 的设计本身很值得一看:
- 直接建在 HTTP 上(团队的原话是"2026 年了,不在 HTTP 上做数据库协议才是没想清楚"),顺带白送了 load balancer、防火墙、鉴权网关的全套生态;
- 序列化用新的 MIME type
application/duckdb,复用了 WAL 文件用了多年的内部序列化原语; - 连接建立后,一条查询一个 round trip 搞定;
- 默认端口
9494(94 是 Netscape Navigator 发布的年份); - 默认只绑 localhost,启动时随机生成 token,鉴权/授权回调可以被用户代码覆盖,甚至可以是纯 SQL 宏;
- DuckDB-Wasm 发行版原生说 Quack——浏览器里的 DuckDB 可以直连 EC2 上的 DuckDB。
-- 服务端
CALL quack_serve('quack:localhost', token = 'super_secret');
CREATE TABLE hello AS FROM VALUES ('world') v(s);
-- 客户端
CREATE SECRET (TYPE quack, TOKEN 'super_secret');
ATTACH 'quack:localhost' AS remote;
FROM remote.hello;
-- 复杂查询直接整条推到远端执行
FROM remote.query('SELECT s FROM hello');
Quack 的存在,意味着 DuckDB 开始跑在服务器上,而不只是笔记本上。服务器意味着长期驻留、多租户、并发查询——每一条都在放大 I/O 阻塞的代价。
第三,存算分离成了默认架构。 这个不用多说。当你的部署形态是"EC2 + 同区 S3",你面对的就不再是 50 μs 的 SSD,而是:
- 单次 S3 GET 的首字节延迟:通常 20–60 ms;
- 单条 TCP 连接的吞吐:几十到一两百 MB/s(受 TLS、拥塞窗口、S3 单流限流影响);
- 但网卡带宽:25 Gbit/s ≈ 3.1 GB/s。
1.3 算一笔带宽延迟积的账
这里有个经典的网络工程概念叫带宽延迟积(Bandwidth-Delay Product, BDP),套到这个场景上,能非常清楚地解释为什么同步 I/O 必然打不满网卡。
要把 25 Gbit/s 的管道填满,在 40 ms 往返延迟下,你需要"在飞行中"的数据量是:
BDP = 3.125 GB/s × 0.04 s = 125 MB
也就是说,任意时刻必须有 125 MB 的数据正在网络上传输,才能吃满带宽。
现在看同步 I/O:一个 worker 线程发一个 byte-range GET,阻塞,等 40 ms,拿到比如 4 MB 数据,然后花几毫秒解码,再发下一个。这个线程贡献的平均带宽是:
4 MB / (40 ms + 5 ms) ≈ 89 MB/s
64 个 worker 线程全都这么干,理论上限 64 × 89 MB/s ≈ 5.7 GB/s。看起来够了?
不够。因为 worker 线程不是只干 I/O,它还要解码、过滤、聚合、join。当它在解码的时候,它没有在发请求。实测 v1.5.5 卡在 5 Gbit/s ≈ 0.6 GB/s,只有网卡的 1/5。
更要命的是并发场景。四条查询同时跑的时候,v1.5.5 的平均 CPU 只有 5.9 核——因为线程池里的线程被 I/O 阻塞霸占了,真正的计算任务排不上队。这是一个典型的**线程池毒化(thread pool poisoning)**问题:把长时间阻塞的任务和 CPU 密集任务放进同一个池子,阻塞任务会把池子吃干。
到这里,结论就很清楚了:问题不在于 DuckDB 读得慢,而在于它同时读得太少。 解法也很清楚——把 I/O 从 worker 线程上剥离,并且提前发起。
二、核心概念:Job、Fetch Task 与两个线程池
2.1 工作单元的两层拆分
DuckDB 的异步 I/O 把扫描工作拆成两层:
Job(作业):可以独立调度和处理的工作单元,粒度由文件格式决定。
- Parquet:一个文件的一个 row group 就是一个 job;
- CSV:一个 scan boundary(通常是文件里的一段固定字节范围)就是一个 job。
Fetch Task(取数任务):一个 job 底下拆出的若干次实际字节范围请求。
Parquet 的 fetch task 划分逻辑相当讲究,取决于四个因素:
- 查询投影(只读需要的列);
- 过滤下推(哪些列需要提前读来做 filter);
- 列的物理位置(列在文件里的字节偏移);
- 邻近字节范围能否合并(相邻的两个 column chunk 可以合成一次 GET)。
这第 4 点很关键,也是很多人写 Parquet writer 时忽略的:列的物理排布顺序会直接影响远端读的请求数。如果你查询要的 4 个列在文件里恰好是连续的,DuckDB 可以合成 1–2 次大 GET;如果它们被其他 12 个列隔开,那就是 4 次小 GET,请求数翻倍,每次都要吃一遍 40 ms 的延迟。
博客里提到 Q6 的实际情况是"每个 row group 两次 fetch 请求"——这就是列合并生效后的结果。
CSV 就粗糙得多,因为没有元数据可用。一个 job 的 fetch task 就是:加载它的起始 buffer(如果还没在内存里),以及当 scan boundary 触到 buffer 末尾时,再加载下一个 buffer(用来处理跨 buffer 的行)。正是因为这种"row-oriented + 固定大小 buffer + 必须顺序扫"的特性,CSV 从异步 I/O 里拿到的收益反而最大——20 倍。
2.2 两个线程池:REGULAR 与 ASYNC
DuckDB 在 task_scheduler_type.hpp 里定义了两个池:
// src/include/duckdb/common/enums/task_scheduler_type.hpp
enum class TaskSchedulerType : uint8_t {
REGULAR = 0, // CPU 密集:解码、join、聚合
ASYNC = 1 // 阻塞式 I/O
};
REGULAR 池:默认每个 CPU 线程一个 worker。它们干真正的活——解码、join、聚合。优先执行常规任务,空闲时也可以顺手干 I/O 任务。
ASYNC 池:专门跑阻塞 I/O。默认大小是 4 × system threads,上限 256。
这个 4× 的系数值得单独说一句。为什么异步 I/O 线程可以超配到 CPU 数的 4 倍?因为这些线程的 CPU 占用几乎为零——它们绝大部分时间挂在一个 recv() 上等 HTTP 响应。操作系统调度器根本不会给它们分配时间片。超配的唯一成本是线程栈内存(每个 8 MB 虚拟地址空间,实际驻留只有几十 KB)和上下文切换开销,相比打满 25 Gbit/s 带来的收益,这个成本可以忽略。
用 BDP 反推一下这个默认值是否合理。64 核机器 → 256 个 ASYNC 线程(正好撞到上限)。如果每个线程持有一条连接,平均每条流拿 100 MB/s:
256 × 100 MB/s = 25.6 GB/s(远超 3.125 GB/s 的网卡上限)
也就是说,默认配置下 ASYNC 线程数不会是瓶颈,瓶颈会先落到网卡或者 S3 的限流上。这个设计判断是对的:宁可超配线程(成本近乎为零),也不要让带宽空着。
补充一句:博客里 tuned 那组配置反而把
async_threads设成了 48(低于默认的 256)。这不矛盾——"更少但更热的连接"(fewer, hotter connections)可以减少 TLS 握手次数、让 TCP 拥塞窗口充分长大,从而降低吞吐抖动。这是一个典型的"并发数不是越高越好"的例子,后面调优章节会展开。
对比一下这个设计和别的系统:
| 系统 | I/O 并发模型 | 备注 |
|---|---|---|
| DuckDB v1.x | worker 线程同步阻塞 | 简单,本地场景够用 |
| DuckDB v2.0 | REGULAR + ASYNC 双池 + 预读队列 | 线程隔离,任务可 park |
| ClickHouse | 异步读缓冲 + 线程池 prefetch | 类似思路,remote_filesystem_read_method=threadpool |
| Trino | 每个 split 一组异步 HTTP 请求 | JVM NIO,split 粒度并行 |
| Spark | Executor 内多线程 + 分区并行 | 靠堆分区数硬拉并发,粒度粗 |
可以看到,DuckDB 走的是和 ClickHouse 相近的路线——线程池 prefetch,而不是全异步 event loop。这是个务实的选择:全异步(比如 Rust 那套 async/await 或者 io_uring)需要把整条执行链路都改成非阻塞,改造量巨大;而"另开一个池子专门挨阻塞"能用很小的改动拿到 80% 的收益。
三、架构拆解:预读队列的三个精妙之处
真正有意思的是预读队列(read-ahead queue)的设计。这部分我读下来觉得有三个判断特别漂亮。
3.1 精妙之一:没有专门的生产者线程
按常规思路,预读队列需要一个生产者:某个后台线程不断往队列里塞 job,ASYNC 线程从队列里取。这会引入一个新线程、一套生产者速率控制逻辑,以及生产者本身成为瓶颈的风险。
DuckDB 的做法是:任何一个来找扫描活干的 REGULAR worker,先负责把队列补满,然后再去干自己的活。
原文:"Filling the queue requires no dedicated producer thread. Any regular worker that comes looking for scan work first tops up the queue as far as it is allowed to."
伪代码大概是这样:
// 概念化伪代码,非 DuckDB 真实源码
TaskResult ScanTask::Execute() {
// 步骤 1:先把预读队列补满(在允许的额度内)
while (queue.HasFreeSlot() && MemoryBudgetAllows()) {
auto job = CreateNextJob(); // 下一个 row group / scan boundary
if (!job) break; // 文件扫完了
auto tasks = job->SplitIntoFetchTasks(); // 按投影/过滤/字节邻接拆分
job->pending_fetches = tasks.size(); // 倒计时初始化
for (auto &t : tasks) {
async_pool.Schedule(t); // 立刻扔进 ASYNC 池
}
queue.PushBack(job); // 按批次顺序入队
}
// 步骤 2:认领最老的 job
auto job = queue.ClaimOldest();
if (!job) return TaskResult::FINISHED;
// 认领动作立即释放一个队列槽位
// 步骤 3:检查 I/O 是否完成
if (job->pending_fetches.load() == 0) {
Decode(job); // 数据齐了,直接解码
return TaskResult::TASK_FINISHED;
} else {
Park(job); // 数据没齐,挂起自己
return TaskResult::BLOCKED; // 线程去干别的 pipeline 任务
}
}
这个"消费者兼职生产者"的模式好在哪?
- 零额外线程:不用管生产者线程的生命周期;
- 自然的反压:worker 忙不过来的时候,自然就没人去补队列,队列自动收缩;worker 空闲了,补队列的频率就上去了。这是一个自调节的闭环,不需要任何显式的速率控制器;
- 无中心瓶颈:多个 worker 可以并发补队列(队列本身用锁或无锁结构保护),不存在单点。
这套思路和 Go runtime 的 work-stealing 调度器、以及 Linux 的 kswapd 之外的"direct reclaim"路径是同一个哲学:让消费者在消费前顺手做一点生产工作,用局部性换掉一个全局协调者。
3.2 精妙之二:fetch task 的倒计时闩
一个 job 可能拆成多个 fetch task,它们在 ASYNC 池里并发执行,且执行顺序与 job 的入队顺序无关。
原文:"ASYNC threads execute individual fetch tasks independently of the job queue's claim order. Fetch tasks from the same job can run concurrently... All fetch tasks of a job share a countdown, and the fetch task that brings it to zero completes the job's I/O."
这是一个经典的 countdown latch(倒计时闩) 模式:
// 概念化:fetch task 完成时的收尾逻辑
void FetchTask::OnComplete() {
WriteBufferToJobSlot(this->byte_range, this->buffer);
// fetch_sub 返回的是**减之前**的值
if (job->pending_fetches.fetch_sub(1, std::memory_order_acq_rel) == 1) {
// 我是最后一个完成的,负责唤醒 park 住的 scan task
job->UnblockScanTask();
}
}
几个细节值得注意:
fetch_sub的返回值语义:返回减之前的值,所以判断条件是== 1而不是== 0。这是无锁计数里最常见的 off-by-one 陷阱,值得记住。- 内存序用
acq_rel:release 保证本线程写入 buffer 的数据对后续读者可见,acquire 保证最后一个线程能看到其他线程写的所有 buffer。用relaxed会出数据竞争。 - 谁唤醒谁不确定:哪个 fetch task 最后完成是随机的,所以唤醒逻辑必须放在"最后一个完成者"手里,而不是某个固定线程。
唤醒之后,scan task 可以在任意一个 REGULAR worker 上恢复。 这意味着任务是可迁移的(migratable),不绑定在原来那个线程上。这对负载均衡很重要——如果 park 的任务必须回原线程执行,那个线程正好在跑一个长 join 的话,数据就白白在内存里躺着。
3.3 精妙之三:认领即释放槽位
第三个细节容易被忽略,但对流水线的连续性至关重要:
原文:"Claiming the job also immediately frees a queue slot, allowing any regular worker looking for scan work to produce a replacement job at the back of the queue."
worker 认领一个 job 的那一刻(而不是解码完之后),队列槽位就被释放了。
想想如果不这么做会怎样:worker 认领 job → 解码 100 ms → 释放槽位 → 下一个 worker 才能补新 job。这 100 ms 里队列是满的但少了一个在飞行的请求,网络出现了一个"气泡"。深度为 N 的队列,实际有效在飞行请求数会退化成 N × (I/O 时间 / (I/O 时间 + 解码时间))。
立即释放槽位,等于把"解码"和"补充预读"完全解耦。整个循环变成:
补队列 → 认领(立即释放槽位) → [并行] 解码 ‖ 其他 worker 补新 job
这是流水线设计的基本功,但很多实现会栽在这里。
3.4 完整生命周期
把三点串起来,一个 job 的完整生命周期是:
[REGULAR worker 找活]
│
├─ 队列有空位 & 内存够?
│ └─ 创建 Job → 拆 Fetch Tasks → 设置倒计时 → 扔进 ASYNC 池 → Job 入队尾
│ (循环补到不能补为止)
│
└─ 认领队首最老的 Job ──► 立即释放一个队列槽位
│
├─ pending_fetches == 0 ?
│ ├─ 是 → 解码 → 输出 DataChunk → 完成
│ └─ 否 → Park scan task,线程转去跑别的 pipeline 任务
│
└─ [异步] 最后一个 Fetch Task 完成 → 倒计时归零 → Unpark
→ scan task 在任意 REGULAR worker 上恢复 → 解码
值得注意的是"队首最老"这个顺序约束。job 是按批次顺序入队、按顺序认领的,虽然 fetch task 乱序完成。这个 FIFO 约束保证了预读不会退化成"随机预取"——如果允许 worker 挑已经就绪的 job 优先处理,短期看吞吐更高,但长期会让队尾的 job 饿死,且内存里堆积大量已就绪未消费的 buffer。
FIFO 是一个用局部吞吐换全局内存可控性的决定。
四、内存治理:read_ahead_depth 与温度计式的自适应
4.1 预读的代价是内存
预读是拿内存换吞吐。这句话说起来简单,做起来是灾难的源头:
如果网络很快而解码很慢,预取的数据会以远高于消费速度的速率堆积,直到 OOM。
这个场景一点都不罕见。举个例子:一张宽表,你 SELECT * 然后做一个复杂的正则提取或者 JSON 解析。网络能给你 3 GB/s,但你的解码只能吃 200 MB/s。15 倍的差速,10 秒钟就能堆出 28 GB 的未消费 buffer。
DuckDB 的解法是引入 read_ahead_depth 配置项,它有三种语义:
| 值 | 语义 | 适用场景 |
|---|---|---|
-1(默认) | 深度不限,由内存预算约束 | 绝大多数场景 |
N > 0 | 最多预读 N 个 job,不走内存预算 | 已知负载特征、需要确定性行为 |
0 | 关闭预读,每个 scan task 只为自己发 I/O | 排障、对照实验 |
-- 手动固定深度
SET read_ahead_depth = 5;
-- 完全关掉(排障用)
SET read_ahead_depth = 0;
-- 回到内存治理模式
SET read_ahead_depth = -1;
4.2 和 TemporaryMemoryManager 抢预算
默认模式下最有意思的一点:预读队列的内存预算是和 TemporaryMemoryManager 协商出来的——就是那个在并发 join、sort、window 算子之间分配内存的管理器。
原文:"the budget is negotiated with the temporary memory manager, which is the same manager that splits memory between concurrent joins, sorts, and window operators."
这个设计的含义是:预读队列被当成了一个"算子",和 hash join 的 build 侧、外部排序的 run buffer 平起平坐地抢内存。
行为表现是:
- 某个算子(比如 Q18 那种大 group by)吃掉大量内存 → 内存管理器给预读的额度瞬间归零 → 队列缩到"一次只允许一个 job" → 扫描退化成近似同步行为;
- 那个算子跑完释放内存 → 额度回来 → 队列重新充满 → 吞吐恢复。
这是一个温度计式的自适应机制:不是二值开关,而是随内存压力连续滑动。
博客里那组内存限制实验完美印证了这一点:
| 版本 | 内存限制 | 总耗时 | 平均 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 |
读这张表要抓三条线:
第一条线:内存限制越紧,v2.0 的 RSS 越低(20.1 → 15.7 → 11.5 GB),耗时越长(15.6 → 22.7 → 24.2 s)。 这是预期行为——内存治理器缩了预读 backlog,同时 Q18 这种内存密集算子开始 spill 到磁盘。这条曲线是可控的、单调的,这才是好的降级行为。
第二条线:即使在 8 GB 限制下,v2.0 依然打满 25.0 Gbit/s。 这条特别值得注意。它说明内存治理没有牺牲带宽饱和度,只是让在飞行的数据"周转更快"(更小的窗口,更高的周转率)。这是一个设计得很成功的降级路径。
第三条线:v1.5.5 无论内存怎么调,都是 35 秒出头。 因为它的瓶颈根本不在内存,而在于同步 I/O 撑不起足够的并发请求。给它再多内存也没用——这是一个诊断信号:如果你加内存查询不变快,八成是 I/O 并发不足,而不是内存不够。
4.3 那个 11.5 GB 的坑:jemalloc
博客里有个脚注特别实在:8 GB 限制下 RSS 峰值仍然是 11.5 GB。
原因是 jemalloc 会把刚释放的页面保留约 1 秒,方便复用。这部分内存已经不被 DuckDB 的内存管理器统计了,但操作系统还认为它属于这个进程。
这个坑在容器环境里会要命:你给 Pod 设了 memory.limit = 10Gi,DuckDB 内部限制设成 8 GB,觉得留了 2 GB 余量很安全——结果 jemalloc 的滞留页把你顶到 11.5 GB,OOMKilled,而且日志里什么异常都没有。
实用建议:
# 容器内存上限至少留 DuckDB memory_limit 的 1.5 倍
# 或者调紧 jemalloc 的脏页回收(DuckDB 内置 jemalloc 的场景下)
export MALLOC_CONF="dirty_decay_ms:0,muzzy_decay_ms:0"
注意这会带来一定的性能损失(更频繁的 madvise 系统调用),属于用 CPU 换内存确定性的取舍。在 K8s 这种 OOMKill 无情的环境里,这笔买卖通常划算。
五、性能数据全解读:五组实验讲了五件不同的事
DuckDB 这篇博客的实验设计相当扎实。我按"每组实验回答了什么问题"的角度重新组织一下。
统一的测试环境:EC2 r7i.16xlarge(64 vCPU / 512 GB / 25 Gbit/s),S3 同区,TPC-H SF100,lineitem 600,037,902 行,关掉文件缓存(SET enable_external_file_cache = false;),跑 5 次取均值。
5.1 实验一:单个大 Parquet —— 基线收益
| 版本 | Q6 耗时 |
|---|---|
| v1.5.5(同步) | 8.230 s |
| v2.0.0-dev(异步) | 2.844 s |
| v2.0.0-dev(调优后) | 2.227 s |
22 GB 文件,约 4,880 个 row group,每个约 122,880 行。
调优配置是:
SET read_ahead_depth = 64; -- 固定 64 个在飞行的 job
SET async_threads = 48; -- 注意:比默认的 256 少
SET http_retries = 8;
SET http_retry_wait_ms = 50;
SET http_retry_backoff = 2;
这组参数背后的洞察是全文最反直觉的一点:把 ASYNC 线程数从 256 降到 48,反而更快。
为什么?博客的原话是"fewer, hotter connections and cheap retries"(更少、更热的连接,加上廉价的重试)。拆开讲:
- TLS 握手摊销:每条新连接一次 TLS 握手就是一个 RTT 起步。256 条连接意味着 256 次握手,而 48 条连接可以被复用得更充分。
- TCP 拥塞窗口:新连接从 slow start 开始,cwnd 要爬好几个 RTT 才能长到能吃满带宽。短命连接永远跑不到稳态。48 条长命连接可以一直待在拥塞避免阶段的高位。
- S3 侧限流:并发请求数过高时 S3 会返回
503 SlowDown。配上http_retries=8+ 50 ms 起步的指数退避(backoff=2,即 50/100/200/400... ms),让重试变得"便宜",避免一次限流就把整个查询搞挂。
结果是吞吐方差降到最小,25 Gbit/s 几乎全程饱和。这比"平均吞吐高但抖得厉害"要好得多,因为抖动会让下游算子的流水线断断续续。
5.2 实验二:本地冷盘 —— 收益的下界
MacBook Pro(M4 Max / 14 核 / 36 GB),每次跑前用 macOS 的 purge 清 OS 缓存:
| 版本 | Q6 耗时 |
|---|---|
| v1.5.5 | 1.321 s |
| v2.0.0-dev | 0.883 s |
1.5 倍,降 33%。热跑(数据已在 page cache)差别可忽略。
这组数据的价值在于划出了收益的下界:即便是本地 NVMe,冷读场景异步 I/O 依然有 1.5 倍。别以为"我不用 S3 所以跟我没关系"——批处理作业、CI 里的数据校验、容器冷启动后的第一次查询,全都是冷读。
5.3 实验三:小文件 —— 证伪了一个常见担忧
同样 SF100,但切成 976 个文件,每个 5 个 row group、约 615,000 行、约 22 MB:
| 版本 | Q6 耗时 |
|---|---|
| v1.5.5 | 9.344 s |
| v2.0.0-dev | 2.945 s |
依然是 3 倍。
这组实验回答的是一个很实际的担忧:分区表会不会因为"开文件 + 读 footer"的固定开销把异步 I/O 的收益吃掉?
答案是不会。原文:"read-ahead can also parallelize across multiple files without becoming bottlenecked by opening files or fetching their footers." 因为 footer 读取本身也走 ASYNC 池,976 个文件的 footer 请求是并发发出去的,而不是串行的。
对于按天/按小时分区、动辄几千个小文件的数据湖用户,这是个好消息。
5.4 实验四:Row Group 尺寸的 U 型曲线 —— 全文最有价值的一段
这组实验我认为价值最高,因为它直接推翻了一条广为流传的最佳实践。
同一份 lineitem,只改 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 ← 最慢 |
从 2.11 秒到 25.26 秒,12 倍的差距,只因为 row group 大小不同。
这条曲线是两股力量拉扯的结果:
力量 A(往大了推):请求延迟摊销。 row group 越大,单次 GET 拿到的数据越多,40 ms 的固定延迟被摊得越薄。从 4 MB 涨到 70 MB,耗时从 2.74 降到 2.11。
力量 B(往小了推):并行度。 row group 是 DuckDB 的 Parquet 扫描并行粒度单位。 一个 row group 只能被一个线程扫。所以 row group 数量 < 线程数的时候,你的 CPU 就闲了。
64 vCPU 的机器上:
- 306 个 RG → 每核约 4.8 个,调度余量充足 → 2.11 s(最优)
- 64 个 RG → 每核 1 个,刚好够但没有余量(尾部长尾效应明显)→ 2.27 s
- 10 个 RG → 只能用 10 个核 → 3.69 s
- 4 个 RG → Q6 每 RG 两次 fetch,只有约 8 条并发 S3 流 → 8.01 s
- 1 个 RG → 退化成 2 条巨型流 → 25.26 s
最后一行最有意思:单 row group 的文件因为压缩效果好,体积只有 12,300 MB,是 4,886 个 RG 版本(21,600 MB)的 57%——数据少了 43%,却慢了 9 倍。
原文的总结一针见血:"the extra bandwidth required by smaller row groups is cheaper than the parallelism lost with extremely large ones."(小 row group 多花的带宽,比超大 row group 损失的并行度便宜。)
这条结论对写 Parquet 的人是硬性要求。 很多 ETL 管道为了"压缩率好看"和"文件数少",把 row group 调到 1 GB 甚至更大。在本地单机可能没事,一旦上了存算分离,这就是自杀。
给一条可操作的公式:
目标 row group 数 ≈ 目标机器 vCPU 数 × 3 ~ 5
# 例:数据 20 GB,跑在 64 vCPU 上
# 目标 RG 数 = 64 × 4 = 256
# 单 RG 大小 = 20 GB / 256 ≈ 80 MB → 落在 64~128 MB 这个甜点区间
配合实测数据(70 MB 最优),64–128 MB 的 row group 是一个非常安全的默认值。
用 DuckDB 自己写 Parquet 时可以直接控制:
COPY (SELECT * FROM lineitem)
TO 's3://bucket/lineitem.parquet'
(FORMAT parquet,
ROW_GROUP_SIZE 1966080, -- 约 196 万行,实测最优档
COMPRESSION zstd,
COMPRESSION_LEVEL 3);
PyArrow 侧:
import pyarrow.parquet as pq
pq.write_table(
table,
"s3://bucket/lineitem.parquet",
row_group_size=1_966_080, # 不是字节数,是行数
compression="zstd",
compression_level=3,
# 关键:让常一起查询的列物理相邻,减少 fetch task 数量
# 通过预先 select 调整列顺序实现
)
Spark 侧要注意,parquet.block.size 是字节数,且是"目标值"不是硬约束:
spark.conf.set("parquet.block.size", 128 * 1024 * 1024) // 128 MB
spark.conf.set("spark.sql.files.maxRecordsPerFile", 20000000)
5.5 实验五:CSV —— 20 倍的极端案例
| 版本 | Q6 耗时 |
|---|---|
| v1.5.5 | 877.563 s |
| v2.0.0-dev | 45.264 s |
80.89 GB 的 CSV,19.4 倍。
为什么 CSV 收益这么夸张?三个原因叠加:
- 行式格式,没有投影下推。 你要 4 个列,也得把 16 个列的字节全拉过来。传输量是 Parquet(22 GB)的 3.7 倍。
- 没有统计信息,没有 row group 裁剪。 每一个字节都得读。
- 固定大小 buffer 读取。 每个 buffer 是一次独立的 range GET,请求数极多。同步模式下这些请求排成一条长队,每个都吃满 RTT。
传输量越大、请求数越多,异步的收益就越大。这是个线性放大关系。
顺带说一句,这也再次证明了一件事:如果你还在用 CSV 做数据湖,换 Parquet 的收益(877 s → 一个更小的数)远大于任何 I/O 优化。 异步 I/O 只是让 CSV 从"不可用"变成"能忍"。
另外注意一个限制:目前异步 I/O 只支持 未压缩、可 seek 的 UTF-8 CSV。gzip 压缩的 CSV 因为无法随机定位,享受不到这个优化。
六、代码实战:怎么验证、怎么调、怎么监控
理论讲完了,说点能直接用的。
6.1 装 preview 版本
异步 I/O 目前在 v2.0.0-dev preview 里,v2.0 正式版秋天发布后会默认开启。
# 拉 preview CLI(示例,具体链接以官方 install/preview 页面为准)
curl -L https://install.duckdb.org/preview | sh
# 或者 Python 侧
pip install --pre --upgrade duckdb
import duckdb
print(duckdb.__version__) # 期望看到 2.0.0-dev...
6.2 一个可复现的 A/B 对照脚本
#!/usr/bin/env python3
"""
DuckDB 同步 vs 异步 I/O A/B 对照
用 read_ahead_depth=0 模拟同步行为,与默认的内存治理模式对比
"""
import time
import statistics
import duckdb
S3_PATH = "s3://your-bucket/tpch/sf100/lineitem.parquet"
QUERY = f"""
SELECT sum(l_extendedprice * l_discount) AS revenue
FROM read_parquet('{S3_PATH}')
WHERE l_shipdate >= DATE '1994-01-01'
AND l_shipdate < DATE '1995-01-01'
AND l_discount BETWEEN 0.05 AND 0.07
AND l_quantity < 24
"""
CONFIGS = {
"sync-like (depth=0)": {
"read_ahead_depth": 0,
},
"async default (memory-governed)": {
"read_ahead_depth": -1,
},
"async tuned": {
"read_ahead_depth": 64,
"async_threads": 48,
"http_retries": 8,
"http_retry_wait_ms": 50,
"http_retry_backoff": 2,
},
}
def bench(name: str, settings: dict, runs: int = 5):
con = duckdb.connect()
con.execute("INSTALL httpfs; LOAD httpfs;")
# 关键:关掉外部文件缓存,否则第二次跑全是内存命中,测不出 I/O 差异
con.execute("SET enable_external_file_cache = false;")
con.execute("""
CREATE SECRET s3_cred (
TYPE s3,
PROVIDER credential_chain,
REGION 'us-east-1'
);
""")
for k, v in settings.items():
con.execute(f"SET {k} = {v!r};" if isinstance(v, str) else f"SET {k} = {v};")
timings = []
for i in range(runs):
t0 = time.perf_counter()
con.execute(QUERY).fetchall()
dt = time.perf_counter() - t0
timings.append(dt)
print(f" [{name}] run {i+1}: {dt:.3f}s")
con.close()
return timings
if __name__ == "__main__":
results = {}
for name, cfg in CONFIGS.items():
print(f"\n=== {name} ===")
results[name] = bench(name, cfg)
print("\n=== Summary ===")
baseline = statistics.mean(results["sync-like (depth=0)"])
for name, ts in results.items():
m = statistics.mean(ts)
print(f"{name:35s} mean={m:7.3f}s p50={statistics.median(ts):7.3f}s "
f"speedup={baseline/m:5.2f}x")
这个脚本有两个容易踩的坑,务必注意:
- 必须
SET enable_external_file_cache = false;。DuckDB 有自己的外部文件缓存,第二次跑同样的查询会直接命中,你测出来的是内存速度,跟 I/O 完全无关。很多人的 benchmark 就是这么假的。 read_ahead_depth = 0不完全等于 v1.5.5 的同步行为,它只是关掉了预读,架构上仍然可能有 ASYNC 池参与。真正严谨的对照要装两个版本分别跑。但作为快速验证,depth=0 已经能看出量级差异。
6.3 用 HTTP 日志看请求形态
v1.5.5 给 HTTP 日志加了两个很有用的字段:请求体长度(#23316)和传输层错误(#23327)。这让你能直接看到 fetch task 的实际形态:
SET enable_logging = true;
SET logging_level = 'debug';
SELECT sum(l_extendedprice * l_discount)
FROM read_parquet('s3://bucket/lineitem.parquet')
WHERE l_shipdate >= DATE '1994-01-01' AND l_shipdate < DATE '1995-01-01';
-- 查看 HTTP 请求明细
SELECT * FROM duckdb_logs
WHERE type = 'HTTP'
ORDER BY timestamp;
重点看三件事:
- 请求总数:太多说明 fetch task 拆得过碎(row group 太小或列不连续);
- 单请求字节数分布:如果大量请求 < 1 MB,说明摊销不足;
- 是否有
503 SlowDown:有的话就该调http_retries和退避参数了。
统计一下:
SELECT
count(*) AS n_requests,
round(avg(request_body_length) / 1024.0, 1) AS avg_kb,
round(min(request_body_length) / 1024.0, 1) AS min_kb,
round(max(request_body_length) / 1024.0, 1) AS max_kb,
count(*) FILTER (WHERE status >= 400) AS n_errors
FROM duckdb_logs
WHERE type = 'HTTP';
6.4 检查你的 Parquet 文件长什么样
在调任何参数之前,先看看你的文件结构。DuckDB 自带元数据函数:
-- 看 row group 分布
SELECT
file_name,
count(DISTINCT row_group_id) AS n_row_groups,
round(avg(row_group_num_rows)) AS avg_rows_per_rg,
round(sum(total_compressed_size) / 1024.0 / 1024.0, 1) AS total_mb,
round(sum(total_compressed_size) / 1024.0 / 1024.0
/ count(DISTINCT row_group_id), 1) AS avg_rg_mb
FROM parquet_metadata('s3://bucket/lineitem.parquet')
GROUP BY file_name;
然后拿这个结果对照第五节那条 U 型曲线自查:
-- 一个简易体检:row group 数是否够喂饱你的核数
WITH stats AS (
SELECT count(DISTINCT row_group_id) AS n_rg
FROM parquet_metadata('s3://bucket/lineitem.parquet')
),
cores AS (SELECT current_setting('threads')::BIGINT AS n_threads)
SELECT
n_rg,
n_threads,
round(n_rg::DOUBLE / n_threads, 2) AS rg_per_thread,
CASE
WHEN n_rg::DOUBLE / n_threads < 1 THEN '危险:并行度不足,考虑重写文件'
WHEN n_rg::DOUBLE / n_threads < 3 THEN '偏低:尾部长尾效应明显'
WHEN n_rg::DOUBLE / n_threads > 20 THEN '偏碎:请求数过多,延迟摊销不足'
ELSE 'OK'
END AS verdict
FROM stats, cores;
还可以看列的物理排布,判断 fetch task 能否合并:
-- 列在文件中的物理位置(影响字节范围能否合并)
SELECT
row_group_id,
path_in_schema AS column_name,
data_page_offset,
total_compressed_size
FROM parquet_metadata('s3://bucket/lineitem.parquet')
WHERE row_group_id = 0
ORDER BY data_page_offset;
如果你的查询常用列在 offset 上是分散的,考虑在写文件时调整列顺序,把它们放到一起。
6.5 系统级监控:确认瓶颈在哪
博客里的方法很朴素但有效:每 50 ms 采样一次网卡的接收字节计数器,差分算吞吐。
#!/bin/bash
# nic_throughput.sh <interface> <interval_ms>
# 例:./nic_throughput.sh ens5 50
IFACE=${1:-ens5}
INTERVAL_MS=${2:-50}
INTERVAL_S=$(echo "scale=3; $INTERVAL_MS/1000" | bc)
PREV=$(cat /sys/class/net/$IFACE/statistics/rx_bytes)
echo "timestamp_ms,gbit_per_sec"
while true; do
sleep "$INTERVAL_S"
CUR=$(cat /sys/class/net/$IFACE/statistics/rx_bytes)
DELTA=$((CUR - PREV))
GBPS=$(echo "scale=3; $DELTA * 8 / $INTERVAL_S / 1000000000" | bc)
echo "$(date +%s%3N),$GBPS"
PREV=$CUR
done
跑之前先用 s5cmd 确认机器的实际带宽上限,别把 S3 的限流当成自己代码的问题:
# 先确认物理带宽能到多少
s5cmd cp s3://bucket/lineitem.parquet /dev/null
诊断决策树:
查询慢
├─ 网卡吞吐 << 标称带宽?
│ ├─ CPU 利用率也低? → I/O 并发不足 → 调 read_ahead_depth / async_threads
│ └─ CPU 利用率高? → 解码是瓶颈 → 检查压缩算法、类型转换、正则
├─ 网卡吞吐 ≈ 标称带宽?
│ └─ 已经饱和 → 只能减少传输量(换列式格式 / 加过滤 / 调分区裁剪)
└─ 有 503 SlowDown? → S3 限流 → 减少并发 + 调退避参数 + 考虑打散 key 前缀
6.6 Parquet 重写:把老文件修好
如果体检发现 row group 太大,直接用 DuckDB 重写就行:
-- 单文件重写
COPY (SELECT * FROM read_parquet('s3://bucket/old/lineitem.parquet'))
TO 's3://bucket/new/lineitem.parquet'
(FORMAT parquet, ROW_GROUP_SIZE 1966080, COMPRESSION zstd);
-- 带 Hive 分区的批量重写
COPY (SELECT * FROM read_parquet('s3://bucket/old/**/*.parquet', hive_partitioning = true))
TO 's3://bucket/new/'
(FORMAT parquet,
PARTITION_BY (year, month),
ROW_GROUP_SIZE 1966080,
COMPRESSION zstd,
OVERWRITE_OR_IGNORE);
重写是一次性成本,但收益是永久的。按上面那条 12 倍的曲线算,这笔账基本没有不划算的可能。
七、七条调优规律
把上面所有实验数据提炼成可执行的规律:
规律一:row group 目标数 = vCPU × 3~5,单个大小 64–128 MB。
这是本文最硬的一条。低于 1×vCPU 灾难性,超过 20×vCPU 请求过碎。
规律二:ASYNC 线程数不是越多越好,48–64 常常优于 256。
少而热的连接能让 TCP 拥塞窗口长起来、摊销 TLS 握手。从默认值开始,往下调试试。
规律三:重试要"便宜"。http_retries=8 + http_retry_wait_ms=50 + http_retry_backoff=2。宁可多重试几次,也别让一次 503 把查询搞死。指数退避从 50 ms 起步,第 8 次是 6.4 秒,总窗口约 12.75 秒,对分析查询完全可接受。
规律四:benchmark 必须关文件缓存。SET enable_external_file_cache = false; 否则你测的是 memcpy。
规律五:容器内存上限 ≥ DuckDB memory_limit × 1.5。
jemalloc 的 1 秒滞留页会让 RSS 显著超出内部统计值。8 GB 限制实测峰值 11.5 GB。
规律六:内存紧张时优先降 read_ahead_depth,而不是降 threads。
从实验数据看,降内存限制会自动收缩预读,带宽仍然饱和,只是周转变快。而降线程数会直接砍掉解码能力。
规律七:如果加内存查询不变快,问题在 I/O 并发。
v1.5.5 在默认/16 GB/8 GB 三档下都是 35 秒出头,这是一个极其清晰的诊断信号。
八、十条踩坑清单
read_ahead_depth = N > 0会绕过内存预算。 手动设固定深度等于放弃内存治理保护。宽表 + 慢解码的组合下,很容易 OOM。除非你非常清楚负载特征,否则保持-1。gzip 压缩的 CSV 吃不到异步 I/O。 目前只支持未压缩、可 seek 的 UTF-8 CSV。
.csv.gz无法随机定位,只能顺序解压。想要提速只能先转 Parquet。JSON 和 DuckDB native 格式暂不支持异步读。 博客明确说这是"接下来的计划"。out-of-tree 扩展里的格式更是完全没排期。
热数据场景没有收益。 数据已经在 page cache 或 DuckDB 外部文件缓存里的时候,异步 I/O 的差别可忽略。别在热跑的 benchmark 里找收益然后得出"没用"的结论。
单 row group 的 Parquet 是性能杀手。 12,300 MB 单 RG 的文件比 21,600 MB / 4,886 RG 的文件慢 9 倍。别为了压缩率牺牲并行度。
查询启动有几百毫秒的固定开销。 连接建立 + TLS 握手 + 打开文件 + 下载 footer + 解析 footer。对亚秒级查询来说这是主要成本。博客说这块 v2.0 发布前还会继续优化,但短期内别指望 DuckDB 做低延迟点查。
fetch task 数量受列物理位置影响。 同样的投影,列在文件里连续 vs 分散,请求数可能差 2–4 倍。写文件时把常一起查的列放一起。
TemporaryMemoryManager曾有死锁 bug(v1.5.5 #23351 修复)。 如果你在跑 1.5.x 早期版本且遇到并发查询挂起,先升到 1.5.5。同批修复的还有外部 hash 聚合在 radix bits 增长后转外部时的段错误(#23757)、并发ALTER和INSERT崩溃(#23861)。enable_external_file_cache在生产里别乱关。 那是给 benchmark 用的。生产环境关掉它意味着每次查询都重新拉数据,S3 流量费会让你的财务同事来找你聊天。不要把 Quack 端点直接暴露到公网。 官方明确不推荐。默认只绑 localhost、默认不开 SSL(因为本地通信上 SSL 是多余的依赖)。要公网暴露就在前面放 nginx 终止 SSL,官方文档有反向代理指南。历史上数据库裸奔公网的惨案已经够多了。
九、横向对比与未来:io_uring 会是下一步吗
9.1 DuckDB 选了一条中间路线
现代系统做异步 I/O 大致有三条路:
路线 A:线程池 + 阻塞调用。 就是 DuckDB 现在这条。改造量小、代码好懂、跨平台(macOS/Windows/Linux 一套代码)。代价是线程数多、上下文切换有开销、每个阻塞线程占一份栈。
路线 B:全异步 event loop(epoll / kqueue / IOCP)。 Node.js、Nginx 那套。CPU 效率最高,但要求整条调用链都是非阻塞的。对一个用 C++ 写了七年同步代码的数据库来说,这是重写级别的工程量。
路线 C:io_uring。 Linux 5.1+ 的统一异步接口,提交队列 + 完成队列共享内存,可以做到近乎零系统调用。性能天花板最高,但只有 Linux、内核版本要求高、心智负担重。
博客的结论段明确说了:"We will also investigate io_uring... which could reduce system-call overhead and the number of threads blocked on I/O. If it proves beneficial in practice, we will integrate it into DuckDB."
我的判断是:io_uring 大概率会作为 Linux 上的一个可选后端进来,而不是替换掉线程池。 理由有三:
- DuckDB 的跨平台承诺很硬(Windows、macOS、Wasm 都是一等公民),不可能把核心路径绑死在 Linux 特性上;
- 网络 I/O 用 io_uring 的收益不如文件 I/O 明显——远端读的瓶颈是 40 ms 的网络 RTT,不是几微秒的 syscall 开销;
- 真正能吃到 io_uring 红利的是本地 NVMe 冷读场景,而那个场景现在的收益只有 1.5 倍,优化空间本来就有限。
换句话说,io_uring 是锦上添花,不是雪中送炭。
9.2 和 ClickHouse / Trino 的路线对比
| 维度 | DuckDB v2.0 | ClickHouse | Trino |
|---|---|---|---|
| 部署形态 | in-process / Quack server | 独立集群 | 独立集群 |
| I/O 并发单位 | row group / scan boundary | granule + 异步读缓冲 | split |
| 线程模型 | REGULAR + ASYNC 双池 | 多个专用线程池 | JVM 线程 + NIO |
| 预读控制 | read_ahead_depth + 内存治理 | remote_fs_read_max_backoff_ms 等 | split 队列深度 |
| 内存协商 | 与 join/sort/window 共享治理器 | 独立的缓存/预读配置 | query memory pool |
| 单机上限 | 高(无网络 shuffle) | 中(单节点) | 低(依赖集群) |
DuckDB 最独特的地方是把预读队列纳入统一的内存治理器。ClickHouse 的预读缓冲和查询内存基本是两套账,容易出现"预读把内存吃了导致查询 OOM"的情况。DuckDB 让它们在同一张桌子上谈判,行为更可预测。
代价是复杂度:内存管理器要处理一个新的、行为模式完全不同的"算子"(预读队列的内存需求是连续的、可随时放弃的,而 hash join 的内存是阶跃的、不可放弃的)。从 v1.5.5 修了一个 TemporaryMemoryManager 死锁(#23351)来看,这块确实是有难度的。
9.3 一个更大的判断:单机分析的边界又被推远了
我想说个更大的观察。
过去几年,"用 DuckDB 替代小型数据仓库"这个论调一直存在,但有一条硬边界:一旦数据不在本地,DuckDB 的优势就打折。 你的数据在 S3,那你要么先 aws s3 cp 下来(慢且占盘),要么忍受直接查的低吞吐。
异步 I/O 把这条边界推走了。一台 64 核的 EC2,用 25 Gbit/s 打满 S3,2.2 秒扫完 SF100 的 Q6——这个数字放在三年前是需要一个 Spark 集群才能达到的。而现在是一个进程、一个二进制、零集群运维。
再叠加:
- DuckLake v1.0 提供了 production-ready 的湖仓元数据层,且规范简单到"连个 clanker 都能给 dataframe 实现一个"(DuckLake 团队自己的说法);
- DuckDB-Iceberg v1.5.3 补齐了
MERGE INTO、分区转换、V3 支持,能直接接现有的 Iceberg REST Catalog; - Delta 扩展支持写入、Unity Catalog、time travel;
- Quack 协议让 DuckDB 能做多写并发的 server,还能让浏览器里的 Wasm 实例直连;
- 腾讯云 PostgreSQL 上线了 DuckDB 引擎(2026 年 7 月),一条
SET语句就在同一个 PG 实例里开 OLAP 加速,OLTP 走原生路径,分析走 DuckDB 向量化引擎。
这些拼在一起是一个很清晰的方向:DuckDB 正在从"笔记本上的分析玩具"变成"存算分离架构下的通用计算节点"。 而异步 I/O 是这个转型里最关键的一块拼图——它把"计算能力"和"数据获取能力"重新拉回了平衡。
博客里那句话其实说得最好:"any of the data lake solutions supported in DuckDB can already benefit from asynchronous I/O automatically as long as the underlying data format is Parquet."(只要底层是 Parquet,DuckDB 支持的任何数据湖方案都能自动吃到异步 I/O 的红利。)
不用改代码,不用改配置,升级就有。这才是好的架构改进。
十、总结与行动清单
回到开头那个数字:5.9 / 64。
这个数字之所以刺眼,是因为它揭示了一个在分布式时代反复出现的错误:我们习惯于优化计算,却容忍数据获取路径的低效。 DuckDB 花七年把向量化引擎打磨到极致,结果在存算分离场景下,90% 的算力在等 HTTP 响应。
修复它的方案在概念上朴素得可以:把 I/O 从工作线程上剥离,并且提前发起。 但在工程实现上,每一个细节都需要判断:
- 用双线程池而不是全异步重写——务实,改造量可控;
- ASYNC 池超配到 4× 核数——因为阻塞线程不占 CPU,超配近乎免费;
- 消费者兼职生产者——省掉一个线程,白送一套自然反压;
- 倒计时闩 + 任务 park/unpark——让 worker 在等 I/O 时不闲着;
- 认领即释放槽位——把解码和预读彻底解耦,消除流水线气泡;
- 预读队列参与内存治理器竞价——压力下优雅降级到近似同步,而不是 OOM 崩掉;
- FIFO 认领顺序——用局部吞吐换全局内存可控。
每一条都是可以搬到你自己系统里的模式。
行动清单
如果你现在就在用 DuckDB 查远端数据:
- 跑一遍第 6.4 节的 Parquet 体检 SQL,看 row group 数够不够喂饱你的 vCPU;
- 如果
rg_per_thread < 1,立刻安排重写,这是 12 倍的差距,不是 12% 的差距; - 目标:单 row group 64–128 MB,总数 = vCPU × 3~5;
- 升到 v1.5.5(修了
TemporaryMemoryManager死锁、外部 hash 聚合段错误、并发ALTER/INSERT崩溃)。
如果你想现在就试异步 I/O:
- 装 v2.0.0-dev preview,用第 6.2 节的 A/B 脚本量化收益;
- benchmark 记得
SET enable_external_file_cache = false;; async_threads从 48 开始试,别一上来就拉满;- 配上
http_retries=8/http_retry_wait_ms=50/http_retry_backoff=2。
如果你在 K8s 上跑:
- 容器内存上限 ≥ DuckDB
memory_limit× 1.5(jemalloc 滞留页); - 内存紧张优先降
read_ahead_depth,不要降threads。
如果你在做架构选型:
- 秋天 v2.0 发布后,重新评估一遍"我到底需不需要一个 Spark/Trino 集群"这个问题。单机 25 Gbit/s 打满 S3 之后,很多中等规模的场景答案会变。
最后说一句题外话。这篇 DuckDB 博客我读得挺舒服的一点是:它老老实实把不好看的数据也放出来了。 单 row group 25.26 秒、8 GB 限制下 RSS 超标到 11.5 GB、查询启动有几百毫秒说不清的空档"我们相信这块还能继续优化"——这些在很多厂商的发布稿里是会被藏起来的。
一个愿意公开自己性能曲线上凹坑的团队,通常那个凹坑很快就会被填上。
参考资料
- Asynchronous I/O in DuckDB: Work, Thread, Work — Pedro Holanda, 2026-07-31
- Announcing DuckDB 1.5.5 — 2026-07-22
- Quack: The DuckDB Client-Server Protocol — 2026-05-12
- New DuckDB-Iceberg Features in v1.5.3 — 2026-05-29
- DuckLake v1.0 — 2026-04-13
- Thank You for 40 000 Stars on GitHub — 2026-08-05