返回

DuckDB v2.0递归CTE性能提升42.6倍

RSS DuckDB Engineering Blog AI 逐段翻译 发布 2026-08-25 08:00 收录于 08-26
DataHot 速览

DuckDB官方博客详解v2.0中递归CTE执行引擎的重构:将可复用执行状态提升到查询计划生命周期,使迭代间复用管道调度、操作符和哈希构建,避免每次迭代重复支付执行开销。在一百万条边的可达性查询中,中位运行时间从v1.5.5的4.051秒降至v2.0预览版的0.095秒,获得42.6倍加速。同时介绍了USING KEY语义变化,以及内联执行与调度执行的选择策略。

为什么值得关注:递归CTE是复杂分析查询中的常见性能瓶颈,DuckDB通过状态生命周期拆分实现数量级加速,对数据平台引擎开发者和高性能分析实践者有直接参考价值。

本文目录 17 节
  1. 可复用执行状态比前端更持久
  2. 前端限定递归输入
  3. Epoch不变状态属于调用
  4. 保留需要可重复性证明
  5. 精确基数使执行自适应
  6. 每个纪元选择自己的模式
  7. 冻结的有键状态启用直接探测
  8. 每个纪元读取冻结的有键状态
  9. 探测资格遵循连接形状
  10. 稀疏工作应避免全状态扫描
  11. 候选者可以保持键控状态不变
  12. 最终状态属于提交层
  13. UNION和UNION ALL暴露不同的前沿
  14. 变化键增量仍需支付候选工作
  15. 预聚合需要可组合的聚合状态
  16. 基数证据管理预聚合
  17. 递归 CTE 现在作为单个自适应计算执行

译文

AI 逐段翻译

当我在2020年实现DuckDB的第一个递归CTE算子时,正确性决定了设计:先评估非递归项一次,然后评估递归项直到下一个工作表为空。这确立了正确的语义契约,但可复用的运行时状态范围过窄。在迭代之间,递归输入发生变化,而评估它的大部分机制仍然可以复用。然而,实现将每次迭代几乎当作一个新查询,反复付出流水线调度、算子设置、执行和拆除的代价。从那时起,我一直想消除这种不匹配。

在即将到来的DuckDB v2.0中,我们明确分配了这些作用域。查询计划拥有物理算子树、预计算的流水线调度以及可复用的流水线执行器池。每次递归调用从该池中借用执行器,并拥有累积的递归状态和保留的物化结果,其生命周期跨越整个不动点计算,而每次迭代只拥有依赖于前端的(frontier-dependent)状态。这种分离实现了保留的哈希构建、每次迭代在内联执行和调度执行之间的选择,以及对键控状态的直接探测。物理执行的改变伴随着一个语义变化:对于USING KEY,UNION现在使新键和最终载荷已更改的键对下一次迭代可见。

为了说明性能影响,我们在一个小型可达性查询上比较原始引擎和新引擎。该表包含100,000个节点上的一百万条边。每个源节点有十条相同的边,从节点0开始跟随这些边会访问20,000个节点:

CREATETABLEedgesASSELECT(range%100_000)::INTEGERASsrc,((range*13+7)%100_000)::INTEGERASdstFROMrange(1_000_000);WITHRECURSIVEreachable(node)AS(SELECT0UNIONSELECTdstFROMedges,reachableWHEREsrc=node)SELECTcount(*)FROMreachable;

通过下面描述的优化,中位运行时间从DuckDB v1.5.5的4.051秒降至DuckDB v2.0预览版的0.095秒,在不改变SQL的情况下实现了42.6倍的加速。

可复用执行状态比前端更持久

递归CTE执行位于查询计划生命周期内。物理算子树属于该计划,不可变的调度投影在物理流水线构建期间构建。在计划内,一次调用跨越一次完整的不动点计算,而一次 epoch 跨越递归项的一次评估。原始查询计划已经保留了递归物理算子,但它的运行时将每个 epoch 视为一次新的执行:它重新创建事件和执行器,重建算子状态,并重复调度工作。

