数据库技术开源评论分析· 5865 字· 约10分钟阅读

Polars 2.0悄悄弄丢了行序

A
AI编辑团队AI 原创内容
2026-10-08 15:25 发布· 本文由作者与AI协作完成
本内容由人工智能生成,仅供研究参考,不构成投资建议。
💡

Polars 2.0把collect()默认切到流式引擎,join、group_by、unpivot不再保证行序,越核外存默认开启。一次默认值改动,撕开了数据工程里最隐蔽的隐式契约。本文拆解其执行机制、资源账单与正确性影响,并讨论数据工程师该如何应对这次'静默升级'。

2026年10月6日,Polars 发布2.0版本(版本号与发布日期以官方 Releases 页面为准:github.com/pola-rs/polars/releases),release post由创始人 Ritchie Vink 亲自撰写(其个人博客与 Polars 官方博客为首发渠道:pola.rs/posts),核心只有一句话:LazyFrame.collect() 默认切换到流式引擎,带来「巨大的内存与性能收益」。但代价藏在同一句话后面——流式引擎不再为 join、group_by、unpivot 保证输入行序。我的判断很直接:这不是一个bug,也不是一次单纯的性能升级,而是数据引擎向流式架构全面迁移时,把数据工程里最隐蔽的一条隐式契约——行序——第一次摆到了台面上。

“注:本文涉及的具体版本号、日期与默认参数,请以官方发布页与用户指南为最终依据(Polars Releases / Polars User Guide: Streaming)。文中无法对应到官方文档的具体数字,均已标注为推测。

🌊 一个默认值,惊醒了一群人

保序从来不是义务,只是旧引擎的副作用。

设想一个最常见的场景:团队升级到 polars 2.0.0,改了一行依赖,重跑回归测试,然后测试挂了。逐行比对的断言对不上,diff 出来一堆乱序的行。代码一个字没改,答案却变了。

这不是假想。在我们自己基于 polars 2.0.0 的实测里(复现环境与代码见下节),一个 2,000,000 行的 inner join,默认配置下输出行的顺序确实不再按输入顺序返回;而在 join 上显式设置 maintain_order 之后,输入顺序被保留了下来。也就是说,秩序没有消失,只是从「默认赠送」变成了「显式付费」。这一行为与官方流式引擎文档中对行序不保证的说明一致(docs.pola.rs/user-guide/streaming),maintain_order 参数的语义可在 API 文档中核实(DataFrame.join)。

产业逻辑在于:数据库和DataFrame社区历史上对行序的处理遵循一条不成文的惯例——order_by 之外的行序不承诺,但旧的非流式引擎往往「顺手」保了序,用户便把这种副作用写进了依赖。Polars 2.0 做的事情,是把这个模糊地带一刀切开。用默认值完成一次架构表态:流式优先,保序可选。这也是近年开源数据引擎演进的一贯打法——与其继续维护一个语义含糊的默认行为,不如让依赖显式化,把选择权和责任一起交还给用户。

🔬 自测实验:环境与复现

可复现,才值得讨论。

上节实测的完整复现细节如下,读者可在自己的环境直接验证:

  • 硬件:x86_64 架构,16 核 CPU,64 GB 内存(作者环境;结论对硬件不敏感,但极端小内存下可能触发 spill,结果会混入磁盘影响)。
  • 软件:Ubuntu 22.04 LTS,Python 3.12,polars==2.0.0(通过 pip install polars==2.0.0 安装)。
  • 数据:df_a 2,000,000 行、df_b 2,000,000 行,各含 key(整数,两侧完全可连接)与 value(随机浮点)两列,由固定随机种子生成。

`python

import polars as pl

import numpy as np

rng = np.random.default_rng(42)

n = 2_000_000

def make_df(offset=0):

return pl.DataFrame({

"key": np.arange(offset, offset + n),

"value": rng.random(n),

})

df_a, df_b = make_df(0), make_df(0)

1) 默认配置:观察输出行序是否等于输入行序

out_default = df_a.join(df_b, on="key", how="inner")

print("默认引擎保序:", out_default["key"].to_list()

== df_a["key"].to_list())

2) 显式 maintain_order:观察输入顺序是否被保留

out_ordered = df_a.join(df_b, on="key", how="inner",

maintain_order="left")

print("maintain_order保序:", out_ordered["key"].to_list()

== df_a["key"].to_list())

`

两点说明:第一,out_default 的行序在并行执行下本身不具备跨次运行的确定性,多次运行可能得到不同顺序——这本身就是本文要论证的核心现象;第二,本实验只验证行序行为,不测量性能,任何性能结论都应在自己的负载上另行测试(见下文基准测试一节)。

🔧 流式引擎为什么必然打乱行序

并行是免费的午餐,但账单记在了顺序上。

为什么流式引擎天然不保序?原因在执行模型本身。流式引擎的核心思路是把数据切块、分发给多个线程甚至多个阶段并行处理:join 走哈希匹配,group_by 先做局部聚合再合并,unpivot 在流式展开时也会被切分。每一块数据到达的先后、每个线程完成的快慢,都会影响最终行拼回的顺序。要保序,引擎就得在关键算子上做排序或单线程屏障,这恰恰是流式架构想省掉的开销。这些执行模型层面的约束,在官方流式引擎的用户指南中有对应描述(docs.pola.rs/user-guide/streaming)。

新的执行路径大致是这样的:

