Python pyarrow 计算引擎:绕过 pandas 直接操作列式数据
Python pyarrow 计算引擎绕过 pandas 直接操作列式数据pandas 是数据分析的瑞士军刀但大家都经历过那个痛苦瞬间——读一个 2GB 的 CSV内存直接飙到 8GB然后 DataFrame 操作越来越慢最后 OOM 爆炸。问题出在 pandas 的行式内存布局上。pyarrow 提供了一条绕路直接用 Arrow 列式格式操作数据内存占用更小、计算更快、还能和 Spark/Hive/ClickHouse 无缝对接。今天我们来实战 pyarrow 计算引擎看看它怎么帮你摆脱 pandas 的内存噩梦。一、pandas 的内存问题与 Arrow 列式格式的优势pandas 的底层存储是 NumPy ndarray每列一个连续数组。看起来很列式但它有个致命问题——每一列的数据类型必须统一。一个包含 100 万个整数和 10 个字符串的列pandas 会把整个列升级为 object 类型内存占用直接翻 10 倍。为什么 pandas 的 object 类型内存开销这么大因为 object 类型的底层不是存储数据本身而是存储指向 Python 对象的指针。100 万个元素光指针数组就是 8MB64 位地址。更致命的是每个字符串都是一个独立的 PyObject自带引用计数、类型元信息等开销一个 5 字节的字符串北京在内存里可能占用 50 字节。pandas 不是在存数据是在存 Python 对象。Arrow 的做法是所有字符串连在一起放进一个大的字节缓冲区再用一个偏移量数组标记每个字符串的起止位置。100 万个 北京 只需要1000000 * 7字节 7MBUTF-8 编码 偏移量数组 4MB 11MB比 pandas 的 50MB 省了 4 倍。Arrow 列式格式的核心优势在存储结构上传统行式存储将每一行的所有字段连续存放导致内存碎片多且类型混合容易产生内存浪费而 Arrow 列式存储则将同一列的数据集中存放支持精确类型定义和列压缩从而实现 3 到 10 倍的内存节省。特性pandas (NumPy)pyarrow (Arrow)内存布局每列 ndarray但 object 列存储 Python 对象指针每列精确类型string 用可变长度编码空值处理float 列用 NaNobject 列用 None语义不统一统一 validity bitmap零额外内存字符列object 类型每个字符串是独立 Python 对象string 类型连续字节缓冲区 偏移量数组列间操作需要 DataFrame 对象中转直接 compute 函数零 DataFrame 开销一个直观的对比import pandas as pd import pyarrow as pa # 创建相同内容的 DataFrame 和 Arrow Table df pd.DataFrame({ id: range(1_000_000), name: [user_ str(i) for i in range(1_000_000)], age: [25 (i % 50) for i in range(1_000_000)], score: [50.0 (i % 50) for i in range(1_000_000)] }) table pa.Table.from_pandas(df) # 内存对比 print(fpandas DataFrame 内存: {df.memory_usage(deepTrue).sum() / 1024**2:.1f} MB) print(fArrow Table 内存: {table.nbytes / 1024**2:.1f} MB) # pandas: ~45MBname列object类型占大头 # Arrow: ~14MBstring列紧凑编码二、pyarrow 核心计算 APIpyarrow 提供了一套 compute 函数直接对 Arrow 列ChunkedArray做运算不需要创建 DataFrame。2.1 基础计算操作import pyarrow as pa import pyarrow.compute as pc # 创建 Arrow Table table pa.table({ user_id: pa.array(range(1, 100001), typepa.int32()), age: pa.array([25 i % 60 for i in range(100000)], typepa.int8()), score: pa.array([50.0 (i * 0.01) for i in range(100000)], typepa.float32()), city: pa.array([北京 if i % 3 0 else 上海 if i % 3 1 else 广州 for i in range(100000)], typepa.string()) }) # 直接对列做筛选不需要转 DataFrame # 找出年龄大于30且分数大于80的用户 age_mask pc.greater(table.column(age), 30) # age 30 → boolean mask score_mask pc.greater(table.column(score), 80.0) # score 80 → boolean mask combined_mask pc.and(age_mask, score_mask) # 两个条件 AND 组合 filtered table.filter(combined_mask) # 用mask过滤整个表 print(f原始行数: {table.num_rows}, 筛选后行数: {filtered.num_rows})2.2 聚合计算# 聚合统计——直接在 Arrow 列上计算不经过 pandas age_col table.column(age) score_col table.column(score) print(f年龄均值: {pc.mean(age_col).as_py():.2f}) print(f年龄中位数: {pc.approximate_median(age_col).as_py():.2f}) print(f分数最大值: {pc.max(score_col).as_py():.2f}) print(f分数最小值: {pc.min(score_col).as_py():.2f}) print(f分数标准差: {pc.stddev(score_col).as_py():.2f}) print(f分数方差: {pc.variance(score_col).as_py():.2f})2.3 分组聚合# 分组聚合——pyarrow 方式比 pandas groupby 更省内存 # 按 city 分组计算每组的平均分数 group_result table.group_by(city).aggregate([ (score, mean), # 计算 score 的均值 (age, max), # 计算 age 的最大值 (user_id, count) # 计算每组行数 ]) print(group_result.to_pandas()) # 输出: # city score_mean age_max user_id_count # 0 广州 80.00 84 33333 # 1 上海 70.00 84 33333 # 2 北京 60.00 84 333342.4 排序与 Join# 排序——按 score 降序排列 sorted_table table.sort_by([(score, descending)]) print(sorted_table.slice(0, 5).to_pandas()) # 查看 top 5 # Join——两个 Arrow Table 的关联 orders pa.table({ order_id: pa.array([1, 2, 3, 4, 5], typepa.int32()), user_id: pa.array([10, 20, 30, 10, 20], typepa.int32()), amount: pa.array([100, 200, 150, 80, 300], typepa.float32()) }) # 按 user_id 关联用户表和订单表 joined pa.join( table, # 左表用户信息 orders, # 右表订单信息 keysuser_id, # 关联键 join_typeinner ) print(fJoin 结果行数: {joined.num_rows})三、文件读写与零拷贝数据交换Arrow 格式的另一个大杀器是零拷贝数据交换——不同系统之间共享 Arrow 数据不需要序列化/反序列化。为什么零拷贝这么重要传统 Spark → Python 的数据传输链路是Spark JVM 内存 → 序列化成 JSON/CSV 字符串 → 通过 Socket 传 → Python 接收 → 反序列化成 dict/list → 转成 pandas DataFrame。每一步都有 CPU 开销和内存膨胀。Arrow IPC 的做法完全不同数据一直留在操作系统层面的共享内存里Spark 写入Python 直接 mmap 映射两边看到的是同一块物理内存。这就是为什么 Spark 3.0 的toPandas()改用了 Arrow 后端后速度提升了 10-100 倍——不是算法优化了是序列化这步直接跳过了。3.1 Parquet 高效读写import pyarrow.parquet as pq # 写 Parquet 文件列式压缩存储 pq.write_table( table, /data/users.arrow.parquet, compressionzstd, # zstd压缩比snappy更省空间 compression_level3, # 压缩级别3速度和压缩率的平衡点 use_dictionaryTrue # 对低基数列如city使用字典编码 ) # 读 Parquet 文件直接生成 Arrow Table不经过 pandas read_table pq.read_table( /data/users.arrow.parquet, memory_mapTrue # 内存映射大文件不一次性加载到内存 ) # 只读部分列列式存储的优势不需要读整行 partial_table pq.read_table( /data/users.arrow.parquet, columns[user_id, score] # 只读2列跳过name和age )3.2 Arrow IPC 格式进程间零拷贝共享import pyarrow.ipc as ipc # 序列化为 Arrow IPC 流格式用于进程间传输 sink pa.BufferOutputStream() writer ipc.new_stream(sink, table.schema) writer.write_table(table) writer.close() # 接收端零拷贝反序列化不需要解码直接映射为 Arrow Table buf sink.getvalue() reader ipc.open_stream(buf) received_table reader.read_all() # 两个进程共享同一块内存没有任何拷贝开销 # 这就是 Arrow 在 Spark/Hive/ClickHouse 间高效交换数据的底层机制 这种机制与传统数据交换方式形成了鲜明对比。在传统方式中Spark JVM 内存中的数据需要先序列化成 JSON 或 CSV 格式经过网络传输后再反序列化到 Python pandas整个过程涉及多次拷贝耗时通常在秒级。而采用 Arrow 方式后Spark JVM 内存通过 Arrow IPC 零拷贝直接映射到共享内存区域Python pyarrow 可直接读取耗时仅为毫秒级。 ## 四、pandas Arrow 混合策略与性能实测 完全抛弃 pandas 不现实——生态太丰富了。最佳策略是Arrow 做底层存储pandas 做上层分析。 **为什么 pandas 2.0 搞了 Arrow 后端但很多操作还是慢** 因为 pd.read_parquet(dtype_backendpyarrow) 只是在存储层用了 Arrow 类型但 df.groupby().agg() 这种计算还是会走 pandas 自己的计算引擎不是 Arrow 的 compute 函数。Arrow 类型省了内存但你groupby 的时候 pandas 仍然会把 Arrow 数组转换成 NumPy 数组再聚合多了一次拷贝。真正的加速得在 pyarrow.compute 层直接算用 table.group_by() 而不是 df.groupby()。 ### 4.1 pandas 2.0 的 Arrow 后端 pandas 2.0 开始支持 ArrowDtype直接用 Arrow 列作为底层存储 python # pandas 2.0 使用 Arrow 后端需要安装 pyarrow df_arrow pd.read_parquet( /data/users.arrow.parquet, dtype_backendpyarrow # 使用 Arrow 类型系统 ) # df_arrow 的各列类型是 ArrowDtype内存占用接近原生 Arrow # 或者逐列转换 df pd.DataFrame({ city: pd.array([北京, 上海, 广州] * 100000, dtypestring[pyarrow]), age: pd.array([25 i % 60 for i in range(300000)], dtypeint8[pyarrow]) }) print(df.memory_usage(deepTrue)) # 比传统 object/int64 列节省 70%4.2 大数据场景的性能对比import time # 创建 1000 万行测试数据 n 10_000_000 large_df pd.DataFrame({ id: range(n), value: [50.0 (i * 0.001) for i in range(n)], category: [A if i % 4 0 else B if i % 4 1 else C if i % 4 2 else D for i in range(n)] }) # 方式1: pandas 纯操作 start time.time() result_pandas large_df[large_df[value] 75].groupby(category)[value].mean() pandas_time time.time() - start # 方式2: 转 Arrow 后操作 large_table pa.Table.from_pandas(large_df) start time.time() mask pc.greater(large_table.column(value), 75.0) filtered large_table.filter(mask) result_arrow filtered.group_by(category).aggregate([(value, mean)]) arrow_time time.time() - start print(fpandas 耗时: {pandas_time:.3f}s, Arrow 耗时: {arrow_time:.3f}s) # 典型结果: pandas ~1.5s, Arrow ~0.8s4.3 何时用 pyarrow 何时用 pandas场景推荐工具原因大文件读写 (1GB)pyarrow内存映射 列裁剪内存友好简单筛选/聚合pyarrow compute绕过 DataFrame 中转更快复杂分析 (pivot/melt/window)pandaspyarrow compute API 不够丰富跨系统数据交换Arrow IPC零拷贝Spark/Hive 原生支持可视化/探索性分析pandas与 matplotlib/seaborn 无缝衔接 踩坑提醒table.to_pandas()是把 Arrow 数据又拷回 pandas 了没有零拷贝。如果你想画图table.to_pandas()会在内存里复制一份完整的数据你的 14MB Arrow Table 变成 45MB DataFrame瞬间又回去了。如果只是画图考虑直接用 Polars 的 Arrow 后端画或者用table.column(score).to_pylist()只取需要的列而不是整个表。pc.mean()对包含 null 的列直接返回 null。这是 Arrow 的计算语义有 null 参与计算结果就是 null。pandas 的mean()默认skipnaTrue会自动跳过Arrow 里得显式用pc.mean(col, skip_nullsTrue)。一字之差排查了半天才发现为什么均值结果是null。内存映射memory_mapTrue不是万能加速。你在机械硬盘上memory_mapTrue读一个 10GB 的 Parquet随机访问的性能会烂到你想哭——mmap 依赖操作系统的页缓存机械硬盘随机 I/O 延迟是 SSD 的 100 倍。云存储S3/OSS更是完全不支持 mmap——网络文件系统上的 mmap 就是自欺欺人。memory_map只在本地 SSD 顺序扫描的场景下有用。五、总结pyarrow 计算引擎为数据分析提供了一条绕过 pandas 内存瓶颈的路径。Arrow 列式格式的精确类型编码、统一空值处理和紧凑字符串存储让同样的数据内存占用减少 3-10 倍。pyarrow compute 函数直接在列上做筛选、聚合、排序和 Join不需要创建 DataFrame 中转对象。文件读写方面Parquet 列裁剪 Arrow IPC 零拷贝共享让大数据场景的 I/O 效率大幅提升。实际落地建议采用混合策略Arrow 做底层存储和大文件读写pandas 做复杂分析和可视化。pandas 2.0 的 ArrowDtype 后端是这两者融合的趋势方向——以后可能不需要在两者之间做选择Arrow 成为统一底座pandas 成为高层 API。