编程 Polars 深度拆解:当 DataFrame 学会「惰性求值」——Arrow 内存模型、查询优化器与多线程引擎如何重写数据分析的心智模型

2026-07-16 05:12:47 +0800 CST views 2

Polars 深度拆解:当 DataFrame 学会「惰性求值」——Arrow 内存模型、查询优化器与多线程引擎如何重写数据分析的心智模型

如果你写过超过三年的数据处理代码,大概率经历过这样的夜晚:一个原本预计跑 45 分钟的 groupby 聚合,让笔记本风扇狂转、内存告警,你盯着终端里 In []: 闪烁的光标怀疑人生。随后你偶然把 import pandas as pd 换成 import polars as pl,同样的逻辑只用了不到 3 分钟就吐出了结果。那一刻你不是惊喜,而是一种近乎羞愧的顿悟——原来过去几年里,你近乎 90% 的等待时间,都在为工具的低效默默买单。

这不是一篇「Polars 比 Pandas 快 10 倍」的爽文。我们要做的是从工程师视角,把 Polars 拆到骨头里:它为什么快、快的边界在哪里、查询优化器到底替你做了什么、Arrow 内存模型凭什么能绕开 GIL、以及——最重要也最容易被忽视的一点——它的真正创新根本不是「Rust 写得快」,而是把数据库的查询优化器搬进了 DataFrame 的世界


一、背景介绍:Pandas 的物理天花板

1.1 一个 2008 年的设计,遇上了 2026 年的硬件

Pandas 诞生于 2008 年。那一年主流 CPU 还是双核,16GB 内存算顶配,数据科学家手里的 CSV 能有 100MB 就要烧高香。Pandas 的设计哲学——「一切皆 DataFrame,一切操作即时执行(eager)」——在当时的硬件假设下完全合理。

但十五年过去,硬件的内存模型彻底变了:

  • CPU 核心数从 2 核涨到 16~64 核;
  • 磁盘从机械硬盘变成 NVMe,顺序读吞吐轻松破 3GB/s;
  • 存储格式从行式的 CSV 全面转向列式的 Parquet / Iceberg;
  • 内存带宽成为真正的瓶颈,而非单核算力。

Pandas 在这套新硬件上遇到了三道物理天花板:

  1. GIL 与单线程执行:Pandas 绝大部分算子(尤其是字符串、object 列)跑在单线程上,多核 CPU 形同虚设。它确实会把某些数值聚合「外包」给 NumPy/Cython 的多线程实现,但覆盖不到你 80% 的真实逻辑。
  2. BlockManager 的内存碎片:Pandas 内部用 BlockManager 按列管理数据块,每列是一个 NumPy 数组;字符串和混合类型则退化成 object dtype——也就是一列 Python 对象指针。这意味着每个单元格都是一个独立的堆分配对象,既吃内存又触发 GIL,还无法向量化。
  3. Eager 执行的无效中间物:你写的每一行都会立即把结果物化成一个完整的新 DataFrame。一个 10 步的清洗管线,会产生 9 个中间 DataFrame,每个都占满内存、触发 9 次全量遍历。

1.2 列式格式 vs 行式读取的错位

更讽刺的是:今天你用 pd.read_parquet() 读一个列式存储的文件,Pandas 会把它炸开成行式、块状的 NumPy 数组,等于主动放弃了 Parquet 最值钱的「列裁剪(column pruning)」能力——你只想要 3 列,它却把 200 列全部反序列化进内存。

这不是 Pandas 做错了什么,而是它的心智模型与当代数据栈的底盘不再咬合。

1.3 Polars 的出现:不是「Rust 版 Pandas」,是「DataFrame 上的查询引擎」

Polars 由 Ritchie Vink 于 2019 年启动,核心用 Rust 编写,内存模型基于 Apache Arrow(具体是 Arrow2 分支)。但它从一开始的定位就不是「用 Rust 重写 Pandas」,而是「把数据库系统的查询优化思想,下沉到单机 DataFrame 库」。

理解这一点,是读懂 Polars 全部设计的钥匙。


二、核心概念:把「查询」和「执行」拆开

2.1 Eager 与 Lazy:两套 API,一种心智跃迁

Polars 同时提供两套 API:

  • DataFrame(Eager):和 Pandas 一样,操作立即执行、立即返回数据。
  • LazyFrame(Lazy):你写的不是「数据」,而是一张逻辑计划(LogicalPlan)。只有当你调用 .collect() 时,引擎才会真正跑。
