ARTICLE · 1254510
从 dev 到 prod,我按官方文档做,结果卡在哪,最后怎么解决
——数据链路从 dev 到 prod,最难的不是报错,是『不报错』
环境:pandas 3.0.5 · numpy 2.5 · scipy 1.18 · polars 2.0
写在前面
后台有个问题被顶上来了:
Optimizing dev-to-prod workflow for proc
翻译一下:数据处理这条链路(问题里的 proc,说白了就是我们每天要跑的那条数据管道),从开发到生产,怎么优化?
先说结论:优化本身不难,难的是——你以为你优化完了。
这篇文章不聊 API,也不当文档的复读机,就讲一个我自己踩过的坑:一个不报错、不报警、不崩溃,却能让你白忙好几天的地方。
路线图先摆上:我按官方文档把生产管道切到了『流式』→ 全量一跑,卡住了 → 卡住的不是代码,是『说好的分批』没有发生 → 靠官方文档里一句差点看漏的话,加上一张图,把它揪出来 → 最后四处重写,解决。看完,你至少能少走一遍我走过的弯路。
一、先交代背景:这条链路,之前苦在哪
我们这条管道每天要干的事很朴素:原始文件进来 → 清洗(字段规整)→ 计算(分组汇总)→ 结果落盘 → 下游接着消费。数据量这两年一路涨,涨到了单机内存开始『脸红』的程度。
两个环境,两副面孔:
- dev(开发):
抽 1% 样本,几百万行,pandas 一把梭,喝口水的工夫就出结果; - prod(生产):
全量,几百个 GB。老写法的表现是——先把数据整个读进内存,再算,再整个写出去。
于是问题全在 prod 这边:内存先顶到天花板,机器吭哧吭哧,跑一轮几十分钟起步;中间任何一步出错,全部重来。我们靠什么扛着?靠加内存、换更大的机器——治标不治本的那种扛。
所以『优化 dev 到 prod 这条链路』的第一刀,我砍得很果断:别再把全量数据一股脑装进内存,改成边读边算、分批处理、边算边写。
大方向完全正确。错的是接下来发生的事。
二、我按官方文档做:scan → 流式 → sink
升级到 Polars 2.0 之后,我把官方文档系统读了一轮。跟我们这条链路直接相关的,有三条写得特别清楚:
- 读写尽量用 scan_*,别用 read_*。
惰性执行,能把优化『下推』到读数据那一步:不需要的列不读、不需要的行不读; - 大过内存的数据,用流式引擎。
官方原话是『process datasets that do not fit in memory』——分批执行,放不进内存的数据也能处理,而且通常比内存引擎更快; - 结果尽量 sink 落盘。
『don't necessarily have to store all data in RAM』——边算边写,不用把全部结果堆在内存里。
三条全中我的痛点,照做。管道从『读进来 → 算 → 写出去』,改成了这套『标准动作』:
# 环境:pandas3.0.5, numpy2.5, scipy1.18, polars2.0import polars as pldefclean_name(s: str) -> str:"""字段清洗:去空白、去横线、转小写。当时图省事,写成了逐行处理"""return s.strip().replace("-", "").lower()lf = ( pl.scan_parquet("prod/day=*/part-*.parquet") .filter(pl.col("ts") >= "2026-01-01") .with_columns(# 地雷 1:自定义函数逐行清洗 pl.col("name").map_elements(clean_name, return_dtype=pl.String) ))# 地雷 2:为了『看一眼中间结果』,顺手 collect 了一段mid = lf.select(["id", "name"]).head(1000).collect()print(mid.head())# 结果落盘lf.sink_parquet("out/result.parquet")在 dev 上,这套跑得飞起:小样本、秒级返回,内存曲线平得像一条直线。我当时心想:成了,收工。
结果上了生产,全量一跑——卡住了。
(先剧透一句:上面这段代码里,雷已经埋好了,当时的我完全没意识到。)
三、卡住的地方:内存没降、速度没快,而且它不报错
先说『卡』成什么样,就三句话:
- 内存峰值几乎没降。
说好的分批呢?曲线照旧往上爬,好几次贴着机器的天花板走; - 时间也没好多少。
改造前是『慢』,改造后是『慢得不明不白』; - 最折磨人的:不报错。
不崩溃、不报警、连一行警告都没有。
我宁愿它崩了。崩了有堆栈,堆栈就是线索;报错了有提示,提示就是方向。但它什么都不说,就那么安静地跑着——像一个看着正常、其实哪儿都不对的系统。
那几天我做过的『无用功』,你们大概也做过:
- 换更大的机器
——内存曲线原地重现,只是天花板高了一点; - 瞎调参数
——batch 大小、线程数来回试,没有任何一个参数能解释『为什么没有分批』; - 把日志开到最细
——翻来翻去,只看到『跑完』两个字,没有任何一条跟『回退』有关的记录; 甚至开始怀疑——是不是文档在吹牛?
说实话,卡到第三天的时候,我一度想:『算了,回退老写法,加机器保平安。』现在回头看,最危险的不是问题本身,是那种『算了』的念头——因为那时候我还不知道,答案其实一直在文档里,只是被我看漏了。
直到我回头,把官方文档里一句之前一眼扫过的话,读了三遍:
Some operations are inherently non-streaming, or are not implemented in a streaming manner (yet). In the latter case, Polars will fall back to the in-memory engine for those operations.
翻译成人话:有些操作天生不能流式化,或者还没有实现流式;碰到这种操作,它会悄悄回退到内存引擎。
把这句话套回我的管道,一切都通了:我这条管道看起来是『流式』的,但只要中间藏着哪怕一个不能流式化的环节,那个环节就相当于一道『闸门』,把数据重新拽回内存里处理。分批,名存实亡;省内存,自然也没发生。
更要命的是它后面那句:『A user doesn't have to know about this.』——用户不需要知道这件事(除非你在排查内存或性能问题)。
好家伙,这就是最典型的『静默行为』:不报错、不告警,只在你看不见的地方,把你的优化一口一口吃掉。
人话总结一句:『优化没生效』这种问题,最怕的不是难,是它根本不吭声。
四、怎么把『卡住的地方』揪出来:一张图 + 一个笨办法
官方文档在同一节里,其实把排查方法也写出来了:把查询的物理计划(physical plan)画出来看——图例里会标注每种操作的内存开销量级。你看,答案它就摆在那儿,只是『用户不需要知道』。
我又给自己加了个笨办法:把管道拆开,逐段跑、逐段盯内存。哪一段进去内存就起飞,哪一段就是嫌疑犯。
两个办法合起来,我管道里那几处『反流式』的写法,就全藏不住了。一共揪出来三处:
第一处:那个『顺手』的自定义函数(UDF)。 就是上面代码里的 map_elements。当时图省事——逐行清洗一个字段,写起来最直观。问题在于:这类函数进了惰性管道,就相当于一个『黑盒』:流式跑不了,优化器也绕着走——谓词下推、列剪裁这些能省 I/O、省内存的优化,遇到它会直接失效。而它呢?不报错,最多给你一条不痛不痒的提示。
第二处:那次『顺手』的中间 collect。 我为了看一眼中间结果对不对,把管道拦腰截了一段出来。这一眼,代价是:管道被掰成两截,前面那段要先全量算完;同一个查询计划被反复用时还会重新计算——看一眼的代价,是双份的计算,和一份多余的内存占用。
第三处:一个从没被当回事的全量排序。 体检的时候才发现,管道中段还有一次全局 sort。这类『必须看全量才能下结论』的操作,本身就是流式的天敌——数据一大,它要么憋内存,要么往磁盘上倒腾。而我排它,只是为了输出『好看一点』。
到这一步,『卡住的地方』终于有了正脸:
不是流式引擎不行,是我的管道里藏着几个没流式化的环节。它们不出现在报错里,只出现在物理计划里。
五、最后怎么解决:四处重写 + 一次验收
找到就好办,逐个改。先给你一张对照表,都是『看着无辜、实则致命』的写法:
① UDF 换成原生表达式。 清洗逻辑拆成链式表达式,从『黑盒逐行』变成『列式向量化』:
# 环境:python3.12, pandas3.0.5, numpy2.5, scipy1.18, polars2.0# 修复:UDF → 原生表达式(可流式、可被优化器识别).with_columns( pl.col("name") .str.strip_chars() .str.replace_all("-", "") .str.to_lowercase() .alias("name"))② 中间 collect 挪出管道。 『看一眼』挪到落盘之后,单独拿小结果去查;主链一次到底,不打断。
③ 过滤提前、只留必要列。select 先砍列、filter 尽量早——让谓词下推和列剪裁真正生效。省内存、省 I/O 的两个大头都在这里,之前因为 UDF 挡着,一个都没吃到。
④ 落盘改 sink_parquet。 边算边写,不再『全量回内存再写文件』。
重写后的管道,长这样:
lf = ( pl.scan_parquet("prod/day=*/part-*.parquet") .select(["ts", "id", "name", "amount"]) # 列剪裁:不读用不上的列 .filter(pl.col("ts") >= "2026-01-01") # 过滤提前:谓词下推 .with_columns( pl.col("name").str.strip_chars() .str.replace_all("-", "").str.to_lowercase() ) .group_by("id") .agg(pl.col("amount").sum().alias("total")))lf.sink_parquet("out/result.parquet") # 边算边写,不整表回内存最后一步是验收,这一步别省。 而且我给 dev 环境定了一条新规矩:样本必须『像真的』——之前就是吃了『干净小样本』的亏。现在固定两个动作:
从生产数据里抽 1%~5% 的样本,保持脏(缺值、异常、边界值都留着),用同一条管道在 dev 跑通; 落盘结果做抽样对账:pandas 读结果、numpy 算差异、scipy 看分布:
# 环境:pandas3.0.5, numpy2.5, scipy1.18, polars2.0import pandas as pd, numpy as npfrom scipy import statsprod = pd.read_parquet("out/result.parquet").sample(10_000, random_state=42)dev = pd.read_parquet("dev/result_sample.parquet")ratio = prod["total"].to_numpy() / dev["total"].to_numpy()print("均值:", np.mean(ratio), "抖动:", np.std(ratio)) # 应贴着 1.0print(stats.describe(ratio)) # 分布没炸毛 = 口径一致改完以后,数字总得交代(对照公开基准案例 + 我们的复测量级):
再叠上『谓词下推 + 列剪裁』,公开案例里(100GB 级别数据)的量级可以做到 120GB → 8GB → 4GB → 2GB 这种降幅;做日志分析的同行还有个案例:24 小时的日志,从『要 400 多 GB 内存』变成『几个 GB 循环着用』。
不是我吹流式——是『真流式』和『假流式』的差距,就有这么大。
六、复盘:dev 到 prod,卡住你的往往不是『不会』,是『看不见』
这一轮下来,我在显示器边上贴了四张便利贴(纯粹写给自己看的):
- 『能跑就行』的写法,往往是生产上最贵的写法。
UDF、顺手 collect、全量排序……每写一行的时候多问一句:这个操作,能流式吗? - 不报错 ≠ 没问题。
数据链路从 dev 到 prod,要给自己装上『可观测性』:内存曲线、耗时基线、物理计划。看不见的地方,才最容易卡住你。 - 上生产前,用『接近真实的规模 + 脏数据』演练一次。
干净的玩具样本,只会给你虚假的安全感;结果对不齐,先查计划,再查数据。 - 别迷信『流式』两个字。
它有额外调度开销,小数据上反而更慢;需要顺序的地方记得显式排序——会流式的引擎,不保证行序。
一句话收束:文档把路给你铺好了,但坑都藏在『不报错』里;能流则流、能显式则显式、能对账就对账。
回到开头那个问题——Optimizing dev-to-prod workflow for proc:
这条链路的优化,别只盯着『我用了什么新写法』,先盯住『我改的写法,真的生效了吗』。
我卡住的地方,就是这一条。
声明:本文核心观点与结论基于 Polars 官方文档(流式引擎、数据源与写出口径)及社区实测案例整理,AI 辅助进行语言优化与组织;文中数字为对照案例量级,实际效果以你的数据为准。
代码环境:pandas 3.0.5 · numpy 2.5 · scipy 1.18 · polars 2.0















































































