ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Polars实战:列式存储与惰性求值如何加速大规模CSV日志分析

Polars实战:列式存储与惰性求值如何加速大规模CSV日志分析

最近接了一个日志分析需求,文件是几个日常服务的访问日志,压缩前一个CSV就有3.2GB,大概2400万行。字段不多,但要做小时级分组、P95延迟、错误率这类偏分析的计算。第一反应当然是Pandas,但我很清楚这个体积的CSV丢给Pandas是什么后果:内存直接翻五六倍,一个groupby跑几分钟,风扇能当场起飞。

所以我这次从第一行代码就用Polars,而且是惰性模式:先用scan_csv把文件结构摸清楚,构造出LazyFrame,把需要的清洗、筛选、聚合全部挂成一个表达式链,最后再统一collect()。整个过程内存峰值不到3GB,单次聚合耗时从原来Pandas的4分多钟压到40秒以内。这篇文章就围绕Polars的列式存储、惰性求值机制,以及我在实际数据管道里用下来的工程经验展开,适合正在为“单机几十GB数据怎么更快分析”发愁的读者。

1. 从一次“CSV卡死”说起:Polars 想解决哪类问题

1.1 为什么 Pandas 处理 2GB 数据就这么吃力

先说结论:Pandas慢不是因为它“写得烂”,而是它的内存模型和执行模型天生不适合这类分析负载。

Pandas底层是行式存储,每一行数据在内存里是一组独立的Python对象,尤其当你读CSV时,字符串列会变成一串Python str对象。你可以把这种布局理解成一个巨大的Excel表格:想算某一列的平均值,Excel也得把整行整行的数据都过一遍。问题是,行式存储对“点查询、改某一行”很友好,但数据分析里绝大多数操作都是“整列计算”,比如groupby().sum()、filter()、merge(),列式布局才能真正把I/O和CPU利用率打满。

第二个问题是Pandas默认单线程执行。虽然很多聚合操作底层会调部分C扩展,但整个执行流程中DataFrame的分片、索引对齐、结果合并仍然受制于Python全局解释锁和对象模型。你去看任务管理器,跑Pandas时CPU占用经常只有一两个核在动,剩下十几个核围观。

我在一次测试里试过:一个2GB的CSV,十来列,包含字符串、整数、时间戳,Pandas做一版groupby聚合,内存峰值约14GB,耗时240秒左右。同样的逻辑用Polars惰性执行,内存峰值不到3GB,耗时32秒。差距不是几倍,而是接近一个数量级。

1.2 “大规模”到底指什么量级

先厘清概念。很多人一说大规模就想到Spark、几百TB集群,但现实中大部分团队遇到的是另一个量级:数据量从几百MB到几十GB,单机内存能存下,或者勉强能存下但很吃力,需要快速做探索分析和批处理。

这个区间是Polars的主场。它跟Spark这类分布式引擎不同,Polars仍然跑在单机多核上,但通过列式内存格式和查询优化器,把单机性能压榨到很接近硬件上限。换句话说,你的CPU如果没跑满,Polars会想办法让它跑满。

所以这篇文章里说的“大规模数据”,是指单机场景下“大到让Pandas难受,但还没大到必须上分布式”的那一段。这也是绝大多数业务分析、特征工程、报表预处理最常踩的量级区间。

2. 列式存储与惰性求值的底层逻辑:为什么这套组合拳能提速

2.1 列式布局:按需取列,而不是吞下全表

Polars底层基于Arrow内存格式,每一列都是连续存放的固定类型数组。时间戳就是int64数组,浮点就是double数组,字符串也不是一个个Python对象,而是连续字节加偏移量的紧凑结构。这种布局有三个直接好处。

第一,读取阶段可以只加载你需要的列。数据分析的典型场景是:表有20个字段,但聚合只用其中5个。列式存储允许只把那5列从磁盘读出来,而Pandas在读取CSV时哪怕最后只用到两列,前面也把每一行完整解析成了对象。第二,连续数组对CPU缓存非常友好,扫描一列几百万个int64,实际上就是顺序读取一大块内存,配合SIMD向量化指令可以同时处理多个数据点。第三,类型固定省掉了大量运行时类型判断,很多操作在编译期就能确定内存布局。

这个差异在你用read_csv还是scan_csv时就已经决定了。Polars读取CSV后如果以DataFrame形式存在内存里,布局是列式的,而Pandas的DataFrame内部虽然也有列块,但每个值更偏向对象模型,尤其字符串和混合类型会退化成Python对象指针数组,内存和速度双双失控。

2.2 LazyFrame:先规划,后执行