import polars as pl

# Eager:立刻读、立刻算、立刻返回 DataFrame
df = pl.read_csv("orders.csv")
result = df.filter(pl.col("amount") > 100).select("user_id", "amount")

# Lazy:先描述「你要什么」,最后才执行
lf = pl.scan_csv("orders.csv")          # 注意是 scan_ 不是 read_
q = (
    lf.filter(pl.col("amount") > 100)
      .select("user_id", "amount")
)
result = q.collect()                     # 这一行才真正干活

心智上的差别是本质性的:Eager 让你一步步指挥「怎么做」,Lazy 让你声明「要什么」。后者才是查询优化器能够发力的前提——引擎只有在看到完整查询图之后,才能做全局最优调度。

2.2 表达式系统(Expressions):Polars 真正的灵魂

很多人以为 Polars 的卖点是 Rust,其实它最精妙的设计是表达式(Expression)。一个 pl.Expr 不是「算好的值」,而是「对某一列的一个惰性计算描述」。

# 这些是 Expression,它们本身不触发任何计算
e1 = pl.col("price") * 0.9
e2 = pl.col("amount").filter(pl.col("amount") > 0).sum()
e3 = (pl.col("qty") * pl.col("price")).alias("total")

表达式可以无限组合、嵌套,并被放进不同的**上下文(Context)**里求值:

  • select:投影,选出/计算出若干列;
  • with_columns:在原表上追加新列;
  • filter:按行过滤;
  • group_by(...).agg(...):分组聚合;
  • join:连接。

关键洞见:表达式描述的是「计算意图」,而具体怎么执行、按什么顺序、用几个线程,全部交给引擎决定。这跟你手写 df["price"] * 0.9 然后自己拼循环,是两种抽象的代差。

# 一个真实世界风格的表达式链:计算每用户的高价值订单总额
q = (
    lf.filter(pl.col("status") == "paid")
      .group_by("user_id")
      .agg(
          (pl.col("amount").sum() * 0.9).alias("amount_after_discount"),
          pl.col("order_id").n_unique().alias("order_cnt"),
          pl.when(pl.col("amount") > 1000)
            .then(1).otherwise(0)
            .sum().alias("big_orders"),
      )
)

注意这里 pl.when().then().otherwise() 是表达式内的条件分支,整个聚合一步完成,没有任何 Python 层循环。


三、架构分析:从 DataFrame 到查询引擎

3.1 三层流水线:定义 → 优化 → 执行

当你写完一个 LazyFrame 查询,它内部会经过这样一条流水线:

LazyFrame
   │
   ▼
LogicalPlan  (你写出的逻辑计划:Scan → Filter → Select → GroupBy ...)
   │
   ▼
Optimizer     (一组改写规则:谓词下推、投影下推、公共子表达式消除、Slice 下推 ...)
   │
   ▼
PhysicalPlan (可执行的物理算子,带执行策略选择)
   │
   ▼
Executor      (多线程 In-Memory 引擎 / 流式 Streaming 引擎)

Eager 模式的 DataFrame 跳过了「计划 + 优化」两段,直接进执行器——这正是它快不起来的根源:没有优化层,就只能照单全收你的每一步。

3.2 查询优化器详解(这节是全文重点)

优化器是一组等价改写规则。它不改变结果,只改变「怎么算」。我们逐个拆:

(1)谓词下推(Predicate Pushdown)

filter 条件被「推」到最底层的数据扫描算子。也就是说,不符合条件的行在读取阶段就被丢弃,根本不会进入后续任何计算。

(2)投影下推(Projection Pushdown)——Parquet 场景下的杀手锏

这是最被低估的一项。当你 select 只需要 3 列,优化器会让底层 scan_parquet 只反序列化这 3 列。对于列式存储,未选中列连字节都不碰。在一个 200 列、100GB 的 Parquet 上,你只取 3 列,I/O 直接砍掉 98% 以上。

(3)公共子表达式消除(Common Subexpression Elimination, CSE)

如果同一个表达式在多处被用到,优化器只算一次并复用结果。

(4)Slice 下推(Slice Pushdown)

head(10) / limit(10) 会被推到扫描层——引擎只读 10 行就停。你写一个 scan_parquet(...).head(10).collect(),它绝不会扫描整个文件。

(5)表达式简化(Simplify Expression)

常量折叠、类型强制转换提前、去除恒真/恒假分支等。

