关掉一个叫preserve_insertion_order的配置项,开上多线程,用OFFSET给一个2000万行的Parquet文件分页——请求返回的总行数分毫不差,还是2000万。但里面600多万行从没出现过,另外500多万行被重复发送了最多5次。DuckDB不报错,行数校验也发现不了。这是最近一组针对DuckDB分页方案的实测暴露的问题:对比LIMIT/OFFSETfile_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倍——跳读无从谈起。

性能实测:谁分页更快 2.53x 平均快于OFFSET全表分页 37/37 次测试全部胜出 163 个Row Group,20M行
  • 建议.分页边界应从parquet_metadata读取实际Row Group大小对齐,不要按固定行数硬切。

OFFSET不是你以为的那样

一个常见误解是LIMIT/OFFSET要数过前面所有行,越翻到后面越慢,复杂度是O(n²)。实测显示DuckDB其实会把OFFSET悄悄改写成行号查找,再哈希半连接拼回数据,跳读逻辑和file_row_number几乎一样。

但这个优化有明确的失效开关:LIMIT超过100万行,或查询里带了WHERE,改写就不生效,退化成真正的全量扫描。你现在用的OFFSET分页,背地里一直在借用file_row_number的机制——只是这个机制什么时候生效,优化器说了算,不是你。

两种分页,锚点不同 file_row_number 锚定文件物理位置 与线程数、顺序设置无关 跳读整块Row Group LIMIT / OFFSET 被优化器改写成行号查找 仅LIMIT≤100万行时生效 无ORDER BY,顺序不保证

行数对不等于数据对

真正的隐患不在速度。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越往后翻越慢,这跟分页该有的样子正好相反。

翻页越深,OFFSET越慢 倍速 = OFFSET耗时 / file_row_number耗时 首页 1.77x 前10页 1.80x 前25% 1.93x 全部页面 2.43x 末页 3.07x
性能可以调,数据丢了没法调。

无状态分页本身也有代价。跟一次性流式读完整个文件比,分页要慢约1.5倍;跟直接把文件甩给客户端、或按Row Group索引直接读比,代价还要更大。换来的是可恢复、内存可控、任意worker都能接着上一页继续——对一个跑在负载均衡后面、没有服务端会话状态的API来说,这笔税基本躲不掉,问题只是愿不愿意算清楚。

2.53倍的差距是真的,但不是这篇测试里最该记住的数字。最该记住的是那两个丢了六百多万行的数字——它们说明性能优化可以慢慢调,出错概率再低,只要建立在"默认行为不会被改"这种假设上,就是在赌。