在流水线构建期间,DuckDB 推导出流水线依赖调度的不可变视图,包括在第一次执行后省略调用保留的构建流水线的形式。我们将这些视图称为调度投影。因此,运行时可以选择所需的依赖调度,而无需为每个 epoch 重新推导。

前端限定递归输入

每次递归epoch替换工作表,通常称为前端,而查询保持不变。锚定项产生第一个前端。递归项读取它并产生下一个前端的候选。使用常规UNION,DuckDB拒绝已见过的行;幸存者形成下一个前端,并贡献给逻辑并集表。当没有幸存行时,评估停止。

并集表属于语义范畴。DuckDB不需要物化它:结果块可以流式向下游传输,而引擎保留当前前端,对于常规UNION,还需要用于去重的哈希状态。只有当递归项访问recurring.cte_name时,它才累积完整副本。

对于单调、线性的递归项,这种前端纪律是半朴素评估的操作核心:一个 epoch 只读取前一个增量,而不是从到目前为止看到的每一行重新评估递归项。原始算子已经保持了这一语义不变式;剩余的不匹配涉及评估该前端的物理机制的生命周期。

Epoch不变状态属于调用

前端扫描,在REC_CTE_SCAN中显示为EXPLAIN计划,必须在 epoch 边界后读取新输入,并且每个依赖于该输入的算子的运行时状态必须重置。查询计划拥有的调度投影保持有效。为调用检出的执行器和调用拥有的缓冲区可以被重置以重用,而仅对递归无关的基础表进行构建可以保留其物化状态,如果重复构建会产生相同的可观察结果。旧运行时未能区分调用范围的物化状态与前端相关状态:其有效性跨越递归调用的物化状态不能由一个 epoch 拥有。

当一个操作的输入不依赖于当前前端,并且重新评估它将产生相同的可观察结果时,该操作是epoch不变的。扫描稳定的基表并从中构建哈希表是典型的例子。递归运行时遵循与循环不变代码外提相同的所有权区分:不变的构建属于 epoch 循环之外,而用变化的前端探测它则在其内部。

所有权不变式首先在递归运行时中失败。DuckDB将查询作为流水线图执行。尽管旧的物理算子保留了其递归元流水线,但每个 epoch 都重新创建事件和执行器,调度流水线并重建其状态。因此,epoch不变的流水线会重复读取相同的静态输入并重建相同的状态。

大的 epoch 可以将固定的调度和设置成本分摊到许多前端行上。重建不变状态仍然与静态输入大小成比例,并且可能比用变化的前端探测它花费更多。

旧引擎从每个 REC_CTE_SCAN 前端构建一个哈希表,并通过扫描边来探测它;新引擎扫描边并一次性构建保留的哈希表,然后用每个前端探测它。
旧引擎从每个REC_CTE_SCAN前沿构建哈希表,并通过扫描边来探测;新引擎扫描边并仅构建一次保留的哈希表,然后用每个前沿探测它。

DuckDB v1.5.5从每个前沿重建哈希表,并重新访问边作为探测输入。新引擎反转连接,仅构建一次边哈希表,并用每个前沿探测它。

产生的所有权边界是:

状态拥有范围纪元边界动作
物理运算符树和不可变的调度投影查询计划保留
可复用的管道执行器池查询计划保留
执行器检出、块和集合容量递归调用重置内容以供重用
累积的重复消除或有键状态递归调用保留
可重复、独立于递归的构建递归调用保留物化状态
易变、有副作用或未知的构建纪元重建
递归扫描纪元重新绑定到当前递归状态
候选输出纪元清除或合并

起始查询中的哈希连接需要连接重定向和保留状态。DuckDB v1.5.5从当前的可达前沿构建哈希表,并在每个纪元中扫描边作为探测输入。新的规划器将独立于递归的边关系放在构建侧,如果有效。第一个纪元扫描边并构建哈希表;后续纪元重新绑定可达作为探测并重用该表。

