关掉一个叫preserve_insertion_order的配置项,开上多线程,用OFFSET给一个2000万行的Parquet文件分页——请求返回的总行数分毫不差,还是2000万。但里面600多万行从没出现过,另外500多万行被重复发送了最多5次。DuckDB不报错,行数校验也发现不了。这是最近一组针对DuckDB分页方案的实测暴露的问题:对比LIMIT/OFFSET和file_row_number两种写法后发现,后者不仅平均快2.53倍,还从根上避开了这类静默错误。
2.53倍从哪来
在一个20M行、163个Row Group的Parquet文件上跑了37次测试,file_row_number方案全部跑赢OFFSET,平均快2.53倍。原理不复杂:Parquet文件按Row Group分块存储,DuckDB能从文件footer算出目标行号落在哪几个块,前面的块直接跳过,不解压不读取。
这个优势强依赖Row Group数量。同样的数据,写成一个巨型Row Group时优势掉到1.25倍——跳读无从谈起。
- 建议.分页边界应从
parquet_metadata读取实际Row Group大小对齐,不要按固定行数硬切。
OFFSET不是你以为的那样
一个常见误解是LIMIT/OFFSET要数过前面所有行,越翻到后面越慢,复杂度是O(n²)。实测显示DuckDB其实会把OFFSET悄悄改写成行号查找,再哈希半连接拼回数据,跳读逻辑和file_row_number几乎一样。
但这个优化有明确的失效开关:LIMIT超过100万行,或查询里带了WHERE,改写就不生效,退化成真正的全量扫描。你现在用的OFFSET分页,背地里一直在借用file_row_number的机制——只是这个机制什么时候生效,优化器说了算,不是你。
行数对不等于数据对
真正的隐患不在速度。LIMIT/OFFSET不带ORDER BY,对返回哪些行没有承诺,能对上全靠DuckDB默认保留插入顺序——这是个文档标注过的性能开关,一旦关掉再叠加多线程,问题就来了。
| 测试轮次 | 丢失行数 | 重复行数 | 单行最多重复 |
|---|---|---|---|
| 第1轮 | 613万 | 494万 | 5次 |
| 第2轮 | 641万 | 522万 | 5次 |
丢的和多的刚好在总数上抵消,两轮返回的行数都精确等于2000万,只数行数根本发现不了。原文建议改用哈希校验——对整份结果集做blake3摘要,一位不同摘要就会变。
- 风险.关闭插入顺序且开启多线程时,OFFSET分页总行数正确,但可能悄悄丢行、重复行。
file_row_number不会有这个问题,因为它锚定的是行在文件里的物理位置,range切分和线程数、顺序设置无关。但它不是业务主键——文件一旦被重写,同一行的file_row_number可能就变了,只能当分页游标,不能当身份标识。
翻页深度也在拉开差距。首页只慢1.77倍,翻完全部页面拉到2.43倍,翻到末页是3.07倍——OFFSET越往后翻越慢,这跟分页该有的样子正好相反。
性能可以调,数据丢了没法调。
无状态分页本身也有代价。跟一次性流式读完整个文件比,分页要慢约1.5倍;跟直接把文件甩给客户端、或按Row Group索引直接读比,代价还要更大。换来的是可恢复、内存可控、任意worker都能接着上一页继续——对一个跑在负载均衡后面、没有服务端会话状态的API来说,这笔税基本躲不掉,问题只是愿不愿意算清楚。
2.53倍的差距是真的,但不是这篇测试里最该记住的数字。最该记住的是那两个丢了六百多万行的数字——它们说明性能优化可以慢慢调,出错概率再低,只要建立在"默认行为不会被改"这种假设上,就是在赌。
