DuckDB 的三次自我否定:当进程内数据库长出网络协议——Quack、VARIANT 与 v2.0 异步 I/O 的全链路架构拆解
有些技术演进是"加特性",有些是"打自己的脸"。DuckDB 在 2026 年干的是后者。
这个项目自 2019 年发布以来,最响亮的口号就是"in-process"——没有客户端,没有服务端,没有协议,只有函数调用。团队为此拍了一堆演讲视频,写了论文《Don't Hold My Data Hostage》专门吐槽传统数据库协议有多蠢。结果 2026 年 5 月,他们自己发布了一个叫 Quack 的客户端-服务器协议。
同一年,他们又干了两件事:把 JSON 这个"文本存储、灵活但慢"的老方案,用 VARIANT 类型重做了一遍;然后在即将到来的 v2.0 里,把跑了七年的同步 I/O 路径改成了异步的。
这三件事看起来毫不相干,但如果你把它们放在一张时间线上看,会发现是同一个判断的三个落点:DuckDB 不再假设数据在本地 SSD 上,也不再假设只有一个进程在用它。
本文把这三刀逐层拆开:Quack 的协议设计与真实压测数字、VARIANT 的 shredding 原理与 10-100 倍提速从哪来、异步 I/O 的双线程池模型与 read-ahead 队列。全程带可跑的代码和踩坑清单。
顺带一提,DuckDB 在 2026 年 8 月 5 日刚过了 GitHub 40,000 Star。对一个"嵌入式数据库"来说,这个数字本身就说明了它早就不只是嵌入式数据库了。
一、背景:为什么一个进程内数据库需要改变
1.1 in-process 的黄金期
先说清楚 DuckDB 原本的设计假设有多合理。
DuckDB 的典型场景是:数据科学家在 Jupyter Notebook 里跑分析,数据就在同一个 Python 进程的内存或者本地 SSD 上。这种场景下,客户端-服务器架构纯属自找麻烦——序列化、反序列化、socket 拷贝、协议编解码,每一步都是纯开销。
在这个假设下,DuckDB 的性能优化重心非常明确:数据访问不是瓶颈,算子才是。
于是团队把精力砸在了向量化执行、morsel-driven 并行、子查询解嵌套、join order 优化上。至于读文件?把 filter 和 projection 尽早下推,只读需要的那几列几个 row group,同步读本地 SSD 快得很,没必要搞复杂。
这个判断在很长一段时间里是对的。
1.2 三个假设同时失效
然后现实变了,而且是三个方向同时变:
假设一失效:数据不在本地了。
数据湖起来了。DuckLake、Iceberg、Delta,典型部署是数据躺在 S3,计算跑在同区域的 EC2 上。这时候延迟和带宽突然变成一等公民——如果并发请求数不够,线程大把时间在等 HTTP 响应,CPU 在那儿闲着看戏。
假设二失效:不止一个进程在写。
一堆采集进程往同一个库里插遥测数据,同时还有一个 dashboard 在查同样的表。DuckDB 在内存里维护了大量状态,多进程同时改动就得同步这些状态——这在 in-process 架构下是做不到的。
社区的应对方式是各种"套壳":自己撸 RPC、上 Arrow Flight SQL(比如 GizmoSQL)、用 MotherDuck 的私有协议,或者干脆上 pg_duckdb 搞个"EleDucken"(Turducken 的梗,PostgreSQL 里塞 DuckDB)。轮子被造了这么多次,本身就是需求信号。
假设三失效:半结构化数据成了主流。
可观测性数据、API 输出、各种"大体形状一样但又不完全一样"的脏数据。DuckDB 原本的 JSON 类型把数据当文本存,灵活是灵活,但每次查询都得解析,而且取出来永远是 VARCHAR。
三个假设一起塌了,就得动手术。
二、第一刀:Quack 协议——2026 年该怎么设计数据库协议
2.1 历史包袱与从零设计的奢侈
数据库协议的历史大致是这样:80 年代 Sybase 第一次引入了 client / server 的概念,此后所有人都默认数据库就该是这样。好处是单一可变状态集中在服务端,多客户端可以同时读写;坏处是协议开销可能大到离谱。
2000 年 SQLite 逆流而上,2019 年 DuckDB 跟进。然后 2026 年,DuckDB 转头去做协议了。
但这里有个别人没有的优势:从零设计,没有任何历史包袱。PostgreSQL 的线协议要兼容三十年的客户端,MySQL 同理。DuckDB 可以直接参考 Arrow Flight SQL 这些现代方案的经验,然后挑最优解。
2.2 设计决策逐条拆解
决策一:直接建在 HTTP 上
这是最容易被喷"不够硬核"但其实最务实的一条。
官方原话大意是:HTTP 从 CERN 起家,已经成为 TCP 之上的事实标准层,整个技术栈都为高效传输 HTTP 消息流做过优化;只要实现得当,协议开销出乎意料地低;而且负载均衡、认证、防火墙、入侵检测这些基础设施,全世界都知道怎么处理 HTTP。
2026 年再去发明一个私有二进制线协议,然后自己重写一遍 LB、鉴权、可观测性——这才是不理智的。
附带的红利是:DuckDB-Wasm 可以原生说 Quack。浏览器里跑的 DuckDB 能直接连 EC2 上的 DuckDB 实例,不需要中间层。这个能力对做 BI 前端的人来说价值很大。
决策二:纯 request-response,客户端驱动
Quack 上的交互永远由客户端发起。消息类型包括连接请求(带 token 鉴权)、执行查询并返回第一批结果、以及后续的 fetch 消息拉取大结果集——fetch 可以多线程并行。
没有服务端推送,没有双向流。这个选择直接决定了它可以被任何 HTTP 中间件透明代理。
这里有个有意思的对照:MCP 协议在 2026 年 7 月也做了从"有状态双向 RPC"到"无状态客户端驱动"的迁移。两个完全不同领域的协议在同一年做了同方向的收敛,说明这不是巧合——可被中间件理解的简单模型,在分布式环境里的工程价值远大于协议本身的表达力。
决策三:复用 WAL 的序列化原语
请求和响应用一个新的 MIME 类型 application/duckdb 编码。这个编码直接复用了 DuckDB 内部的序列化原语——就是多年来写 WAL(Write-Ahead Log)文件用的那套。
这一步很聪明。WAL 序列化是数据库里被打磨得最狠的代码路径之一:性能敏感、正确性要求极高、经过了多年生产验证。拿来做线协议编码,等于白嫖了多年的优化和 battle-test。
决策四:默认不安全就是最安全的默认
Quack 的安全默认值组合是:
- 服务端启动时随机生成认证 token,必须手动交给客户端
- 服务端默认只绑 localhost
- 默认不开 SSL(理由:为了本机通信引入整套 TLS 依赖挺蠢的)
- 客户端对非本地连接默认假设 SSL 已启用
官方明确建议:不要把 Quack 端点直接暴露到公网。要暴露就在前面放 nginx,让反向代理去终止 SSL(配 Let's Encrypt)。
这套默认值的设计哲学值得学:不做"看起来安全"的复杂配置,而是把不安全的路径设成需要显式操作才能走通。
决策五:单次往返完成查询
一旦连接建立,一个查询可以在一个 round trip 内完整处理。这对延迟敏感场景是关键优化。
同时批量传输也做了重优化。官方的说法相当自信:据他们所知,Quack 是目前把表塞进 socket 最快的方式。
决策六:鉴权授权全部可插拔,甚至能用 SQL 宏
这条是我最喜欢的设计。
Quack 自带一个默认认证方法(比对随机 token)和一个"对所有查询都说 yes"的默认授权函数。但两者都可以被用户代码覆盖:
- 认证回调可以去查 LDAP、读文本文件、甚至掷骰子
- 授权回调可以逐条检查客户端要执行的查询,并关联到之前用的认证串
- 这些回调可以直接是普通的 SQL 宏
用 SQL 宏写鉴权策略,这个抽象层级选得非常刁钻——它让"给分析库加行级权限"这种需求,从"写 C++ 扩展"降级成了"写个 macro"。
决策七:端口 9494
默认监听 9494。理由是 94 好记,是 Netscape Navigator 发布的年份。
(是的,官方文档里真是这么写的。)
2.3 压测数据:数字比形容词诚实
测试环境:AWS m8g.2xlarge(8 vCPU / 32GB RAM / "up to 15 Gbps" 网络),Ubuntu on Arm,客户端和服务端在同一个可用区的不同机器上,平均 ping 0.280 ms。
批量传输:TPC-H lineitem 表
对比对象是 PostgreSQL 协议和 Arrow Flight SQL(由内部同样用 DuckDB 的 GizmoSQL 提供)。传输行数递增到 6000 万行(CSV 格式 76 GB),取 5 次运行的中位数:
| 行数 | DuckDB Quack | Arrow Flight | PostgreSQL |
|---|---|---|---|
| 100k | 0.07 s | 0.07 s | 0.20 s |
| 1M | 0.24 s | 0.38 s | 2.20 s |
| 10M | 0.89 s | 2.90 s | 25.64 s |
| 60M | 4.94 s | 17.40 s | 158.37 s |
6000 万行,5 秒内传完。
这张表里真正值得拆的是斜率,不是绝对值:
- 100k 时 Quack 和 Arrow Flight 打平(都是 0.07s)。这说明在小结果集下,瓶颈是固定开销(连接、往返、启动),协议编码效率无关紧要。
- 10M 时差距拉到 3.3 倍,60M 时 3.5 倍。这才是编码效率和并行 fetch 的真实差距。
- PostgreSQL 在 60M 时慢了 32 倍。官方很公道地补了一句:标准 PG 客户端不做多线程并行读,而 Quack 和 Arrow 都可以。
所以这个 32 倍要拆成两部分理解:一部分是行式文本协议 vs 列式二进制协议的编码差距,另一部分是客户端并行度的差距。前者是协议设计问题,后者是客户端实现问题。别一看到 158 秒就下结论说"PG 协议不行"——它在设计年代面对的约束完全不同。
小事务写入:这里才有反直觉的结论
第二个压测建一张和 lineitem 同结构的空表,然后每行一个独立 INSERT 事务插随机值,跑 5 秒,逐步增加并行线程数,取 5 次中位数:
| 线程数 | DuckDB Quack | Arrow Flight | PostgreSQL |
|---|---|---|---|
| 1 | 1,038 tx/s | 469 tx/s | 839 tx/s |
| 2 | 1,956 tx/s | 799 tx/s | 1,094 tx/s |
| 4 | 3,504 tx/s | 1,224 tx/s | 2,180 tx/s |
| 8 | 5,434 tx/s | 1,358 tx/s | 4,320 tx/s |
按常理,一个 OLAP 引擎在小事务基准上应该被 PostgreSQL 吊打。结果 Quack 在 1-8 线程区间全程领先,峰值约 5,500 tx/s。
但官方自己指出了天花板在哪:超过 8 线程之后,撞上的是 DuckDB 自身对同一张表并发插入速率的限制,不是协议的限制。PostgreSQL 在这之后扩展性更好。
这段"自曝其短"是整篇发布文里最有价值的一段。它告诉你:
- 协议不是瓶颈,存储引擎才是。 Quack 把协议层的开销压到足够低之后,瓶颈自然上浮到了 DuckDB 的事务/存储层。
- 不要拿这个数字去做容量规划。 如果你的写入并发会超过 8,Quack 的 tx/s 曲线会走平,这时候该考虑的是分表、批量提交或者换 OLTP 系统,而不是加机器。
- OLAP 引擎做小事务,天花板是设计决定的。 DuckDB 的列式存储 + MVCC 实现本来就不是为高频单行写优化的。
2.4 上手实战
最小可运行示例
服务端(DuckDB 实例 #1):
-- 启动 Quack 服务,绑定 localhost,指定 token
CALL quack_serve(
'quack:localhost',
token = 'super_secret'
);
CREATE TABLE hello AS
FROM VALUES ('world') v(s);
客户端(DuckDB 实例 #2):
-- 用 SECRET 机制保存凭据
CREATE SECRET (
TYPE quack,
TOKEN 'super_secret'
);
ATTACH 'quack:localhost' AS remote;
-- 像查本地表一样查远端表
FROM remote.hello;
注意从 v1.5.3 起 Quack 已经是核心扩展,首次使用时会透明地自动安装加载,不用手动 INSTALL。
反向写入
客户端也能往服务端建表写数据:
-- 在客户端执行,表建在服务端
CREATE TABLE remote.hello2 AS
FROM VALUES ('world2') v(s);
服务端 FROM hello2; 就能看到数据。这就是官方说的"multiplayer 体验"——多个独立进程可以并行修改同一批表,不再互相锁死。
查询下推:大数据集的正确姿势
上面的 ATTACH 方式对简单查询很方便,但对复杂查询有个隐患:你不一定清楚哪部分在远端执行、哪部分把数据拉回本地再算。
数据量大的时候要用 query 函数显式下推整条 SQL:
-- 整条查询原样发到远端执行,只回传结果
FROM remote.query(
'SELECT
l_returnflag,
l_linestatus,
sum(l_quantity) AS sum_qty,
sum(l_extendedprice) AS sum_base_price,
avg(l_discount) AS avg_disc,
count(*) AS count_order
FROM lineitem
WHERE l_shipdate <= DATE ''1998-09-02''
GROUP BY l_returnflag, l_linestatus
ORDER BY l_returnflag, l_linestatus'
);
经验法则:聚合度高的查询一律用 query 下推。一个把 6 亿行聚合成 4 行的查询,下推之后网络上只走 4 行;不下推则可能把整表拖回来。这中间是 6 亿倍的差距。
生产部署:nginx 反代 + TLS
# /etc/nginx/conf.d/quack.conf
upstream quack_backend {
server 127.0.0.1:9494;
keepalive 64; # 复用连接,避免每查询重建 TCP
}
server {
listen 443 ssl http2;
server_name analytics.internal.example.com;
ssl_certificate /etc/letsencrypt/live/analytics.internal.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/analytics.internal.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
location / {
proxy_pass http://quack_backend;
proxy_http_version 1.1;
proxy_set_header Connection ""; # 保持 keepalive
# 大结果集传输:超时必须放宽,否则 60M 行传一半被掐
proxy_read_timeout 600s;
proxy_send_timeout 600s;
# 关闭缓冲,否则 nginx 会把整个结果落盘再转发
proxy_buffering off;
proxy_request_buffering off;
}
}
三个必改项,漏一个就会在生产上被咬:
proxy_buffering off—— 不关的话 nginx 会尝试把整个响应缓冲到磁盘再转发,几 GB 的结果集会把/var/cache/nginx撑爆,同时首字节延迟暴涨。- 超时放宽 —— 默认 60s 的
proxy_read_timeout对批量传输完全不够。 Connection ""+ upstream keepalive —— Quack 的单往返优化建立在连接复用上,每次重建 TCP + TLS 会把优势吃掉一大半。
自定义授权:用 SQL 宏做查询审计
利用可插拔授权回调,可以在不写一行 C++ 的前提下加一层查询白名单:
-- 服务端:定义一个授权宏
CREATE MACRO my_authz(auth_string, query_text) AS (
-- 只允许只读查询,且禁止访问 sensitive_ 前缀的表
NOT regexp_matches(upper(query_text), '\b(INSERT|UPDATE|DELETE|DROP|ATTACH|COPY)\b')
AND NOT regexp_matches(lower(query_text), 'sensitive_')
);
然后在 quack_serve 的配置里把授权回调指向这个宏(具体参数名请以官方文档为准,Quack 仍处于 beta,函数名和协议可能有破坏性变更)。
这里必须泼一盆冷水:正则匹配 SQL 文本做鉴权是脆弱的,/*注释*/、大小写、Unicode 同形字都能绕。真要做严肃的授权,应该在回调里解析后的查询计划上做判断,或者干脆在上游网关做。上面这个例子演示的是机制,不是推荐的策略。
三、第二刀:VARIANT——半结构化数据不该用文本存
3.1 JSON 类型的原罪
DuckDB 原本处理半结构化数据靠 JSON 类型。它的实现方式是:把数据当文本存。
灵活性拉满,代价有三条:
- 每次查询都要解析。 哪怕你只要一个字段,也得先把整个 JSON 文档扫一遍。
- 取出来永远是 VARCHAR。 想当数字用就得再转一次,转换开销 + 类型不安全。
- 压缩效率差。 文本形态下,列式存储的那套编码优化(RLE、字典、delta)基本白瞎,因为一整个 JSON 串就是一个不透明的字符串。
对可观测性场景来说这就很致命——每个服务打自己关心的 key-value,形状大体相同但不完全一致,数据量还特别大。
3.2 VARIANT 的核心:类型化二进制 + 自动 shredding
DuckDB 1.5.0 引入的 VARIANT 类型(灵感来自 Snowflake),关键差异只有两点,但都在要害上:
第一,存的是带类型的二进制,不是文本。
CREATE TABLE events (id INTEGER, data VARIANT);
INSERT INTO events VALUES
(1, 42::VARIANT),
(2, 'hello world'::VARIANT),
(3, [1, 2, 3]::VARIANT),
(4, {'name': 'Alice', 'age': 30}::VARIANT);
同一列可以塞完全不同类型的值,类型信息是随值走的:
SELECT id, data, variant_typeof(data) AS vtype FROM events;
┌───────┬────────────────────────────┬───────────────────┐
│ id │ data │ vtype │
│ int32 │ variant │ varchar │
├───────┼────────────────────────────┼───────────────────┤
│ 1 │ 42 │ INT32 │
│ 2 │ hello world │ VARCHAR │
│ 3 │ [1, 2, 3] │ ARRAY(3) │
│ 4 │ {'name': Alice, 'age': 30} │ OBJECT(name, age) │
└───────┴────────────────────────────┴───────────────────┘
第二,自动 shredding(切碎)。
这才是性能提升的真正来源。DuckDB 会自动把 VARIANT 里的数据"切碎"到不同的物理列中。注意"自动"这个词——它保留了灵活性,你不需要预先声明 schema。
3.3 10-100 倍提速从哪来:拆解
官方内部基准测得 10 到 100 倍的提速。这个区间跨度很大,说明提速幅度高度依赖查询形态。拆开看有三个来源:
来源一:投影下推能穿透到子字段(这是大头)
JSON 存储下,SELECT data.user_id FROM logs 必须读取整个 data 列的全部字节,然后逐行解析。
VARIANT + shredding 之后,user_id 物理上就是一个独立的列。查询只需要从磁盘读这一列。如果你的日志文档有 50 个字段而你只要 1 个,理论上 I/O 就降到 1/50。
这解释了 100 倍的上界:字段越多、投影越窄,提速越夸张。反过来,SELECT data FROM logs(取整个文档)几乎不会有提速,甚至可能因为要重组文档而略慢。
来源二:类型已经就位,不用运行时转换
shredding 出来的列已经是正确的物理类型(INT32 就是 INT32),不是永远的 VARCHAR。省掉的不只是 CAST 的 CPU,还有向量化执行的关键前提——类型确定的列才能走 SIMD 化的算子。
来源三:压缩编码重新生效
切碎成同类型列之后,列式压缩那一套(字典、RLE、FOR、delta)全都能用上了。这既省磁盘,也省 I/O 带宽——而在 S3 场景下,省 I/O 带宽就是省时间和省钱。
3.4 提取字段与 Parquet 互通
嵌套字段提取支持点号语法,也可以用 variant_extract:
-- 点号提取
SELECT data.name FROM events WHERE id = 4;
-- 显式函数
SELECT variant_extract(data, 'name') FROM events WHERE id = 4;
Parquet 里的 VARIANT 也能直接读,并且支持 shredded 存储。 这一条的意义比看上去大:它意味着 VARIANT 不是 DuckDB 的私有格式黑盒,而是能在湖仓格式里流通的东西。Iceberg v3 规范里的 variant 类型支持,官方也说了会在后续版本跟进。
3.5 什么时候不该用 VARIANT
这是官方博客不会说但你必须知道的部分:
如果你的字段是稳定的,就建真列,别用 VARIANT。
VARIANT 是给"半结构化"数据用的——形状大体相同但不保证一致。如果你明确知道每条记录都有 user_id INTEGER 和 ts TIMESTAMP,那就老老实实声明成列。理由:
- 真列有完整的统计信息(min/max/null count),能做 row group 级别的裁剪;VARIANT shredded 列的统计信息支持程度取决于实现细节
- 真列的约束、索引、外键这些能力是完整的
- schema 本身就是文档,VARIANT 会让下游的人不知道里面到底有什么
合理的分层策略是:高频访问、类型稳定的字段抽成真列;剩下的长尾字段丢进一个 VARIANT 列里兜底。
CREATE TABLE app_events (
ts TIMESTAMP NOT NULL,
service VARCHAR NOT NULL,
level VARCHAR NOT NULL,
trace_id VARCHAR,
-- 稳定字段建真列,上面这些 99% 的查询都会用到
attributes VARIANT
-- 长尾的、每个服务各不相同的字段扔这里
);
四、第三刀:v2.0 异步 I/O——把线程从等待里解放出来
注意版本:异步 I/O 从 v2.0 开始默认启用,v2.0 计划 2026 年秋季发布。现在想试可以用 v2.0.0-dev 预览版构建。目前支持 Parquet 和未压缩、可 seek 的 UTF-8 CSV;DuckDB 原生格式和 JSON 还在路上。
4.1 问题定义:同步读打不满带宽
考虑最简单的远程查询:
FROM read_parquet('s3://bucket/file.parquet');
Parquet 扫描被切分成以 row group 为单位的 job,每个 job 包含一个或多个发起 byte-range 请求的 fetch task。
同步 I/O 下,worker 线程发出请求后就阻塞在那儿等数据到达,什么活都干不了。 解码、聚合、join,全在排队等一个 HTTP 响应。
在本地 SSD 上这不算大问题(延迟低、带宽高)。但在 EC2 + S3 的组合下,这是灾难。
4.2 双线程池模型
DuckDB 的解法是引入两个独立的线程池:
REGULAR—— worker 线程池,默认每个 CPU 线程一个。干真活:解码、join、聚合。优先处理常规任务,空闲时也可以去执行 I/O 任务。ASYNC—— 专门跑异步任务(主要是阻塞式 I/O)的线程池。
关键在于 ASYNC 池的规模:默认 4 × 系统线程数,总数上限 256。
为什么可以开这么多?因为这些线程绝大部分时间是阻塞在等 HTTP 响应上的,CPU 利用率极低。它们消耗的不是 CPU,是"在途请求数"这个资源。 而在高延迟网络下,打满带宽需要的正是足够多的在途请求(这本质上是 Little's Law:并发数 = 吞吐 × 延迟)。
用一个 64 vCPU 的机器算:ASYNC 池会开到 256(4×64 正好撞上限)。256 个并发 HTTP 请求,这才是能把 25 Gbit/s 网络喂饱的量级。
4.3 read-ahead 队列:无生产者线程的设计
光有线程池不够,还得让它们有活干。DuckDB 用的是**预读(read-ahead)**而不是按需读:在 worker 还在解码当前 job 时,ASYNC 线程已经在拉后面几个 job 的数据了。
job 的粒度按格式不同:
- Parquet:一个 job = 一个文件的一个 row group
- CSV:一个 job = 一个 scan boundary(通常是文件里的固定字节范围)
Parquet 的一个 job 会被拆成多少个 fetch task,取决于查询的投影、filter 下推、物理列位置、以及哪些相邻 byte range 可以合并。最后一条是个容易忽略的优化:如果你要的两列在文件里物理相邻,合并成一次请求比发两次划算得多。
CSV 因为没有 Parquet 那么丰富的元信息,job 的 fetch task 就是加载起始 buffer,以及当扫描边界抵达 buffer 末尾时加载下一个 buffer(用来处理跨 buffer 的行)。
队列填充机制是这个设计里最漂亮的部分:不需要专门的生产者线程。
流程是这样的:
- 任何一个来找扫描工作的 REGULAR worker,先负责把队列填满(在允许范围内)
- 允许范围由用户指定的 slot 数或内存预算决定
- 有空间就创建 job 及其 fetch task,fetch task 立刻调度到 ASYNC 池,job 按批次顺序进入 read-ahead 队列
- ASYNC 线程独立执行 fetch task,不管 job 队列的认领顺序;同一个 job 的多个 fetch task 可以并发跑
- 一个 job 的所有 fetch task 共享一个倒计数器,把计数减到零的那个 fetch task 负责完成该 job 的 I/O
- worker 认领队列里最老的 job,检查计数器:I/O 完成就开始解码;没完成就把扫描任务 park 掉,自己去跑别的 pipeline 任务
- 最后一个 fetch task 会 unpark 这个扫描任务,它可以在任意 REGULAR worker 上恢复
- 认领 job 的动作会立刻释放一个队列槽位,让任何 worker 都能在队尾补一个新 job
这套设计里没有中心协调者,没有专用生产者,没有全局锁瓶颈。worker 自己维护自己的供给管道,park/unpark 机制保证没有线程在等待中空转。
4.4 内存治理:预读是用内存换吞吐
预读买到的吞吐是拿内存换的。如果解码慢而网络快,预取的数据会堆积,直接 OOM。
DuckDB 用 read_ahead_depth 配置项来管这件事,三种取值语义完全不同:
| 值 | 语义 |
|---|---|
-1(默认) | 深度无限制,由内存预算兜底 |
N > 0 | 最多预读 N 个 job,不设内存预算 |
0 | 关闭预读,每个扫描任务只为自己的 job 调度 I/O |
SET read_ahead_depth = 5;
注意 N > 0 那一行的坑:显式设了数字,就没有内存预算保护了。 如果你设了 read_ahead_depth = 64 而每个 row group 有 300MB,理论上可能囤 19GB 在内存里。设固定值之前先算一下 depth × 单 job 预期字节数。
默认模式(-1)下,预算是和临时内存管理器协商出来的——就是那个在并发 join、sort、window 算子之间分配内存的同一个管理器。
这个设计有个很实际的行为:内存压力大的时候(比如某个算子吃了大量内存),队列的预留会立刻超预算,实际效果是队列一次只允许一个 job,扫描退化成接近同步的行为。 等那个吃内存的算子跑完,管理器有余量了,队列又填回去。
也就是说,异步 I/O 会在内存紧张时自动降级,而不是 OOM 崩掉。这是个好设计——优雅降级永远比硬失败强。
4.5 压测数据:3 倍到 3.7 倍
测试环境:TPC-H Q6,SF100,数据在 S3,计算用 EC2 r7i.16xlarge(64 vCPU / 512 GB RAM),机器和 bucket 同区域。lineitem 表 600,037,902 行。跑 5 次取平均。
关键前提:SET enable_external_file_cache = false;,每次执行都真的从 S3 读,不吃缓存。
单个大 Parquet 文件
文件约 22 GB,约 4,880 个 row group,每个约 122,880 行:
| 版本 | Q6 运行时间 |
|---|---|
| v1.5.5(同步) | 8.230 s |
| v2.0.0-dev(异步 I/O) | 2.844 s |
接近 3 倍。
更有意思的是网络吞吐曲线的解读:
- v1.5.5 全程稳在 5 Gbit/s 左右——不是网络不行,是同步读发不出足够多的在途请求
- **v2.0.0-dev(默认配置)**明显更有效地用带宽,多次触及网络上限
- **v2.0.0-dev(调优版)**几乎全程打满 25 Gbit/s
调优版的配置是:
SET async_threads = 48;
SET http_retries = 8;
SET http_retry_wait_ms = 50;
SET http_retry_backoff = 2;
-- 外加把 read-ahead 上限设到 64 个在途 job
调优后跑出 2.227 秒,比未调优的 v2.0.0-dev 又快 21.7%,相比 v1.5.5 是 约 3.7 倍。
这里的工程洞察值得单独拎出来:官方的解释是"更少、更热的连接 + 便宜的重试,让吞吐方差降到最小"。
拆开说:
async_threads = 48比默认的 256 少。连接少了,但每条连接更"热"(复用充分、拥塞窗口涨起来了)。并发数不是越大越好——超过带宽延迟积之后,多出来的连接只会加剧拥塞和抖动。http_retries = 8+http_retry_wait_ms = 50+http_retry_backoff = 2:S3 在高并发下会返回 503 慢下来。与其等一个慢请求,不如快速重试。50ms 起步、指数退避、最多 8 次,这套参数的本质是"把长尾延迟砍掉"。
对象存储的长尾延迟是分布式查询的头号敌人。积极重试是对抗长尾最简单有效的手段,前提是请求幂等——而 byte-range GET 天然幂等。
本地磁盘冷读
MacBook Pro(M4 Max,14 核,36 GB),每次跑之前用 macOS purge 清 OS 缓存:
| 版本 | Q6 运行时间 |
|---|---|
| v1.5.5(同步) | 1.321 s |
| v2.0.0-dev(异步 I/O) | 0.883 s |
约 1.5 倍,降低 33%。
差距明显小于远程场景,因为 SSD 的延迟低得多、带宽高得多。热读(数据已缓存)场景下差异可以忽略——没有磁盘访问,异步没有用武之地。
结论很直接:如果你的 DuckDB 只读本地热数据,v2.0 的异步 I/O 对你意义不大。它是为远程存储准备的。
小文件场景:976 个文件
分区数据集很容易把数据摊成一堆小文件。同样的 SF100 数据,这次生成 976 个文件,每个 5 个 row group、约 615,000 行、约 22 MB:
| 版本 | Q6 运行时间 |
|---|---|
| v1.5.5(同步) | 9.344 s |
| v2.0.0-dev(异步 I/O) | 2.945 s |
同样约 3 倍。
这个结果比单文件那个更重要:它证明 read-ahead 能跨多个文件并行,不会被"打开文件"和"拉取 footer"这两个动作卡住。小文件问题在数据湖里是常态(分区一多就散),如果异步 I/O 在这个场景失效,价值就打对折了。
Row group 大小的影响
同一份 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 |
最优点在 ~70 MB / 约 200 万行左右,两头都变慢,但曲线相当平缓(2.11s 到 2.74s,差 30%)。
原因不难推断:
- RG 太小:job 数量爆炸(4,886 个),每个 job 的调度开销、元数据开销占比上升,请求数多但每个请求都小,HTTP 固定开销摊不薄
- RG 太大:并行粒度变粗,尾部效应明显(最后几个大 job 没法再切分给空闲线程),而且单个 fetch 占用的内存变大,挤压预读深度
实践建议:写 Parquet 时把 row group 控制在 64-256 MB 区间。这个建议和 Spark / Trino 生态的经验值也是吻合的。
那两段"几百毫秒"的空档
官方观察到一个细节:所有实验里,第一次网络流量凸起前有几百毫秒,之后到主数据传输开始前又有几百毫秒。
拆解:
- 第一段空档 = 建立 DuckDB 连接 + 首次 TLS 握手 + 打开文件
- 中间的凸起 = 下载文件 footer(Parquet 的元数据在文件尾部)
- 第二段空档 = 处理 footer 信息(解析 schema、row group 元数据、统计信息,然后做 filter 下推的裁剪决策)
官方说这是 v2.0 发布前还要继续优化的地方。
对使用者的启示:这是分析查询的"冷启动税",而且它不随数据量增长。查 22 GB 要交这个税,查 2 GB 也要交。所以:
- 交互式场景下小查询的延迟下限,很大程度上被这个固定开销决定
- 一个有 4,886 个 row group 的文件,footer 本身就不小,解析它要时间——这也是小 row group 的隐性成本之一
- 长连接复用能省掉第一段的 TLS 握手,这也是前面 nginx keepalive 配置重要的另一个理由
五、三刀合一:DuckDB 正在变成什么
把三件事拼起来看:
| 手术 | 打破的假设 | 换来的能力 |
|---|---|---|
| Quack 协议 | 只有一个进程在用 | 多写者、远程访问、浏览器直连 |
| VARIANT 类型 | 数据是结构化的 | 半结构化数据的列式性能 |
| 异步 I/O | 数据在本地 SSD | 远程存储打满带宽 |
官方自己的总结是:DuckDB 正在"进一步走出它最初那个用于交互式分析的进程内数据库的小生态位,成为现代数据架构的核心构件"。
这话不是自吹。看它 2026 年的动作序列:
- DuckLake v1.0(4 月)达到生产可用,一个"建在 SQL 上的湖仓格式"
- Quack(5 月)让 DuckDB 能当服务端
- 1.5.3(5 月)把 Quack 提升为核心扩展,同时增强 Iceberg / AWS / HTTPS
- 异步 I/O(7 月宣布,秋季 v2.0 落地)
- 官方明说:Quack 会集成进 DuckLake,让 DuckDB 自己能当远程可访问的 Catalog server,这会解锁 data inlining 等能力
这条线索非常清晰:DuckDB 在补齐成为湖仓控制面所需的每一块拼图。
对比一下就更明显:Iceberg 的 REST Catalog 需要单独部署一个服务;Delta 依赖 Unity Catalog;而 DuckLake 的路线是——catalog 就是一个 DuckDB 实例,通过 Quack 远程访问。用同一个引擎同时做 catalog 和 compute,架构复杂度直接砍一层。
值得一提的是,这个"一库多态"的思路在国内云厂商那边也有呼应:腾讯云 PostgreSQL 在 2026 年 7 月上线了 DuckDB 引擎,在同一个 PG 实例内让 DuckDB 作为向量化执行引擎承载 OLAP 负载,事务型请求走原生 PG 路径,一条 SET 语句切换。这是 pg_duckdb 那条路线的产品化——从另一个方向印证了 DuckDB 正在被当成"可嵌入的分析内核"来用。
六、生产落地剧本:一个完整的可观测性链路
理论讲完了,来一套能跑的。场景:多台机器的采集进程往中心写指标,dashboard 实时查,冷数据落 S3。
6.1 架构
[采集进程 ×N] --Quack--> [中心 DuckDB 服务端] --定时归档--> [S3 Parquet]
^ |
| |
[Dashboard] -----------------------+ |
[分析查询] ------------------------ 异步 I/O 直查 --------------------+
6.2 服务端启动脚本
#!/usr/bin/env bash
# /opt/quack/start_server.sh
set -euo pipefail
TOKEN_FILE=/etc/quack/token
DB_PATH=/var/lib/quack/metrics.duckdb
# token 从文件读,不要硬编码在命令行(ps 能看到)
TOKEN=$(cat "${TOKEN_FILE}")
exec duckdb "${DB_PATH}" -c "
-- 只绑本地,公网由 nginx 反代
CALL quack_serve('quack:localhost', token = '${TOKEN}');
CREATE TABLE IF NOT EXISTS metrics (
ts TIMESTAMP NOT NULL,
service VARCHAR NOT NULL,
metric VARCHAR NOT NULL,
value DOUBLE NOT NULL,
labels VARIANT -- 长尾标签走 VARIANT
);
-- 内存上限,防止查询把机器打爆
SET memory_limit = '16GB';
SET threads = 8;
"
配套 systemd unit:
# /etc/systemd/system/quack.service
[Unit]
Description=DuckDB Quack Server
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=quack
Group=quack
ExecStart=/opt/quack/start_server.sh
Restart=on-failure
RestartSec=5s
# 收紧权限
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/quack
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target
LimitNOFILE=65536 别漏。异步 I/O + 大量 HTTP 连接很容易吃满默认的 1024 个 fd。
6.3 采集端:Python 批量写入
#!/usr/bin/env python3
"""
指标采集端:批量写入远程 Quack 服务。
关键点:批量提交,不要一行一个事务。
"""
import os
import time
import json
import duckdb
from typing import List, Dict, Any
QUACK_HOST = os.environ.get("QUACK_HOST", "quack:analytics.internal.example.com")
QUACK_TOKEN = os.environ["QUACK_TOKEN"]
BATCH_SIZE = 5_000
FLUSH_INTERVAL_S = 10.0
class MetricSink:
def __init__(self) -> None:
self.con = duckdb.connect() # 本地内存实例,仅作客户端
self.con.execute(
"CREATE SECRET (TYPE quack, TOKEN ?)", [QUACK_TOKEN]
)
self.con.execute(f"ATTACH '{QUACK_HOST}' AS remote")
self._buf: List[Dict[str, Any]] = []
self._last_flush = time.monotonic()
def emit(self, service: str, metric: str, value: float,
labels: Dict[str, Any] | None = None) -> None:
self._buf.append({
"ts": time.time(),
"service": service,
"metric": metric,
"value": value,
"labels": json.dumps(labels or {}),
})
self._maybe_flush()
def _maybe_flush(self) -> None:
now = time.monotonic()
if (len(self._buf) >= BATCH_SIZE
or now - self._last_flush >= FLUSH_INTERVAL_S):
self.flush()
def flush(self) -> None:
if not self._buf:
return
# 关键:先塞进本地临时表,再一次性 INSERT 到远端
# 这样只有 1 次网络往返 + 1 个事务,而不是 N 次
self.con.register("_batch", self._to_arrow())
self.con.execute("""
INSERT INTO remote.metrics
SELECT
to_timestamp(ts) AS ts,
service,
metric,
value,
CAST(json(labels) AS VARIANT) AS labels
FROM _batch
""")
self.con.unregister("_batch")
self._buf.clear()
self._last_flush = time.monotonic()
def _to_arrow(self):
import pyarrow as pa
return pa.Table.from_pylist(self._buf)
def close(self) -> None:
self.flush()
self.con.close()
if __name__ == "__main__":
sink = MetricSink()
try:
while True:
sink.emit("api-gateway", "request_latency_ms", 42.7,
{"route": "/v1/query", "status": 200, "region": "cn-north"})
time.sleep(0.01)
except KeyboardInterrupt:
sink.close()
这段代码里最重要的是 flush() 的实现方式。
回想第二章的压测:单行独立事务的天花板是约 5,500 tx/s,而且 8 线程之后就走平了。如果你老老实实一行一个 INSERT,5,500 就是你的上限。
而批量提交走的是完全不同的代码路径——批量传输那张表告诉我们,6000 万行只要 4.94 秒。换算下来是每秒 1200 万行量级,差了三个数量级。
规律:Quack 的小事务性能已经很好了(比 PG 还快),但再好的小事务也打不过批量。能批就批。
6.4 归档到 S3
-- 每天凌晨执行:把 T-1 数据归档为 Parquet 并清理本地
COPY (
SELECT *
FROM metrics
WHERE ts >= CURRENT_DATE - INTERVAL 1 DAY
AND ts < CURRENT_DATE
)
TO 's3://metrics-lake/dt=' || strftime(CURRENT_DATE - INTERVAL 1 DAY, '%Y-%m-%d') || '/data.parquet'
(
FORMAT parquet,
COMPRESSION zstd,
ROW_GROUP_SIZE 2000000 -- 约 200 万行 ≈ 70MB,对齐前面的最优区间
);
DELETE FROM metrics WHERE ts < CURRENT_DATE - INTERVAL 1 DAY;
CHECKPOINT; -- 1.5.0 起 checkpoint 非阻塞,不会卡住并发读写
ROW_GROUP_SIZE 2000000 这个值不是拍脑袋来的,直接对应 4.5 节压测里那个 2.11 秒的最优点。
CHECKPOINT 这行也值得说:DuckDB 1.5.0 实现了非阻塞检查点,checkpoint 期间可以并发读、写、带索引插入和删除,TPC-H SF100 吞吐提升 17%。在归档脚本里显式 checkpoint 不再有"卡住整个库"的风险。
6.5 查询归档数据(v2.0 异步 I/O)
-- 针对 S3 的查询调优
SET async_threads = 48;
SET http_retries = 8;
SET http_retry_wait_ms = 50;
SET http_retry_backoff = 2;
SET read_ahead_depth = 64;
-- 跨天查询,一次扫多个分区文件
SELECT
date_trunc('hour', ts) AS hour,
service,
approx_quantile(value, 0.99) AS p99,
count(*) AS samples
FROM read_parquet('s3://metrics-lake/dt=2026-08-*/data.parquet',
hive_partitioning = true)
WHERE metric = 'request_latency_ms'
AND labels.region = 'cn-north' -- VARIANT 字段直接过滤
GROUP BY 1, 2
ORDER BY 1 DESC, 3 DESC;
labels.region = 'cn-north' 这行是 VARIANT 价值的直观体现:如果 labels 是 JSON 文本列,这个过滤要把每一行的整个 JSON 串读出来解析一遍;VARIANT + shredding 之后,region 是独立物理列,只读它。
七、性能优化清单
Quack 侧
- 能批就批。 单行事务上限约 5,500 tx/s,批量传输是每秒千万行量级。
- 聚合查询一律用
remote.query()显式下推,别依赖ATTACH的自动下推推断。 - nginx 必须
proxy_buffering off+ 放宽超时 + upstream keepalive。 - 别把 Quack 直接暴露公网。 默认 localhost 绑定是特性不是缺陷。
- 写并发别超过 8。 超过之后是 DuckDB 存储层的限制,加线程无用。
VARIANT 侧
- 稳定字段建真列,长尾字段进 VARIANT。 不要把所有东西都塞进一个 VARIANT。
- 投影越窄,VARIANT 收益越大。
SELECT data取整个文档基本没有收益。 - Parquet 里的 VARIANT 支持 shredded 存储,跨系统流通没问题。
异步 I/O 侧
async_threads不是越大越好。 官方调优用的是 48(远小于 64 vCPU 机器的默认 256),更少更热的连接方差更小。- 积极重试对抗长尾:
http_retries = 8/http_retry_wait_ms = 50/http_retry_backoff = 2。byte-range GET 天然幂等,重试很便宜。 - Row group 控制在 64-256 MB(约 200 万行是实测最优点附近)。
read_ahead_depth设固定值就失去内存保护,设之前先算depth × 单 job 字节数。- 压测记得
SET enable_external_file_cache = false,否则第二次跑测的是缓存不是 I/O。 - 本地热数据场景不用指望异步 I/O。 它是为远程存储设计的。
八、踩坑清单(14 条)
- Quack 仍是 beta。 官方明说协议、函数名等可能有破坏性变更。别把它放进不能停机的关键链路,或者做好版本锁定。
- 默认 token 是随机生成的,服务重启会变。生产环境务必显式指定,并从文件读(命令行传 token 会被
ps看到)。 - 客户端对非本地连接默认假设 SSL 已启用。 如果你的反代没配 TLS 但走的不是 localhost,连接会失败。可以覆盖,但先想清楚为什么要覆盖。
- nginx 不关 buffering,大结果集会把磁盘写爆。 这是最常见的一个坑。
- 正则匹配 SQL 文本做授权是脆弱的。 注释、大小写、Unicode 同形字都能绕。演示可以,生产别这么干。
variant_typeof返回的是运行时类型,不是列的声明类型。 同一列不同行可能返回不同值,写下游逻辑时要考虑。- VARIANT 会让 schema 变成隐式知识。 三个月后接手的人不知道里面有什么字段。补文档,或者建个
information_schema风格的元数据表。 - Lambda 箭头语法
x -> x + 1从 v1.5 起会告警,DuckDB 2.0 默认禁用。存量代码尽早改成lambda x: x + 1,可以用lambda_syntax配置项过渡。 - GEOMETRY 轴序在变。 v1.5 默认仍是旧行为(纬度/经度)但会告警,v2.0 旧行为报错,v2.1 起新行为(经度/纬度)成为默认。有空间数据的现在就该开
geometry_always_xy测一遍。 - 异步 I/O 目前只覆盖 Parquet 和未压缩、可 seek 的 UTF-8 CSV。 gzip 压缩的 CSV 享受不到——因为压缩流不能随机 seek,切不出独立 job。
read_ahead_depth = N(N>0)没有内存预算保护。 大 row group + 大 depth = OOM。- 内存压力下异步 I/O 会自动降级成近似同步。 如果你发现远程查询突然变慢,先看是不是有个吃内存的 join/sort 在跑,而不是去调 I/O 参数。
- 冷启动的几百毫秒固定开销不随数据量下降。 交互式小查询的延迟下限由它决定,别指望靠调 I/O 参数把 200ms 的查询变成 20ms。
- httpfs 后端在 1.5.0 从 httplib 换成了 libcurl。 配置项(超时、重试)保持不变,但如果你之前依赖过 httplib 的某些边缘行为,升级后要回归测一遍。
九、版本与升级路线
理清一下版本关系,别搞混:
| 版本 | 状态 | 关键内容 |
|---|---|---|
| v1.4.x | LTS,维护至 2026 年 9 月 | 稳态选择 |
| v1.5.0(Variegata) | 已发布 | VARIANT、GEOMETRY 内核化、非阻塞 checkpoint(+17%)、CLI 重写、PEG 解析器(实验)、read_duckdb |
| v1.5.3 | 已发布 | Quack 提升为核心扩展,Iceberg / AWS / HTTPS 增强 |
| v1.5.5 | 当前稳定版 | — |
| v2.0 | 2026 秋季 | 异步 I/O 默认启用,箭头 lambda 默认禁用,GEOMETRY 旧轴序报错 |
自 v1.4 以来近 100 位贡献者提交了超过 6,500 个 commit。
升级建议:
- 求稳:待在 v1.4 LTS,但注意 9 月就到期了,得规划迁移
- 要 VARIANT 和非阻塞 checkpoint:上 v1.5.5
- 要 Quack:v1.5.3+(核心扩展,自动安装)
- 要异步 I/O:等 v2.0,或者现在用 v2.0.0-dev 预览版做 POC——别上生产
升级到 v2.0 前的检查清单:
-- 1. 检查是否还在用箭头 lambda 语法
-- 搜索代码库里的 ' -> ' 模式,改成 lambda x: ...
-- 2. 空间数据:提前验证轴序变更
SET geometry_always_xy = true;
-- 然后跑一遍空间查询回归测试
-- 3. 确认存储版本兼容性
SELECT database_name, tags FROM duckdb_databases();
-- 4. 异步 I/O 上线前,先在预览版上压测你的真实查询
SET enable_external_file_cache = false;
-- 对比 v1.5.5 与 v2.0.0-dev 的实际差距,别直接信 3x
十、总结:三刀之后的 DuckDB
回到开头那个判断。DuckDB 这三次自我否定,本质上是承认了一件事:
"分析界的 SQLite"这个定位,容不下它现在想去的地方。
- SQLite 不需要网络协议,因为它的数据永远在本地。DuckDB 做了 Quack,因为它的数据现在在 S3,用它的进程现在有一堆。
- SQLite 不需要 VARIANT,因为它的 schema 是应用定的。DuckDB 做了 VARIANT,因为它现在要吞可观测性数据和 API 输出这些没人管形状的东西。
- SQLite 不需要异步 I/O,因为读本地文件同步就够快。DuckDB 做了异步 I/O,因为跨越 25 Gbit/s 的网络,同步读只能跑出 5 Gbit/s。
三刀指向同一个终点:从"跑在你机器上的分析工具",变成"跑在数据湖里的查询引擎"。
这条路上它不是没有对手。Trino、Spark、ClickHouse 都在这个位置。但 DuckDB 有一个别人很难复制的优势:它可以同时是嵌入式的和分布式的。同一个二进制,在你笔记本上是 pip install duckdb,在 EC2 上是 Quack server,在浏览器里是 Wasm 客户端——而且三者说同一种协议。
这种"同一个引擎覆盖全部形态"的能力,在架构上意味着你可以在本地开发时用完全一样的语义调试生产查询。这个价值,用过 Spark 本地模式和集群模式行为不一致的人应该都懂。
至于风险,也很清楚:
- Quack 还是 beta,协议会变
- 小事务的扩展性天花板在 8 线程,存储层还没跟上协议层
- 异步 I/O 要等 v2.0,秋季才落地
- 摊子铺大了,从查询引擎到 catalog server 到湖仓格式,DuckLabs 团队的工程带宽是真实约束
但方向是对的。当一个项目愿意公开推翻自己讲了七年的架构主张,并且在发布文里主动写出"这里 PostgreSQL 比我们扩展性好,是我们近期要看的问题"——这种坦诚本身,比任何 benchmark 都更能说明团队的成色。
给不同角色的行动建议:
- 数据工程师:现在就可以把 VARIANT 用起来,长尾字段的存储和查询成本能立竿见影地降。归档 Parquet 时把 row group 调到 64-256MB,这是免费的性能。
- 平台工程师:Quack 值得做 POC,特别是你现在正在用自研 RPC 或 Arrow Flight 套壳 DuckDB 的话。但等 v2.0 稳定再上生产。
- 架构师:DuckLake + Quack 作为 catalog 的组合,是 Iceberg REST Catalog 之外一条值得评估的路径,架构层数更少。
- 所有人:v1.4 LTS 9 月到期,现在就该规划升级路径,尤其注意箭头 lambda 和 GEOMETRY 轴序这两个破坏性变更。
秋天 v2.0 发布的时候,值得再回来看一眼——异步 I/O 的那个"几百毫秒冷启动",他们说要继续优化。能压到多少,是观察这个团队工程能力的一个好指标。
参考来源:DuckDB 官方博客(Quack 协议发布、异步 I/O 深度解析、1.5.x 系列版本公告)、DuckDB 官方文档、DuckLake 项目站点。文中所有压测数据均来自 DuckDB 官方公布的基准测试结果,测试环境已在对应章节标注。