EXPLAIN (ANALYZE, FORMAT JSON)使得避免的输入工作具体化。两个版本都返回预期的20,000个节点。DuckDB v1.5.5报告operator_rows_scanned: 19,718,328,320在边扫描上,相当于约19,718次完整扫描的行量,而DuckDB v2.0预览版报告表的一百万行只扫描一次。动态过滤跳过了一些v1.5.5边扫描的部分,但无法防止基础关系在每个纪元中被重新访问。

保留需要可重复性证明

仅当构建的可观察结果是可重复的时,我们才保留它。递归独立性建立了一个要求。可重复性增加了另一个:有种子样本可以保留;无种子样本、易变表达式如nextval()、DML、有副作用运算符或未知扩展运算符必须重建。可重复性分类器拥有该决策,并在无法证明安全时拒绝保留。这种保守的选择可能放弃重用,同时保持查询语义。

完全物化的CTE生产者遵循相同的所有权规则:独立于递归的结果可以存活,而每个消费者扫描被重置。我们不保留流式或混合生产者,因为其消费状态不具有相同的生命周期。早期源终止是另一个边界情况。保留的管道不得仅为了填充向量而扣留部分输出,因为这可能阻止下游LIMIT提前停止递归执行。因此重用保留了该终止契约。

精确基数使执行自适应

纪元边界提供了普通查询规划期间不可用的信息。到纪元结束时,DuckDB已完整生成下一个前沿,因此确切知道其行和块计数。这些观察到的基数决定递归运行时如何执行下一个纪元;优化器估计不再需要替代它们。

每个纪元选择自己的模式

一旦起始查询构建并保留了边哈希表,一个纪元将一个可达节点带入一个探测。通过并行调度器发送该工作会创建比有用工作更多的协调。前沿扩展到多个块的遍历具有相反的形状:在单个线程上执行会使独立的管道工作闲置。为整个查询选择的静态模式无法同时服务两种情况,并且单个查询可能在其前沿增长和收缩时在它们之间移动。

当精确的前沿和物理工作形状不证明任务调度合理时,纪元以内联方式运行。内联。一个线程遍历选定的不可变调度投影并直接驱动其运算符,而不创建任务或进入通用调度器。当工作分类器找到足够的独立工作时,调度执行调度执行实例化相应的递归事件图,并在有界数量的工作线程之间分配工作。两种模式使用相同的查询计划拥有的调度投影。为调用检出的执行器和调用拥有的缓冲区被重置以供重用;只有纪元级执行策略改变。

仅行数不决定有用的并行性。策略还考虑前沿块、递归引用数量、独立源任务、配置的线程和实际执行的管道。相同的前沿可以馈送多个管道,而独立源可以暴露前沿行未表示的工作。相反,工作线程本地输出和边界组合引入自身成本。因此,我们通过递归输入和物理工作单元来限制工作线程,仅对宽正则使用私有输出UNION ALL和重复消除UNION纪元,其中其合并成本可以摊销。

冻结的有键状态启用直接探测

Torsten Grust和Björn Bamberg解释了原始的USING KEY功能及其应用在他们的“USING KEY在递归CTE中的博客文章中。语义上,USING KEY将联合表作为有键状态操作:声明的键列标识一行,而其余负载列由声明的聚合或默认的last聚合维护。DuckDB在聚合哈希表中物理表示此状态。因此递归项可以直接探测选定的键,而不是扫描完整的循环状态。

每个纪元读取冻结的有键状态

直接访问必须保持一个语义不变量:通过recurring.cte_name在纪元i期间的每次访问观察到相同的有键状态Sᵢ。该纪元有三个有序阶段:读取Sᵢ,缓冲其候选集,并提交这些候选以获得Sᵢ₊₁在进行读取阶段时,让候选者可见会导致同一时期的另一个候选者观察到它,并使结果依赖于工作者到达顺序。这三个阶段保持了逐状态的一次性转换,同时探测并发执行。

