Hacker News 中文摘要

RSS订阅

DuckDB 2.0 为何更快 -- Why DuckDB 2.0 is faster

鸭子数据库(DuckDB)2.0 将于今年秋季发布,alpha 版已推出。作者在 M5 笔记本和亚马逊云科技(AWS)的简单存储服务(Amazon S3)上试了新特性,看对建表、做数据管道的人有何变化。DuckDB 2.0 确实更快,但要提速得先理解数据形状并调整建模。文章讲三个重点特性与实测数字,数字来自一台 M5 笔记本和家庭网络,引用前最好自己跑;开篇先讲异步 I/O,在 AWS S3 上查询快了很多,且查询语句不用改。

正文

DuckDB 2.0 将于今年秋季发布,alpha 版本已经推出!我在自己的笔记本上,以及针对 S3,试跑了那些有意思的新特性,想看看对于建表和做数据管道(而不是做数据库引擎)的人来说,究竟有哪些实际变化。

没错,DuckDB 2.0 更快。但要想获得这个速度提升,你需要了解自己的数据形态,有时还需要调整建模方式。

下面介绍我认为最重要的三个特性,附上我测得的数字,以及我在提交日志里发现的一些隐藏亮点。

以下所有数字都来自同一台机器(M5 笔记本)和家庭网络,家庭网络对两个版本的拖慢程度大致相同。引用这些数字前请先自己跑一遍 ;)

Async I/O:通过 AWS S3 查询快得多

这是我最喜欢的一个特性,因为你的查询不用做任何改动。下面这个查询读取 S3 上的一个 2.2 GB Parquet 文件(Stack Overflow 投票数据,2.28 亿行,2268 个 row group),按类型统计投票数。它只读取四列中的一列,大约 230 MB。

`CREATE SECRET s3 (TYPE s3, PROVIDER credentialchain, REGION 'us-east-1'); SET enableexternalfilecache = false; -- so every run really hits S3

SELECT VoteTypeId, count(*) AS n FROM read_parquet('s3://us-prd-motherduck-open-datasets/stackoverflow/parquet/2023-05/votes.parquet') GROUP BY ALL ORDER BY 1;`

| 相同查询、相同笔记本 | | | --- | --- | | DuckDB 1.5.5 | 18.8 s | | DuckDB 2.0 alpha | 7.7 s |

快速说明一下:正如前面提到的,这是从我的家庭网络连接到 us-east-1,所以两个数字都偏慢。如果从云计算环境运行,预计会更快。

那么这里的“黑魔法”是什么?这个文件被切成 2268 个 row group,每个大约 12.2 万行。对于每个 row group,DuckDB 会下载字节、解码 Parquet、按类型统计投票数,最后合并部分计数。这里有两类工作:等待网络,以及 CPU 计算。

在 1.5.5 中,18 个工作线程各自轮流做这两件事:下载、等待、解码、下载、等待。线程等待时 CPU 空闲,解码时又没有正在进行的下载,因此并发下载数永远不会超过 18。

在 2.0 中,一个独立的线程池只负责下载,让几十个 row group 同时处于在途状态,并把字节暂存在缓冲区里。工作线程只负责解码,而且总是有准备好解码的 row group。网络和 CPU 同时都在忙。

控制这一点的是一个设置:read_ahead_depth,即下载线程池允许比工作线程提前多少个 row group。它默认是 -1(自动,根据线程数决定大小),因此异步 I/O 开箱即用。把它设为 0,就会退回 1.5 的行为。

| S3 读取 | 1.5.5 | 2.0 alpha | | --- | --- | --- | | 一个 2.2 GB Parquet 文件,一列 | 18.8 s | 7.7 s | | 23 个大 Parquet 文件,13.6 GB,一列 | 11.8 s | 3.9 s | | 一个 1.7 GB 纯 CSV | 116 s | 55 s | | 30 个小 Parquet 文件,每个约 1 MB | 3.7 s | 3.3 s |

关于这些小文件,有一点说明:没有实质变化,因为这里的耗时来自每个文件的往返(先读 footer,再读数据),预读取消除不了这部分。把数据湖存成几千个 1 MB 的 Parquet 文件本来就是糟糕的做法,2.0 也救不了。基本功依然重要!

