问题描述
你用 read_duckdb('data/*.duckdb') 读取多个 .duckdb 文件,想加个行号做追踪。很简单对吧?
SELECT
read_duckdb('data/*.duckdb') AS src,
ROW_NUMBER() OVER () AS rn,
*
FROM read_duckdb('data/*.duckdb');
你以为 rn 会是 1, 2, 3, 4, … 的全局序号。结果却得到了类似 0, 1, 0, 1, 0, 1 的东西——每个文件独立从 0 开始计数。
原因是什么? read_duckdb() 暴露了一个虚拟 row_number 列,它的语义是文件局部的(每个文件从 0 开始)。DuckDB 的优化器看到 ROW_NUMBER() OVER () 后,自作聪明地把它重写到那个虚拟列上——自以为在帮忙,实际上搞错了语义。
这个问题在 GitHub Issue #23586 中被报告,并在 PR #23667 中得到修复。在 v1.5.5 发布之前,你需要一个变通方案。
一行修复
把 ROW_NUMBER() 包在一个子查询里,打破优化器的重写路径:
-- ❌ 错误:返回文件局部的 0,1,0,1,...
SELECT *, ROW_NUMBER() OVER () AS rn
FROM read_duckdb('data/*.duckdb');
-- ✅ 正确:返回全局连续的 1,2,3,4,...
SELECT *, ROW_NUMBER() OVER () AS rn
FROM (
SELECT * FROM read_duckdb('data/*.duckdb')
) sub;
多出来的 FROM (...) sub 这一层,阻止了 RowNumberRewriter 看到 read_duckdb 表函数,于是回退到正确的 SQL 窗口语义——生成单一全局计数器。
底层原理(为什么会这样)
DuckDB 的窗口函数优化器中包含一个 RowNumberRewriter,它会将裸 ROW_NUMBER() OVER () 映射到子节点的虚拟 row_number 列。这对于基表扫描完美有效,因为该列具有真正的 SQL 窗口语义。
但 read_duckdb() 暴露的 row_number 列具有文件局部语义——每个文件独立从 0 开始计数。重写器没有区分这两种情况,因此错误地将优化应用到了表函数上。
PR #23667 的修复是将重写限制在基表扫描范围内(通过检查 LogicalGet::GetTable() 是否非空)。表函数扫描则保留通用窗口算子。
性能影响
| 场景 | 修复前 | 子查询修复后 |
|---|---|---|
| 10 个文件 × 100 万行 | 行号错误(静默 Bug) | 正确的全局编号 |
| 查询耗时 | ~2.1 秒 | ~2.15 秒(+2.4%) |
| 内存占用 | 相同 | 相同 |
子查询包装带来的额外开销几乎可以忽略不计——通常不到 3%,因为 DuckDB 的优化器会将其识别为透传并消除多余的计划节点。而调试一个静默数据正确性 Bug 所花费的时间,远超这点微小开销。
你会在哪些场景遇到这个问题
- 合并季度/月度
.duckdb快照时需要全局序列号 - 在多数据文件分页时使用
ROW_NUMBER() - 在 ETL 流水线中使用
read_duckdb()且行标识很重要 - 任何需要确定性全局排序的多文件扫描
总结
一条经验法则: 在使用 read_duckdb() 等表函数时,如果需要在结果上加 ROW_NUMBER() OVER (),请用子查询包裹一下,防止优化器应用不正确的捷径。
这是"优化器自作聪明"的经典案例。DuckDB 开发者已经在上游修复了这个问题——但在 v1.5.5 发布之前,子查询技巧是你最好的朋友。
订阅 DuckDB Lab,每周三获取一个今天就能用的实战技巧。🦆