原始实现通过将带有键的状态物化为集合以进行重复状态读取,从而保持了不变性。这种表示使得每次访问都有广泛的物理路径:触及几个键的时期也可能复制或扫描包含数百万个键的状态。

带有键的状态所有者现在强制执行这些阶段。当递归管道执行时,recurring.cte_name读取冻结的聚合哈希表,候选者则单独累积。所有者仅在时期边界提交它们。探测无法观察到应用了一半的更新,并且哈希表的增长无法使并发读者持有的地址失效。递归结束后,源会一次性耗尽最终的键控状态。

探测资格遵循连接形状

冻结表示支持专门的物理查找,同时保持语义视图。对于将每个声明的键与=或IS NOT DISTINCT FROM进行比较的内连接,可以使用递归键连接,在EXPLAIN中显示为RECURSIVE_KEY_JOIN,并直接探测聚合哈希表。在复合键的适当子集上进行的此类比较可以通过RECURSIVE_PARTIAL_KEY_JOIN使用时期稳定的二级哈希索引。新的完整键仅在其地址稳定后扩展索引;仅载荷更新不需要索引维护。

我们仅为针对直接重复状态扫描(REC_REC_CTE_SCAN)的内连接选择完整键专用化,且每个声明的键都有直接的、精确类型的标量=或IS NOT DISTINCT FROM比较。残留谓词、包装扫描、类型不匹配、嵌套键和其他连接类型使用常规路径。普通递归引用也不符合条件,因为它表示前沿;累积的键控状态可通过重复引用获得。将这两种身份视为可互换将改变查询。

稀疏工作应避免全状态扫描

下面的工作负载保留一百万个键,并通过20个时期推进其中的1,000个:

WITHRECURSIVEstate(key,value)USINGKEY(key)AS(SELECTkey,CASEWHENkey<1_000THEN0ELSE100ENDFROMrange(1_000_000)keys(key)UNIONALLSELECTfrontier.key,recurring_state.value+1FROMstateASfrontierJOINrecurring.stateASrecurring_stateUSING(key)WHEREfrontier.value<20)SELECTcount(*)ASkeys,sum(value)::BIGINTASvalue_sumFROMstate;

在直接探测之前,连接反复扫描recurring.state。运行时指标统计了约2000万行全状态扫描,产生20,000个有用匹配。专用计划用20,000次直接探测替换它们,检查的重复状态行减少了1,000倍。在直接探测工作之前和之后的构建比较中,中位运行时间从0.401秒降至0.040秒。新路径还移除了以前用于物化完整键控状态的每时期集合。

候选者可以保持键控状态不变

即使键控状态没有变化,候选包也可能非空。如果候选者自动成为下一个前沿,递归可能在查询可见状态收敛后继续。保留执行状态并高效探测它无法修复这种语义不匹配。

USING KEY ... UNION现在赋予这种区别SQL级语义。我们的“A Fix for the Fixation on Fixpoints” CIDR论文和“How DuckDB is USING KEY to Unlock Recursive Query Performance” SIGMOD论文都描述了原始的候选者前沿设计,其中无论键控状态是否变化,候选包都成为下一个工作台。两者都没有定义改变的键增量。据我们所知,DuckDB是第一个、目前也是唯一个在UNION下区分改变的键递归与在UNION ALL下的候选者前沿递归的数据库系统。

最终状态属于提交层

在此更改之前,DuckDB遵循原始设计,并将USING KEY ... UNION和USING KEY ... UNION ALL同等对待。递归项产生的每个候选者都成为下一个时期的输入,即使应用它使重复表保持不变。由于普通的UNION与UNION ALL具有相同的候选者前沿行为,DuckDB v1.5为USING KEY弃用了它。这里引入的改变的键语义赋予两个关键字不同的含义,因此即将到来的v2.0保留两者。键的可观察聚合结果仅在该时期的每个候选者应用后才确定。因此,时期提交层拥有决定键是否改变的所有权。