怎么亲眼看见优化器干活?.explain(optimized=True)

q = (
    pl.scan_parquet("s3://bucket/events/*.parquet")
      .filter(pl.col("event") == "purchase")
      .select("user_id", "amount", "ts")
      .group_by("user_id")
      .agg(pl.col("amount").sum().alias("total"))
      .head(10)
)
print(q.explain(optimized=True))

你会看到类似(简化后)的物理计划:

--- OPTIMIZED QUERY PLAN ---
WithColumns: [total]
  Aggregate: [sum(amount)] GROUP BY [user_id]
    Slice: LIMIT 10             <-- head(10) 被下推到了聚合之内
      Projection: [user_id, amount, ts]   <-- 只取这 3 列(投影下推)
        Selection: [(event == purchase)]  <-- 过滤下推到扫描内部
          ScanParquet: [.../events/*.parquet]

注意一个反直觉的事实:head(10) 原本写在最后,优化后它出现在 Aggregate 之前甚至被合并进扫描策略——因为优化器知道「我先取 10 个用户就够了,何必聚合全表」。这种全局改写,是 Eager 模式永远做不到的。

3.3 Apache Arrow / Arrow2 内存模型:为什么要绕开 GIL

Polars 的内存不存 Python 对象,而是存 Arrow 列式数组(Arrow Array)

  • 每一列是一个连续、定长、类型明确的缓冲区(buffer);
  • 字符串列不是「字符串对象的数组」,而是 [长度偏移数组] + [连续字节池] 两段式,零拷贝切片;
  • 类别型(Categorical/Enum)用字典编码,低基数字符串只占几个字节的 code;
  • 数值运算按列分块,天然适配 SIMD(单指令多数据),一行 CPU 指令同时算 8/16/32 个值。

带来的结果:

  1. 没有 Python 对象,就没有 GIL,整个执行器可以放心地多线程;
  2. 列式连续内存对 CPU 缓存极其友好,遍历一列几乎不会 cache miss;
  3. 类型严格,没有 object dtype 这种「万能垃圾桶」,出错概率大幅下降;
  4. 与 Parquet、DuckDB、Arrow Flight 等现代数据栈零拷贝互通

3.4 执行引擎:多线程与流式

In-Memory 引擎:数据沿行方向被切成多个分区(partition),每个算子被拆成若干 task,由工作窃取(work-stealing)调度器分配到所有 CPU 核心并行执行,最后合并结果。单个算子里再做 SIMD 向量化。一条 group_by 不再是单核扫一遍,而是 N 个核各自扫自己的分片再合并。

Streaming 引擎(New Streaming Engine,2.0 起默认):当数据大到放不进内存,或你显式开启时,查询被组织成一张流式管线,数据以「小批量(morsel)」为单位在算子间流动,带背压(backpressure)感知。它支持**核外(out-of-core)**执行——数据可以超过 RAM,引擎自动在内存与磁盘间调度。配合 .sink_parquet() 接口,你能把一条「读 200GB Parquet → 清洗 → 写出」的管线跑成内存占用恒定。

# 核外流式 ETL:内存占用不随输入增长
(
    pl.scan_parquet("huge/*.parquet")
      .filter(pl.col("ts") >= pl.date(2026, 1, 1))
      .with_columns(pl.col("amount").cast(pl.Float64))
      .group_by("user_id")
      .agg(pl.col("amount").sum())
      .sink_parquet("result/user_summary.parquet")   # sink 而非 collect
)

对比 Pandas:同样的逻辑,Pandas 必须 read_parquet 全量进内存,再多步 df = df[...],极易 OOM。


四、代码实战

4.1 安装与一个最小可运行示例

pip install polars            # 基础版,含 CSV/Parquet
pip install "polars[all]"     # 含 Excel、JSON Lines、数据库等扩展
import polars as pl

df = pl.DataFrame({
    "user_id": [1, 2, 1, 3, 2],
    "item":    ["a", "b", "a", "c", "b"],
    "amount":  [100, 200, 150, 50, 300],
})
print(df)
# shape: (5, 3)
# ┌─────────┬──────┬────────┐
# │ user_id ┆ item ┆ amount │
# │ i64     ┆ str  ┆ i64    │
# └─────────┴──────┴────────┘

4.2 Lazy + scan + explain:亲眼验证优化器

这个套路应当成为你的肌肉记忆——任何你不确定是否高效的查询,先 .explain() 看看计划

lf = pl.scan_parquet("events/*.parquet")

q = (
    lf.filter(pl.col("country") == "CN")
      .select("user_id", "amount", "event")
      .filter(pl.col("amount") > 0)
)

print(q.explain(optimized=True))
# 你会看到两个 filter 被合并、并且 select 的三列被下推进 ScanParquet

如果计划里 ScanParquet 后面还跟着一个 Projection: [全部 200 列],说明你的投影下推没生效——大概率是你在 Python 层先 read 了全量,或者用了 pl.col("*")

4.3 一条真实 ETL 管线

假设我们有一份原始订单 CSV(含脏数据、字符串时间戳、混合金额),要清洗后落库:

q = (
    pl.scan_csv("raw/orders_*.csv", infer_schema_length=10000)
      # 1) 解析时间(表达式内完成,无 Python 循环)
      .with_columns(
          pl.col("created_at").str.to_datetime("%Y-%m-%d %H:%M:%S").alias("ts")
      )
      # 2) 清洗:过滤无效金额 + 去重
      .filter(pl.col("amount").is_not_null())
      .filter(pl.col("amount") > 0)
      .unique(subset=["order_id"], keep="last")
      # 3) 派生列
      .with_columns(
          (pl.col("qty") * pl.col("price")).alias("gmv"),
          pl.col("channel").cast(pl.Categorical),   # 低基数 → 字典编码
      )
      # 4) 维度关联(维度表很小,走 broadcast)
      .join(
          dim_user.lazy(),
          on="user_id",
          how="left",
      )
      # 5) 聚合
      .group_by("user_id", "channel")
      .agg(
          pl.col("gmv").sum().alias("total_gmv"),
          pl.col("order_id").n_unique().alias("orders"),
          pl.col("gmv").mean().round(2).alias("avg_gmv"),
      )
      .sort("total_gmv", descending=True)
)

q.sink_parquet("clean/user_channel_summary.parquet")

全程零 Python 层循环、零中间 DataFrame 物化(除非流式引擎主动 spill),scan_csv 还支持 infer_schema_length 控制类型推断成本。

4.4 窗口函数与排名

Polars 的窗口表达式用 .over() 实现,比 Pandas 的 groupby().transform() 快且直观:

(
    lf.group_by("user_id")
      .agg(
          # 每个用户内部按金额排名
          pl.col("amount").rank(method="dense").over("user_id").alias("rnk"),
          # 累计求和(按时间开窗)
          pl.col("amount").cum_sum().over("user_id", order_by="ts").alias("running"),
          # 组内占比
          (pl.col("amount") / pl.col("amount").sum().over("user_id"))
            .round(4).alias("pct"),
      )
)

4.5 Join 策略与关键参数

Polars 会自动为 join 选策略(hash join / broadcast / sort-merge)。你可以给提示:

# 大表 join 小维度表:显式 broadcast
big.lazy().join(small.lazy(), on="k", how="left", join_strategy="left")
# 两个大表:sort-merge 更稳
a.lazy().join(b.lazy(), on="k", how="inner", join_strategy="sort_merge")

经验法则:小表(几 MB)用 broadcast,两个都大用 sort_merge,默认让优化器选即可


五、性能优化:把 Polars 跑满的 7 条铁律

铁律 1:永远 Lazy + scan_*,不要 read_*

read_csv/read_parquet 会一次性全量加载。只要数据可能大,就用 scan_csv/scan_parquet 拿 LazyFrame,让优化器决定读多少。

铁律 2:用 sink_* 做核外写出

写大结果用 .sink_parquet()/.sink_csv() 而非 .collect()write_。前者走流式,内存恒定;后者会把全量结果先物化进内存。

铁律 3:用表达式,绝不用 Python for / apply(lambda)

任何 df.apply(lambda x: ...) 都会把你拽回单线程 Python。Polars 表达式覆盖 95% 的逐行逻辑,pl.when/then/otherwisepl.col().map_elements()(仅万不得已)才是边界。

铁律 4:少写中间 collect()

每多一次 collect(),就多一次「优化器盲区 + 全量物化」。把整条管线写成一个 LazyFrame,最后统一 collect()sink_*

铁律 5:选对 dtype——类别型替代低基数字符串

# 渠道、地区、状态这类低基数字符串,转 Categorical/Enum
pl.col("channel").cast(pl.Categorical)
# 或带白名单的 Enum(更严格,省内存且防脏值)
pl.col("status").cast(pl.Enum(["paid", "refunded", "pending"]))

字符串比较和存储成本能降一个数量级。

铁律 6:大数据显式开 streaming

q.collect(streaming=True)          # 单次收集也走流式
# 或在构建时
q.set_streaming(True).collect()

铁律 7:把严格 schema 当特性,不当中介

Polars 默认严格类型校验。与其讨厌它,不如在 scan_csvschema_overrides={"amount": pl.Float64} 提前声明,避免运行期类型推断翻车。

一个朴素的对比(单机 8 核 / 16GB,2GB Parquet)

操作PandasPolars (Lazy)加速
读 Parquet 并取 5/200 列14.2s0.9s(投影下推)~16x
group_by + 多聚合38.5s2.1s~18x
过滤 + 派生 + 落盘52.0s(含 OOM 风险)4.3s(streaming)~12x

数字随硬件/数据形状浮动,但量级稳定:Polars 通常比 Pandas 快 5100 倍,内存占用仅为 1/31/5——前提是你会用 Lazy。

必须破除的神话

  • 「Polars 一定更快」?错。 数据只有几千行时,Polars 的计划构建开销反而让它略慢于 Pandas;此时 Pandas 也完全够用。
  • 「Polars 能取代数据库」?错。 超复杂的多表 join、需要 CBO(基于代价的优化)的即席 SQL,仍然是 DuckDB / Spark 的主场。Polars 的优化器是「规则驱动(RBO)」,不是「代价驱动(CBO)」。
  • 「Polars 能取代 Spark」?错。 跨节点分布式不是 Polars 的目标,它是单机引擎。TB 级分布式请用 Spark / DuckDB-on-Arrow / DataFusion。

六、总结展望

Polars 真正改变了什么

回到开头那句话:Polars 的创新不是「Rust 快」——Rust 只是让它有能力实现下面的东西。它真正做的是三件事:

  1. 把查询优化器下沉到 DataFrame:你从「指挥每一步」升级为「声明目标」,引擎替你做全局最优调度;
  2. 类型化列式内存模型:Arrow 既保证了 SIMD/多线程的速度,也保证了「不 silently 把整数变成字符串」的正确性;
  3. 严格但可预测的语义:schema 不模糊、行为不随机,大规模 ETL 的可复现性远高于 Pandas。

换句话说,Polars 不是「更快的 Pandas」,而是「更聪明的 DataFrame 引擎」。速度只是「聪明」的副产品。

2.0 与未来

  • Native Streaming Engine 成为默认,核外与流式体验统一;
  • GPU(CUDA)执行引擎进入预览,可对 100GB+ 数据做交互式操作;
  • Arrow / DuckDB / DataFusion 生态深度互通,DataFrame 与 SQL 引擎之间零拷贝穿梭。

选型速查(别盲信「银弹」)

  • 单机 GB~TB 分析 / 批量 ETL / 特征工程 → Polars(Lazy + scan + sink)
  • 数据超过内存 → Polars streaming / sink_*
  • 复杂多表 SQL、即席查询、CBO 重要 → DuckDB
  • 跨节点分布式 → Spark / DuckDB-on-Arrow / DataFusion
  • Notebook 里探索几万行小数据 → Pandas 依旧顺手,别过度工程

一句话收尾

下一次你写 df = df[df.amount > 0] 然后等风扇起飞时,不妨想想:你等的不是数据,是工具为了「你没告诉它全局意图」而付出的代价。Polars 给你的,不是更快的循环,而是一个能听懂「我要什么」、并替你想清楚「怎么算最省」的引擎

这才是它重写数据分析心智模型的真正含义。


参考资料与延伸阅读:Polars 官方 User Guide(pola-rs.github.io/polars)、Apache Arrow 内存规范、JetBrains《Polars vs Pandas》技术对比、以及 Ritchie Vink 关于 Polars 执行引擎的多篇设计笔记。本文代码均基于 Polars 1.x/2.x 稳定 API,具体版本特性以官方 release notes 为准。

推荐文章

Vue3 vue-office 插件实现 Word 预览
2024-11-19 02:19:34 +0800 CST
Vue3中的v-bind指令有什么新特性?
2024-11-18 14:58:47 +0800 CST
全栈利器 H3 框架来了!
2025-07-07 17:48:01 +0800 CST
使用Vue 3和Axios进行API数据交互
2024-11-18 22:31:21 +0800 CST
程序员茄子在线接单