TL;DR:在 2.0 中通过 S3 读取数据的速度提升了 2 到 3 倍,且无需修改任何查询,因为独立的下载线程池会提前于工作线程下载。该功能默认开启(read_ahead_depth = -1);设为 0 则恢复旧行为。

Recursive CTE:为深层父子数据集加速

DuckDB 团队重写了递归 CTE 引擎,并称在图可达性上提升了 40 倍。别担心,我会用你已经熟悉的一张表来解释这意味着什么。

递归 CTE 就是对一张表做循环。以 employees 表为例,它有两列:manager 和 employee。你想回答一个简单问题:谁向谁汇报。

| manager | employee | | --- | --- | | Ana | Ben | | Ana | Cléa | | Ben | Dev | | Ben | Eli | | Cléa | Fay | | Dev | Gus | | Fay | Hal |

做法是:从一行开始,也就是 CEO Ana。第一轮,找出 manager 是 Ana 的所有人(Ben、Cléa)。第二轮,找出 manager 是这些人之一的所有人(Dev、Eli、Fay)。如此继续,直到某一轮找不到新的人。组织结构的每一层对应一轮。

查询长这样:

WITH RECURSIVE team(person) AS ( SELECT 'Ana' UNION SELECT e.employee FROM team t JOIN employees e ON e.manager = t.person ) SELECT count(*) FROM team;

组织结构、文件夹树、物料清单、回复串、数据血缘、git 历史:这些数据通常都表现为同样的两列——父和子。唯一不同的是深度,而深度就是查询的轮数。组织结构大概有八层。git 历史则有几万层。

1.5 的问题在于:每一轮它都要回去重读整张表,以找出下一级。八层意味着八次全表读取。几千层就是同张表几千次全表读取。在 2.0 中,表只读一次,父列上的查找结构只建一次,每一轮只查找刚找到的那几行。现在的开销大约只和你实际触碰到的行数有关,而不是轮数乘以表大小。

回到 git 历史,这通常是最能看到提升的地方。每个提交都指向它的父提交,所以这张表就是 commit_id, parent_id,遍历 HEAD 的祖先每个提交对应一轮。我生成了一个包含 2 万个提交、带几次合并的仓库,并用下面这样的递归 CTE 一路回溯到根:

WITH RECURSIVE ancestors(id) AS ( SELECT max(id) FROM commits -- HEAD UNION SELECT p.parent_id FROM ancestors a JOIN commit_parents p ON p.commit_id = a.id ) SELECT count(*) FROM ancestors;

查询耗时如下:

| 祖先回溯,2 万个提交 | | | --- | --- | | DuckDB 1.5.5 | 多次运行从 1.8 s 到 16 s | | DuckDB 2.0 alpha | 每次运行 0.10 s |

TL;DR:如果你要遍历深层父子链(git 历史、数据血缘、回复串、完整物料清单),2.0 能把以前要丢给图数据库做的任务变成普通查询。如果你的层级很浅,比如组织结构图,就看不到太大变化。无论哪种情况,都要把层级保存为一张带整型 id 的父子表,并在递归需要携带深度或成本等值时使用 USING KEY。

VARIANT:比 JSON 更小更快

大家都喜欢 JSON。VARIANT 现在是 DuckDB 里的一等数据类型,需要记住的词是 shredding。

不是吉他那种 shred。在 VARIANT 语境下,shredding 的意思是:当 DuckDB 把一个 row group 写入磁盘时,它会检查你的 JSON 列,找出那些在多数行里都出现、且每次都是同一种值类型的字段。

下面是一个常见的例子,取自 500 万个事件中的一个:

{"type": "purchase", "user": {"id": 42, "country": "FR", "premium": true}, "props": {"amount": 12.5, "currency": "EUR", "items": 2}, "tags": ["a", "b"]}

事件类型始终是文本,user.id 始终是数字,props.amount 始终是小数。这些字段会在底层被抽出来,变成真正的独立列。那些罕见的字段,以及在这一行是数字、下一行是文本的字段,则留在一个二进制 remainder(剩余部分)里。因此,JSON 中规整的部分会像普通表一样存储,只有混乱的部分才存为 blob。

