来源:https://duckdb.org/2026/08/25/how-duckdb-runs-recursive-ctes-faster.html
DuckDB 如何更快地运行递归 CTE
Denis Hirn
2026-08-25 | 23分钟
TL;DR: DuckDB 的递归 CTE 引擎现在将递归视为一个长期运行的计算:它保留符合条件的不变状态,根据精确的前沿基数(frontier cardinalities)和物理工作量选择执行模式,直接探测键控状态(keyed state),并为 USING KEY ... UNION 提供变更键(changed-key)语义。
当我在 2020 年实现 DuckDB 的第一个递归 CTE 操作符时,正确性决定了设计:先执行非递归项一次,然后执行递归项,直到下一个工作表(working table)为空。这确立了正确的语义契约,但可重用的运行时状态范围过于狭窄。在多次迭代中,递归输入会发生变化,但评估它的大部分机制是可重用的。然而,该实现几乎将每次迭代都视为一个新的查询,并反复付出管道调度、操作符设置、执行和拆除的成本。自那以后,我一直想消除这种不匹配。
在即将发布的 DuckDB v2.0 中,我们显式地分配了这些作用域。查询计划拥有物理操作符树、预计算的管道调度以及一个可重用的管道执行器池。每次递归调用从该池中借用执行器,并拥有累积的递归状态和保留的物化结果,这些结果的生命周期跨越整个不动点计算,而每次迭代仅拥有依赖于前沿(frontier-dependent)的状态。这种分离使得保留哈希构建、每次迭代在内联执行和调度执行之间选择,以及直接探测键控状态成为可能。物理执行的变更伴随着一个语义变更:对于 USING KEY,UNION 现在使新键以及最终载荷发生变更的键在下一迭代中可见。
为了说明性能影响,我们在一个小的可达性查询上比较了原始引擎和新引擎。该表包含 100,000 个节点上的 100 万条边。每个源节点发出十条相同的边,从节点 0 开始跟随它们将访问 20,000 个节点:
sql
CREATE TABLE edges AS
SELECT (range % 100_000)::INTEGER AS src,
((range * 13 + 7) % 100_000)::INTEGER AS dst
FROM range(1_000_000);
WITH RECURSIVE reachable(node) AS (
SELECT 0
UNION
SELECT dst
FROM edges, reachable
WHERE src = node
)
SELECT count(*)
FROM reachable;
通过下文描述的优化,中位运行时间从 DuckDB v1.5.5 的 4.051 秒下降到 DuckDB v2.0 预览版的 0.095 秒,在不改变 SQL 的情况下实现了 42.6 倍的加速。
可重用执行状态的生命周期超越前沿
递归 CTE 执行位于查询计划的生命周期内。物理操作符树属于该计划,不可变的调度投影(schedule projections)在物理管道构建期间构建。在计划内,一次调用(invocation)跨越一个完整的不动点计算,而一个时期(epoch)跨越递归项的一次评估。原始查询计划已经保留了递归物理操作符,但其运行时将每个时期视为一次全新的执行:它重新创建事件和执行器,重建操作符状态并重复调度工作。
在管道构建期间,DuckDB 派生出管道依赖调度的不可变视图,包括在首次执行后省略了调用保留的构建管道的视图。我们称这些视图为调度投影(schedule projections)。因此,运行时无需为每个时期重新派生所需的依赖调度。
前沿界定递归输入
每个递归时期都会替换工作表(通常称为前沿),而查询保持不变。初始项(anchor term)产生第一个前沿。递归项读取它并产生下一个前沿的候选。对于常规的 UNION,DuckDB 会拒绝已见过的行;存活下来的行形成下一个前沿,并且也对逻辑并集表(logical union table)做出贡献。当没有行存活时,评估停止。
并集表属于语义范畴。DuckDB 不必物化它:结果块可以流式传输到下游,同时引擎保留当前前沿,以及对于常规 UNION,保留去重所需的哈希状态。仅当递归项访问 recurring.cte_name 时,它才会累积完整的副本。
对于单调的线性递归项,这种前沿规范是半朴素评估(semi-naive evaluation)的操作核心:一个时期只读取前一个增量(delta),而不是从迄今看到的每一行重新评估递归项。原始操作符已经保持了这一语义不变性;剩余的不匹配涉及评估该前沿的物理机制的生命周期。
时期不变状态属于调用
在 EXPLAIN 计划中显示为 REC_CTE_SCAN 的前沿扫描,必须在时期边界后读取新的输入,并且依赖该输入的每个操作符的运行时状态都必须重置。查询计划拥有的调度投影仍然有效。为调用签出(checked out)的执行器和调用拥有的缓冲区可以重置以供重用,而仅基于递归无关基表(recursion-independent base tables)的构建,如果重复构建会产生相同的可观察结果,则可以保留其物化状态。旧的运行时未能区分调用作用域的物化状态和依赖于前沿的状态:其有效性跨越递归调用的物化状态不能由单个时期拥有。
当一个操作的输入不依赖于当前前沿,并且重新评估它将产生相同的可观察结果时,该操作是时期不变的(epoch-invariant)。扫描一个稳定的基表并从中构建哈希表是典型的例子。递归运行时遵循与循环不变代码移动(loop-invariant code motion)相同的所有权区分:不变的构建属于时期循环外部,而使用变化的前沿探测它则留在循环内部。
所有权不变性首先在递归运行时中失效。DuckDB 将查询执行为管道图(graph of pipelines)。尽管旧的物理操作符保留了其递归元管道(meta-pipeline),但每个时期都重新创建了事件和执行器,调度了管道并重建了它们的状态。因此,时期不变的管道会重新读取相同的静态输入并重建相同的状态。
一个大的时期可以将固定的调度和设置成本摊销到许多前沿行上。重建不变状态的成本仍与静态输入大小成比例,并且可能远高于使用变化的前沿探测它的成本。
旧引擎从每个 REC_CTE_SCAN 前沿构建一个哈希表,并通过扫描边来探测它;新引擎扫描边并一次性构建一个保留的哈希表,然后用每个前沿探测它。
DuckDB v1.5.5 从每个前沿重建哈希表,并重新访问边作为探测输入。新引擎反转了连接,一次性构建边哈希表,并用每个前沿探测它。
由此产生的所有权边界是:
| 状态 | 拥有作用域 | 时期边界操作 |
|---|---|---|
| 物理操作符树和不可变调度投影 | 查询计划 | 保留 |
| 可重用的管道执行器池 | 查询计划 | 保留 |
| 执行器签出、块和集合容量 | 递归调用 | 重置内容以供重用 |
| 累积的去重或键控状态 | 递归调用 | 保留 |
| 可重复的、递归无关的构建 | 递归调用 | 保留物化状态 |
| 易变的、有副作用的或未知的构建 | 时期 | 重建 |
| 递归扫描 | 时期 | 重新绑定到当前递归状态 |
| 候选输出 | 时期 | 清空或合并 |
开头查询中的哈希连接需要连接重定向(join reorientation)和保留状态。DuckDB v1.5.5 在每个时期从当前可达前沿构建哈希表,并扫描边作为探测输入。新的规划器在有效时将递归无关的边关系置于构建侧。第一个时期扫描边并构建哈希表;后续时期将 reachable 重新绑定为探测并重用该表。
EXPLAIN (ANALYZE, FORMAT JSON) 使避免的输入工作具体化。两个版本都返回预期的 20,000 个节点。DuckDB v1.5.5 报告边扫描的 operator_rows_scanned: 19,718,328,320,按行量相当于约 19,718 次完整扫描,而 DuckDB v2.0 预览版报告该表仅被扫描一次(100 万行)。动态过滤跳过了 v1.5.5 中部分边扫描,但它无法阻止基表在每个时期被重新访问。
保留需要可重复性证明
仅当其可观察结果可重复时,我们才保留构建。递归无关性确立了一个要求。可重复性增加了另一个要求:种子样本(seeded sample)可以保留;非种子样本、易变表达式如 nextval()、DML、有副作用的操作符或未知的扩展操作符必须重建。可重复性分类器(repeatability classifier)负责此决策,并在无法证明安全性时拒绝保留。这种保守选择可能会放弃重用,同时保留查询语义。
完全物化的 CTE 生产者遵循相同的所有权规则:递归无关的结果可以保留,而每个消费者扫描则被重置。我们不会保留流式或混合生产者,因为它们的消费状态不具有相同的生命周期。提前源终止(early source termination)是另一个边界情况。保留的管道不得仅仅为了填充向量而保留部分输出,因为这样做可能会阻止下游的 LIMIT 提前停止递归执行。因此,重用会保留该终止契约。
精确基数使执行自适应
时期边界提供了普通查询规划期间无法获得的信息。当一个时期结束时,DuckDB 已经完整地生成了下一个前沿,因此确切地知道其行数和块数。这些观察到的基数决定了递归运行时如何执行下一个时期;优化器的估计不再需要替代它们。
每个时期选择自己的模式
一旦开头查询构建并保留了其边哈希表,一个时期将一个可达节点带入一个探测。通过并行调度器发送该工作将产生比有用工作更多的协调。前沿扩展到许多块的遍历则具有相反的形状:在单线程上执行它将使独立的管道工作处于空闲状态。为整个查询选择的静态模式无法同时服务于这两种情况,并且单个查询可能在其前沿增长和收缩时在两者之间移动。
当精确前沿和物理工作形状不足以证明任务调度的合理性时,一个时期内联执行(inline)。一个线程遍历选定的不可变调度投影并直接驱动其操作符,而无需创建任务或进入通用调度器。当工作分类器找到足够的独立工作时,调度执行(scheduled execution)实例化相应的递归事件图,并将工作分配到有限数量的工作线程上。两种模式都使用查询计划拥有的相同调度投影。为调用签出的执行器和调用拥有的缓冲区将重置以供重用;只有时期级别的执行策略会改变。
仅凭行数并不能确定有用的并行性。策略还会考虑前沿块数、递归引用数量、独立源任务、配置的线程数以及实际会执行的管道。同一个前沿可以供给多个管道,而独立源可以暴露前沿行未表示的工作。相反,工作线程本地的输出和边界组合会引入自身成本。因此,我们通过递归输入和物理工作单元两者来限制工作线程数,仅在广泛的常规 UNION ALL 和去重 UNION 时期使用私有输出,因为其组合成本可以摊销。
冻结的键控状态支持直接探测
Torsten Grust 和 Björn Bamberg 在其博客文章 "USING KEY in Recursive CTEs" 中解释了原始 USING KEY 特性及其应用。从语义上讲,USING KEY 将并集表作为键控状态(keyed state)操作:声明的键列标识一行,而其余的有效载荷列由声明的聚合函数或默认的 last 聚合函数维护。DuckDB 在聚合哈希表中物理地表示此状态。因此,递归项可以直接探测选定的键,而无需扫描完整的 recurring 状态。
每个时期读取冻结的键控状态
直接访问必须保留一个语义不变性:在时期 i 期间,每次通过 recurring.cte_name 的访问都观察到相同的键控状态 Sᵢ。该时期包含三个有序阶段:读取 Sᵢ,缓冲其候选包(candidate bag),并提交这些候选以获得 Sᵢ₊₁。在读取阶段使候选可见将允许同一时期的另一个候选观察到它,并使结果依赖于工作线程到达的顺序。这三个阶段在探测并发执行时保留了每个时期一次状态转换(epoch-at-a-time state transition)。
原始实现通过将键控状态物化为集合以供 recurring 状态读取来保留此不变性。该表示为每次访问提供了广泛的物理路径:一个接触少数几个键的时期仍然可能复制或扫描包含数百万个键的状态。
键控状态所有者现在强制执行这些阶段。当递归管道执行时,recurring.cte_name 读取冻结的聚合哈希表,候选者单独累积。所有者仅在时期边界提交它们。探测无法观察到半应用的更新,并且哈希表增长不会使并发读取器持有的地址失效。递归结束后,源会一次性耗尽最终的键控状态。
探测资格遵循连接形状
冻结表示支持专门的物理查找,同时保留语义视图。将每个声明键与 = 或 IS NOT DISTINCT FROM 进行比较的内部连接可以使用递归键连接(在 EXPLAIN 中显示为 RECURSIVE_KEY_JOIN),并直接探测聚合哈希表。对复合键的适当子集进行此类比较可以通过 RECURSIVE_PARTIAL_KEY_JOIN 使用时期稳定的二级哈希索引。新的完整键仅在其地址稳定后才扩展索引;仅有效载荷的更新不需要维护索引。
我们仅对针对直接 recurring 状态扫描(REC_REC_CTE_SCAN)的内部连接选择完整键特化,该连接对每个声明键进行直接的、类型精确的标量 = 或 IS NOT DISTINCT FROM 比较。剩余谓词、包装扫描、类型不匹配、嵌套键和其他连接类型使用通用路径。普通的递归引用也不符合条件,因为它表示前沿;累积的键控状态可通过 recurring 引用获得。将这两种身份视为可互换将改变查询。
稀疏工作应避免全状态扫描
下面的工作负载保留了一百万个键,并通过 20 个时期推进了其中 1,000 个键:
sql
WITH RECURSIVE state(key, value) USING KEY (key) AS (
SELECT key, CASE WHEN key < 1_000 THEN 0 ELSE 100 END
FROM range(1_000_000) keys(key)
UNION ALL
SELECT frontier.key, recurring_state.value + 1
FROM state AS frontier
JOIN recurring.state AS recurring_state USING (key)
WHERE frontier.value < 20
)
SELECT count(*) AS keys, sum(value)::BIGINT AS value_sum
FROM state;
在直接探测之前,连接重复扫描 recurring.state。运行时指标统计了大约 2000 万次全状态扫描行,以获得 20,000 个有效匹配。特化计划将其替换为 20,000 次直接探测,检查的 recurring 状态行数减少了 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 论文都描述了原始的候选前沿设计,其中候选包成为下一个工作表,而不管键控状态是否改变。两者都没有定义变更键增量(changed-key delta)。据我们所知,DuckDB 是第一个也是目前唯一一个在 UNION 下区分变更键递归与在 UNION ALL 下候选前沿递归的数据库系统。
最终化状态属于提交层
在此更改之前,DuckDB 遵循原始设计,将 USING KEY ... UNION 和 USING KEY ... UNION ALL 等同对待。递归项产生的每个候选者都成为下一个时期的输入,即使应用它使 recurring 表保持不变。因为普通的 UNION 具有与 UNION ALL 相同的候选前沿行为,DuckDB v1.5 弃用了 USING KEY 的 UNION。这里引入的变更键语义赋予了两个关键字不同的含义,因此即将发布的 v2.0 保留两者。键的可观察聚合结果仅在该时期的所有候选者都被应用后才确定。因此,时期提交层(epoch-commit layer)拥有决定键是否更改的所有权。
最短路径时期演示了为什么候选者自身无法决定这一点。几条路线可能到达同一节点,每条路线都必须参与 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 的递归在键控状态停止变更时终止。
时期提交层通过记录先前的键存在性,并在首次接触现有键时快照该键在时期前的最终化有效载荷来实现该规则。然后,它在最终化并比较每个接触键一次之前应用所有候选者。重复候选者和失败的 min 或 max 输入仍然参与聚合评估,但它们自身不会成为递归工作。
USING KEY UNION ALL 转发每个候选者,而 UNION 应用所有候选者并仅转发值已更改的最终化键。
对于相同的键控状态和候选包,两种形式计算相同的状态更新;下一个前沿要么是该包,要么是变更键增量。
我们使用 SQL 语义比较最终化值,包括 NULL 和排序规则(collations)。键标识使用与 GROUP BY 相同的规范化,并且带排序规则的标量键仍然符合直接和部分键探测的条件。该实现还涵盖了多列和嵌套有效载荷、NaN 和带符号零。
两种前沿也具有不同的类型。UNION ALL 前沿包含原始候选行,因此其有效载荷列使用聚合输入类型。UNION 前沿包含最终化的键控行,因此其有效载荷列使用聚合结果类型。此区别通过 SQL 契约可见,并约束内部表示。因为关键字选择了前沿的内容、类型和终止条件,我们永远不会作为一种优化从一种形式推断为另一种形式。
变更键增量仍需为候选工作付费
变更键增量仅在所有候选者被应用后才减少下一个前沿。因此,即使最终只有少数键发生更改,数百万个重复候选者仍然可能使递归哈希表更新代价高昂。对于符合条件的重复密集型时期,我们首先在临时键控哈希表中合并候选者,然后将这些聚合状态合并到 recurring 状态中。
预聚合需要可组合的聚合状态
早期合并必须在观察上等同于直接应用候选者。我们仅当每个有效载荷聚合都提供状态组合操作(state-combine operation)且没有一个是顺序依赖的(order-dependent)时,才启用预聚合。因此,默认的 last 聚合和没有组合回调的扩展聚合保留在直接更新路径上。没有组合操作,预聚合缺乏有效的状态转换。对于顺序依赖的聚合,它可能会改变观察到的输入顺序,从而改变结果。
基数证据指导预聚合
资格确立了正确性;观察到的基数决定了有效的转换是否值得。小于一个标准向量的时期绕过分类,同样,候选数不超过先前前沿的非扩展时期也绕过分类。较大的合格时期会在候选键上构建一个 HyperLogLog 草图。仅当草图的误差膨胀基数估计低于候选数的四分之一时,我们才进行预聚合,并在有足够的不同键证据拒绝额外哈希表时提前停止绘制草图。因此,该决策适应每个时期观察到的重复情况。
证明值未变更的成本限制了此策略。对于具有 102,400 个唯一现有键更新的宽工作负载,在新的 UNION 语义下,中位运行时间增加了 0.913 毫秒,即 6%。执行器必须比较最终化值,而以前的实现则转发候选者而无需证明变更。min 和 max 的聚合特定快捷方式会将聚合语义置于递归执行器中,因此我们不使用它们。聚合级别的变更报告契约可以将该责任放在聚合接口中。当前接口不提供此类契约。
大型激励性工作负载是 LDBC SF100 路径查找查询。它产生了约 2100 万个聚合候选者,但时期级聚合仅产生了 370 万个可观察的键控结果。自适应路径预聚合了 1700 万个候选者。在变更键工作前后匹配的 Release 构建上,查询时间中位数从 19.319 秒下降到 2.948 秒,加速了 6.55 倍,同时峰值驻留内存从 3.918 GB 降至 2.663 GB。
更广泛的回归测试套件限制了这一结果。在相同的构建比较中,63 个递归基准测试的几何平均值提高了 5.5%,其中 60 个在 ±2% 以内。重复扇入和重复密集型预聚合有显著改善;上面的宽唯一键案例是唯一的稳定性损失。因此,我们按时期对工作进行分类,而不是将赢得激励性查询的策略应用于每个工作负载。
递归 CTE 现在作为一次自适应计算执行
新引擎解决了我在实现 DuckDB 原始递归操作符后一直想消除的生命周期不匹配问题。查询计划拥有物理操作符树、调度投影和可重用的执行器池。在每次调用中,运行时签出执行器,并在各个时期保留经过证明可重复的、递归无关的状态,同时每个时期重新绑定和重置依赖于前沿的状态。这种分离使保留的不变工作和每时期执行策略兼容。键控递归使用时期边界冻结 recurring 状态以进行直接探测,然后提交候选者,并在 UNION 下仅将可观察的变更作为下一个前沿暴露出来。该实现已在 #22211, #24031, #24565 和 #24647 中落地。
这很重要,因为递归会使放置在时期循环内的每个物理成本倍增。一次扫描、哈希构建或通过通用调度器的行程,单独看来可能微不足道,但可能运行数万次。新引擎在每个调用中为符合条件的不变成本支付一次,在观察到的工时无法摊销时避免调度器开销,并分发暴露足够独立工作的时期。符合条件的键连接可以直接访问所需状态,而使用 USING KEY ... UNION 的递归在该状态收敛时终止。这使得递归 SQL 成为图遍历、路径查找和状态机更实用的执行模型,尤其是当前沿保持较小而静态输入很大,或者许多候选者更新相同的键时。
下一步是将此基础扩展到更多递归计划。未来的工作可以将调用作用域的重用扩展到更多符合条件的操作符状态,并使用在时期边界观察到的基数进行进一步的执行决策。每个扩展都必须保留相同的前沿和状态转换语义。我打算继续沿着这条边界努力:降低迭代的物理成本,同时保持递归 SQL 的可预测性。