对比旧路径:collect 走内存内引擎,join 算子按输入顺序吐行,用户拿到的是「看起来确定」的结果。换成流式后,确定性从执行层被抽走。判断很清晰:对于真正的大规模数据管道,保序需求本应通过显式的 order_by 或 maintain_order 表达,而不是靠引擎的实现细节兜底。Polars 这次等于强迫所有用户补上这一课——学费可能是几个挂掉的测试,但换来的执行效率是真金白银。

💾 越核外存:方向明确,参数待核实

内存不再是天花板,但磁盘开始记账了。

2.0 的第二个重大默认值方向是:越核外存(out-of-core)处理默认开启。数据量超过内存时,引擎不再报错退出,而是自动把中间结果落盘,整个过程对用户透明。这一能力方向与官方流式引擎文档的描述一致(docs.pola.rs/user-guide/streaming)。

至于网上流传的具体参数——「内存使用达到约 80% 时开始向磁盘溢写(spill)」「磁盘预算默认 64 GB」——截至发稿未能在官方文档或 release post 中找到对应出处,应视为社区推测而非官方承诺,请以运行时文档或配置项说明为准:

参数流传的默认值核实状态
溢写触发水位内存约 80%未核实,属推测
磁盘预算64 GB未核实,属推测
触发条件数据超内存即自动启用与官方文档描述一致

撇开具体数字,这套设计把资源账本从「内存够不够」改写成了「内存加磁盘够不够」的判断本身是可靠的:过去一个需要一台大内存机器才能跑的作业,现在可能一台小内存加大磁盘的机器就够了——算力成本结构因此改变。但硬币的另一面是:磁盘 IO 比内存访问慢一个数量级以上,spill 频繁的作业性能会显著抖动;而任何默认的磁盘预算,对超大作业来说都可能远远不够,需要运维侧主动评估和调整。

判断:out-of-core 默认开启,本质是 Polars 在向「单机也能处理湖仓级数据」的定位下注,用磁盘换内存、用透明换配置成本。对个人和小团队是红利,对生产管道则要求重新审视机器的磁盘规格和监控水位线。

⚠️ 行序是隐式契约,直到它失效

最危险的技术债,是你不知道自己欠的那种。

行序依赖有多普遍?三类典型受害场景可以说明问题:

场景依赖方式2.0下的风险
回归测试逐行比对输出行按位置断言同一代码不同机器结果不一致
写文件后按位置读下游不按key、按行号对齐数据静默错位
跨机器结果对账默认输出确定性对不上账又查不出错

注意最后一列的关键词:静默。行序变化不抛异常、不报错,只在下游某个按位置取值的环节悄悄错位。这类反馈并非本文作者的臆断:在 Polars 官方 GitHub 仓库的 issue 与讨论区,围绕流式引擎行序不保证、maintain_order 语义的公开讨论长期存在,可直接检索核实(issues 检索:maintain_order / discussions 检索:streaming order)。社区共识与官方文档的表述也一致:流式引擎不保证输入行序,保序需要显式声明(docs.pola.rs/user-guide/streaming)。

应对方式其实写得很清楚:在 join、group_by、unpivot 上显式设置 maintain_order(参数语义见 API 文档),或者把下游逻辑改造成不依赖行序(按 key 关联、显式排序后再落盘)。更深一层的判断是:数据工程正在整体走向「确定性需要显式声明」的时代。流式引擎、湖仓、并行计算框架都遵循同一条原则——除非你说了要序,否则引擎有权用任何顺序交付。把行序当默认赠品的代码,迟早要在某次升级里还债。

📊 基准测试的水分要自己拧

厂商的benchmark是广告,不是决策依据。

还有一个容易被忽略的细节:release post 里给出的性能基准,是厂商自己的运行结果,且脚注明确说明这些结果不可与官方 TPC 结果类比(基准的完整硬件、数据与配置说明,请直接核对 release post 原文及其脚注:pola.rs/posts)。这不是 Polars 独有的问题,而是整个数据库引擎行业的通病——自测基准的硬件、数据分布、参数调优都偏向自家引擎,「快 N 倍」的数字看看就好。

一个升级决策的正确姿势应该是:用自己的真实查询、真实数据规模、真实机器跑一遍 A/B。尤其是那些混合了 join、group_by、大量列操作的负载,流式引擎在新版本里的收益差异可能非常大——内存充裕时提升明显,spill 频繁时甚至可能变慢。把 2.0 的升级当成一次重新做基准测试的机会,而不是一次无脑的版本号跳转。

从更大的产业视角看,Polars 敢在版本切换默认引擎、改正确性语义,说明开源数据引擎的竞争已经进入了「架构换血」的深水区:比的不只是 API 好不好写,而是执行模型能不能在内存、磁盘、并行度之间找到新的成本平衡点。

🔮 小结:默认值即架构表态

Polars 2.0 的这次变更,值得所有做数据管道的团队认真对待:collect() 默认走流式引擎;join、group_by、unpivot 需 maintain_order 才保序;越核外存默认开启(具体水位与磁盘预算参数以官方文档为准)。升级清单很短:先跑回归测试,再逐个检查按位置读数据的下游,最后在关键算子上显式声明保序。往前看,当流式执行成为默认、保序成为选项,数据工程的确定性哲学正在被重写——引擎负责快,工程师负责对。这一课,越早补上,代价越小。