你有没有发现,现在但凡与大数据分析相关的场景,最终存储格式十有八九都会落到 .parquet 文件上。无论是使用 Hive 做数仓查询,还是用 Spark 处理数据,甚至只是本地进行一些小型分析,常常会听到这样的建议:“别再用 CSV 了,直接用 Parquet 吧。”
为什么它几乎成了大数据存储的“默认选项”?这背后其实有非常清晰的技术逻辑,一点也不玄学。
我先分享一个真实的场景。某天深夜,业务方临时提出需要查询一批历史订单数据,总量高达几十亿条。当时数据是以 text 格式配合 gzip 压缩存储的,在 Hive 中执行全表扫描查询,等待时间长得让人绝望。后来,我们将这批历史表迁移到了 Parquet 格式。当业务方再次执行相同条件的查询时,体验发生了天壤之别——几乎是命令刚提交,结果就出来了,快到让人来不及刷手机。
那一刻,我彻底被 Parquet 的效率折服了。
那么,Parquet 究竟是什么?
简单来说,它是一个 列式存储 的文件格式,并附带一套严谨的 元数据规范。它本身并非数据库,更像是一个“为各类大数据计算引擎设计的高性能文件格式标准”。
用大白话理解“行存”与“列存”
假设我们有一张订单表,包含以下字段:
- id
- user_id
- amount
- status
- created_at
传统的行式存储(如 CSV、JSON 或多数关系型数据库的表)是这样工作的:数据按行写入磁盘。当你想读取 amount 字段时,系统需要把整行数据都加载出来,其他无关字段也被顺带读取了。
列式存储(以 Parquet 为代表)则完全不同:
- 它将
id 这一列的所有值 连续地存储在一起。
user_id 列也单独存储在一块。
amount 列同样如此。
- ……
因此,当你只需要查询其中两三个字段时,磁盘只需扫描对应的那几列数据,其他列完全不用触碰。这种“只读取所需数据”的特性,正是 Parquet 在大数据场景下性能卓越的首要原因。
大数据场景为何偏爱 Parquet?
我们不照本宣科,就从实际应用的角度聊聊。
1. 仅扫描所需列,大幅减少磁盘 I/O
想象一个最常见的分析需求:运营想看“每日用户下单金额 Top N”。
你真正关心的字段只有:
- user_id
- amount
- created_at
如果底层数据是 CSV:
- 必须读取表中每一行的完整数据。
- 然后在内存中丢弃不需要的字段。
如果底层数据是 Parquet:
- 引擎可以直接定位并只读取这三列对应的数据块。
- 其他列的数据完全不会被访问。
在大数据处理中,磁盘 I/O 往往是最大的性能瓶颈和成本之一。少读一半的列,就意味着直接减少一半的 I/O,性能提升立竿见影,无需复杂的调优技巧。
2. 压缩效率极高,既省空间又提速
列式存储带来一个附带优势:同一列的数据值通常非常相似。
例如 status 字段,无非是 “PAID”、“CREATED”、“CANCELLED” 等值的重复,非常适合字典编码压缩。created_at 这类时间戳具有明显的递增趋势,也极易压缩。
Parquet 按列进行压缩,远比压缩整行数据能获得更高的压缩比。而且,压缩不仅是为了节省硬盘空间,还能间接提升读取速度——因为从磁盘读出的数据量本身就变小了。解压工作由 CPU 完成,而大多数大数据作业是 I/O 受限而非 CPU 受限,因此整体耗时反而更短。
在写入 Parquet 时,可以指定压缩算法:
import pandas as pd
df = pd.DataFrame({
"user_id": [1, 2, 3, 4],
"amount": [10.5, 20.3, 7.8, 100.0],
"status": ["PAID", "PAID", "CREATED", "CANCELLED"],
})
# 使用 pyarrow 引擎写入 parquet,并指定压缩算法
df.to_parquet(
"orders.parquet",
engine="pyarrow", # 常用引擎
compression="snappy" # 常用选项:snappy / gzip / zstd
)
snappy 是一个均衡的选择:压缩速度极快,压缩率适中;gzip 能获得更高的压缩率,但 CPU 消耗更大;zstd 是近年来流行的新选择,在压缩率和速度之间取得了更好的平衡。
3. 天生为分析型查询而生
行式存储适合“点查”:
而列式存储则更擅长“扫描大量数据并进行聚合”:
- GROUP BY
- SUM / AVG / COUNT
- 多维分析、OLAP、指标看板等场景。
因此,像 Hive、Spark、Flink 这类偏向离线批处理或分析的 大数据 计算引擎,都与 Parquet 格式深度集成。你会发现,在线事务处理(OLTP)数据库和离线分析数仓,在存储设计上通常是完全不同的思路。
Parquet 文件内部结构简述
不必被 “RowGroup”、“ColumnChunk” 这些术语吓到,可以将其想象成一个多层的收纳盒:
- 最外层:一个 Parquet 文件。
- 文件内被切分为多个 Row Group(行组,可理解为按批次打包的数据块)。
- 每个 Row Group 内,再按列切成 Column Chunk(列块)。
- Column Chunk 内部又分为 Page,用于更细粒度的编码和统计。
最关键的部分是 文件尾部的元数据。这里存储了:
- 每列的模式(Schema):名称、类型、是否可为空等。
- 每列在每个 Row Group 中的统计信息(最小值、最大值、空值数量等)。
- 每块数据在文件中的偏移量和长度。
这样做的好处是什么?查询引擎无需读取整个 Parquet 文件,只需先查看元数据。例如,如果查询条件是 amount > 100,引擎会检查每个 Row Group 中 amount 列的 min/max 值。如果某个 Row Group 的 max 值都小于 100,那么整个 Row Group 都可以被跳过,根本不需要读取其中的数据。这就是所谓的 谓词下推(Predicate Pushdown) 优化。
动手实践:用 Python 操作 Parquet
如果你习惯使用 pandas,上手 Parquet 非常容易。
写入示例(含分区):
import pandas as pd
df = pd.DataFrame({
"user_id": [1001, 1002, 1003, 1004],
"amount": [9.9, 199.0, 35.5, 800.0],
"status": ["CREATED", "PAID", "PAID", "REFUND"],
"dt": ["2025-01-01", "2025-01-01", "2025-01-02", "2025-01-02"],
})
# 按日期分区保存,会生成 dt=2025-01-01/ 这样的目录结构
df.to_parquet(
"data/orders_parquet",
engine="pyarrow",
partition_cols=["dt"], # 指定分区列
compression="snappy"
)
读取时,可以利用列裁剪和过滤:
import pandas as pd
# 只读取指定的三列(列裁剪)
df = pd.read_parquet(
"data/orders_parquet",
columns=["user_id", "amount", "dt"]
)
# 然后在 pandas 中进行条件过滤
high_value = df[df["amount"] > 100]
print(high_value.head())
如果使用 PySpark,则能更充分地利用底层优化:
from pyspark.sql import SparkSession
spark = SparkSession.builder \
.appName("parquet-demo") \
.getOrCreate()
df = spark.read.parquet("hdfs:///warehouse/orders_parquet")
# 选择特定列并附加查询条件
result = (
df.select("user_id", "amount", "dt")
.where("dt >= '2025-01-01' and amount > 100")
.groupBy("dt")
.sum("amount")
)
result.show()
这里的 where 条件过滤和列选择(select),Spark 会尽可能将其下推到 Parquet 文件的元数据层面进行优化,从而减少实际需要读取的数据量。
不可忽视的 Schema(模式)管理
Parquet 的一个显著优点是 强 Schema。
数据并非随意塞入,而是:
- 每列的数据类型都被明确记录在元数据中。
- 支持复杂的嵌套结构(如 struct, list, map)。
这对大数据平台至关重要:
- 跨语言:用 Python 写的 Parquet 文件,Java、Scala、Go 都能正确读取,且类型一致。
- 跨引擎:Spark 写入的数据,Trino、Flink、Hive、DuckDB、Pandas 等引擎都可以直接读取。
只要约定好统一的 Schema 和分区规则,整个“数据湖”就可以成为多个计算引擎共享的 Parquet 文件目录。
当然,业务 Schema 很少一成不变,常见的变更包括:
Parquet 对“新增字段”比较友好:新写入的文件包含新字段,而老文件缺少该字段,读取时自动补为 NULL 即可。但对于“修改字段类型”(如 int 改为 string)则需格外谨慎,部分引擎可能无法自动兼容而直接报错。最佳实践是:尽量通过新增字段来演进 Schema,避免原地修改已有字段的类型。
Parquet 成为“默认选择”的领域
回顾大数据的主流场景,Parquet 几乎无处不在:
- 数据仓库 / 数据湖
- Hive 表:
STORED AS PARQUET 已成为常见配置。
- Spark/Flink 作业:中间结果表、宽表、特征表普遍采用 Parquet。
- 湖仓一体方案(Iceberg / Delta Lake / Hudi):其底层存储格式大多基于 Parquet,并在此基础上增加了事务、索引、快照等能力。
- 日志与埋点数据
- 数据入口可能是原始的 JSON 或文本。
- 但经过清洗和转换后,长期存储层通常会转换为 Parquet 格式,以方便后续的分析查询。
- 特征存储与机器学习
- 训练样本往往有数百甚至上千个特征(列)。
- 单次模型训练通常只用到其中的一个子集。
- 列式存储 + 列裁剪 + 高效压缩,简直是为此场景量身定做。
如果你查看多数开源大数据项目的文档,在“支持的文件格式”列表中,Parquet 几乎总是位列前茅。
避坑指南:Parquet 的常见陷阱
Parquet 虽好,但也并非银弹,以下几个“坑”需要特别注意:
1. 小文件灾难
最常见的问题是 Spark 等作业写出了 大量体积极小的 Parquet 文件(每个只有几百KB到几MB),导致目录下堆积了数万甚至数十万个文件。
后果是:
2. 过度分区
Parquet 是列存,而分区是在文件/目录级别的“粗粒度”切分。两者结合效果更佳,但分区策略不能过于细致。
例如,按 user_id 或毫秒级时间戳分区,会导致目录数量爆炸,产生海量小文件,反而降低查询性能。
稳妥的分区维度通常是:
- 日期(年/月/日)或小时。
- 地域、业务线等较高层级的维度。
目标是让单个分区内有“足够多”的数据,避免切分过细。
3. Schema 演进管理不当
如前所述,新增字段相对安全,但修改类型风险高。更糟糕的情况是,同一张逻辑表,在不同时间批次的数据中使用了不一致的 Schema。例如,某天的作业额外写入了一个调试字段,但上游的 Hive Metastore 并未同步更新 Schema 定义。
这会导致部分引擎读取出错,部分引擎却能兼容,造成混乱。成熟的团队通常会:
- 使用集中的 Schema 注册管理(如 AWS Glue、Hive Metastore)。
- 建立严格的 Schema 变更审批流程。
4. 不适合高频单条更新
这是列式存储的通病,并非 Parquet 独有。它更适用于 追加写入 和 批量重写 的场景,不适合像 OLTP 数据库那样进行高并发的单行随机更新或删除。
因此,如果有实时更新、高并发点查的需求,应该选择专用的数据库,而让 Parquet 专注于离线分析领域。
直观对比:CSV vs Parquet
如果你想在本地有个感性认识,可以运行以下对比代码(请注意,这并非严谨的性能基准测试):
import pandas as pd
import numpy as np
import time
# 模拟一百万行订单数据
n = 1_000_000
df = pd.DataFrame({
“user_id”: np.random.randint(1, 100_000, size=n),
“amount”: np.random.rand(n) * 1000,
“status”: np.random.choice([“CREATED”, “PAID”, “CANCELLED”, “REFUND”], size=n),
“dt”: np.random.choice([“2025-01-01”, “2025-01-02”, “2025-01-03”], size=n),
})
# 写 CSV
t0 = time.time()
df.to_csv(“orders.csv”, index=False)
print(“csv write:”, time.time() - t0, “s”)
# 写 Parquet
t0 = time.time()
df.to_parquet(“orders.parquet”, engine=“pyarrow”, compression=“snappy”)
print(“parquet write:”, time.time() - t0, “s”)
# 只读三列——对比读取时间
t0 = time.time()
df_csv = pd.read_csv(“orders.csv”, usecols=[“user_id”, “amount”, “dt”])
print(“csv read:”, time.time() - t0, “s”)
t0 = time.time()
df_parquet = pd.read_parquet(“orders.parquet”, columns=[“user_id”, “amount”, “dt”])
print(“parquet read:”, time.time() - t0, “s”)
通常你会观察到:
- Parquet 文件体积显著小于 CSV 文件(得益于压缩)。
- 在仅读取部分列时,Parquet 的读取速度明显快于 CSV。
当然,线上生产环境还需考虑网络、分布式执行、数据缓存等诸多因素,但性能优化的基本原理是一致的。希望这篇探讨能帮助你更深入地理解 Parquet,并在实际工作中更好地运用它。更多关于大数据和数据处理技术的深度讨论,欢迎访问云栈社区进行交流。