最理想的场景是结构化日志。level、service、latency_ms、trace_id 每一行都有,且始终是同一种值类型,因此它们全都会被 shred。奇怪的 extra 对象留在 remainder 里,仍然可以查询,只是更慢。有个坑:如果 latency_ms 在某一行是 231,下一行是 "231ms",它也会掉进 remainder。要保持值类型一致。

VARIANT 不只是关于速度。文本在存储和 CPU 上都很贪婪。我拿这 500 万个事件,用三种方式存储同样的数据:

-- A: the JSON string CREATE TABLE ev AS SELECT json AS payload FROM read_ndjson_objects('events.ndjson'); -- B: VARIANT CREATE TABLE ev AS SELECT json::VARIANT AS payload FROM read_ndjson_objects('events.ndjson'); -- C: a normal table, one typed column per field CREATE TABLE ev AS SELECT * FROM read_json('events.ndjson');

然后在每种存储上跑三个查询:按两个字段过滤、按国家分组对数字字段求和,以及列表查找。

`-- Q1: filter on two fields SELECT count(*) FROM ev WHERE payload.type::VARCHAR = 'purchase' AND payload.user.country::VARCHAR = 'FR';

-- Q2: sum of a numeric sub-field, grouped SELECT payload.user.country::VARCHAR AS country, sum(payload.props.amount::DOUBLE) AS amount FROM ev WHERE payload.type::VARCHAR = 'purchase' GROUP BY ALL ORDER BY 1;

-- Q3: list lookup SELECT count(*) FROM ev WHERE list_contains(payload.tags::VARCHAR[], 'c');`

| | JSON 字符串 | VARIANT(2.0 alpha) | VARIANT(1.5.5) | 每字段一个带类型列 | | --- | --- | --- | --- | --- | | 磁盘占用 | 224 MB | 85 MB | 81 MB | 45 MB | | Q1 过滤 | 366 ms | 63 ms | 4.96 s | 52 ms | | Q2 按国家求和 | 408 ms | 61 ms | 5.28 s | 51 ms | | Q3 列表包含 | 357 ms | 1.96 s | 4.65 s | 67 ms |

能看出什么?

  • VARIANT 比 JSON 字符串小 2.7 倍,用 pragma_storage_info('ev') 就能看出原因:对象被拆成子列,事件类型以字典形式存储。EXPLAIN 显示过滤被下推到扫描中,所以针对 payload.type 的查询只读取一个子列。
  • 在涉及被 shredded 字段的查询上——比如对数字子字段做过滤或求和——VARIANT 比解析 JSON 文本快大约 6 倍,并且与带类型列的差距在 20% 以内。相比 1.5.5 中的 VARIANT,它快了 78 倍,因为 1.5.5 只有类型、没有 shredding。
  • 列表查询是例外。在这个 alpha 里,把 VARIANT 列表转成 VARCHAR[] 要花两秒,比 JSON 路径还慢。目前 shredding 的收益体现在字段访问上;列表还不行。

所以建模的黄金法则依然成立:把你确定的东西建模出来。每个查询都会碰到的字段值得拥有真实列,而提升一个字段只需两条语句:

ALTER TABLE ev ADD COLUMN type VARCHAR; UPDATE ev SET type = payload.type::VARCHAR;

TL;DR:如果你的事件共享一组一致、且值类型也一致的字段,就用 VARIANT 存储,而不是 JSON 字符串。你只需要三分之一的存储,字段查询也能跑得像真正的列一样。把每个查询都会碰到的字段提升为真实列,长尾字段留在 VARIANT 里,并且目前要避免在热点查询中做列表转换。

发布版和提交日志里的速览

触发器(Triggers)。当行发生变化时,表会自己执行一小段 SQL。例如,更新一些价格,触发器会看到变化前后的每一行(即“transition tables”),并把两者都写进历史表。以前,每个碰这张表的工具都得记得在某处写这条历史记录;现在可以直接在数据库侧完成。