惰性求值是Polars另一个关键设计。你调用scan_csv时,Polars不会真的读文件,只是记录了一个“数据源”的描述信息。后面挂上filter、with_columns、group_by这些操作,也都是在构建一棵逻辑执行树,直到你调用collect(),优化器才拿到完整查询计划并开始执行。

这就像你去餐厅点菜,急切模式是每点一道厨房就立刻做一道,哪怕最后你只吃两口;惰性模式则是服务生先记完菜单,厨房可以统一规划:哪几个菜能共用一锅油、哪个菜先炸哪个后炒、哪些食材可以一起备。放到数据处理里,Polars可能做的优化包括:把filter下推到CSV扫描阶段,边读边过滤,不读不需要的行;把只出现在输出里的列单独提取,减少中间结果;把多个with_columns合并成一个表达式链以减少循环次数。

想直观看到它干了什么,打印一下执行计划:

import polars as pl lazy_query = ( pl.scan_csv("api_access_log.csv") .filter(pl.col("status") >= 400) .group_by("endpoint") .agg(pl.len()) ) print(lazy_query.explain())

执行计划里能看到FILTER被推到了扫描源附近。这意味着Polars可能在读取CSV时直接丢弃status < 400的行,后面的聚合压力自然小很多。这种优化靠手动写代码很难每次都做对,而交给查询优化器能在不牺牲代码可读性的前提下稳定生效。

2.3 不是所有场景都要用 lazy

惰性求值不是银弹。如果你只是处理一个不到几十MB的小文件,或者在一个循环里反复切切改改,用eager模式完全没问题,写起来也更直观。Polars的eager DataFrame本身也很快,只不过少了跨多个步骤的全局优化。

我的习惯是:只要数据量超过200MB,或者一个处理链里有三个以上步骤,就默认用scan_*起步,最后一次性collect()。如果中途想看看中间结果,可以在某个节点临时调.collect()调试,然后再把.lazy()接回去。Polars允许你在LazyFrame和DataFrame之间来回切换,这非常灵活。

3. 实战:用 Polars 对一个 3GB 访问日志做小时级指标聚合

3.1 业务需求和数据说明

模拟一个很常见的数据场景:某服务的访问日志CSV,字段包括timestamp、endpoint、status、latency_ms、user_agent等,原始文件约3GB。需求是统计每个小时、每个接口的请求量、P95延迟、客户端错误数和服务端错误数。

这类指标在监控系统里很常见,放在Pandas里就是经典的多分组多聚合操作。但文件一旦到2GB以上,Pandas会变得非常难受,而Polars的表达式API写这种需求非常顺手。

3.2 完整代码:从 scan_csv 到写出 Parquet

我用的Python环境是Python 3.11、Polars 1.x,完整代码如下:

import polars as pl lazy_query = ( pl.scan_csv( "api_access_log.csv", schema_overrides={"latency_ms": pl.Int64}, ) .with_columns( pl.col("timestamp") .str.to_datetime("%Y-%m-%d %H:%M:%S") .dt.truncate("1h") .alias("hour") ) .group_by("hour", "endpoint") .agg( pl.len().alias("total_requests"), pl.col("latency_ms").quantile(0.95).alias("p95_latency"), (pl.col("status") >= 500).sum().alias("server_errors"), (pl.col("status") >= 400).sum().alias("client_errors"), ) .with_columns( (pl.col("server_errors") / pl.col("total_requests")).alias("server_error_rate"), (pl.col("client_errors") / pl.col("total_requests")).alias("client_error_rate"), ) .sort(["hour", "endpoint"]) ) result = lazy_query.collect(streaming=True) result.write_parquet("hourly_endpoint_summary.parquet")

代码里几个细节值得说明。

scan_csv不会真的加载全部数据,它只读取schema和一小部分数据进行类型推断。如果CSV文件巨大,类型推断本身也要花时间,所以我会用schema_overrides提前指定关键列的类型,能省掉一次遍历。这里的latency_ms明确指为Int64,避免被推断成Int32或者Float导致后续计算产生额外转换。

时间戳解析用str.to_datetime,然后dt.truncate("1h")直接截断到小时。Polars的时间处理API很全,不需要先构造字符串再分组,直接一步做掉。

group_by里的聚合表达式是Polars最有代表性的地方。pl.len()等价于count(*),pl.col("latency_ms").quantile(0.95)计算95分位数,(pl.col("status") >= 500).sum()表示把布尔数组求和,也就是统计满足条件的行数,非常简洁。

3.3 执行计划与资源占用

跑完后我看了一眼实际耗时和内存:CSV 3.2GB,约2400万行,在16核机器上完整执行时间约34秒,内存峰值2.8GB。作为对比,Pandas在同样机器上跑同样聚合大约需要240秒,内存冲到14GB。

差距主要来自三方面:一是Polars只读了timestamp、endpoint、status、latency_ms四列,而Pandas会把所有列完整加载;二是布尔条件下推让大量不属于错误请求的行在早期就被丢弃;三是多核并行下分组聚合被分到了多个线程。

如果进一步想确认查询计划,可以把lazy_query.explain()打出来看,能看到聚合是否推到了扫描层。上游如果没有扫描层下推,通常表现为数据读得很慢且内存持续上涨。

3.4 第一次跑这个任务时容易踩的坑

这类日志分析我跑过不止一次,有几个坑属于“不踩不知道”的类型。

第一个坑:时间戳解析失败。日志里偶尔会出现异常格式的时间字段,比如多了毫秒、缺了秒,或者干脆是空字符串。str.to_datetime一旦遇到不匹配格式会直接报错,可以用strict=False让无法解析的变成null,再结合.drop_nulls()或fill_null处理,而不是整个任务崩溃。

第二个坑:错误率的分母。如果先filter掉正常请求再做分组,分母就丢了,错误率算不对。正确做法是先保留所有行,分组聚合时用布尔sum统计错误数,再在最后一步用除法生成比率列,也就是上面代码里的思路。

第三个坑:输出文件的Partitioning。我见过有人把聚合结果写成一个几GB的CSV再交给下游,CSV既慢又占空间。Polars直接write_parquet,文件体积通常缩到原来的20%以下,而且下游如果用Arrow生态读取还能零拷贝,推荐从一开始就养成写列式格式的习惯。

4. 性能优化与资源边界:把 Polars 用到顺手为止

4.1 数据类型是第一道关卡:别让字符串拖垮你

Polars的性能很大程度上取决于数据类型是否准确。很多人从Pandas转过来,习惯用字符串表示一切,但在Polars里字符串的开销远大于数值类型,尤其是在分组和排序时,字符串比较比整数比较贵得多。

如果一个字段的取值种类有限,比如endpoint只有四五十个值,status只有几种,可以把它转成Categorical或枚举类型。Polars内部对枚举会用整数ID表示,分组时按整数操作,速度会快不少:

pl.col("endpoint").cast(pl.Categorical)

如果是一个表示“城市代码”的字段,但实际是纯数字,就应当cast(pl.Int32),别老老实实当字符串。字段类型对了,后续一切聚合都能直接受益。

CSV读取时可以在scan_csv里设schema_overrides,一次到位。我用这个参数解决过很多次“时间戳被推断成字符串”的尴尬,也能避免Pandas里常见的“读出来int变float”这类隐形类型转换。

4.2 线程、内存与 Streaming:别让CPU闲着,也别让内存爆掉

Polars默认会让所有CPU核参与计算,这在笔记本上很舒服,但在共享服务器上就容易干扰其他任务。想限制并发,可以设置环境变量POLARS_MAX_THREADS=8再启动Python进程,或者直接用配置API调整线程池大小。

内存方面要分两种模式讲。普通collect()会尽可能把所有中间结果放进内存,追求速度;collect(streaming=True)则把执行过程改成流式,按批处理数据,内存占用显著下降,适合数据量接近内存上限的场景。注意不是所有算子都支持streaming,但常见的filter、group_by、join、sort等主要算子都已经支持。

我在处理更大文件时的做法是:先用schema_overrides固定类型,再用collect(streaming=True)跑,内存不够时配合POLARS_MAX_THREADS减小并行度,避免内存墙。实测下来,当数据量超过内存一半时,streaming是比强行调collect()更稳的选择。

4.3 group_by、join、窗口函数:热点操作的经验

group_by是Polars最常用的操作,它支持一次agg里挂一堆聚合表达式,不要拆成多次group_by再merge。下面的写法只扫描一次数据:

( df.group_by("endpoint") .agg( pl.len().alias("cnt"), pl.col("latency_ms").mean().alias("avg_latency"), pl.col("latency_ms").quantile(0.99).alias("p99_latency"), ) )

相比Pandas里agg的一堆字符串规则,Polars的表达式写法更接近“你要什么就写什么”。但要记住:聚合表达式的顺序会影响中间结果布局,把计算量大的放后面,必要时用select顺序调整输出。

再说join。Polars的join会用哈希表,性能很好,但也容易因为数据膨胀翻车。最常见的问题是两个表在关联键上有大量重复值,join后行数指数级增长。我处理日志和维表时总会先对维表做unique()去重,再关联,行数可控得多。

