ESC
开源 1 分钟阅读

为什么 DuckDB 2.0 更快

DuckDB 2.0 性能大幅提升:引入独立下载线程池实现异步 I/O,S3 读取提速 2-3 倍且默认开启;重写递归 CTE 引擎,深层图遍历最高提升 40 倍;VARIANT 成为一等数据类型,通过 shredding 机制大幅节省存储;还新增 Triggers、嵌套 schema、CTE 内 DML,Quack 协议随之一同发布 1.0。

来源:Hacker News

那么这里的"黑魔法"是什么?文件被切分为 2268 个行组(row group),每个约 122,000 行。对每个行组,DuckDB 下载字节、解码 Parquet、按类型统计票数,最后合并各部分计数。这里有两类工作:等待网络,以及在 CPU 上进行计算。

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

在 2.0 中,一个独立的线程池只负责下载,让数十个行组同时处于传输中,并把字节暂存到缓冲区。工作线程只负责解码,而且总有现成的行组等着它们处理。网络与 CPU 同时保持忙碌。

这一切由一个设置项驱动:read_ahead_depth,即下载池可以领先工作线程预取多少个行组。默认值为 -1(自动,根据线程数确定大小),因此异步 I/O 开箱即用。将其设为 0 即可回到 1.5 的行为。

关于小文件多说一句:没有实质性变化,因为那里的耗时在于每个文件的往返请求(先读 footer,再读数据),这是预读取无法消除的。把数据湖存成成千上万个 1 MB 的 Parquet 文件本来就是坏实践,2.0 也救不了它。基本功依然重要!

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

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

递归 CTE 就是对一张表的循环。假设有一张 employees 表,包含 manager 和 employee 两列。你想回答一个简单的问题:谁向谁汇报。

为此,你从一行开始:CEO Ana。第一轮,找出所有经理是 Ana 的人(Ben、Cléa)。第二轮,找出所有经理是上述人员之一的人(Dev、Eli、Fay)。如此继续,直到某一轮没有发现新人。组织架构图的每一层就是一轮。

组织架构、文件夹树、物料清单、回复线程、数据血缘、git 历史:这些数据的表结构往往都是同样的两列——父节点和子节点。唯一的区别在于深度,而深度就是查询的轮数。组织架构大概八层,而 git 历史可能上万层。

这就是 1.5 的问题所在。每一轮它都要回头重新读取整张表来找出下一层。八层意味着八次全表读取,上千层就意味着对同一张表进行上千次全表读取。在 2.0 中,表只读一次,在 parent 列上构建一次查找结构,每一轮只查找它刚找到的那几行。现在的成本取决于你实际触及的行数,而不是轮数乘以表大小。

回到 git 历史,这正是你会看到性能提升的典型场景。每个提交都指向其父提交,所以表就是 commit_id, parent_id,遍历 HEAD 的祖先链就是每个提交一轮。我生成了一个包含 20,000 个提交、带几次 merge 的仓库,并用类似下面的递归 CTE 一直回溯到根提交:

TL;DR:如果你需要遍历很深的父子链(git 历史、数据血缘、回复线程、完整物料清单),2.0 把过去不得不推给图数据库的任务变成了普通查询。如果你的层级很浅,比如组织架构,那提升不会明显。无论如何,把层级数据保存为一张带整数 id 的父子表,当递归需要携带 depth 或 cost 之类的值时,使用 USING KEY。

人人都爱 JSON。VARIANT 现在是 DuckDB 中的一等数据类型,需要记住的词是 shredding。

不是弹吉他那种。在 VARIANT 语境下,shredding 的意思是:当 DuckDB 将一个行组写入磁盘时,它会检查你的 JSON 列,找出在大多数行中都出现、且每次值类型都一致的字段。

一个常见的例子,五百万条事件中的一条:

event kind 始终是文本,user.id 始终是数字,props.amount 始终是小数。这些字段在底层被抽取出来,存入各自真正的列。那些罕见字段,以及在一行是数字、下一行却是文本的字段,则一起留在二进制余量(remainder)中。于是,JSON 中规律一致的部分像普通表一样存储,只有混乱的部分才作为 blob 存储。

最理想的场景是结构化日志。level、service、latency_ms、trace_id 在每一行都出现且值类型一致,因此都能被 shredding。奇怪的 extra 对象留在余量中,仍可查询,只是慢一些。陷阱在于:如果 latency_ms 在一行是 231,下一行却是 "231ms",它也会掉进余量。请保持值类型一致。

VARIANT 不只关乎速度。文本对存储的贪婪程度不亚于对 CPU。我把这五百万条事件用三种方式存储:

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

因此建模的黄金法则依然有效:为你已知的东西建模。每个查询都会触及的字段值得拥有真正的列,而提升一个字段只需两条语句:

TL;DR:如果你的事件共享一组一致的字段且值类型稳定,用 VARIANT 而不是 JSON 字符串来存储。你可以只用大约三分之一的存储空间,字段查询的表现如同真正的列。把每个查询都触及的字段提升为真正的列,把长尾部分留在 VARIANT 中,目前热查询中请避免列表 cast。

触发器(Triggers)。当行发生变化时,表会自动执行一小段 SQL。例如,更新一些价格,触发器能看到每行变化前后的状态(即"transition tables"过渡表),并把两者写入历史表。以前,每个接触该表的工具都得记得把这条历史记录写到某个地方。现在可以直接在数据库端完成。

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

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

Quack 客户端-服务器协议是本次发布的另一半,并随之一同升级到 1.0。我在专门的视频中介绍过它,这里不再重复。

CLI 只需一行即可安装 alpha 版,其他客户端请见 DuckDB 安装页面: