去年年底我接手了一个数据清洗的活儿单表三亿多行用Pandas跑一个 groupby 加两个 join结果内存直接飙到 70G把服务器干趴了三次。后来换到 Polars同样的逻辑内存占用压到了 4G 以内跑完只用了不到两分钟。那次之后我就把“Polars 与大规模数据处理”这组关键字记在了心里也花了大概两周时间把手头能迁移的任务全部迁了过去。今天这篇就围绕列式存储、惰性求值和工程实践这三块把我在真实项目里折腾 Polars 的细节、踩过的坑和沉淀下来的套路完整写出来。不管你是被内存压得喘不过气的 Pandas 老用户还是刚接触大规模数据但不想一上来就上 Spark 的重型组件这篇内容应该都能帮你在半小时内建立一个可复用的操作框架。很多人第一次接触 Polars 的时候最困惑的是它到底和 Pandas 差在哪我换个说法来解释——Pandas 的行式存储模型像一个巨大的 Excel 表格每一行数据物理上存放在一起而 Polars 的列式存储模型则像一摞按字段独立归档的卡片每一列数据单独存放。这个差异直接决定了内存占用、Cache 命中率、压缩率和并行化效率。列式存储配合上惰性求值框架和底层基于 Rust 实现的高效向量化操作让 Polars 在单机环境下的表现甚至能逼近某些分布式引擎。下面我会按照从原理到实操的顺序把整套内容展开来聊。先讲为什么要理解列式存储再讲表达式系统怎么说人话然后重点拆解惰性求值到底帮我们省了什么最后给出一套可以直接上生产的工程实践模板和故障排查心得。1. 为什么要从 Pandas 换到 Polars列式存储与内存模型的核心差异1.1 行式存储 vs 列式存储不只是“换个姿势”先说内存布局。在 Pandas 里如果你有 1000 万行、200 列的数据它内部的 BlockManager 虽然会把同类型列做合并存储但在计算过程中尤其是筛选、聚合、合并的时候数据往往要不断从行视角被抽出来再塞回去。更关键的是数据在实际计算之前经常会被整体复制一遍甚至多遍。这在单列小规模的时候无所谓但数据一旦过亿行式模型每一行都带着所有字段一起搬运Cache 的局部性会变得非常差CPU 花在等内存数据上的时间远大于真正计算的时间。Polars 这边不一样。它把每一列保存为独立的 Arrow 内存块也就是连续的、同类型的内存区域。你要对某一列做计算比如求均值它只需要顺序读取这一列对应的那一段连续内存其他的列完全不碰。这个特性在多列聚合的场景里尤为关键你通常只需要对 200 列中的三五列做聚合列式模型允许计算本质上只扫描这几列而 Pandas 即便在优化后也不得不至少遍历所有列对象来对齐行索引。我用一个具象的数字来说明。某次做用户行为事件表大概 1.2 亿行、80 列从中提取用户 ID、事件类型、时间戳三列做聚合再关联另一张维度表。Pandas 在读入全部数据后光是基础的内存映射和副本就干掉了 25G。换成 Polars 之后我可以在扫描阶段就只读取所需的列scan_parquet 配合 select 操作实际加载进内存的数据只有三列整条链路跑完峰值内存也就 2.8G。这个差距不是优化能完全弥合的而是存储模型带来的底子差异。1.2 内存分配的精细程度Polars 是怎么做到“能省则省”的另一个值得展开的点是 Polars 的内存分配策略。我们在 Pandas 里经常遇到的状况是filter 之后数据变了但内存没降。原因是 Pandas 的很多操作会产生中间副本而这些副本的生命周期管理不够积极。Polars 则基于 Rust 的所有权系统设计了一套按需复制的机制。它不是完全不复制而是在表达式链条中大量使用视图和零拷贝的 mask 操作。举个例子对一个 5 亿行的 DataFrame 执行 df.filter(pl.col(score) 100)。在 Polars 里这个操作会在底层生成一个布尔掩码然后只把布尔掩码为真的位置所对应的数据暴露出来整个过程不会产生全量的中间矩阵。而 Pandas 的布尔索引背后是先把整个布尔 Series 物化出来再基于它 copy 一份新的完整 DataFrame。数据量小的时候感受不到数据量一大Polars 的省内存优势就完全压过 Pandas。提示如果你刚开始迁移最容易获得收益的第一步就是先读小数据量的文件跑一遍相同的 filter、groupby 逻辑用 memory_profiler 对比一下内存曲线。你会发现 Polars 不仅在峰值内存上更低曲线波动也更平稳很少出现“突然飙高再骤降”的情况。1.3 从全局视角看数据模型列式存储在压缩率上的红利列式存储还带来一个附带收益更高的压缩率。因为同一列的数据类型相同、值域往往相近用压缩算法比如 Snappy、Zstd可以得到远比按行混合类型压缩更高的压缩率。这意味着同样的磁盘数据量列式读取时实际需要从磁盘传输的数据更少。这个特性在与 Parquet 格式搭配时尤其明显——Polars 对 Parquet 的原生读取支持加上列裁剪几乎是把“只读需要的部分”做到了极致。我自己做过一次对比一份 182GB 的 Parquet 数据集直接用 Pandas read_parquet 如果不动任何参数基本就是内存不足直接崩溃即使指定了列名底层还是要解析整个 Schema。Polars 的 scan_parquet 则在解析元数据阶段就知道每列的位置配合 lazy API 可以在物化前完成列裁剪实际使用 120G 内存左右的机器就跑出了一套按月聚合报表。这就是列式格式和列式引擎配合起来的效果不是简单的 112而是一个乘法效应。2. 表达式系统是 Polars 的灵魂告别“方法链地狱”2.1 从方法到表达式思维方式的一次切换Pandas 的表达范式是“对象-方法”df.groupby(a).agg({b: sum})每一段逻辑调用 DataFrame 或 Series 上的方法操作对象始终是整张表或者整列。Polars 不一样它的核心抽象是表达式Expressionpl.col(b).sum() 描述的是“对 b 列执行求和”这个计算意图它不关心数据在哪、什么类型、应该什么时候执行。表达式可以被组合、被复用、被延迟执行。这看起来只是一个语法层面的差异但它带来的工程影响是巨大的。首先表达式是可复用的。过去我在 Pandas 里封装计算逻辑往往要写一堆函数来传 df而在 Polars 里可以直接把表达式对象当成变量比如total_expr pl.col(quantity) * pl.col(price) revenue_expr total_expr.sum().alias(revenue)然后把这个 revenue_expr 放在不同的 groupby、不同的窗口计算里复用。这个能力让代码的可维护性一下子提升了。其次是管道化编程的体验完全不同。Polars 的 DataFrame API 天然支持链式调用而且每一步都是表达式不会在中途物化出临时对象。对比一下同样功能的写法Pandas 风格df df[df[status] success] df df.groupby(user_id)[amount].sum().reset_index() df df[df[amount] 100]Polars 风格df ( df.filter(pl.col(status) success) .group_by(user_id) .agg(pl.col(amount).sum()) .filter(pl.col(amount) 100) )表面上看差不多但 Polars 这条链在 lazy 模式下会作为一个整体进入优化器执行顺序可以被重排。比如把过滤条件下推到读取阶段先减数据量再聚合。Pandas 的执行顺序是从上往下按行执行无法全局重排。2.2 常用表达式的实践拆解聚合、窗口、条件逻辑我选了三个日常使用频率最高的表达式模式展开说明。第一个是条件聚合。以前在 Pandas 里做“同时统计成功率和平均耗时”这种需求经常要用 np.where 再造列再分步聚合。在 Polars 里可以直接在 agg 里嵌套条件表达式result ( df.group_by(server_id) .agg( success_ratepl.col(status).filter(pl.col(status) success).count() / pl.len(), avg_latencypl.col(latency).filter(pl.col(status) success).mean(), ) .sort(success_rate, descendingTrue) )这里的 pl.col(status).filter(...) 并不是访问列上的方法而是构造一个“先按条件过滤这列再参与计算”的表达式。它会在同一批数据上执行多遍但因为有列式存储的底层支持性能开销小到可以忽略。第二个是窗口函数。Polars 的窗口函数是通过 .over() 实现的和 SQL 的 PARTITION BY 语义对应df.with_columns( pl.col(sales) .rank(descendingTrue) .over(region) .alias(region_rank) )这个操作默认按 region 分组在每个组内对 sales 列计算排名。模型上相当于“把列按 group key 切成很多小段分别计算后再拼回来”。窗口函数配合表达式链几乎能覆盖 90% 的分组 TopN、同期对比需求不用再 left join 自己。第三个是条件分支逻辑。Polars 里用 pl.when(condition).then(value).otherwise(value) 取代 Pandas 的 np.where 嵌套df.with_columns( pl.when(pl.col(category).is_in([A, B])) .then(pl.col(price) * 0.8) .otherwise(pl.col(price)) .alias(discounted_price) )这个写法的可读性确实比多层 np.where 强很多尤其在条件超过三个的时候优势尤其明显。我强烈建议你在迁移初期就把这个模式练熟它是整个表达式系统里最直观易懂的入门点。2.3 表达式的组合与性能为什么越“描述式”越高效初学者容易陷入一个误区觉得 Polars 表达式写得越花哨性能越差。事实上Polars 的设计目标之一就是让描述式代码在底层获得更好的优化空间。表达式彼此组合之后会形成一个未被物化过的计算图这个计算图带有列级别的依赖信息。优化器可以根据依赖关系裁剪计算分支比如你最终只 select 两列那中间所有只对第三列生效的计算节点都会被剪掉。这种“只算你需要的部分”的优势在写报表任务里面非常实用。我做过一个场景有 3 亿行埋点数据需要按天统计 UV 和 PV。如果我写两个单独的查询任务分别计算 UV 和 PV内存压力和耗时都是翻倍的。而写成一条表达式链daily_stats ( df_lazy.group_by(pl.col(event_date)) .agg( pl.col(user_id).unique().count().alias(uv), pl.len().alias(pv), ) .collect() )一个查询就完成两个指标而且因为列裁剪user_id 和 event_date 两列之外的数据根本不会被加载。这就是表达式系统配合列式存储产生的真实收益。3. 惰性求值从“走一步算一步”到“全盘优化后一次执行”3.1 急切执行 vs 惰性执行两条路线的本质区别Pandas 是典型的急切执行Eager每写一行 DataFrame 操作数据立刻就发生变化中间结果马上占用内存。好处是调试直观坏处是执行计划碎片化——你无法在跑完第一步之前知道后面还需要哪些列、哪些数据可以被提前过滤。Polars 提供了两种模式普通的 Eager API 和 lazy API。lazy 模式不返回结果而是返回一个 LazyFrame它只是一个计算计划。只有当调用 .collect() 时Polars 才会把整条计算链提交给优化器生成物理执行计划并执行。你可以把惰性求值理解为做菜时先不开火把所有食材的准备、切配、调味全部规划好再一次性下锅而急切执行是每一步都开火锅热了就放料发现缺葱了再去洗葱切葱。显然整体规划更省时间也更省能源。3.2 查询优化器到底做了什么Predicate Pushdown 与 Projection PushdownPolars 的惰性求值优化器有几个关键动作。其中最重要的两个是谓词下推和投影下推。谓词下推也就是把 filter 条件尽可能前推到数据扫描阶段。比如你写lf pl.scan_parquet(events.parquet) result ( lf.filter(pl.col(event_date) 2024-01-01) .join(pl.scan_parquet(users.parquet), onuser_id, howleft) .select([event_date, user_name]) .collect() )如果急切执行步骤是先加载全部 events 数据再加载全部 users 数据然后做 join最后 select 两列并过滤。但在惰性执行中优化器会自动把 filter 下推到读取 events 的扫描阶段只读取 event_date 大于阈值的那部分行同时把 select 下推为列裁剪只读取 event_date、user_id 和 user_name 三列。这个效果单靠手写优化几乎不可能做到。投影下推的逻辑类似只保留后续计算真正用到的列其他列在扫描阶段直接弃读。对于 Parquet 这类列式文件格式这是 10 倍以上的 IO 差异。3.3 Collect、Streaming 与内存阈值何时真正物化数据惰性求值还引出一个问题结果最终都要物化到内存里才能用。.collect() 默认把全部结果加载进内存。如果结果集也大到几个 GB那就需要 streaming 模式——.collect(streamingTrue)或者把结果写出到磁盘。Streaming 模式的核心是增量执行优化后的执行计划被切成若干可独立执行的段每一段只处理一个数据分片内存里始终只保留当前分片的数据。这非常适合数据量超过内存上限的场景。result ( pl.scan_csv(huge_log_*.csv) .filter(pl.col(level) ERROR) .group_by(service_name, hour) .agg(pl.len().alias(error_count)) .collect(streamingTrue) )注意streaming 模式下有一些操作是不支持的比如某些类型的排序和 join。Polars 会给出明确的 panic 或者错误提示遇到这种情况要么手动调整分片逻辑要么回到 collect 默认模式。我个人的习惯是先查一下执行计划 optimize 之后的大小再用流式来兜底。3.4 惰性求值不是银弹什么时候该用 Eager什么时候不用 lazy惰性求值有额外的心智负担。调试时你不能直接看到中间结果每次都要 .collect() 才能打印。对数据量小、步骤少的任务其实 eager 足够对探索性分析、画图、快速看数据分布的场景我也更推荐直接用 eager API开发和排查效率更高。工程中我的划分标准很简单单表 5000 万行以下且查询不超过三五步直接 eager5000 万以上或管道超过五步或者同一个逻辑需要被多个入口复用一律写 lazy 并整体 collect。特别是当你有多个上游文件拼接、过滤、join、groupby、最终写表的场景时lazy 带来的可读性和维护性收益远大于额外学习的成本。4. 工程实践模板从数据读取到结果回写的完整链路4.1 数据读取的准确姿势scan_* 系列 vs read_* 系列在 Polars 中read_csv、read_parquet 这类 eager 读取函数会把整个文件立即加载进内存适合小文件和快速探索。而 scan_csv、scan_parquet 则创建 LazyFrame只在 collect 时真正读取。这里有个工程建议凡是文件大小超过内存的 1/3就默认走 scan 系列。因为即便最后你要全量数据scan 配合列裁剪也能在扫描阶段省掉大量 IO。scan_csv 有个比较实用的参数low_memory。某些时候你明确知道接下来要做的操作会导致数据量膨胀比如 cross join可以把 low_memory 打开它会让底层的读取行为更保守不提前做预分配缓存副作用是单列读取速度会略慢但对总内存曲线的平滑效果很显著。这个参数在 Pandas 里是没有的。4.2 架构选型单一 LazyFrame 链路还是多链路合并在实际项目中我倾向于把整个 ETL 任务抽象成一个函数输入是若干个 LazyFrame输出是一个 LazyFrame。函数内部只做转换逻辑不做 collect。只有在最终写表的入口处统一 collect。这种方式有三个好处一是逻辑可以被单元测试直接调用并加 .collect() 验证二是多个任务可以共享同一个转换函数三是后续如果要从单机迁移到集群只需要替换数据读取和写入层转换逻辑可以原样保留。我举个例子。有一个任务从日志目录读取多天文件做清洗然后按用户维度聚合最后写出一张汇总表。用这一套思想代码结构大概是def clean_events(lf): return ( lf.filter(pl.col(event_time).is_not_null()) .with_columns( pl.col(event_time).str.to_datetime().alias(event_time), pl.col(user_id).cast(pl.UInt64), ) .rename({event_name: action}) ) def aggregate_user(lf): return ( lf.group_by(user_id, pl.col(event_time).dt.date().alias(day)) .agg( pl.col(action).count().alias(action_count), pl.col(session_id).n_unique().alias(session_count), ) ) lf clean_events(pl.scan_parquet(logs/*.parquet)) result aggregate_user(lf).collect(streamingTrue) result.write_parquet(output/user_daily.parquet)4.3 与 Pandas / NumPy / Arrow 的互操作边界Polars 不是一座孤岛。现实工程里我们经常需要把 Polars 的结果喂给下游的 Pandas 代码、Scikit-learn 模型或者直接用 Arrow 的库。这个时候直接转换就行# LazyFrame / DataFrame - pandas DataFrame pandas_df polars_df.to_pandas() # pandas - polars polars_df pl.from_pandas(pandas_df) # arrow table - polars polars_df pl.from_arrow(arrow_table)不过要注意Polars 和 Pandas 在数据类型和索引语义上有差异。Polars 没有显式的行索引概念转到 Pandas 后会自动生成 RangeIndex。如果你在 Pandas 那边依赖了自定义索引需要提前把它转成普通列。还有一个容易踩的坑是 null 和 NaN 的处理Polars 的 Float 列天然区分 null 和 NaN但 Pandas 经常混用互转之后数据语义可能会变。我的建议是跨框架的边界尽量放到最后。能在一个框架内完成的计算不要来回倒腾。来回转换的时间成本和内存开销在数据量大时会非常夸张。4.4 工程实践中的三大铁律类型显式化、分区合理拆分、写出要审计从个人经验里提炼几条铁律吧。第一多数据源合并时尽量在读取后立刻做类型显式化。比如 user_id 在 Parquet 里是 Int64在某个 CSV 片段里被推断成了字符串直接 join 会报错或者大乌龙。所以进入主链路之前统一 cast 是关键。第二自建数据文件最好按某种可过滤的键做分区。比如按日期分区或者按用户 ID 哈希分桶。这样 scan_parquet 的谓词下推才能发挥最大作用。没有分区的文件下推只能靠文件内部的行组索引效果打了折扣。第三写出数据后要做行数和主键唯一性审计。Polars 里很容易做到result_count result.select(pl.len()).item() src_count lf.select(pl.len()).collect().item() assert result_count src_count, fcount mismatch: {result_count} vs {src_count}这行断言看起来简单但它在生产环境里救过我太多次了尤其是 join 之后发现数据膨胀、分组粒度没有对齐的时候。5. 一个完整实战百 GB 级事件数据的多步加工流水线5.1 项目背景与数据形态为了把上面的零散内容串起来我给你讲一个虚构但典型的案例。假设某平台有一个事件日志系统每天产生约 30GB 的 Parquet 文件包含用户 ID、设备 ID、会话 ID、事件名、时间戳、页面路径、停留时长、业务字段若干。任务要求产出两张表一张是“用户日活与行为频率统计表”一张是“设备类型维度的平均会话时长表”。数据量将近 100GB运行机器 64G 内存。用 Pandas 做一次全量重刷的耗时大概是 4 到 6 小时且经常中途内存不足。迁移到 Polars 后我们把流程重构成 lazy 管道整个重刷控制在 50 分钟以内。5.2 流水线设计与分阶段实现第一阶段是多文件读取。Parquet 文件已经按日期分区存储路径格式是 /data/events/date2024-01-01/part-*.parquet。我用一个 LazyFrame 统一扫描全量base_lf pl.scan_parquet(/data/events/date2024-*/part-*.parquet)第二阶段做基础清洗。具体是过滤掉测试流量 user_id0 的记录把 event_time 从字符串转成 Datetime并识别出只有 mobile 端才有的字段cleaned_lf ( base_lf.filter(pl.col(user_id) ! 0) .with_columns( pl.col(event_time).str.to_datetime().alias(event_time), pl.col(device_type).fill_null(web), ) )第三阶段分别产出两张结果表。每日活跃用户表user_daily_lf ( cleaned_lf.group_by(user_id, pl.col(event_time).dt.date().alias(day)) .agg( pl.len().alias(event_count), pl.col(session_id).n_unique().alias(session_count), pl.col(stay_seconds).sum().alias(total_stay), ) .with_columns(pl.col(total_stay) / pl.col(session_count).alias(avg_session_len)) )设备维度的时长表device_daily_lf ( cleaned_lf.group_by(pl.col(device_type), pl.col(event_time).dt.date().alias(day)) .agg( pl.col(session_id).n_unique().alias(session_count), pl.col(stay_seconds).sum().alias(total_stay), ) .with_columns( (pl.col(total_stay) / pl.col(session_count)).alias(avg_session_len) ) )5.3 物化策略与最终写出这里的关键决策是两张结果表是否可以共用同一条清洗后的 LazyFrame如果我用 collect 把 cleaned_lf 物化一次再分别 groupby那内存峰值会比较高。合理做法是直接对同一个 LazyFrame 做两个分支分别 collect。查询优化器会调度两次扫描但每一次都能复用清洗阶段的表达式代码层面保持高复用内存上也避免保存全量中间结果。最后写出时统一用 parquet 格式并做分区user_daily_lf.collect(streamingTrue).write_parquet( /output/user_daily/, partition_cols[day], ) device_daily_lf.collect(streamingTrue).write_parquet( /output/device_daily/, partition_cols[day], )5.4 执行计划验证学会用 explain 看优化器干了什么调试 lazy 管道时我强烈建议你用 explain 来看优化器的执行计划。它提供两种模式默认是物理计划true 参数是逻辑计划。我一般先看逻辑计划确认列裁剪和谓词下推有没有如预期生效print(cleaned_lf.join(user_daily_lf, onuser_id).explain())从输出里可以看到哪些节点做了 PROJECT 裁剪、哪些 FILTER 被下压到了 PARQUET SCAN 层。如果发现某项优化没有生效大多数时候是类型不匹配或者表达式里用了非可下推的上下文例如自定义函数。6. 常见问题与故障排查实录6.1 Schema 冲突CSV 多文件合并时的类型不统一这是我在生产环境里遇到最多的一个问题。多个 CSV 文件同名字段一个推断成 Int64另一个因为空值被推断成 Float64scan_csv 之后报 SchemaError。解决思路有两种。一种是在读取时就指定 dtypes 覆盖推断pl.scan_csv(path/*.csv, schema_overrides{user_id: pl.UInt64, amount: pl.Float64})另一种是先全部读成 Utf8等进入主链路后再统一 cast。第二种更稳妥尤其是字段特别多、历史数据质量不太好的时候能避免不少隐式类型转换造成的 data loss。6.2 Join 时的小心机on 键的重复与 null 值Polars 的 join 默认是 inner join且 on 键如果是 null两边的 null 会被匹配到一起。这在某些场景是不符合预期的。我习惯在每个 join 前先确认 on 键没有 null用 drop_nulls 预先过滤掉无意义的行。否则 join 之后会出现预期外的膨胀尤其是多个 on 键且有 null 时膨胀倍数会非常明显。还有一个问题是 join 的重复列名。两边如果有同名但不需要做连接键的字段结果里会出现 _right 后缀的列。处理方式有三个join 之前先 select 掉不用的列用 .drop_suffix或者在 join 参数里设置 howleft 之后手动重命名。我推荐第一种语义最清晰。6.3 惰性求值下的“隐性 Bug”中间结果被多次触发LazyFrame 的一个隐蔽问题是同一个 LazyFrame 被 collect 多次会导致底层扫描多次执行。如果底层是昂贵的远程文件读取这会白白消耗 IO。你会发现同样的查询跑两遍耗时却不减反增。这个时候可以用 .fetch(n) 做小规模预跑或者在条件允许时把中间结果缓存成 Parquet 文件再接一个 scan 出来。lf.fetch(1000) # 取前 1000 行验证逻辑 lf.collect(streamingTrue) # 全量执行6.4 排查“内存不降反升”的技巧即便用了 streaming有时也会发现内存缓慢爬升。这个时候首先检查是不是写出的目标端在做缓冲其次检查是不是用了 group_by 之后又 sort 全量结果sort 会在流式模式下把整个分片喂到内存中排序最后检查 collect 出的结果集本身是不是比预期大可以用 estimated_size() 提前预估。lf.estimated_size() # 返回字节数帮助你预算内存如果预估结果集本身就超内存那就不能硬 collect必须配合 streaming 直接写外部存储或者分块处理。6.5 类型系统的坑时区、日期和字符串Polars 的 Datetime 类型支持时区但时区运算的语义和 Pandas 不太一样。我在处理跨时区日志时习惯先把时间统一转成 UTC 存储展示层再转本地时区。如果原始数据里混有带时区和不带时区的 Datetime直接做比较会报错。解决办法是先 .dt.replace_time_zone(None) 再统一。字符串这块也要留意Polars 的字符串是 UTF-8 编码的但某些场景下直接 str.contains 的正则语法跟 Python 略有不同。遇到复杂正则匹配时我会先把列 cast 到 Object 类型再传入 Python 正则库不过性能会下降所以只在小数据量场景使用。7. 我踩过几次坑之后的体会实际上迁移到一个新工具最难的不是学 API而是把脑子里“以 Pandas 的方式思考问题”的习惯扭转过来。列式存储、表达式、惰性求值这三个概念每一样单独拿出来都不难理解但它们组合在一起之后会改变你拆解数据处理任务的方式。过去我拿到任务第一反应是“怎么用分组循环一点点凑”现在第一反应是“怎么设计一条表达式链让优化器帮我把没用的数据挡在门外”。在工程实践中我最终还是回归到几个很朴素的原则能懒则懒能剪则剪能流则流。数据读取用 scan数据清洗用表达式数据聚合提前到扫描阶段最终物化前先看执行计划。这一套组合拳打下来我不敢说能解决所有大数据问题但至少单机内 100GB 以内的数据基本不再需要求助 Spark。最后分享一个小技巧如果你在犹豫要不要迁移某个任务别直接拿全量数据做试验先取一天的数据同时用 Pandas 和 Polars 跑一遍相同逻辑对比执行时间和内存曲线。这个过程通常只需要半小时但会让你对 Polars 的实际效果有一个非常具体的体感。我当初就是这么干的做完当天晚上就把迁移计划列好了。
阅读完成 · 觉得有帮助?