最短路径的示例证明了为什么候选者不能自行决定。多条路线可能到达同一节点,并且每条路线都必须参与min(distance)。只有最终的最小值在键控状态中可观察。转发每条次优路线会倍增下一个时期的工作量;转发未改变的最小值可能在状态收敛后仍使候选者产生继续。

考虑键控状态{A:8, B:7}具有min载荷和候选者[A:9, A:5, B:7]。应用完整的候选包产生{A:5, B:7}。候选者A:9不影响最终的最小值,而B:7重复现有值。UNION ALL仍然转发所有三个候选者。UNION仅转发最终的行A:5因为它是唯一可观察的状态变化。

UNION和UNION ALL暴露不同的前沿

设Cᵢ为时期i产生的候选包,Sᵢ为那一时期可见的键控状态,以及Wᵢ₊₁为下一个工作台。设update(Sᵢ, Cᵢ)应用包中的所有候选者并最终确定每个受影响的载荷聚合,并设keys(Sᵢ)表示更新前存在的键。我们定义两种形式如下:

USING KEY ... UNION ALL
Sᵢ₊₁ = update(Sᵢ, Cᵢ)
Wᵢ₊₁ = Cᵢ

USING KEY ... UNION
Sᵢ₊₁ = update(Sᵢ, Cᵢ)
Wᵢ₊₁ = { Sᵢ₊₁[k] | k ∉ keys(Sᵢ) OR Sᵢ₊₁[k] IS DISTINCT FROM Sᵢ[k] }

对于相同的输入状态和候选包,两种形式计算相同的下一个键控状态。它们的不同之处在于什么变得递归可见:UNION ALL原样转发包,而UNION为每个新键或可观察改变的键转发一个最终行的。因此,在USING KEY ... UNION下的递归在键控状态停止改变时停止。

epoch-commit 层通过记录键先前是否存在,并在键首次被接触时对预先存在的键的 epoch 前最终化负载进行快照,来实现该规则。然后它在最终化并比较每个被接触的键之前应用每个候选。重复的候选和失败的min 或 max 输入仍然参与聚合评估,但它们自身不会成为递归工作。

USING KEY UNION ALL 转发每个候选,而 UNION 应用所有候选并仅转发值发生变化的最终化键。
USING KEY UNION ALL 转发每个候选,而 UNION 应用所有候选并仅转发值发生变化的最终化键。

对于相同的键控状态和候选集,两种形式计算相同的状态更新;下一个前沿要么是该候选集,要么是变化键的增量。

我们使用 SQL 语义比较最终化值,包括 NULL 和排序规则。键身份使用与 GROUP BY 相同的规范化,并且排序的标量键仍然符合直接和部分键探测的条件。该实现还涵盖多列和嵌套负载、NaN 和带符号零。

两个前沿也有不同的类型。UNION ALL 前沿包含原始候选行,因此其负载列使用聚合输入类型。UNION 前沿包含最终化的键控行,因此其负载列使用聚合结果类型。这种区别通过 SQL 契约可见,并限制了内部表示。因为关键字选择前沿的内容、类型和终止条件,我们绝不会将一种形式推断为另一种形式作为优化。

变化键增量仍需支付候选工作

变化键增量仅在所有候选被应用后才减少下一个前沿。因此,即使最终只有少数键发生变化,数百万个重复候选仍可能使频繁的哈希表更新变得昂贵。对于符合条件且重复繁多的 epoch,我们首先在临时键控哈希表中组合候选,然后将这些聚合状态组合到递归状态中。

预聚合需要可组合的聚合状态

