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 表估进去了,估算本身就是失真的。
● ● ●
但「一样的慢」也是真的
我又测了三种形态,提速幅度差得很明显:
SELECT x+1 FROM cnt WHERE x 小于 100000 |
四种写法的提速倍数对照
最后一种最扎心:经典的计数器式递归,每一轮只产出一行,不碰任何大表。这种慢的根源是「每轮固定开销 × 轮数」,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