alitrack

DuckDB 2.0 递归快 42 倍?4 种写法里只有 1 种快

昨晚有人在群里问:DuckDB 升到 2.0 了,我写的递归查询怎么一样慢,还爆栈了。官方博客的说法是递归 CTE 提速 42 倍,SQL 一行不用改。他觉得什么都没变。

两个人都对,因为说的是不同的查询。

我在本机装了 1.5.6 和 2.0.0,把常见的四种递归写法各跑一遍,又把爆栈复现了出来。先说结论:提速是真的,但它偏袒一种特定写法;爆栈几乎不是"递归太深"的锅。

● ● ●

快是真的,但 42 倍是官方 benchmark 的数字

先看对 2.0 最友好的形态:图可达性。官方 blog 用的 benchmark 就是它——100 万条边、10 万个节点,从节点 0 出发,每一轮拿刚找到的那批节点,去边表做一次等值 join,找下一轮的邻居:

CREATE TABLE edges AS
    SELECT (range % 100000)::INTEGER AS src,
           ((range * 13 + 7) % 100000)::INTEGER AS dst
    FROM range(1000000);

WITH RECURSIVE reachable(node) AS (
    SELECT 0
    UNION
    SELECT dst FROM edges, reachable WHERE src = node
)
SELECT count(*) FROM reachable;

本机 3 次取中位:1.5.6 跑 6.0 秒,2.0.0 跑 0.20 秒,约 30 倍。官方博客的 42.6 倍(4.051 秒降到 0.095 秒)是他们机器上的数字,方向一致,绝对值随机器变。引用时记得 42.6 是官方 benchmark 口径,不是普适数字。

快的机制一句话:1.5 把每一轮递归当一条新查询执行,重建执行计划、重建执行状态,大表每轮重扫一遍;2.0 把大表只扫一次、join 结构只建一次,每轮只探测新增的那几行。成本从「轮数 × 表大小」变成「实际碰到的行数」。

EXPLAIN 能看出差别:2.0 的递归项是一个 hash join,一边是当前 frontier(估算 1 行),一边是边表(100 万行),顶层估算 8001 行,合理;1.5 的顶层估算直接写 ~1,000,000,001 行,把整个 union 表估进去了,估算本身就是失真的。

● ● ●

但「一样的慢」也是真的

我又测了三种形态,提速幅度差得很明显:

形态
负载
1.5.6
2.0.0
提速
图可达
100 万边,每轮一批新节点 ⋈ 边表
6.0s
0.20s
≈30×
git 链
5 万 commit,从 HEAD 回溯,每轮 1 行 ⋈ 表
7.4s
0.38s
≈20×
3 万层数据
深度 3 万,每轮 1 行 ⋈ 表,跑 3 万轮
4.4s
0.23s
≈19×
计数器
SELECT x+1 FROM cnt WHERE x 小于 100000
,纯单行自增,不 join 任何表
2.7s
0.38s
≈7×
四种写法的提速倍数对照

四种写法的提速倍数对照

最后一种最扎心:经典的计数器式递归,每一轮只产出一行,不碰任何大表。这种慢的根源是「每轮固定开销 × 轮数」,2.0 把每轮开销降了,但轮数没变,所以只给 7 倍。这是 DuckDB 的老毛病——向量引擎处理单行批次有固定成本,GitHub 上 2023 年就有 issue 抱怨:单行 30 万次迭代跑 3 分钟,PostgreSQL 1 秒。2.0 没有根治它。

所以升级后觉得「一样慢」,按顺序自查这四条:

第一条,你真的在 2.0 上吗。SELECT version(); 看一眼。2.0 LTS 是 10 月 21 日才发布的,很多环境里嵌的驱动还是 1.5,Python 3.10 上 pip install duckdb 会静默装回 1.x,没有任何提示。这条最常见。

第二条,你的 frontier 是不是每轮只有一行。那是 7 倍档,体感确实慢。

第三条,join 条件是不是简单等值。2.0 的快速路径(直接探测 key 的哈希表)只对简单的 = / IS NOT DISTINCT FROM 生效,条件里裹了函数或表达式,快速路径就走不上。

第四条,你是不是用了 USING KEY。它的语义和语法在 2.0 都变了:递归项里的 UNION 旧语法默认禁用,只认 UNION ALL;frontier 的语义也改了,只有新 key 和 payload 发生变化的行才传给下一轮。照 1.5 的写法在 2.0 上跑,要么报错,要么结果整个变了。

升级后还是慢的四条自查

升级后还是慢的四条自查

● ● ●

爆栈不是递归的锅

这条最反直觉。常见直觉:我的递归太深,栈溢出了。在 DuckDB 里这个因果不成立——递归 CTE 是迭代执行的,一个循环加一张工作表,循环一万轮,涨的是内存,不是调用栈。

我试过了:深度 3 万的数据链,每轮一层 join,跑 3 万轮才收敛。两个版本都正常跑完,零崩溃。3 万轮「深度递归」在 DuckDB 里不算什么。

真正爆栈的,是把递归 CTE 嵌套在另一个递归 CTE 里面。我做了个探针:N 层嵌套的 WITH RECURSIVE,只跑 EXPLAIN(走解析和计划,不执行):

2.0.0:N=200 通过,N=300 直接 Windows fatal exception: stack overflow,整个进程死掉。

1.5.6:N=200 就崩,门槛比 2.0 还低。

整进程崩,报错里没有任何数据相关的信息。这不是 2.0 的回归——两个版本都崩,是老 bug 原样还挂着。GitHub 上对应的 issue 叫 Vuln71(#25174),内容很直白:解析器和 binder 对递归节点的嵌套结构直接递归处理,没有深度保护,状态还是 open。Linux 上 8MB 线程栈大约 5000 层爆,我本机 Windows 上 200 到 300 层就爆,阈值取决于线程栈大小。

还有一种容易被说成爆栈的:union 表无限膨胀,查询不收敛,内存打满崩掉。崩掉之后的报错被口口相传,传成了「栈溢出」。要报 bug 就贴完整报错原文:stack overflow、Out of Memory、查询永远不返回,是三种不同的东西,三种不同的原因。

● ● ●

收束

升级之前,先 SELECT version(),确认真的在 2.0 上跑。

觉得慢,先看查询形态:frontier 每轮是一行还是一批,join 是不是等值。没有「每轮去大表捞下一批」这个主成本的递归,2.0 帮不了你太多。

爆栈,先检查有没有嵌套递归 CTE,再贴完整报错。

官方自己的建议:关键业务,等 11 月的第一个 patch 再说。

● ● ●

参考来源

https://duckdb.org/2026/08/25/how-duckdb-runs-recursive-ctes-faster.html

https://duckdb.org/2026/10/21/announcing-duckdb-200.html

https://github.com/duckdb/duckdb/issues/25174

https://github.com/duckdb/duckdb/issues/8700

https://duckdb.org/2025/05/23/using-key.html