CREATE TRIGGER price_history AFTER UPDATE ON prices REFERENCING OLD TABLE AS before_rows NEW TABLE AS after_rows FOR EACH STATEMENT INSERT INTO price_history (id, old_price, new_price) SELECT a.id, b.price, a.price FROM before_rows b JOIN after_rows a USING (id);

嵌套 schema:CREATE SCHEMA finance.reports;,以及其中的表。

CTE 中的 DML:在 WITH 里使用 DELETE ... RETURNING,然后从它 INSERT,这就是原子化搬移行的模式。

WITH moved AS MATERIALIZED (DELETE FROM staging RETURNING *) INSERT INTO prod SELECT * FROM moved;

其他有意思的东西:

  • SET dialect_compatibility_mode = 'spark':如果你需要把某些管道从 Spark SQL 迁移到 DuckDB,这是一个 Spark SQL 兼容模式。
  • SET external_file_cache_spill = true:DuckDB 会在内存中缓存远程文件块;当这些块被逐出时,这个设置会把它们溢出到临时目录,而不是重新下载。在 300 MB 内存限制下,第二次读取 S3 上一个 854 MB Parquet 文件的耗时从 23.9 s 降到 0.35 s。
  • CLI 新增了 SQL 格式化器(duckdb -format,或 .auto_format on)、可查询的历史(.history、FROM shell_history())、.about 和 .manual <function>。
  • read_json 现在会把带偏移量的 ISO-8601 时间戳识别为 TIMESTAMPTZ,而不是悄悄丢掉偏移量。
  • CREATE SECRET s IN CONNECTION (...) 把一个 secret 限定在某个连接内。

Quack 是客户端-服务器协议,也是本次发布的另一半,并随本次发布进入 1.0。我在专门的视频里讲过它,这里就不重复了。

试用并反馈

图片

CLI 的 alpha 版只需一行命令,其他客户端可在 DuckDB 安装页面 找到:

curl https://install.duckdb.org | DUCKDB_VERSION=alpha bash ~/.duckdb/cli/latest/duckdb -c "SELECT version()"

评论

讨论规模较小,多数人对可视化评价正面并有意尝试;分歧主轴集中在行文风格上,少数人怀疑文案由大语言模型生成、晦涩有害。技术层面由个别人主导,批评主流数据库和软件在大规模并行上的普遍落后,并补充了扩展 API 与瓶颈排查等细节。

称赞可视化并计划实际试用

  • 较多人喜爱文中的可视化,有人已明确表示会在下一个 Jupyter Notebook 里试用(认可度:较多人)。

批评行文风格疑似大语言模型生成、晦涩难读

  • 少数人认为文章散文带有浓重的大语言模型痕迹,句式令人费解,并警告这种写法对读者健康有害、原作者不该用模型代写(认可度:少数人)。
  • > 这种文风让读者头脑发麻、有害健康,不该用大语言模型代写(@scythmic_waves)

认为多数数据库引擎的并行设计落后,应转向基于任务的设计

  • 少数人指出,多数数据库仍停留在“n 线程”式并行、交换操作和糟糕的异步输入输出管理,而 Umbra / CedarDB 那样基于任务的设计才是对的方向;DuckDB 虽在改进,也只是追赶几十年前的研发(认可度:少数人)。
  • 论据:面对 1024 核、高带宽但有延迟的机器,几乎没有任何软件能用单条查询把系统跑满,SQL Server 单条查询最多只用到 64 个硬件线程。不只数据库,压缩、哈希校验、上传下载等常见操作也普遍无法充分并行或重叠。作者在生物信息学代码上的测试发现,很多程序被困在焦油坑里,无论加多少核都喂不饱现代固态硬盘的数百万 IOPS 或数百 Gbps 网络。AMD Zen 6 时代的双路 EPYC 9006 将拥有 512 核 1024 线程,证明这并非假想。

有价值的补充

  • 新的 C++ 扩展 API 从开发和分发扩展的角度看也会更快(@stacktraceyo)。
  • Atlas 和 Fable 曾在源码与运行时之间轮流来回排查,以追踪疑似瓶颈(@larodi)。