编程 DuckDB 的自我背叛:Quack 协议 + 异步 I/O 如何把「进程内数据库」推上服务器——从协议设计到 v2.0 读前队列全链路拆解

2026-08-17 15:51:07 +0800 CST views 11

DuckDB 的自我背叛:Quack 协议 + 异步 I/O 如何把「进程内数据库」推上服务器——从协议设计到 v2.0 读前队列全链路拆解

一、背景:一个坚持了七年的架构信仰,正在被自己推翻

2019 年 DuckDB 第一次发布的时候,它的卖点里有一条是带着攻击性的:没有 client,没有 server,没有协议,只有函数调用

那几年 DuckDB 团队到处做演讲,反复讲同一件事——传统数据库的 client-server 架构在分析场景下是纯粹的负担。你在 Jupyter Notebook 里跑一个聚合,数据就在同一个进程的内存里,为什么要序列化成协议报文、走一遍 TCP、再反序列化回来?他们甚至专门写了一篇研究论文来量化数据库协议的开销("Don't Hold My Data Hostage")。

这个判断在当时完全正确,而且这个正确一直延续到今天。DuckDB 现在 GitHub 上 4 万星(2026 年 8 月 5 日官方发文致谢),稳定版是 1.5.5(2026 年 7 月 22 日发布),另有 1.4.5 LTS(代号 Andium)和 1.5.4(代号 Variegata)在 6 月 17 日同日放出。它是这个时代事实上的「分析领域 SQLite」。

但是 2026 年,DuckDB 团队干了两件跟七年前的自己拧着来的事:

  • 2026 年 5 月 12 日,他们发布了 Quack——DuckDB 的客户端-服务器协议。原文用词很有意思:"So we bit the bullet, eventually, finally"(我们最终、终于还是咬牙认了)。
  • 2026 年 7 月 31 日,核心开发者 Pedro Holanda 发文公布 异步 I/O 实现,将在 v2.0(2026 年秋季)默认开启

网上大部分中文报道把这两件事当成两个独立的新特性来写:一个是「DuckDB 终于支持多进程写入了」,一个是「DuckDB 读 S3 变快了」。

我认为这个理解是错的,而且错得很关键。

这两件事是同一次架构转向的两面。看异步 I/O 那篇文章的原文,Pedro 自己把因果链写得很清楚:

"我们意识到 DuckDB 的架构非常适合查询远程存储的大规模数据集,比如数据湖。从今年 5 月开始,我们甚至可以用 Quack 协议把 DuckDB 跑成服务器。因此,『数据文件就躺在本地 SSD 上』这个最初的假设,不再总是成立了。"

翻译成工程语言:Quack 让 DuckDB 离开了它出生的那台笔记本,而一旦离开笔记本,同步 I/O 就立刻变成了架构级的瓶颈。 前者是因,后者是果。你不能只装其中一个然后指望拿到收益。

这篇文章要做的事:

  1. 把 Quack 的协议设计逐层拆开,包括它为什么拒绝了 Arrow Flight SQL;
  2. 把异步 I/O 的双线程池 + 读前队列(read-ahead queue)机制讲到能自己推演调参的程度;
  3. 用官方基准数据反推出一批官方没明说的运维结论——尤其是 Parquet row group 大小的那条 U 型曲线,这是全文我认为最值钱的部分;
  4. 给出可直接落地的生产部署代码、调参决策树,以及什么时候不该用

二、核心概念:为什么 in-process 曾经是对的,又为什么不够了

2.1 单进程写入的技术性根因

先说清楚一件很多人搞混的事:DuckDB 过去不支持多进程写同一个库,不是因为懒,是因为架构上做不到

原文的解释是:DuckDB 在主内存里保存了大量状态(catalog、buffer pool、事务可见性信息、统计信息……),如果多个进程同时开始改动,就必须跨进程同步这些内存状态。

这是个很硬的约束。SQLite 能做多进程是因为它的状态几乎全在文件里,用文件锁就能协调。DuckDB 为了向量化执行和列式存储的性能,把大量元信息和缓存放在了进程内存里——这是性能的来源,同时也是多进程能力的死穴。这是一个典型的架构权衡,不是一个 bug。

2.2 workaround 的数量,就是需求的证明

在 Quack 之前,社区为了绕过这个限制造了一堆东西:

方案做法问题
自建 RPC一个进程持有 DuckDB 实例,对外提供服务每家自己造,没有标准,功能残缺
Arrow Flight SQLGizmoSQL 等服务器,内部还是用 DuckDB引入 Arrow 交换格式的转换开销
MotherDuck 私有协议商业方案自己的一套闭源,绑定
pg_duckdb在 PostgreSQL 里嵌 DuckDB(官方戏称 "EleDucken")两套引擎的阻抗失配
DuckLake用湖仓格式让多写入方通过对象存储协调元数据延迟高,小事务成本大

DuckDB 团队的表态相当务实,我很欣赏这个态度:

"人们为了给 DuckDB 硬装上 client-server 能力而造出的大量 workaround,至少说服了我们:这是大家真正在意的东西。……说到底我们非常在意用户体验,而对于『在架构问题上说了最后一句话』这件事,可能没那么在意。"

这是一句值得每个做基础设施的人抄在墙上的话。 架构纯洁性不是产品目标,能不能解决问题才是。


三、Quack 协议设计拆解

3.1 最小可运行示例

Quack 最反直觉的一点:DuckDB 同时充当 client 和 server,两边跑的是同一个二进制。