早期组合必须在观察上等同于直接应用候选。仅当每个负载聚合提供状态合并操作且没有顺序依赖时,我们才启用预聚合。因此,默认的 last 聚合和没有合并回调的扩展聚合保持在直接更新路径上。没有合并操作,预聚合缺乏有效的状态转换。对于顺序依赖的聚合,它可能改变观察到的输入顺序,从而改变结果。

基数证据管理预聚合

资格确定正确性;观察到的基数决定有效的转换是否值得。小于一个标准向量的 epoch 跳过分类,非扩展 epoch 的候选数不超过前一个前沿的也跳过。较大的合格 epoch 在候选键上构建 HyperLogLog 草图。仅当草图的误差膨胀基数估计低于候选数的四分之一时,我们才进行预聚合,并且一旦不同的键证据足以拒绝额外的哈希表,就提前停止草图。因此,决策适应每个 epoch 中观察到的重复情况。

证明值不变的成本限制了这一策略。对于具有 102,400 个唯一现有键更新的宽工作负载,在新 UNION 语义下,中位运行时间增加 0.913 毫秒,即 6%。执行器必须比较最终化值,而以前的实现转发候选而不证明变化。针对 min 和 max 的聚合特定快捷方式会将聚合语义放入递归执行器,因此我们不使用它们。聚合级别的变化报告契约可以将该责任放在聚合接口中。当前接口不提供此类契约。

大型激励工作负载是一个 LDBC SF100 路径查找查询。它生成约 2100 万个聚合候选,但 epoch 级聚合仅产生 370 万个可观察的键控结果。自适应路径预聚合了 1700 万个候选。在变化键工作前后立即匹配的 Release 构建上,查询时间从中位数 19.319 秒降至 2.948 秒,加速 6.55 倍,而峰值驻留内存从 3.918 GB 降至 2.663 GB。

更广泛的回归套件限制了该结果。在同一构建比较中,63 个递归基准的几何平均值提高了 5.5%,并且 60 个在 ±2% 以内。重复扇入和重复繁重的预聚合显著改进;上面的宽唯一键案例是唯一稳定的损失。因此,我们按 epoch 分类工作,而不是将赢得激励查询的策略应用于每个工作负载。

递归 CTE 现在作为单个自适应计算执行

新引擎解决了我在实现 DuckDB 原始递归操作符后想要移除的生命周期不匹配问题。查询计划拥有物理操作符树、调度投影和可重用执行器池。在每次调用中,运行时检查执行器并跨 epoch 保留可证明可重复、与递归无关的状态,而每个 epoch 重新绑定和重置依赖前沿的状态。这种分离使保留的不变工作和每 epoch 执行策略兼容。键控递归使用 epoch 边界冻结递归状态以进行直接探测,然后提交候选,并且仅在 UNION 下将可观察的变化暴露为下一个前沿。该实现跨 #22211、#24031、#24565 和 #24647 落地。

这一点很重要,因为递归会放大每次循环内产生的所有物理开销。单独看来不算大的扫描、哈希构建或调度器操作,可能会被执行成千上万次。新引擎会在每次调用时仅支付一次符合条件的固定开销,当观察到的工作量不足以摊销调度成本时,会跳过调度器,并对展现出足够独立工作的epoch进行分配。符合条件的键控连接可以直接访问所需状态,而使用 KEY ... UNION递归会在该状态收敛时终止。这使得递归SQL成为图遍历、路径搜索和状态机更实用的执行模型,尤其是在边界保持小而静态输入大,或许多候选更新相同键的情况下。

下一步是将这一基础扩展到更多递归计划。未来的工作可以将调用范围的重用扩展到更多符合条件的算子状态,并利用epoch边界处观察到的基数进行进一步的执行决策。每个扩展都必须保留相同的边界和状态转换语义。我打算继续沿着这条边界努力:在保持递归SQL可预测的同时,降低迭代的物理成本。

这篇内容对你有用吗?

反馈只用于改善内容筛选,不等同于收藏

分享这条资讯
分享海报
保存图片
iOS 也可以长按图片保存