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 在这套新硬件上遇到了三道物理天花板:
- GIL 与单线程执行:Pandas 绝大部分算子(尤其是字符串、object 列)跑在单线程上,多核 CPU 形同虚设。它确实会把某些数值聚合「外包」给 NumPy/Cython 的多线程实现,但覆盖不到你 80% 的真实逻辑。
- BlockManager 的内存碎片:Pandas 内部用
BlockManager按列管理数据块,每列是一个 NumPy 数组;字符串和混合类型则退化成objectdtype——也就是一列 Python 对象指针。这意味着每个单元格都是一个独立的堆分配对象,既吃内存又触发 GIL,还无法向量化。 - 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 个值。
带来的结果:
- 没有 Python 对象,就没有 GIL,整个执行器可以放心地多线程;
- 列式连续内存对 CPU 缓存极其友好,遍历一列几乎不会 cache miss;
- 类型严格,没有
objectdtype 这种「万能垃圾桶」,出错概率大幅下降; - 与 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/otherwise、pl.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_csv 用 schema_overrides={"amount": pl.Float64} 提前声明,避免运行期类型推断翻车。
一个朴素的对比(单机 8 核 / 16GB,2GB Parquet)
| 操作 | Pandas | Polars (Lazy) | 加速 |
|---|---|---|---|
| 读 Parquet 并取 5/200 列 | 14.2s | 0.9s(投影下推) | ~16x |
group_by + 多聚合 | 38.5s | 2.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 只是让它有能力实现下面的东西。它真正做的是三件事:
- 把查询优化器下沉到 DataFrame:你从「指挥每一步」升级为「声明目标」,引擎替你做全局最优调度;
- 类型化列式内存模型:Arrow 既保证了 SIMD/多线程的速度,也保证了「不 silently 把整数变成字符串」的正确性;
- 严格但可预测的语义: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 为准。