服务端(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 机制存放 token,避免明文散落在 SQL 里
CREATE SECRET (
    TYPE quack,
    TOKEN 'super_secret'
);

-- 把远程实例当成一个 catalog 挂载进来
ATTACH 'quack:localhost' AS remote;

-- 像查本地表一样查
FROM remote.hello;

注意这里的设计品味:Quack 复用了 DuckDB 已有的 ATTACH + SECRET 抽象,没有引入新的连接概念。远程实例在客户端眼里就是一个多出来的 catalog,remote.hellolocal_table 在语法层面完全对等。这意味着你已有的 SQL 大部分不用改。

反向写入也成立——客户端可以直接在远程建表:

-- 在 DuckDB #2 上执行,表建在 DuckDB #1 里
CREATE TABLE remote.hello2 AS
    FROM VALUES ('world2') v(s);

3.2 remote.query():控制下推边界的逃生舱

ATTACH 的透明性有代价:优化器要自己决定哪部分下推到远端、哪部分拉回本地算。对复杂查询,它可能猜错,把一亿行拉回本地再聚合。

Quack 给了一个显式的逃生舱:

-- 整条查询原样发到远端执行,只把结果拉回来
FROM remote.query(
    'SELECT s FROM hello'
);

我的实践建议:凡是「远端数据量 >> 结果集大小」的查询,一律用 remote.query() 显式下推,别赌优化器。 一个 GROUP BY 把 6 亿行压成 200 行,走 query() 传 200 行;走 ATTACH 万一没下推成功,你就要在网络上搬 6 亿行。这个差距是三个数量级,不是百分比。

判断标准我一般这样写:

-- 先在远端量一下压缩比,再决定策略
FROM remote.query('
    SELECT
        count(*)                       AS input_rows,
        count(DISTINCT l_orderkey)     AS output_rows,
        count(*) / count(DISTINCT l_orderkey) AS compression_ratio
    FROM lineitem
');
-- compression_ratio > 100 → 果断 remote.query() 下推
-- compression_ratio ≈ 1   → ATTACH 透明访问也无所谓

3.3 为什么建在 HTTP 上

Quack 直接建在 HTTP 之上。原文的理由值得完整引用,因为它反驳了「自造二进制协议才够快」的工程直觉:

"如果实现得好,这个协议的开销出乎意料地低。所有人和他们的弟弟都知道怎么在负载均衡、认证、防火墙、入侵检测里处理 HTTP。 在 2026 年不把数据库协议建在 HTTP 之上,才是相当离谱的。"

我完全同意,而且要补充一个大部分人没注意到的收益:因为是 HTTP,DuckDB-Wasm 发行版可以原生说 Quack。

这条的含义比听起来大得多。浏览器里跑的 DuckDB-Wasm,可以直接连接跑在 EC2 上的 DuckDB 实例。不需要中间那层 Node.js/Python API 服务器。你的前端分析应用可以:

  • 小数据集:在浏览器本地 Wasm 引擎里算,零延迟;
  • 大数据集:同一套 SQL,ATTACH 到远端服务器算。

同一份 SQL,同一个引擎,两种执行位置。 这在过去需要写两套代码(前端 JS 聚合 + 后端 SQL),现在是一个 ATTACH 的区别。如果你做过 BI 前端,应该能立刻明白这省掉了多少胶水层。

3.4 序列化:application/duckdb

请求和响应用一个新的 MIME 类型 application/duckdb 编码。

关键设计:它复用了 DuckDB 内部的序列化原语——同一套原语已经在 WAL(预写日志)文件里用了好几年。

这是我特别欣赏的一个决策,理由是双重的:

  1. 零转换开销。数据从 DuckDB 内部的列式向量格式,直接序列化成协议报文,中间不经过任何第三方格式(这也是拒绝 Arrow 的核心原因,下面细说)。
  2. 代码路径已经被战斗验证过。WAL 是数据库里最不能出错的部分之一,能跑 WAL 的序列化代码,正确性和性能都已经被无数生产环境压过了。复用一条已经承担了「不能丢数据」责任的代码路径,比新写一条要可靠得多。

3.5 Round-trip 优化:单次往返完成一个查询

这条是 Quack 在小事务场景能打赢 PostgreSQL 的技术根因:

"一旦连接建立,一个查询可以在单次往返中完整处理完。这对延迟敏感的环境是关键优化。"

同时对批量传输也做了优化:大结果集的后续 fetch 消息可以从多个线程并行拉取

这两条组合起来很妙——协议同时优化了延迟(单 round-trip)和吞吐(并行 fetch),而传统协议往往只能顾一头。PostgreSQL 的协议是为行式小事务设计的,批量传输就很惨;Arrow Flight 是为批量设计的,小事务就很惨。下面的基准数据会精确地展示这一点。

3.6 安全模型:默认不信任互联网

这部分设计得很克制,我认为是对的:

  • 服务端启动时默认生成随机 token,必须交给客户端;
  • 服务端默认只绑定 localhost(可覆盖);
  • 默认不用 SSL——原文的理由是「只为了 localhost 通信就拉进整套 SSL 基础设施和依赖,有点傻」;
  • 客户端对非本地连接默认假定启用 SSL(这个默认值方向是对的:本地宽松,远程严格);
  • 官方明确表态:不建议把 Quack 端点直接暴露到公网,应该用 nginx 之类反向代理终结 SSL;
  • 默认端口 9494——94 取自 Netscape Navigator 发布的 1994 年,这个梗有点年代感。

生产环境的 nginx 反代配置,我按官方建议的方向写一份可用的:

# /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;

    # 大结果集传输可能持续很久,超时必须放宽
    proxy_read_timeout    3600s;
    proxy_send_timeout    3600s;
    client_max_body_size  0;          # 不限制上传体积(批量 INSERT 会用到)

    location / {
        proxy_pass         http://quack_backend;
        proxy_http_version 1.1;
        proxy_set_header   Connection "";     # 配合 upstream keepalive
        proxy_set_header   Host $host;
        proxy_set_header   X-Real-IP $remote_addr;

        # 关键:关掉缓冲,否则 nginx 会把整个结果集缓存到磁盘再转发
        proxy_buffering    off;
        proxy_request_buffering off;
    }
}

proxy_buffering off 这一行是重点,容易踩坑。 默认开启缓冲时,nginx 会试图把上游响应完整收下来再发给客户端。传 60M 行的时候,这等于让 nginx 先在磁盘上落一份几十 GB 的临时文件——延迟和磁盘都会炸。同理 keepalive 不配的话,你辛苦优化的单 round-trip 会被 TCP 握手 + TLS 握手吃掉。

3.7 可插拔的认证与授权

这是我认为 Quack 设计里最聪明的一处妥协。原文的逻辑:

"数据库查询的认证和授权是无穷无尽的欢乐与复杂性的源头。我们大概没法覆盖所有人的用例,第一个版本肯定不行。因此聪明的做法是别去尝试。"

于是:Quack 自带默认认证方法和「无限制」的默认授权,但两者都可以被用户提供的代码覆盖。客户端连接时提交认证字符串,服务端调用一个认证回调;默认回调就是比对随机 token,但这个回调可以通过配置替换——可以去查 LDAP、读文本文件,甚至可以「扔骰子」(原文玩笑)。

最有意思的是:这些回调甚至可以是纯 SQL 宏。

这意味着你可以用 SQL 表达访问控制策略。示意写法:

-- 用一张表管理 token 与角色的映射
CREATE TABLE auth_tokens (
    token       VARCHAR PRIMARY KEY,
    principal   VARCHAR,
    role        VARCHAR,
    expires_at  TIMESTAMP
);

INSERT INTO auth_tokens VALUES
    ('tok_dashboard_ro', 'dashboard',  'readonly',  '2026-12-31 23:59:59'),
    ('tok_collector_wo', 'collector',  'appender',  '2026-12-31 23:59:59');

-- 认证宏:token 有效且未过期
CREATE MACRO quack_authenticate(token) AS (
    SELECT count(*) > 0
    FROM auth_tokens
    WHERE auth_tokens.token = token
      AND expires_at > now()
);

-- 授权宏:只读角色不许出现写操作关键字
CREATE MACRO quack_authorize(token, query) AS (
    SELECT CASE
        WHEN (SELECT role FROM auth_tokens WHERE auth_tokens.token = token) = 'readonly'
            THEN NOT regexp_matches(
                     upper(query),
                     '\b(INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|COPY|ATTACH)\b'
                 )
        ELSE true
    END
);

提醒:回调的具体注册配置项请对照官方 Quack 文档,各版本可能微调。另外,正则黑名单式的授权只适合内网低风险场景——它对注释注入、大小写变体、字符串拼接这类绕过手法并不健壮。真要做严肃的权限控制,应该走白名单(只允许特定视图/表)而不是黑名单。我把它写在这里是为了展示「授权逻辑可以用 SQL 表达」这个能力的形状,不是推荐你照抄上生产。


四、Quack 的性能数据,以及数据里藏着的那句实话

4.1 测试环境

  • AWS m8g.2xlarge(Arm,Ubuntu),8 vCPU / 32 GB RAM,网络「up to 15 Gbps」
  • 客户端与服务端在不同机器、同一可用区,实例间 ping 平均约 0.280 ms
  • 对比对象:PostgreSQL 协议、Arrow Flight SQL(由内部同样使用 DuckDB 的 GizmoSQL 服务器提供)

这个测试设计我认为是公平的:同可用区不同机器,正好是生产上「应用服务器连数据库」的典型形态,既不是自欺欺人的 localhost,也不是跨区放大网络劣势。

4.2 批量传输:传 TPC-H lineitem

传输递增行数的 lineitem 表,最多 6000 万行(CSV 格式下 76 GB),报告 5 次运行的中位墙钟时间:

行数DuckDB QuackArrow FlightPostgreSQL
100k0.07 s0.07 s0.20 s
1M0.24 s0.38 s2.20 s
10M0.89 s2.90 s25.64 s
60M4.94 s17.40 s158.37 s

6000 万行、不到 5 秒。 官方的说法是「据我们所知,Quack 目前是把表塞进 socket 的最快方式」。

我把关键比值算出来,比原始数字更能说明问题:

行数Quack vs ArrowQuack vs PostgreSQL
100k1.0×2.9×
1M1.6×9.2×
10M3.3×28.8×
60M3.5×32.1×

注意这个倍数随数据量单调增长的形态——它说明差距不是常数开销(那样倍数会随量增大而收敛到 1),而是每行边际成本的差异。PostgreSQL 的行式协议每一行都要付一次编码开销,所以是线性劣势;Arrow Flight 虽然也是批量列式,但要在 DuckDB 内部格式和 Arrow 格式之间做一次转换,这个转换成本同样是按量计的。

官方还很诚实地补了一句:标准 PostgreSQL 客户端不做多线程并行读,而 Quack 和 Arrow 可以。所以 32 倍这个数字里,有一部分来自并行度而非协议效率本身。看基准要看清这类脚注,这也是判断一份基准是否可信的标志——愿意主动写下对自己不利的前提的基准,通常更可信。

4.3 小写入:这里才是选型的关键

第二个基准测小批量追加,模拟「把可观测性数据汇聚到一个中心 DuckDB 实例」。方法:建一张和 lineitem 同构的空表,每行一个独立 INSERT 事务,用递增的并行线程数跑 5 秒,取 5 次实验的中位 TPS。

线程数DuckDB QuackArrow FlightPostgreSQL
11,038 tx/s469 tx/s839 tx/s
21,956 tx/s799 tx/s1,094 tx/s
43,504 tx/s1,224 tx/s2,180 tx/s
85,434 tx/s1,358 tx/s4,320 tx/s

Quack 在 8 线程内全面压过 PostgreSQL,峰值约 5,500 tx/s。这是反直觉的——一个 OLAP 引擎在小事务上打赢了以 OLTP 为业的 PostgreSQL。功劳归于单 round-trip 的协议设计。

但接下来是全文我认为最重要的一句原文:

"超过这个数之后,我们撞上了 DuckDB 自身的一个当前限制:对同一张表的并发插入速率。 PostgreSQL 在这里的扩展性更好,这是我们近期要研究的事情。"

这句话决定了 Quack 的选型边界,比前面所有漂亮数字加起来都重要。

让我把它的工程含义摊开:

  1. 瓶颈不在协议,在引擎。 Quack 协议本身还能往上走,卡住的是 DuckDB 存储层对同表并发插入的处理。换句话说,这不是调参能解决的,得等上游改。
  2. 约 5,500 tx/s 是一堵墙,不是一个起点。 如果你的写入负载现在是 3,000 tx/s 并且在涨,Quack 给你的余量不到一倍。
  3. 正确的用法是「先聚合再写入」。 别让 200 个采集进程每个都开一条 Quack 连接单行 INSERT,而应该在客户端侧攒批。

第 3 点值得给出具体代码,因为这是把这个限制变成非问题的关键:

import duckdb, threading, queue, time
from typing import List, Tuple

class QuackBatchWriter:
    """
    客户端侧攒批写入器。

    为什么必须攒批:DuckDB 对同一张表的并发插入速率上限约 5,500 tx/s
    (官方 Quack 基准,8 线程后不再扩展)。单行 INSERT 会直接把这个
    上限当成吞吐上限。攒批后,一个事务写 5,000 行,5,500 tx/s 的预算
    就变成了千万行级的行吞吐。
    """

    def __init__(self, dsn: str, token: str, table: str,
                 batch_size: int = 5_000, flush_interval: float = 2.0):
        self.table = table
        self.batch_size = batch_size
        self.flush_interval = flush_interval
        self._q: "queue.Queue[Tuple]" = queue.Queue(maxsize=batch_size * 10)
        self._stop = threading.Event()

        self.con = duckdb.connect()
        self.con.execute("INSTALL quack; LOAD quack;")
        self.con.execute(f"CREATE SECRET (TYPE quack, TOKEN '{token}')")
        self.con.execute(f"ATTACH '{dsn}' AS remote")

        self._worker = threading.Thread(target=self._loop, daemon=True)
        self._worker.start()

    def write(self, row: Tuple) -> None:
        self._q.put(row)

    def _drain(self) -> List[Tuple]:
        rows = []
        deadline = time.monotonic() + self.flush_interval
        while len(rows) < self.batch_size:
            timeout = deadline - time.monotonic()
            if timeout <= 0:
                break
            try:
                rows.append(self._q.get(timeout=timeout))
            except queue.Empty:
                break
        return rows

    def _loop(self) -> None:
        while not self._stop.is_set() or not self._q.empty():
            rows = self._drain()
            if not rows:
                continue
            # 单个事务写入整批:这是把 tx/s 上限转换成 row/s 吞吐的核心
            self.con.executemany(
                f"INSERT INTO remote.{self.table} VALUES (?, ?, ?, ?)",
                rows,
            )

    def close(self) -> None:
        self._stop.set()
        self._worker.join(timeout=30)
        self.con.close()


# 用法
w = QuackBatchWriter(
    dsn="quack:analytics.internal.example.com",
    token="tok_collector_wo",
    table="events",
    batch_size=5_000,
    flush_interval=2.0,
)
for i in range(1_000_000):
    w.write((time.time(), "svc-api", "request", i))
w.close()

这个模式的收益可以直接估算:batch_size=5,000 时,5,500 tx/s 的事务预算对应约 2,750 万行/秒的理论行吞吐。你把一个「事务速率受限」的系统,变成了一个「行吞吐充裕」的系统。代价是最多 flush_interval 的可见性延迟——对可观测性和仪表盘场景,2 秒延迟通常完全可以接受。

4.4 为什么不用 Arrow Flight SQL

原文专门写了一节回答这个「房间里的大象」。核心论点:

Arrow 和 ADBC 的价值在于它们是交换 API——像之前的 ODBC/JDBC 一样,用来降低系统之间交换数据的摩擦,这件事它们做得不错。

但把交换格式用在 DuckDB 内部,是另一回事。

我把这个论点补完整:DuckDB 内部有自己的向量表示(包括字典编码、常量向量、选择向量等针对执行优化的技巧),这些结构是为了让算子跑得快,不是为了跨系统传输。如果协议层用 Arrow,每次传输都要做一次 DuckDB 内部格式 ↔ Arrow 的双向转换。

这个成本在 4.2 的基准里是可测的:60M 行时 Quack 4.94s vs Arrow Flight 17.40s,3.5 倍差距。 而且注意,GizmoSQL 内部用的也是 DuckDB——也就是说这两边的查询执行引擎是同一个,17.40s 和 4.94s 的差距,几乎纯粹来自协议层和格式转换。

这是一个非常干净的对照实验,值得记住:同引擎、异协议,3.5 倍。 它量化回答了一个长期争议——「用通用交换格式当内部协议要付多少钱」。答案是:在批量场景付 3.5 倍,在小事务场景付更多(Arrow Flight 1,358 tx/s vs Quack 5,434 tx/s,约 4 倍)。


五、异步 I/O:v2.0 的另一半

5.1 问题定义:为什么以前不需要,现在需要

Pedro 的开篇论证很清晰:

"不管查询算子有多快,如果我们没法快速把数据拉进来,都没有意义。"

但 DuckDB 历史上基本规避了这个问题,靠的是尽早裁剪数据——把 filter 和 projection 下推,确保只读真正需要的部分。

这套做法在本地 SSD 上特别有效:把数据切成分片(Parquet 的 row group、CSV 的固定大小缓冲),用低延迟高带宽加载。于是瓶颈都在别处——子查询、join、聚合。数据访问路径反而是最不受关注的部分,因为同步访问完全够用。

然后事情变了。数据湖(DuckLake)、Quack 服务器模式,让「文件在本地 SSD」不再成立。典型形态变成:数据在 S3,计算在同区的 EC2。这时延迟和带宽成为主角。如果发不出足够多的并发请求来用满可用带宽,性能会急剧下降,线程把大部分时间花在等远程读,而不是处理数据。

同步读的执行形态是这样的(单线程简化):

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

Parquet 扫描被切成基于 row group 的 job,每个 job 包含一个或多个发起 byte-range 请求的 fetch task。同步 I/O 下,worker 线程会阻塞等数据到达,然后才做解码、聚合等真正的工作。

5.2 双线程池:REGULAR 与 ASYNC

DuckDB 的解法是两个独立线程池:

  • REGULAR — worker 线程池,默认每个可用 CPU 线程一个。做真正的活:解码、join、聚合。优先做常规工作,空闲时也可以去做 I/O 任务。
  • ASYNC — 专门跑异步任务(主要是阻塞式 I/O)的线程池。

为什么必须分池?原文说得很清楚:对远程 I/O,这些线程几乎全部时间都阻塞在等 HTTP 响应上,CPU 利用率极低。

所以关键参数是:ASYNC 线程数远多于系统线程数,默认 4 × 系统线程数,总量上限 256。

我想强调这个设计的取舍,因为它和「线程数不要超过核数」的常规直觉是冲突的:

当线程的工作是「等待」而不是「计算」时,线程数就不该按核数配,而该按「需要多少并发在途请求才能填满带宽」配。 这是 Little's Law 的直接应用——在途请求数 = 目标吞吐 × 平均延迟。S3 的单请求延迟在几十毫秒量级,要填满 25 Gbit/s,你需要的在途请求数远超 64。所以 是个合理的起点,256 的上限是防止用户在超大机器上把自己搞爆。

5.3 Job 与 Fetch Task 的粒度

理解调参前必须先理解这两级粒度:

Job 是可独立调度处理的工作单元,其定义取决于文件格式

格式一个 job 是什么
Parquet一个文件的一个 row group
CSV一个 scan boundary,通常覆盖文件内一段固定字节区间

Fetch task 是 job 之下的实际 byte-range 请求:

  • Parquet 的一个 job 可能拆成多个 fetch task,具体数量取决于:查询的 projection、filter 下推、列的物理位置、以及哪些相邻 byte range 可以合并
  • CSV 的 fetch task 加载 job 的起始 buffer(若不在内存中),并在扫描边界到达该 buffer 末尾时加载下一个 buffer(用于处理跨 buffer 的行)。

「一个 Parquet job 拆成几个 fetch task 取决于 projection」这一点,是后面推导 row group 调优公式的基础,请记住它。

目前异步 I/O 已实现于 Parquet未压缩、可 seek 的 UTF-8 CSV。DuckDB 原生格式和 JSON 还在路上。

想现在就试可以用 v2.0.0-dev 预览构建。v2.0(秋季发布)起将默认启用

5.4 读前队列(Read-Ahead Queue):没有生产者线程的巧思

朴素的思路是:需要数据时才发起读。读前策略是:提前调度 worker 还没要的 fetch task

实现细节里有个我很喜欢的设计:

"填充队列不需要专门的生产者线程。 任何来找扫描活干的 regular worker,会先把队列在允许范围内补满。"

这是一个避免了额外协调开销的漂亮做法。 没有专职 producer 线程,就没有 producer 与 consumer 之间的同步开销,也不存在 producer 线程本身被调度饿死的风险。补队列这件事被「摊派」给了每个恰好来取活的 worker——本质上是一种自组织的工作窃取变体。

完整循环:

  1. worker 来找扫描活,先在允许范围内补满队列(上限来自用户指定的槽位数,或内存预算);
  2. 有空间就创建 job 及其 fetch task。fetch task 立刻调度到 ASYNC,job 按批次顺序进入读前队列;
  3. ASYNC 线程独立于队列的领取顺序执行各个 fetch task。同一 job 的多个 fetch task 可以并发跑,不保证具体分配到哪个 ASYNC 线程;
  4. 同一 job 的所有 fetch task 共享一个倒计数(countdown),把它减到零的那个 fetch task 完成该 job 的 I/O;
  5. worker 领取队列中最老的 job 并检查倒计数:
    • I/O 已完成 → 直接开始解码;
    • 未完成 → 把 scan task 挂起(park),该 worker 转去跑流水线里的其他 task。最后一个 fetch task 完成时唤醒 scan task,它可以在任意 regular worker 上恢复;
  6. 领取 job 会立即释放一个队列槽位,任何来找扫描活的 worker 都能在队尾生产一个替补 job。

第 5 步的 park/unpark 是整个机制的精髓:worker 永远不会因为等 I/O 而空转。 数据没到就去干别的活,这才是「异步」的真正含义——不是「让 I/O 变快」,而是「让等待期间的 CPU 不浪费」。这也解释了后面并发查询基准里 CPU 利用率从 5.9 核跳到 48.1 核的现象。

5.5 内存治理:读前队列会来抢你 hash join 的内存

读前是用内存换吞吐。原文点出了风险:如果解码慢而网络快,预取数据会堆积并导致 OOM。

控制旋钮是 read_ahead_depth

含义
-1(默认)深度无限,由内存约束
N > 0最多提前 N 个 job,不设内存预算
0关闭读前,每个 scan task 只为自己的 job 调度 I/O
SET read_ahead_depth = 5;

默认模式下最关键的一点:预算与 temporary memory manager 协商,而这正是在并发的 join、sort、window 算子之间切分内存的同一个管理器。

这条的工程含义我要展开讲,因为它是很多人会踩的坑:

当内存压力大时(比如某个算子占了大量内存),队列预留可能瞬间超预算。实际效果是队列只允许一个 job,扫描行为退化成接近同步扫描。等那个吃内存的算子结束,管理器有更多预算可分,队列再填回去。

换句话说:异步 I/O 的收益不是恒定的,它会被同一查询里的重型算子吃掉。 如果你的查询里有一个巨大的 hash join 或 sort,它会挤压读前队列的内存预算,让扫描退化成同步。你观测到的现象会是「同样的查询,有时快 3 倍,有时几乎没变化」——而原因不在网络,在内存。

这给出一条反直觉的调优建议:在远程数据 + 重型算子的组合下,调大 memory_limit 可能比调大 async_threads 更能提升扫描性能,因为它同时缓解了算子和读前队列的争抢。这是 5.5 节机制推导出的结论,官方没有直接这么说,但基准数据(见 6.5)支持它。


六、异步 I/O 基准数据:以及我从里面挖出来的东西

6.1 测试环境

  • 查询:TPC-H Query 6 @ SF100,数据在 S3
  • 对比基线:DuckDB v1.5.5(当时最新稳定版)
  • 数据布局:Parquet 和 CSV 基准均每表单文件lineitem600,037,902 行
  • 计算:EC2 r7i.16xlarge(64 vCPU / 512 GB RAM),机器与 S3 桶同区
  • 执行 5 次取均值
  • SET enable_external_file_cache = false; —— 文件从不缓存,每次执行都直读 S3

最后这条很重要:这是一个刻意设计的「最坏情况」基准。 关掉外部文件缓存意味着测的是纯冷读性能。生产环境如果开着缓存,热数据的收益会小得多(本地冷读那节的数据可以印证)。看基准要先看它测的是最好情况还是最坏情况,这份是后者,所以数字偏保守,可信度更高。

6.2 Parquet on 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

约 2.9 倍提速。 而调优后更进一步:

官方还跑了一个针对该机器调优的版本——把读前深度上限设为 64 个在途 job,并调整 I/O 设置:

SET async_threads = 48;
SET http_retries = 8;
SET http_retry_wait_ms = 50;
SET http_retry_backoff = 2;
SET read_ahead_depth = 64;

结果:2.227 秒,比未调优的 v2.0.0-dev 又快 21.7%,相对 v1.5.5 约 3.7 倍

网络吞吐曲线的解读是这段里信息量最大的部分:

  • v1.5.5 停在约 5 Gbit/s —— 因为同步读无法保持足够多的在途请求来打满网络
  • v2.0.0-dev 明显更有效地利用带宽,逼近网络上限,并在若干时点达到
  • 调优版更进一步:更少、更热的连接 + 廉价重试,让吞吐方差降到最低,25 Gbit/s 网络几乎全程满载

「更少、更热的连接」值得单独拎出来讲。 直觉会说线程越多并发越高,但调优版把 async_threads 设成 48(小于默认的 4 × 64 = 256)反而更快。原因是:连接数太多时,每条连接都要付 TLS 握手成本、都在争抢带宽、都可能触发 S3 侧限流导致重试。48 条持续复用的热连接,比 256 条时冷时热的连接更能维持稳定吞吐。 配套的 http_retry_wait_ms = 50 + http_retry_backoff = 2 是「廉价重试」——遇到限流快速重试(50ms 起,指数退避),而不是长时间等待。

6.3 一个官方没算、但很有价值的推导:projection 下推还在干重活

我用带宽和时间反推一下实际传输量(以下是我的估算,非官方数据):

  • v1.5.5:约 5 Gbit/s × 8.230 s ≈ 41 Gbit ≈ 5.1 GB
  • 调优版:约 25 Gbit/s × 2.227 s ≈ 56 Gbit ≈ 7.0 GB

而文件总大小是 22 GB

两个结论:

  1. 实际只读了总量的 25%~30%。 TPC-H Q6 只用到 l_shipdatel_discountl_quantityl_extendedprice 四列(lineitem 共 16 列),projection 下推把 22 GB 砍到 5~7 GB。这说明异步 I/O 不是替代了列裁剪,而是叠加在列裁剪之上的第二层优化。 第一节讲的「DuckDB 靠尽早裁剪规避 I/O 问题」依然在起作用,异步 I/O 解决的是「裁剪之后剩下的那部分,怎么尽快搬过来」。
  2. 两个数字不完全相等(5.1 GB vs 7.0 GB),差值大概来自读前预取了未被最终使用的数据,以及重试产生的重复传输。 这是读前策略的固有成本——用一点额外带宽换延迟隐藏。在带宽充裕、延迟高的场景(正是 S3)这笔交易非常划算;在带宽是瓶颈的场景(比如跨区、或者按流量计费的出口)就要重新算账。

这条推导给出一个官方没提的成本提醒:如果你的 S3 流量是跨区或走公网出口的,异步 I/O 的读前预取会让你的流量账单上升,而不只是查询变快。 同区内网流量免费时这不是问题,跨区就要算一下。

6.4 本地 SSD 冷读 vs 热读

远程存储是主要目标,但本地冷读提供了有用的对照。环境:MacBook Pro(Apple M4 Max,14 核,36 GB RAM),每次运行间用 macOS purge 命令清空 OS 缓存,确保每次真的读盘。

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

冷读约 1.5 倍提速,减少约 33%。差距远小于 S3 场景,因为 SSD 的延迟低得多、带宽高得多

而热读的差别是「可忽略」的——数据已正确缓存时根本没有磁盘访问发生。

这是全文最重要的期望管理。 如果你的 DuckDB 是本地跑、数据在本机 SSD、而且反复查同一批文件(OS 缓存命中),那么 v2.0 的异步 I/O 对你几乎没有收益。别为了这个特性去做有风险的升级。异步 I/O 的目标用户画像非常明确:远程存储 + 冷读

6.5 小文件场景:分区数据集

分区很容易把数据摊成大量小文件。官方用同一份 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.2 倍,与单文件基准(2.9 倍)相当。官方结论:读前也能跨多个文件并行,不会被打开文件或获取 footer 卡住。

这条比看起来重要:Hive 分区风格的数据布局(year=2026/month=08/day=17/*.parquet)在异步 I/O 下不会退化。 过去小文件多意味着大量串行的 open + footer 读,是分区策略的一个隐性成本;现在这个成本被并行掉了。

6.6 Row Group 大小:全文最值钱的一张表

官方生成了六个版本的同一张 lineitem 单文件,只改 row group 大小,用 v2.0.0-dev 跑 Q6:

行数 / RGRG 数单 RG 约文件总大小时间
122,8804,886~4 MB~21,600 MB2.74 s
1,966,080306~70 MB~21,400 MB2.11 s
9,375,59364~320 MB~20,500 MB2.27 s
62,914,56010~1,500 MB~14,700 MB3.69 s
150,009,4764~3,200 MB~12,800 MB8.01 s
600,037,9021~12,300 MB~12,300 MB25.26 s

这是一条 U 型曲线,最优点和最差点相差近 12 倍。而且最差的那个配置,文件反而是最小的。

官方的机制解释:

  • 起初,更大的 row group 降低查询时间,因为请求延迟被摊到大得多的传输上;
  • 但超过某个点,可用并行度开始下降row group 是 DuckDB 的 Parquet 扫描并行单元,理想情况下一次扫描应该至少为每个系统线程提供一个 row group
  • 64 vCPU 机器上,64 个 RG 的版本正好满足这条(2.27 s),而最快的是 306 个 RG 的版本(2.11 s);
  • RG 数少于线程数时就失去并行度、无法打满网络。对 Q6,projection 和列的物理位置导致每个 row group 产生 2 个 fetch 请求。 于是 4 个 RG 只能暴露约 8 条并发 S3 流 → 8.01 s;单 RG 的版本 I/O 实际退化成 2 条巨型流 → 25.26 s,即便更好的压缩让文件只有 4,886-RG 版本的一半多一点。

原文的收尾判断很精辟:「小 row group 需要的额外带宽,比极大 row group 损失的并行度要便宜。」

现在让我把它变成一条可以直接用的公式。 从上面的机制可以推出:

并发 I/O 流数 ≈ min(row_group 数, read_ahead 深度) × 每个 RG 的 fetch task 数

要打满带宽,这个数必须显著大于「填满带宽所需的在途请求数」。而 每个 RG 的 fetch task 数 由 projection 决定(Q6 是 2)。所以:

Row group 数量的下限约束:

row_group 数 ≥ 系统线程数        (并行度底线,官方明确给出)
row_group 数 ≥ 目标在途请求数 / 每RG的fetch数   (带宽饱和约束,我的推导)

代入官方数据验证一下:64 vCPU,每 RG 2 个 fetch task。

  • 64 个 RG → 128 条流 → 2.27 s ✓(够用)
  • 306 个 RG → 612 条流 → 2.11 s ✓(最优)
  • 4 个 RG → 8 条流 → 8.01 s ✗(严重不足)

我给出的实操建议:目标 row group 数 ≈ 48 × 计算节点线程数,同时让单个 RG 落在 50150 MB 区间。 306 RG 恰好是 64 线程的 4.8 倍、单 RG 约 70 MB,正是实测最优点。这个区间同时满足了「延迟摊薄」和「并行度充足」两个方向的要求。

对应的写入代码:

-- 反面教材:默认或过大的 row group,在远程读时会毁掉并行度
COPY lineitem TO 's3://bucket/bad.parquet'
    (FORMAT parquet, ROW_GROUP_SIZE 100_000_000);   -- ✗ 只有 6 个 RG

-- 正确做法:按目标 RG 数反算 ROW_GROUP_SIZE
-- 6 亿行 / 目标 300 个 RG ≈ 每 RG 200 万行
COPY lineitem TO 's3://bucket/good.parquet'
    (FORMAT parquet, ROW_GROUP_SIZE 2_000_000, COMPRESSION zstd);

用 SQL 直接算出应该设多少,避免手算出错:

-- 输入:表名、目标计算节点线程数、想要的并行倍数
WITH params AS (
    SELECT
        (SELECT count(*) FROM lineitem) AS total_rows,
        64                              AS target_threads,
        5                               AS parallelism_factor
)
SELECT
    total_rows,
    target_threads * parallelism_factor                       AS target_row_groups,
    total_rows / (target_threads * parallelism_factor)         AS recommended_row_group_size
FROM params;

再补一条官方数据里的隐藏警告:注意 row group 越大,文件越小(21,600 MB → 12,300 MB,因为压缩效果更好)。 这会诱惑你选大 row group——存储省了 43%。但那个版本慢了 12 倍。如果你的团队有「优化存储成本」的 KPI,很可能已经在无意中把远程查询性能砍掉了一个数量级。 这种「一个指标的优化悄悄毁掉另一个指标」的情况,是我见过最多的生产事故来源之一。

6.7 并发查询:CPU 利用率揭示的真相

这是最能说明问题的一组。官方并发跑 TPC-H Q1、Q6、Q9、Q18(覆盖扫描、聚合、join 的不同 CPU 和内存需求组合),针对 S3 上同一份 SF100 Parquet 数据集,用单个 DuckDB 实例。分别在默认内存配置、16 GB、8 GB 限制下重复。运行时间是四个查询全部完成的墙钟时间。

版本内存限制运行时间平均 CPU峰值 CPU峰值带宽峰值 RSS
v1.5.5默认35.8 s5.935.710.7 Gbit/s14.5 GB
v2.0.0-dev默认15.6 s48.164.024.9 Gbit/s20.1 GB
v1.5.516 GB35.6 s6.125.717.4 Gbit/s14.1 GB
v2.0.0-dev16 GB22.7 s35.263.424.8 Gbit/s15.7 GB
v1.5.58 GB35.9 s6.938.816.8 Gbit/s10.4 GB
v2.0.0-dev8 GB24.2 s30.363.725.0 Gbit/s11.5 GB

默认配置下,v1.5.5 平均只让 64 核中的约 6 核忙着。也就是说,机器约 90% 的时间在空转等 S3 同步读。

这个数字应该让每个在云上跑分析负载的人停一下:

你为 64 个 vCPU 付费,实际用掉了 5.9 个。有效利用率 9.2%。 换个说法——同样的工作,你付的钱是理论最优的近 11 倍。而这不是因为你的 SQL 写得差,是因为 I/O 模型和部署形态不匹配。

v2.0.0-dev 平均 48.1 核忙碌,峰值打满 64 核,并跑满 25 Gbit/s 网络,四个查询在不到一半的时间里完成(35.8 s → 15.6 s,2.29 倍)。

内存部分的数据印证了我在 5.5 节的推导。随着限制降低:

  • 内存治理器缩减读前 backlog
  • 像 Q18 里那种吃内存的算子会溢写到磁盘
  • v2.0.0-dev 的峰值 RSS 从默认 20.1 GB 降到 16 GB 限制下的 15.7 GB、8 GB 限制下的 11.5 GB;
  • 额外的溢写和更少的读前降低了平均 CPU 利用率并增加了运行时间(15.6 s → 22.7 s → 24.2 s),但仍持续跑满网络,且在两种情况下都显著快于 v1.5.5

注意 15.6 s → 22.7 s 这个退化:仅仅因为内存从默认降到 16 GB,性能损失 45%。 这就是「读前队列与重型算子争抢内存」的直接证据。所以我在 5.5 节说的那条建议是有数据支撑的:在远程数据场景下,memory_limit 是一个 I/O 性能参数,不只是一个防 OOM 参数。

官方还解释了一个细节:8 GB 限制下峰值 RSS 仍达 11.5 GB,是因为 jemalloc 会把最近释放的页保留约一秒以便复用。这个知识点在你排查「明明设了 memory_limit 为什么 RSS 超了」时能省几个小时。

6.8 首字节前的那几百毫秒

一个容易被忽略但对交互式场景很关键的观察:所有实验中,网络流量第一次抬头之前会先过去几百毫秒,之后又要几百毫秒主数据传输才开始。

拆解:

  1. 第一个空隙 = 建立 DuckDB 连接 + 第一次 TLS 握手 + 打开文件;
  2. 中间那个小凸起 = 下载文件 footer
  3. 第二个空隙 = 处理 footer 里的信息,然后才执行查询。

官方表示这是 v2.0 发布前还可以继续优化的地方。

对我们的实践含义:这几百毫秒是一个几乎固定的地板。 如果你在做一个要求 P99 < 500ms 的交互式仪表盘,直查 S3 Parquet 这条路可能天生达不到——不管你怎么调 async_threads,TLS 握手和 footer 处理的时间是省不掉的。这种场景应该:

  • 开启 enable_external_file_cache,让热数据的 footer 和数据块留在本地;
  • 或者用 Quack 连一个常驻的 DuckDB 服务器,让连接和 footer 缓存被复用,把这几百毫秒摊到很多次查询上。

注意这个建议本身就是 Quack 和异步 I/O 协同的例子——单看任何一个特性都得不出这个方案。


七、工程实战:把两半拼成一个系统

现在把 Quack(集中状态)和异步 I/O(高效读远程数据)组合起来,做一个真实场景:多个采集进程汇聚可观测性数据,同时驱动一个仪表盘,历史数据在 S3。

架构:

┌──────────────┐   ┌──────────────┐   ┌──────────────┐
│ collector-1  │   │ collector-2  │   │ collector-N  │   客户端攒批写入
└──────┬───────┘   └──────┬───────┘   └──────┬───────┘
       │  Quack (HTTP)    │                  │
       └──────────────────┼──────────────────┘
                          ▼
              ┌───────────────────────┐
              │  DuckDB Quack Server  │  ← 热数据(近 24h)在本地
              │  :9494 (nginx 反代)    │
              └───────────┬───────────┘
                          │ 异步 I/O + projection 下推
                          ▼
              ┌───────────────────────┐
              │  S3 / Parquet 归档     │  ← 冷数据,Hive 分区
              │  ~300 RG per file     │
              └───────────────────────┘
                          ▲
                          │ Quack (HTTP)
              ┌───────────┴───────────┐
              │  Dashboard (Wasm/Py)  │  ← 只读 token
              └───────────────────────┘

7.1 服务端初始化

-- init.sql:Quack 服务器启动脚本

INSTALL quack;  LOAD quack;
INSTALL httpfs; LOAD httpfs;

-- ---------- 异步 I/O 与远程读调优 ----------
-- 注意:async_threads 少而热 > 多而冷(见 6.2 节调优版数据)
SET async_threads        = 48;
SET read_ahead_depth     = 64;
SET http_retries         = 8;
SET http_retry_wait_ms   = 50;
SET http_retry_backoff   = 2;

-- 生产环境务必开启(官方基准为测最坏情况才关掉它)
SET enable_external_file_cache = true;

-- 关键:memory_limit 在远程场景下同时是 I/O 性能参数
-- 读前队列与 join/sort 争抢同一个 temporary memory manager 预算
SET memory_limit = '64GB';

-- ---------- 热数据表 ----------
CREATE TABLE IF NOT EXISTS events (
    ts        TIMESTAMP,
    service   VARCHAR,
    kind      VARCHAR,
    value     BIGINT
);

-- ---------- S3 凭证 ----------
CREATE OR REPLACE SECRET s3_archive (
    TYPE s3,
    PROVIDER credential_chain,   -- 走 EC2 instance profile,不硬编码密钥
    REGION 'ap-northeast-1'
);

-- ---------- 冷数据视图:Hive 分区 ----------
CREATE OR REPLACE VIEW events_archive AS
SELECT * FROM read_parquet(
    's3://my-bucket/events/year=*/month=*/day=*/*.parquet',
    hive_partitioning = true
);

-- ---------- 冷热合并视图 ----------
CREATE OR REPLACE VIEW events_all AS
    SELECT ts, service, kind, value FROM events
    UNION ALL
    SELECT ts, service, kind, value FROM events_archive;

-- ---------- 启动服务 ----------
CALL quack_serve('quack:0.0.0.0', token = getenv('QUACK_TOKEN'));

提示:0.0.0.0 覆盖了默认的 localhost 绑定。只有在前面有 nginx 反代 + 安全组限制时才这样做,否则请保持默认。官方明确不建议把 Quack 端点直接暴露到公网。

7.2 systemd 单元

# /etc/systemd/system/duckdb-quack.service
[Unit]
Description=DuckDB Quack Server
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=duckdb
Group=duckdb
WorkingDirectory=/var/lib/duckdb
Environment="QUACK_TOKEN_FILE=/etc/duckdb/quack.token"
# 从文件读 token,避免出现在进程环境和 ps 输出里
ExecStartPre=/bin/sh -c 'test -s "$QUACK_TOKEN_FILE"'
ExecStart=/bin/sh -c 'QUACK_TOKEN="$(cat $QUACK_TOKEN_FILE)" \
    exec /usr/local/bin/duckdb /var/lib/duckdb/hot.db -init /etc/duckdb/init.sql'
Restart=always
RestartSec=5

# 资源限制:ASYNC 池最多 256 线程,文件句柄要给足
LimitNOFILE=65536
# 与 init.sql 里的 memory_limit 留出余量(jemalloc 会保留已释放页约 1s)
MemoryMax=96G

# 基本加固
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=/var/lib/duckdb

[Install]
WantedBy=multi-user.target

MemoryMax 特意设得比 memory_limit 高一截——这正是 6.7 节那个 jemalloc 细节的实际应用:RSS 会短暂超过 memory_limit,如果 cgroup 上限贴太紧,你会被 OOM killer 杀掉,然后困惑「我明明设了 memory_limit」。

7.3 归档作业:按公式生成 row group

#!/usr/bin/env python3
"""
把热表里超过 24h 的数据归档到 S3 Parquet。

核心:按 6.6 节推导的公式反算 ROW_GROUP_SIZE,
让远程查询时 RG 数 ≈ 4~8 × 计算线程数,且单 RG 落在 50~150MB。
"""
import duckdb
from datetime import datetime, timedelta

TARGET_THREADS      = 64   # 未来查这批数据的计算节点线程数
PARALLELISM_FACTOR  = 5    # 官方最优点约 4.8×
MIN_RG_SIZE         = 100_000   # 数据量小时不要切得过碎

def archive(con: duckdb.DuckDBPyConnection, day: datetime) -> None:
    lo = day.replace(hour=0, minute=0, second=0, microsecond=0)
    hi = lo + timedelta(days=1)

    n_rows = con.execute(
        "SELECT count(*) FROM events WHERE ts >= ? AND ts < ?", [lo, hi]
    ).fetchone()[0]
    if n_rows == 0:
        print(f"[skip] {lo:%Y-%m-%d} 无数据")
        return

    target_rgs = TARGET_THREADS * PARALLELISM_FACTOR
    rg_size = max(n_rows // target_rgs, MIN_RG_SIZE)

    path = (f"s3://my-bucket/events/"
            f"year={lo:%Y}/month={lo:%m}/day={lo:%d}/data.parquet")

    con.execute(f"""
        COPY (
            SELECT * FROM events
            WHERE ts >= ? AND ts < ?
            ORDER BY service, ts        -- 排序提升压缩率与 filter 下推效果
        ) TO '{path}' (
            FORMAT parquet,
            COMPRESSION zstd,
            ROW_GROUP_SIZE {rg_size}
        )
    """, [lo, hi])

    actual_rgs = max(1, n_rows // rg_size)
    print(f"[ok] {lo:%Y-%m-%d}  rows={n_rows:,}  "
          f"rg_size={rg_size:,}  rgs≈{actual_rgs}")

    if actual_rgs < TARGET_THREADS:
        print(f"  ⚠ RG 数 {actual_rgs} < 线程数 {TARGET_THREADS},"
              f"远程查询会损失并行度(见 6.6 节 U 型曲线)")

    con.execute("DELETE FROM events WHERE ts >= ? AND ts < ?", [lo, hi])

if __name__ == "__main__":
    con = duckdb.connect()
    con.execute("INSTALL quack; LOAD quack;")
    con.execute("CREATE SECRET (TYPE quack, TOKEN getenv('QUACK_TOKEN'))")
    con.execute("ATTACH 'quack:analytics.internal.example.com' AS remote")
    archive(con, datetime.now() - timedelta(days=1))

那个 告警是我认为最该内建的东西——它把 6.6 节那条 12 倍的坑,变成了归档时就能看见的一行警告,而不是三个月后某个仪表盘变慢时的一次线上排查。

7.4 验证调参是否生效

配完之后必须验证,不然你不知道设置有没有被接受:

-- 检查异步 I/O 相关设置的实际生效值
SELECT name, value, description
FROM duckdb_settings()
WHERE name IN (
    'async_threads',
    'read_ahead_depth',
    'threads',
    'memory_limit',
    'enable_external_file_cache',
    'http_retries',
    'http_retry_wait_ms',
    'http_retry_backoff'
)
ORDER BY name;

再用一个受控实验确认异步 I/O 真的在起作用:

-- 关掉读前,模拟同步行为
SET read_ahead_depth = 0;
SET enable_external_file_cache = false;
.timer on
SELECT sum(value) FROM events_archive WHERE ts >= '2026-08-01';

-- 打开读前
SET read_ahead_depth = -1;
SELECT sum(value) FROM events_archive WHERE ts >= '2026-08-01';

如果这两个数字差不多,说明你的瓶颈不在 I/O,继续调 async_threads 是浪费时间——去看是不是 row group 太大(并行度不足)、或者内存不够(读前队列被挤压)。


八、性能优化清单

按我认为的收益排序:

第一优先:Row group 大小

收益:最高可达 12 倍(6.6 节实测 2.11 s vs 25.26 s)

目标 RG 数 = 4~8 × 计算节点线程数
目标单 RG 大小 = 50~150 MB
下限硬约束:RG 数 ≥ 线程数

这是唯一一个「写入时决定、查询时无法补救」的参数。优先级最高不是因为倍数最大,而是因为它不可事后调整。

第二优先:确认瓶颈真的是 I/O

收益:避免把时间浪费在错的地方

用 7.4 节的 read_ahead_depth = 0 vs -1 对照实验。本地 SSD + 热缓存场景的收益「可忽略」(6.4 节),别在这上面投入。

第三优先:memory_limit

收益:实测 45%(6.7 节 15.6 s vs 22.7 s)

远程数据场景下这是个 I/O 参数。读前队列与 join/sort/window 抢同一个预算,内存不足会让扫描退化成接近同步。

第四优先:async_threads 与重试参数

收益:实测 21.7%(6.2 节 2.844 s vs 2.227 s)

SET async_threads      = 48;   -- 少而热 > 多而冷;默认是 4×threads,上限 256
SET http_retries       = 8;
SET http_retry_wait_ms = 50;   -- 廉价快速重试
SET http_retry_backoff = 2;
SET read_ahead_depth   = 64;   -- 明确上限,绕过内存协商的不确定性

第五优先:缓存与下推

SET enable_external_file_cache = true;   -- 官方基准为测最坏情况才关掉

以及在 Quack 侧对「远端数据量 >> 结果集」的查询坚持用 remote.query() 显式下推。

写入侧:永远攒批

单行 INSERT 会让你撞上约 5,500 tx/s 的引擎级天花板。batch_size=5,000 时理论行吞吐提升到约 2,750 万行/秒。


九、什么时候不该用:一份诚实的边界清单

技术文章最容易缺的就是这部分。我把边界列清楚:

不该用 Quack 的场景

  1. 需要超过约 5,500 tx/s 的同表并发写入。 这是 DuckDB 引擎当前的限制,不是协议问题,调参无解。官方已列入近期计划,但在它落地前,你的写入路径必须能攒批
  2. 把它当 PostgreSQL 替代品。 Quack 在 8 线程内小事务赢了 PG,但 PG 在更高并发下扩展性更好,而且 PG 有几十年的运维生态、成熟的复制方案、完整的权限模型。Quack 赢的是「分析引擎顺便处理写入」这个赛道,不是 OLTP。
  3. 需要生产级复制/高可用。 复制协议还在「正在思考」阶段(见下节)。现在的 Quack 服务器是单点。你得自己做备份和故障恢复。
  4. 需要细粒度权限模型。 默认授权函数「对一切说是」。可插拔回调很灵活,但灵活意味着这套东西得你自己写、自己测、自己承担漏洞责任。
  5. 直接暴露到公网。 官方明确不建议。必须有反代终结 SSL。

不该期待异步 I/O 收益的场景

  1. 本地 SSD + 热缓存。 差别「可忽略」。
  2. DuckDB 原生格式或 JSON 文件。 当前只实现了 Parquet 和未压缩、可 seek 的 UTF-8 CSV。压缩的 CSV 不在内(不可 seek)。
  3. 跨区或走公网出口的 S3 流量按量计费。 读前预取会增加实际传输量(6.3 节的估算显示存在额外传输),流量账单会上升。
  4. P99 < 500ms 的交互式查询直查远程 Parquet。 TLS 握手 + footer 下载 + footer 处理构成了几百毫秒的地板(6.8 节)。
  5. row group 数少于线程数的现有文件。 你得先重写文件,否则异步 I/O 也救不了并行度不足。

版本选择建议

  • 异步 I/O 现在只在 v2.0.0-dev 预览构建里,不要拿预览版上生产
  • 需要稳定:1.5.5。需要长期支持:1.4.5 LTS(Andium)
  • v2.0(2026 年秋季) 正式版再迁移,届时异步 I/O 默认开启、Quack 会有首个生产版本。

十、总结与展望

10.1 官方路线图

从 Quack 一文的「下一步」看,DuckDB 接下来的方向很明确:

  1. 把 Quack 集成进 DuckLake,让远程 DuckDB 服务器能当 DuckLake 的 catalog server。官方预期这会大幅提升性能,尤其是 inlining 场景;
  2. v2.0 随正式版发布 Quack 首个生产版本,并计划让 Quack 扩展在需要时自动安装、自动加载;
  3. 用新解析器改进与远程 SQL 数据库对话的语法
  4. 核心侧大幅提升可达的 TPS,把事务扩展到远超 8 个并行线程;
  5. 考虑允许扩展来扩展 Quack 协议本身——让 DuckDB 扩展能新增协议消息和处理代码;
  6. 考虑在 Quack 之上加复制协议,把一个实例的变更复制到其他服务器,比如搭建只读副本集群。

异步 I/O 侧:v2.0 默认启用,DuckDB 原生格式和 JSON 的支持还在路上,footer 处理前的那几百毫秒也是待优化项。

第 6 点最值得盯——如果 Quack 上真的长出了复制协议和只读副本,DuckDB 就不再是「一个能当服务器用的分析引擎」,而是一套完整的分布式分析数据库了。 那是一个完全不同量级的东西。

10.2 我的判断

回到开头那个论点。我认为 2026 年 DuckDB 这两次动作,标志着它从工具变成基础设施组件

区别在哪?工具是你用完就关的东西——打开 notebook,查一下 Parquet,关掉。基础设施是常驻的、被多方依赖的、需要运维的东西。Quack 让 DuckDB 有了常驻和被多方依赖的能力,异步 I/O 让它在「数据不在本地」这个基础设施的常态下还能跑得快。

官方自己的总结是:「DuckDB 正在进一步走出它最初的『交互式分析用的进程内数据库』这个niche,成为现代数据架构的核心构件。」

但我想给这个乐观叙事加一个必要的注脚:能力扩张的同时,责任也扩张了。

过去 DuckDB 崩了,影响是你的 notebook 要重跑一遍。现在你把它跑成 Quack 服务器、让 20 个采集进程往里写、让仪表盘从里面读,它崩了就是一次线上事故。而 DuckDB 目前没有成熟的复制方案、没有内建的权限模型、没有几十年的运维工具积累。这些不是缺陷指控,是新生阶段的客观状态。

所以我的实操建议是:

用 Quack 去替换那些你本来要自己写 RPC 包装的地方——那里 Quack 是纯粹的改进,因为你的自制方案更不可靠。不要用 Quack 去替换那些 PostgreSQL 正在稳定服务的地方——那里你会拿一堆运维保障去换一点性能。

至于异步 I/O,判断标准更简单,一句话就能说完:

你的数据在远程存储吗?读的是冷数据吗?两个都是 yes,v2.0 会给你 2~4 倍,去准备升级;任何一个是 no,先用 7.4 节那个对照实验验证,别为一个你可能用不上的特性冒风险。

最后回到那条 U 型曲线。我认为它是这次更新里最有普适价值的东西,因为它揭示的规律超出了 DuckDB:当系统的瓶颈从「带宽」转移到「并发度」时,所有关于「批量越大越好」的直觉都会反转。 Row group 从 4 MB 增大到 70 MB 是优化(延迟摊薄),从 320 MB 继续增大到 12 GB 是灾难(并行度崩塌),而后者的文件体积反而更小。

存储省了 43%,查询慢了 12 倍。 这种一个指标的优化悄悄毁掉另一个指标的事,在分布式系统里到处都是。DuckDB 这次只是给了我们一组特别干净的数字来看清它。


参考来源

本文的技术细节与全部基准数据均来自 DuckDB 官方工程博客与发布公告:

  • Quack: The DuckDB Client-Server Protocol(DuckDB 团队,2026-05-12)
  • Asynchronous I/O in DuckDB: Work, Thread, Work(Pedro Holanda,2026-07-31)
  • Announcing DuckDB 1.5.5(2026-07-22)
  • Announcing DuckDB 1.5.4 (Variegata) / Announcing DuckDB 1.4.5 LTS (Andium)(2026-06-17)
  • Thank You for 40 000 Stars on GitHub(2026-08-05)

文中的比值换算、传输量估算、row group 数量公式、调优优先级排序、部署代码与选型边界清单为笔者基于上述数据的分析与实践总结,已在正文中明确标注哪些是官方数据、哪些是笔者推导。配置项的确切名称与语义请以你所用版本的官方文档为准,预览版特性在正式发布前仍可能调整。

推荐文章

黑客帝国代码雨效果
2024-11-19 01:49:31 +0800 CST
go错误处理
2024-11-18 18:17:38 +0800 CST
windows下mysql使用source导入数据
2024-11-17 05:03:50 +0800 CST
一个数字时钟的HTML
2024-11-19 07:46:53 +0800 CST
程序员茄子在线接单