当业务数据从 GB 迈向 TB 甚至 PB 级别,传统基于行式存储的数据库(如 MySQL/PostgreSQL)在处理历史数据分析时会迅速成为整个系统的瓶颈。查询响应慢如龟爬、存储成本指数级增长、OLTP 系统被 OLAP 查询拖垮,是每个技术负责人都会面临的“午夜梦魇”。本文将以首席架构师的视角,深入剖析 Apache Parquet 这一列式存储格式,从计算机底层原理出发,结合一线工程实践中的代码实现、性能调优与架构权衡,为你揭示如何利用 Parquet 作为“银弹”,构建高效、可扩展的海量历史数据解决方案。
现象与问题背景
想象一个高频交易系统,每秒产生数百万条订单和成交回报(Tick Data),或是一个大型电商平台,记录着数亿用户每日的点击、浏览、加购行为日志。这些数据在产生时具有极高的时效性,通常由 OLTP 系统处理。但随着时间推移,它们会转变为“历史数据”,其核心价值在于回顾性分析:例如,分析过去一年的交易滑点分布、复盘某个大促活动的用户转化漏斗、或为机器学习模型提供训练样本。
此时,问题浮出水面。如果我们继续将这些海量历史数据存储在生产环境的 MySQL 中,会遇到一系列灾难性问题:
- 查询性能雪崩:分析型查询(OLAP)通常只关心少数几个字段,例如
SELECT symbol, AVG(price), SUM(volume) FROM trades WHERE trade_date = '2023-10-01' GROUP BY symbol。在行式存储中,即使你只想要 3 个字段,数据库也必须从磁盘上将每一条符合条件的完整记录(可能包含几十个无关字段)加载到内存中。这是巨大的 I/O 浪费和 CPU 缓存污染,我们称之为读放大(Read Amplification)。当扫描的数据量达到亿级别时,查询可能需要数小时甚至直接超时。 - 存储成本失控:关系型数据库为了保证事务和一致性,其存储引擎(如 InnoDB)本身就有不小的空间开销。对于不常更新的历史数据,这种开销尤为“奢侈”。更重要的是,行式存储的数据冗余度高,压缩效率远低于列式存储。
- 业务隔离失效:一个复杂的分析查询可能长时间占用大量 CPU 和 I/O 资源,甚至锁住某些表,严重影响线上 OLTP 业务的稳定性和响应时间。将分析负载和交易负载放在同一个集群中,无异于在高速公路上开拖拉机。
这些问题的根源,在于我们用错了工具。行式存储为事务而生,而分析的场景,则呼唤着为吞吐量和扫描效率设计的列式存储。
关键原理拆解:为什么列式存储是“银弹”?
要理解 Parquet 的威力,我们必须回归到计算机科学最基础的原理:数据局部性(Data Locality)。CPU 从内存加载数据时,并非逐字节读取,而是以 Cache Line(通常为 64 字节)为单位。如果内存中连续的数据正是计算所需要的,那么缓存命中率会极高,计算效率也会大幅提升。磁盘 I/O 也是同理,连续读取(Sequential I/O)远快于随机读取(Random I/O)。存储格式的设计,本质上就是如何组织数据以最大化局部性原理的优势。
行式存储 vs. 列式存储
让我们用一个简化的交易表示例:
| trade_id | symbol | price | volume | timestamp |
|----------|--------|-------|--------|---------------------|
| 1001 | AAPL | 150.5 | 100 | 2023-10-01 09:30:00 |
| 1002 | GOOG | 2800.2| 50 | 2023-10-01 09:30:01 |
| 1003 | AAPL | 150.6 | 200 | 2023-10-01 09:30:02 |
- 行式存储(Row-based)在磁盘上的布局是:
[1001, "AAPL", 150.5, 100, ts1], [1002, "GOOG", 2800.2, 50, ts2], [1003, "AAPL", 150.6, 200, ts3]
它将一条记录的所有字段连续存放。这对于SELECT * WHERE trade_id = 1001这样的点查(OLTP 场景)是完美的,一次 I/O 就能获取所有信息。但对于计算所有交易平均价格的查询,它不得不读取每一条记录的所有字段,即使 `symbol`、`volume` 等字段完全无用。 - 列式存储(Column-based)的布局则是:
[1001, 1002, 1003], ["AAPL", "GOOG", "AAPL"], [150.5, 2800.2, 150.6], [100, 50, 200], [ts1, ts2, ts3]
它将同一列的所有数据连续存放。现在,计算平均价格的查询只需要读取 `price` 这一列的数据,I/O 量减少了几个数量级。这就是列式存储在 OLAP 场景下实现百倍甚至千倍性能提升的核心秘密。
Parquet 的内部结构与“黑魔法”
Parquet 不仅仅是简单地把列数据拼在一起,它的文件格式设计充满了工程智慧,旨在将压缩和查询性能推向极致。
- 文件结构:一个 Parquet 文件由“行组(Row Group)”、“列块(Column Chunk)”和“页(Page)”三层嵌套结构组成。文件末尾还有一个“文件元数据(File Footer)”,记录了文件的 Schema、总行数以及每个行组的元数据指针。
- 行组(Row Group):数据在水平方向上被切分成多个行组(通常 64MB-1GB)。这是数据写入和读取的并行化单元。
- 列块(Column Chunk):在每个行组内,数据按列垂直切分,每一列形成一个列块。
- 页(Page):在列块内,数据进一步被组织成多个页(通常 1MB 左右),页是压缩和编码的基本单元。
- 高效压缩:由于同一列的数据类型相同,分布和重复度也相似(例如 `symbol` 列会出现大量重复的 “AAPL”),这使得列式数据具有极高的可压缩性。Parquet 支持多种编码方式:
- 字典编码(Dictionary Encoding):对于低基数(distinct 值较少)的列,如 `symbol` 或 `country`,Parquet 会构建一个字典,然后用紧凑的整数 ID 替换原始值。这极大地减少了存储空间。
- 游程编码(Run-Length Encoding, RLE):对于连续重复的值,例如排序后的数据,RLE 会将其编码为 `(value, count)` 的形式。例如 `[“US”, “US”, “US”]` 变成 `(“US”, 3)`。
- 在此基础上,Parquet 还会应用 Snappy、Gzip、ZSTD 等通用压缩算法,将压缩比推向极致。通常,Parquet 文件比等效的 CSV 文件小 75% 甚至更多。
- 谓词下推(Predicate Pushdown):这是 Parquet 的查询加速大杀器。在每个列块的元数据中,都存储了该列块数据的统计信息,如最大值(max)、最小值(min)和空值数量(null_count)。当查询引擎(如 Spark, Presto, DuckDB)执行一个带 `WHERE` 条件的查询时,例如
WHERE price > 200.0,它可以先读取元数据。如果某个列块的 `max(price)` 只有 150.6,引擎就知道这个列块里不可能有任何满足条件的数据,于是直接跳过对整个列块的读取和解压。这个操作发生在数据处理的最前端,极大地减少了需要扫描的数据量。
系统架构总览
一个典型的基于 Parquet 的历史数据处理架构通常如下所示,它遵循数据湖(Data Lake)或湖仓一体(Lakehouse)的设计模式:
数据流向:
- 数据源(Sources):包括生产环境的 OLTP 数据库(MySQL, PostgreSQL)、业务系统产生的应用日志(Logs)、消息队列(Kafka)等。
- 数据引入与处理(Ingestion & Processing):
- 离线批处理:使用 Apache Spark 或 Flink 等分布式计算引擎,定期(如每小时或每天)从数据源抽取数据,进行清洗、转换(ETL/ELT),然后以 Parquet 格式写入到分布式文件系统或对象存储中。
- 实时流处理:通过 Flink 或 Spark Streaming 消费 Kafka 中的实时数据流,在内存中聚合一小段时间或一定大小的数据后,写入 Parquet 文件。
- 数据存储(Storage):使用廉价、高吞吐、可扩展的存储系统,如 Amazon S3、Google Cloud Storage、Azure Blob Storage 或自建的 HDFS。数据通常按照业务主题和日期进行分区组织,形成一种目录结构,例如:
s3://my-datalake/trades/year=2023/month=10/day=01/xxx.parquet。 - 查询与分析(Query & Analytics):上层应用通过查询引擎访问存储在数据湖中的 Parquet 数据。常用的查询引擎包括 PrestoDB/Trino、Spark SQL、ClickHouse(通过 `S3` 表函数)、以及新兴的 DuckDB(用于单机分析)。这些引擎都深度优化了对 Parquet 格式的读取,能够充分利用谓词下推等特性。
这种架构实现了读写分离和计算存储分离,生产系统不再受分析查询的干扰,同时存储成本极低,计算资源可以按需弹性伸缩。
核心模块设计与实现:写好 Parquet 的“七种武器”
从“极客工程师”的角度看,仅仅知道 Parquet 的原理是不够的,魔鬼在细节中。如何正确地写入 Parquet 文件,直接决定了下游查询性能的上限。下面是七个关键的实战技巧。
武器一:选择合适的分区策略 (Partitioning)
分区是数据组织的第一道防线,也是最有效的“谓词下推”。将数据按查询中频繁使用的过滤字段(尤其是日期或类别)进行分区。例如,按天分区后,WHERE trade_date = '2023-10-01' 的查询会直接定位到对应的目录,忽略掉其他所有日期的数据。
# 使用 pyarrow 和 pandas 写入分区数据
import pandas as pd
import pyarrow as pa
import pyarrow.parquet as pq
# 假设 df 是一个包含 'trade_date' 列的 DataFrame
table = pa.Table.from_pandas(df)
# 按 'trade_date' 列进行分区写入
pq.write_to_dataset(
table,
root_path='s3://my-datalake/trades',
partition_cols=['trade_date']
)
# 这会自动创建 trade_date=2023-10-01/xxx.parquet 这样的目录结构
坑点:不要过度分区。如果分区后每个目录下的文件过小(比如只有几 KB),会导致“小文件问题”,增加元数据管理的开销并降低查询引擎的吞吐量。通常选择基数适中(几十到几千)的列作为分区键。
武器二:写入前排序 (Sorting)
这是最容易被忽略但效果最显著的优化。在写入一个 Parquet 文件(或一个分区)前,先对数据按照某个或某几个高基数列(如 `symbol`、`user_id`)进行排序。这样做有两大好处:
- 最大化 RLE 编码效率:排序后,相同的值会聚集在一起,极大地提升了 RLE 的压缩效果。
- 增强谓词下推能力:排序使得每个列块的 min/max 值范围变得非常窄且不重叠。对于
WHERE symbol = 'AAPL'这样的查询,引擎可以跳过所有 min/max 范围不包含 “AAPL” 的列块,实现更细粒度的 I/O 裁剪。
# 在 Spark 中,使用 sortWithinPartitions 或 repartitionAndSortWithinPartitions
(df.repartition("trade_date")
.sortWithinPartitions("symbol", "timestamp")
.write
.partitionBy("trade_date")
.option("compression", "zstd")
.parquet("s3://my-datalake/trades"))
武器三:调优行组大小 (Row Group Size)
`row_group_size` 是一个关键的写入参数。它决定了内存中缓冲多少数据才刷一次盘,形成一个行组。
- 太小(如 1MB):元数据开销变大,压缩算法无法有效利用数据上下文,压缩比降低,谓词下推的粒度太细导致 I/O 模式趋向于随机读。
- 太大(如 2GB):写入时需要巨大的内存缓冲区,容易导致 OOM。同时,如果谓词下推只能跳过整个行组,那么粒度又太粗,可能会读取不必要的数据。
经验法则:行组大小通常设置为 128MB 到 1GB 之间,256MB 或 512MB 是一个很好的起点。
武器四:明智的压缩算法选择 (Compression)
Parquet 支持多种压缩算法,选择哪种是在 CPU、I/O 和存储空间之间做权衡。
- Snappy:压缩和解压速度非常快,压缩比尚可。适用于对查询延迟要求高的“热”数据。
- Gzip:压缩比高,但 CPU 开销大,速度慢。适用于对存储成本敏感、查询频率低的“冷”数据。
- ZSTD(Zstandard):由 Facebook 开源,提供了接近 Gzip 的高压缩比和远超 Snappy 的解压速度,是一个非常均衡的选择,正在成为新的行业标准。
实战建议:除非有特殊理由,否则优先考虑 ZSTD。
武器五:利用字典编码与关闭它
字典编码是 Parquet 的默认行为,对低基数列非常有效。但对于高基数列,比如 `trade_id` 或 `uuid`,每个值几乎都是唯一的。强制使用字典编码不仅不会节省空间,反而会因为维护一个巨大的字典而增加开销和内存压力。
在 Spark 等引擎中,可以为特定列禁用字典编码:
spark.sql("SET spark.sql.parquet.dictionary.enabled=false") // 全局关闭
// 或者针对特定列进行更细粒度的控制,通常在写入选项中提供
df.write.option("parquet.dictionary.page.size", "...") // 调整字典页大小
武器六:处理 Schema 演进 (Schema Evolution)
业务总在变化,表的字段也需要增删改。Parquet 原生支持 Schema 演进。由于元数据中存储了完整的 Schema,查询引擎可以智能地合并不同版本的 Schema。通常的规则是:
- 添加新列:安全。旧文件在读取时,新列会被填充为 `null`。
- 删除列:安全。查询时只是简单地忽略旧文件中的这一列。
- 修改数据类型:危险。通常不被允许,需要重写数据。
这个特性使得数据湖的维护变得非常灵活,无需像传统数仓那样每次变更都要执行成本高昂的 `ALTER TABLE`。
武器七:警惕并解决“小文件问题” (Small File Problem)
流式写入或频繁的批处理任务很容易产生大量的小 Parquet 文件(小于几十 MB)。这会给系统带来灾难:
- HDFS/S3 NameNode/API 压力:文件系统的元数据管理开销剧增。
- 查询性能下降:查询引擎需要打开、读取、关闭成千上万个文件,文件打开的开销远大于数据读取本身,任务调度和规划时间也会变得很长。
解决方案:定期运行一个“合并(Compaction)”作业,将小文件合并成更大、更优化的文件(接近目标行组大小)。这正是 Delta Lake、Apache Iceberg 和 Hudi 等“数据湖表格式”提供的核心功能之一。
对抗与权衡:没有免费的午餐
作为架构师,我们需要清醒地认识到技术选型中的 Trade-off。
- Parquet vs. ORC:两者都是顶级的开源列式存储格式。ORC(Optimized Row Columnar)源于 Hive 社区,对复杂嵌套数据类型支持稍好,内置索引功能更强。Parquet 在 Spark 和 Python 生态中更为流行,社区更活跃。在性能上,两者在不同场景下各有胜负,差距不大。选择哪个更多地取决于你的技术栈和生态。
- 文件格式 vs. 表格式:Parquet 是一种“文件格式”,它只关心单个文件如何组织。而 Delta Lake、Iceberg、Hudi 是“表格式”,它们在 Parquet 文件之上构建了一个元数据层,用于管理一组 Parquet 文件,从而提供了 ACID 事务、时间旅行(版本回溯)、Schema 强制、Upsert 等数据库才有的能力。初学者常混淆这两者:Parquet 是砖块,而 Iceberg/Delta 是用砖块盖房子的图纸和规矩。
- 写入开销 vs. 读取性能:列式存储的写入过程远比行式存储复杂。它需要在内存中缓冲数据、排序、构建字典、压缩列块,这带来了更高的写入延迟和 CPU 消耗。这是一种典型的用写入时的“重”来换取读取时极致的“轻”的权衡,非常适合写少读多的分析场景。
架构演进与落地路径
一个组织的数据架构不是一蹴而就的,它会随着业务发展和数据规模的增长而演进。基于 Parquet 的历史数据平台通常遵循以下路径:
- 阶段一:燃眉之急(OLTP 数据库不堪重负)
当生产数据库因分析查询而告警时,最快的解决方案是搭建一个从库(Read Replica),将所有分析流量导向从库。这只是权宜之E计,治标不治本,无法解决存储成本和行式存储的根本低效问题。
- 阶段二:离线数据湖雏形(ETL to Parquet)
引入批处理任务(如每日执行的 Spark 作业),将生产库中的历史数据抽取出来,转换为 Parquet 格式,存储在 S3 或 HDFS 上,并按日期分区。数据分析师和 BI 工具通过 Presto 或 Spark SQL 查询这些 Parquet 文件。这实现了读写分离,显著降低了成本并提升了查询性能。
- 阶段三:拥抱实时(Lambda/Kappa 架构)
业务对数据的时效性要求提高,天级延迟无法满足。引入流处理(如 Flink),构建一条并行的实时链路,将增量数据近实时地写入 Parquet 文件。此时可能会遇到“小文件问题”,需要开发额外的合并任务。
- 阶段四:迈向现代数据湖仓(Lakehouse)
为了解决小文件、ACID 事务、数据版本管理等复杂问题,引入 Apache Iceberg、Delta Lake 或 Hudi 等表格式。它们统一了批处理和流处理的写入终点,屏蔽了底层文件的复杂管理,为数据湖提供了类似数据库的可靠性和易用性。这代表了当前业界最先进的数据架构范式。
从简单的 Parquet 文件到成熟的 Lakehouse,这条演进路径清晰地展示了如何一步步构建一个能够支撑未来业务增长的、健壮且高效的数据平台。而理解并精通 Parquet,正是踏上这条道路的第一步,也是最坚实的一步。
延伸阅读与相关资源
-
想系统性规划股票、期货、外汇或数字币等多资产的交易系统建设,可以参考我们的
交易系统整体解决方案。 -
如果你正在评估撮合引擎、风控系统、清结算、账户体系等模块的落地方式,可以浏览
产品与服务
中关于交易系统搭建与定制开发的介绍。 -
需要针对现有架构做评估、重构或从零规划,可以通过
联系我们
和架构顾问沟通细节,获取定制化的技术方案建议。