窗口函数上,Polars支持over表达式,比如想算每个接口的延迟均值,可以这样:

pl.col("latency_ms").mean().over("endpoint")

这种写法比先group_by再merge要干净,而且执行效率不错。不过要注意,窗口函数会在全表范围内计算,数据量极大时仍然有内存压力,配合streaming模式使用更稳妥。

5. 工程接入的坑与取舍:Polars 不擅长什么,以及和周边生态怎么配合

5.1 从 Pandas 迁移过来时最典型的五个差异

第一,Polars没有“行索引”的概念。Pandas里的df.loc[100]、reset_index这类操作在Polars里基本不存在,你需要把索引当成一列显式处理。刚迁移时最容易不习惯,但也正是因为去掉了行索引,排序、过滤、横向拼接都少了很多隐式对齐的成本。

第二,不能用Python原生循环去改行。在Polars里“逐行处理”是反模式,你应该用表达式解决90%的字段运算。如果实在要跑自定义逻辑,可以使用map_elements,但它比表达式慢很多,只适合少量数据或无法用表达式表达的复杂业务规则。

第三,group_by之后不会保留原来的列名约定。Pandas里groupby("hour")["endpoint"]这类切片写法在Polars里要换成聚合表达式。逻辑更明确,但初学时容易对着文档发懵。

第四,缺失值处理不同。Polars聚合时默认跳过null值,这个和Pandas一致;但如果在filter里直接判断null,容易把含null的行全部过滤掉,必须用is_null()单独处理。

第五,DataFrame对象偏向不可变。Polars的DataFrame没有一个“原地改某一列”的惯用法,每次with_columns都返回新对象。这其实对代码维护是好事,但如果你习惯了Pandas的“原地修改”,需要重新适应。

5.2 什么情况不适合用 Polars

Polars不是万能工具,有些场景强行用它反而别扭。

第一类是事务型处理。如果你要高频地更新某一行、插入某一行、修改某个单元格,Polars的列式不可变设计会很难受,这本来就是OLTP数据库的职责,不是分析引擎该做的事。

第二类是业务逻辑极重、必须逐行判定的场景。比如每条数据要调用几十个规则函数,还要根据上游返回值动态决定下一步分支,这种逻辑本质上是命令式的,适合直接用Python写循环,用Polars会处处受限。

第三类是数据量真的超过单机能力。Polars的streaming可以缓解内存压力,但几十TB的数据无论如何也不该考虑单机内存计算。这种情况下该用分布式引擎就用分布式引擎,Polars更适合作为这些系统的下游计算或单机预处理工具存在。

5.3 生态配合:Parquet、Arrow 与下游衔接

Polars和Parquet几乎是一对搭档。CSV是分析场景里最差的数据格式之一,而Parquet的列式存储、压缩、谓词下推,每个特性都长在Polars的舒适区上。实际工程中我建议:数据落到本地或对象存储时,尽量转成Parquet;处理时用scan_parquet;处理完再写Parquet。整个过程高效稳定。

Polars还基于Arrow内存格式,所以和PyArrow之间的交互很顺。比如把DataFrame转成Arrow Table后再交给其他计算框架,或者从Arrow数据源直接构造Polars DataFrame,基本是零成本。

下游如果是数据库,write_database可以直接把结果写到支持的表引擎;如果只是交付给团队伙伴,Parquet文件也足够通用,很多工具都能直接读,不需要再导成Excel或CSV。

一些更细节的工程体会

最后分享一个很实用的建议:处理大规模CSV时,永远不要用read_csv去一把梭,而是先用scan_csv。你就想象成做一个大型报表,先把数据源、字段结构、输出目标想清楚,再让引擎一次性执行。刚开始可能不太习惯多写几行表达式,但跑过一次几十GB级别的任务后,你就会彻底回不去Pandas那种“读到内存再慢慢算”的节奏了。

另外,Polars的profile功能也很适合排查慢查询。调用lazy_query.profile()能看到每个步骤的实际耗时和内存分配,哪些环节占了大部分时间一目了然。如果发现group_by特别慢,先看是不是类型没有指定对;如果sort很慢,先看是不是字符串列在排序。

数据量超过几个GB时,性能问题往往是结构性的。列式存储、惰性求值、类型固定、多核并行,这几件事同时做对了,单机能扛的数据量比很多人想象的大得多。我的经验是:先让Polars把单机潜力全部用完,再考虑要不要上更重的框架,这个顺序永远不会错。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进