PyArrow读取Spark生成的分区Parquet数据集单个文件时运行速度异常缓慢
问题背景
我尝试从本地文件系统读取单个Parquet文件,该文件是Spark作业生成的分区输出的组成部分,所有Parquet文件按照a=x、b=y这类命名规则存放在层级目录中。为实现读取需求,我使用pandas.read_parquet(底层调用pyarrow.parquet.read_table)并传入filters关键字参数,但该参数的运行耗时远低于预期。
不同读取方式的代码及对应运行表现如下:
# 以下操作耗时约55秒 pd.read_parquet(<整个数据集根路径>, filters=[[('a', '=', 'x'), ('b', '=', 'y')]]) # 以下操作耗时约0.04秒 pd.read_parquet(<整个数据集根路径>/a=x/b=y/) # 以下操作耗时约70秒 pd.read_parquet(<整个数据集根路径>)
通过指定filters条件读取单个Parquet文件的速度仅比全量加载整个数据集快一点,我原本预期运行时间会和读取的文件量近似呈线性相关。
核心疑问
我在操作中存在什么失误?
我知道直接将过滤条件拼接为路径的方式可以实现读取,但由于后续需要过滤的维度会动态变化,这种方式很快就会变得过于复杂;此外我认为read_table理应能够高效加载这类数据。
补充说明:整个数据集包含数百万行数据,目标加载的数据仅数千行。
补充编辑1
按照用户0x26res给出的建议,我手动定义了分区规则,读取速度得到了显著提升,但仍未达到预期,该场景下运行耗时约5秒,对应代码如下:
partitioning = HivePartitioning( pa.schema([ pa.field('a', pa.string()), pa.field('b', pa.int32()), ]) ) pd.read_parquet( <整个数据集根路径>, engine='pyarrow', filters=[ [ ('a', '=', x), ('b', '=', y), ] ], partitioning=partitioning )
你没有操作失误,速度差异完全来自PyArrow Dataset 接口的默认工作流开销,和你传入的filters参数逻辑无关:
- 未手动指定分区规则时,加filters的读取耗时(55s)和全量读取耗时(70s)接近的原因:
- PyArrow 自动识别Hive分区的默认流程,是从根路径开始递归遍历所有子目录,同时读取每个Parquet文件的页脚元数据,用于推断全局数据schema、收集所有可能的分区值。
- 这个全扫描步骤和你是否传入filters完全无关——它会先扫完所有目录、读完全部文件的元数据,才会执行分区裁剪、跳过不符合条件的文件。因此整体耗时和全量扫描元数据的耗时几乎一致,仅比全量读取所有数据快一点。
- 手动指定HivePartitioning后,耗时降到5s但仍远高于直接拼路径读取(0.04s)的原因:
- 手动指定分区schema后,PyArrow跳过了「自动推断分区结构、读取全量文件元数据推断schema」的步骤,因此耗时大幅下降。
- 但它的默认执行逻辑仍然是「先遍历所有子目录收集全部文件路径,再应用filters裁剪路径」,而不是「根据filters条件直接拼接目标分区路径、跳过无关目录的遍历」。当你的分区目录总数较多时,遍历全量目录的IO开销就会达到数秒级,远高于直接定位单个目标文件的耗时。
你不需要硬编码拼接完整的分区路径(这种方式在动态过滤场景下维护成本极高),可以根据场景调整以下参数实现提速:
- 显式传入已知schema,跳过无意义的元数据扫描
如果你确认所有Parquet文件的schema完全一致,直接把数据schema、分区schema显式定义给PyArrow,同时关闭分区自动发现功能,完全跳过读取所有文件页脚的步骤:import pyarrow.dataset as ds import pyarrow as pa import pandas as pd # 显式定义非分区字段的数据schema,PyArrow不会再逐个读Parquet文件页脚推断schema data_schema = pa.schema([ # 按你的实际字段填写,例如: pa.field('id', pa.int64()), pa.field('metric_value', pa.float64()), ]) # 定义Hive分区规则,关闭分区值自动发现 partitioning = ds.HivePartitioning( pa.schema([ pa.field('a', pa.string()), pa.field('b', pa.int32()), ]), discover=False ) # 构造数据集对象 dataset = ds.dataset( '<整个数据集根路径>', format='parquet', partitioning=partitioning, schema=data_schema ) # 应用过滤条件读取,直接转成pandas DataFrame df = dataset.to_table(filter=(ds.field('a') == x) & (ds.field('b') == y)).to_pandas() - 提前根据过滤条件收窄扫描路径
PyArrow目前不支持根据filter条件反向拼接路径、自动跳过无关目录遍历,你可以根据动态生成的过滤条件,提前把需要扫描的根路径收窄到对应分区的上层目录。比如过滤条件为a=x, b=y时,直接把扫描路径设为<整个数据集根路径>/a=x,避免PyArrow遍历所有a=*开头的一级目录,这个调整可以把目录遍历开销降低一个数量级。
常见认知误区:很多开发者误以为PyArrow的filters参数会直接执行路径级裁剪,实际上在不调整默认参数的情况下,它的执行顺序是先全量扫描目录和文件元数据,再应用filter做内容裁剪,扫描阶段的固定开销是无法通过filters参数本身降低的。
内容的提问来源于stack exchange,提问作者Jeroen Bos

