GCP中Parquet文件读取速度异常:小文件与大文件耗时相近问题咨询
这种现象在Spark搭配对象存储(比如GCS)的场景里其实挺常见的,核心原因往往不是数据读取本身的时间,而是元数据扫描、API请求开销、任务调度初始化这些“隐性成本”占了主导。结合你的场景,我整理了几个最可能的原因:
1. 小文件数量过多,元数据扫描开销累加
Parquet是列式存储,Spark读取时第一步要扫描每个文件的Footer元数据(包含Schema、分区信息、数据块位置等)。如果你的B存储位置里有大量小Parquet文件(比如上万个1KB的文件),哪怕总数据量只有A的1/100000,Spark需要逐个向GCS发起请求获取每个文件的元数据,这些请求的延迟累加起来,就会和读取A的少量大文件的元数据时间持平。
举个例子:假设A是10个1GB的文件,Spark只需要发起10次元数据请求;而B是1000个10KB的文件,就要发起1000次请求——GCS每个API请求的固定延迟(比如几十到几百毫秒)叠加后,总耗时很容易追上A。
2. GCS API请求的固定延迟占比高
对象存储的每个操作(列出目录文件、获取文件元数据、打开文件流)都有固定的网络延迟和服务端处理延迟。当读取的数据量极小的时候,这些固定延迟会成为总耗时的主要部分,而实际读取数据的时间可以忽略不计。
比如读取B的实际数据只需要10ms,但发起请求、等待GCS响应的时间就花了30秒;而读取A的实际数据花了30秒,加上请求延迟1秒,总耗时就差不多了。
3. Spark任务调度与初始化开销主导
Spark会根据文件大小生成对应的任务:默认情况下,每个Parquet文件(或文件分片)对应一个任务。如果B的文件都远小于spark.sql.files.maxPartitionBytes(默认128MB),Spark会为每个小文件生成一个独立任务。这些任务的调度、序列化/反序列化、JVM初始化等开销,会远大于实际处理小数据的时间。
比如处理B的1000个任务,每个任务初始化花30ms,总开销就是30秒;而处理A的10个任务,每个任务处理数据花3秒,总耗时也是30秒——两者自然差不多。
4. 缺少文件级统计信息的优化
如果B的Parquet文件没有生成统计信息(比如数据块的min/max值、记录数等),Spark在读取时无法利用任何预优化(比如跳过空数据块、谓词下推),必须完整扫描每个文件的元数据甚至数据内容。而A的大文件虽然数据多,但可能有完整的统计信息,Spark可以更快定位数据位置,但因为数据量本身大,总耗时还是和B的扫描开销持平。
如何验证这些猜想?
- 查看文件数量:用
gsutil ls gs://bucket-name/tables/A/year=2018/month=4/day=5/ | wc -l和B的目录对比,看B是否有大量小文件。 - 查看Spark UI:提交任务后看“Jobs”页面的任务数量,以及每个任务的“Duration”——如果B的任务数量多但每个任务的“Execution Time”极短,就是任务调度开销的问题。
- 查看GCS日志:在GCP控制台的Cloud Logging里搜索存储桶的API请求,对比A和B的请求次数与总延迟。
优化建议
- 合并小文件:写入B的时候用
repartition或coalesce减少文件数量,比如df.repartition(1).write.parquet("gs://.../B/"),让B变成少数几个大文件。 - 调整Spark小文件参数:增大
spark.sql.files.openCostInBytes(默认4MB),让Spark认为打开小文件的成本更高,从而自动合并多个小文件到一个分区。 - 生成Parquet统计信息:写入时开启
spark.sql.parquet.enableVectorizedReader和spark.sql.parquet.mergeSchema,并设置spark.sql.parquet.statistics.enabled=true,让Spark生成文件级统计信息,加速后续读取。
内容的提问来源于stack exchange,提问作者Vishnu Prathish

