从GB到PB:基于Parquet列式存储的大规模历史数据架构实践

本文旨在为中高级工程师与架构师,系统性地剖析在大数据场景下,为何列式存储格式 Parquet 能够成为海量历史数据优化的事实标准。我们将从计算机系统的底层原理出发,深入探讨其在 I/O、CPU 缓存、数据压缩等方面的核心优势,并结合一线工程中的代码实现、性能权衡与架构演进路径,为你揭示从 GB 级到 PB 级数据管理的完整技术图景。

现象与问题背景

在任何一个快速发展的业务中,数据的增长速度往往远超预期。无论是金融交易系统中的逐笔委托与成交记录,电商平台的上亿级用户行为日志,还是物联网设备每秒上报的状态数据,这些历史数据都蕴含着巨大的商业价值。然而,它们也带来了严峻的工程挑战。最初,工程师们可能会选择最直接的方式存储这些数据:

  • 行式数据库: 如 MySQL、PostgreSQL。初期简单高效,但随着单表数据量超过千万甚至上亿,索引维护成本剧增,任何涉及全表扫描的分析查询都会成为一场灾难,最终拖垮线上业务库。
  • 文本文件: 如 JSON、CSV。简单、可读性好,常用于日志落地。但问题很快暴露:存储空间消耗巨大;无数据类型和 schema 信息,解析开销高;最致命的是,无法进行高效的过滤查询,任何分析都需要读取并解析全部文件,I/O 成为不可逾越的瓶颈。

一个典型的场景:我们需要分析过去一年中,某个特定商品品类(如“手机”)的总销售额。如果数据以 JSON 格式按天存储在对象存储上,查询引擎(如 Spark 或 Presto)的任务流将是:1) 拉取一整年的数据文件列表;2) 逐个文件、逐行读取;3) 解析 JSON 字符串,提取`category`和`amount`字段;4) 判断`category`是否为“手机”;5) 如果是,则累加`amount`。在这个过程中,即便我们只关心两个字段,也无可避免地读取了包含商品描述、用户信息、物流地址等所有无关数据,造成了巨大的 I/O 浪费。

这种由数据存储格式导致的查询性能急剧下降、存储成本飙升的问题,是所有大规模数据平台必须面对的第一个坎。而解决方案的核心,在于改变数据的物理组织方式——从面向记录的行式存储,转向面向分析的列式存储。

关键原理拆解

要理解为什么列式存储能带来数量级的性能提升,我们需要回归到计算机系统的几个基础原理。这并非高深莫秘的魔法,而是对硬件特性与算法的极致运用。

1. I/O 局部性原理与数据读取放大

作为一名严谨的“教授”,我们必须首先回顾一下存储系统。无论是机械硬盘(HDD)还是固态硬盘(SSD),数据都是以“块”(Block)为单位进行读取的。操作系统为了摊销寻道和定位的成本,一次I/O操作会读取一个或多个连续的块。这就引出了关键的矛盾:

  • 行式存储(Row-Oriented): 数据在磁盘上按行连续存放。例如,一条记录 `(id, user, product, price)` 在物理上是紧挨着的。这对于 OLTP(在线事务处理)场景是完美的,比如 `SELECT * FROM orders WHERE id = ?`,因为一次 I/O 就能将整条记录加载到内存。但对于 OLAP(在线分析处理)查询,如 `SELECT AVG(price) FROM orders`,系统不得不读取包含 `id`, `user`, `product` 等无关数据在内的所有行,造成巨大的 I/O 浪费。这就是所谓的读取放大(Read Amplification)
  • 列式存储(Column-Oriented): 数据按列连续存放。所有 `id` 存放在一起,所有 `user` 存放在一起,以此类推。当执行 `SELECT AVG(price) FROM orders` 时,系统只需要读取存储 `price` 列的数据块,I/O 负载相比行存可能降低几个数量级。它完美契合了分析查询“只关心部分列”的特点。

2. CPU 缓存行与向量化执行(SIMD)

数据从磁盘加载到内存后,CPU 并不会直接操作内存,而是会将其加载到 L1/L2/L3 Cache 中。CPU Cache 的最小单元是“缓存行”(Cache Line),通常为 64 字节。当 CPU 需要某个数据时,它会把包含该数据及其相邻数据的整个缓存行加载进来。

  • 在行式存储的数据上进行计算,内存中的数据是 `[id1, user1, price1, id2, user2, price2, …]`。CPU 在计算 `price` 的平均值时,其缓存行中充斥着大量无关的 `id` 和 `user` 数据,导致缓存命中率(Cache Hit Ratio)极低,CPU 频繁地从主存加载数据,性能受限。
  • 在列式存储的数据上,内存中的数据是 `[price1, price2, price3, …]`。数据类型统一且连续,一个缓存行可以装下多个 `price` 值。CPU 核心可以高效地预取数据,缓存命中率极高。更重要的是,这种连续的、同质的数据布局,是现代 CPU 向量化执行(SIMD, Single Instruction, Multiple Data)指令集(如 SSE, AVX)的理想工作对象。CPU 可以用一条指令同时对多个数据(例如 8 个 double 类型)执行加法或乘法运算,计算效率成倍提升。

3. 数据压缩的熵理论

信息熵是衡量信息不确定性的指标。数据的熵越低(即重复度越高、模式越明显),其可压缩性就越好。列式存储天然地为数据压缩创造了绝佳条件。

  • 同一列的数据类型相同,语义相近,数据分布更具规律性,熵值更低。例如,一个“国家”列,可能只有几百个不同的值;一个“年龄”列,数值范围也有限。
  • 相比之下,一行数据混合了字符串、整数、浮点数、日期等不同类型,熵值很高,通用压缩算法(如 Gzip)的效果会大打折扣。

因此,列式存储可以针对每一列的数据特点,采用最高效的编码和压缩算法。例如:

  • 字典编码(Dictionary Encoding): 对于低基数(distinct avalue 数量少)的字符串列,如“城市”列,可以将“北京”、“上海”等字符串替换为整数 ID,并建立一个字典。存储和传输时只需处理紧凑的整数。
  • 行程编码(Run-Length Encoding, RLE): 对于连续重复的值,如 `[A, A, A, A, B, B]`,可以编码为 `(A, 4), (B, 2)`。对于有序数据或低基数列非常有效。
  • 增量编码(Delta Encoding): 对于单调递增的序列,如时间戳或自增 ID,可以只存储第一个值,后续每个值只存储与前一个值的差量(Delta)。通常差量值很小,可以用更少的 bit 来表示。

Parquet 正是这些基础原理的集大成者,它通过精巧的文件格式设计,将这些优势发挥到了极致。

系统架构总览

一个典型的基于 Parquet 的历史数据处理架构,通常是一个分层的 Data Lake(数据湖)或 Lakehouse(湖仓一体)架构。我们可以用文字描述其核心组件与数据流:

数据源层 (Source Layer): 包括线上业务数据库(MySQL/Postgres)、应用服务器产生的业务日志(Log)、消息队列(Kafka/Pulsar)中的实时事件流等。

数据采集与注入层 (Ingestion Layer): 使用 CDC 工具(如 Debezium)捕获数据库变更,或使用 Logstash/Fluentd 收集日志,或直接消费 Kafka 消息。数据以原始格式(JSON, Avro)被送入数据湖的“着陆区”(Landing Zone),通常是对象存储(如 AWS S3, HDFS)的一个特定目录下。

数据处理与转换层 (ETL/ELT Layer): 这是核心。一个分布式的计算引擎(如 Apache Spark, Apache Flink)会定期(或流式)地运行作业。这些作业:

1. 读取“着陆区”的原始数据。

2. 进行清洗、转换、数据类型规整,形成统一的 Schema。

3. 将数据以 Parquet 格式写出,并按照业务维度进行分区(Partitioning),存入数据湖的“处理区”(Processed Zone)。例如,路径可能为 `s3://my-bucket/processed/events/year=2023/month=12/day=25/`。

数据存储与管理层 (Storage & Catalog Layer):

存储核心: 以 Parquet 文件格式存储在廉价、高可用的对象存储上。

元数据中心 (Metastore): 如 Hive Metastore 或 AWS Glue Data Catalog。它负责管理数据的 Schema、分区信息、文件位置等元数据,为上层查询引擎提供一个“虚拟表”的视图。用户无需关心底层是哪个目录下的哪个 Parquet 文件,只需像查询数据库表一样查询即可。

查询与分析层 (Query & Analytics Layer):

Ad-hoc 查询引擎: 如 PrestoDB/Trino, ClickHouse,提供低延迟的 SQL 即席查询,供数据分析师使用。

批处理引擎: Apache Spark SQL,用于大规模的、复杂的批量数据分析和机器学习模型训练。

BI 与可视化工具: 如 Tableau, Superset,连接到查询引擎,为业务人员提供报表和仪表盘。

在这个架构中,Parquet 是数据“静止”时的标准形态,是连接存储和计算的桥梁,其性能直接决定了整个系统的效率和成本。

核心模块设计与实现

现在,让我们像个极客一样,深入 Parquet 文件内部,看看它到底是如何组织的。一个 Parquet 文件并不是一个扁平的列数据集合,而是有精密层次结构的。

一个 Parquet 文件包含:1 个文件头(Magic Number)、若干个行组(Row Group)、1 个文件尾(File MetaData)

  • Row Group (行组): 这是数据的水平切分,类似于 HDFS 的 Block。一个大的 Parquet 文件会被切分为多个 Row Group。将数据写入内存时,通常会以一个 Row Group 为单位进行缓存。
  • Column Chunk (列块): 在一个 Row Group 内部,数据按列组织,每一列的数据形成一个 Column Chunk。
  • Page (页): 一个 Column Chunk 又被进一步切分为多个 Page。Page 是压缩和编码的基本单元,通常包含几千个值。每个 Page 都有自己的头信息(Page Header),里面存储了编码方式、压缩算法、值数量等元信息。
  • File MetaData (文件元数据): 位于文件末尾。这是 Parquet 的“目录”。它包含了文件的全局 Schema、所有 Row Group 的元数据、以及每个 Column Chunk 的元数据(包括其在文件中的偏移量、统计信息如 min/max value 等)。查询引擎首先读取 File MetaData,就能快速定位到需要读取的列块,并利用统计信息进行过滤优化。

代码实现:使用 PyArrow 写入 Parquet

Talk is cheap. Show me the code. 让我们用 Python 的 `pyarrow` 库来演示如何生成一个带有特定编码和压缩的 Parquet 文件。


# 
import pandas as pd
import pyarrow as pa
import pyarrow.parquet as pq

# 1. 构造一个有代表性的 DataFrame
data = {
    'timestamp': pd.to_datetime(pd.to_datetime('2023-01-01') + pd.to_timedelta(pd.np.arange(1000), 'S')),
    'product_id': ['prod_' + str(i % 10) for i in range(1000)],
    'country': ['USA', 'CHN', 'USA', 'DEU'] * 250,
    'price': pd.np.random.rand(1000) * 100 + 50,
    'quantity': pd.np.random.randint(1, 5, 1000)
}
df = pd.DataFrame(data)

# 2. 将 Pandas DataFrame 转换为 Arrow Table
# Arrow 是一个标准化的、语言无关的内存列式数据格式,是 Parquet 在内存中的体现
table = pa.Table.from_pandas(df)

# 3. 写入 Parquet 文件,并施加精细化控制
# 这是关键所在,我们不使用简单的 to_parquet,而是通过 ParquetWriter
pq.write_table(
    table,
    'historical_data.parquet',
    row_group_size=500,  # 控制每个行组的大小,这里是500行
    compression='SNAPPY',   # 对所有列默认使用 Snappy 压缩
    use_dictionary=['product_id', 'country'], # 对低基数列显式启用字典编码
    data_page_size=1024 * 64, # 64KB per data page
    write_statistics=True # 必须开启,为谓词下推生成统计信息
)

# 验证读取
# 读取时,引擎会自动处理解压和解码
read_table = pq.read_table('historical_data.parquet', columns=['country', 'price'])
print(read_table.to_pandas().head())

在这个例子中:

  • `row_group_size` 控制了数据水平切分的粒度。太小会导致元数据膨胀,太大则影响内存并行处理的粒度。通常建议设置为 128MB 到 1GB 之间。
  • `compression` 选择了 `SNAPPY`,它在压缩率和解压速度之间取得了很好的平衡。对于需要归档的冷数据,可以换成 `GZIP` 或 `ZSTD` 来获取更高的压缩比。
  • `use_dictionary` 是一个强大的优化。我们明确告诉 Parquet writer,`product_id` 和 `country` 这两列是低基数列,强制使用字典编码,可以极大减少存储体积。
  • `write_statistics` 开启后,Parquet 文件会为每个 Column Chunk 记录 min/max 等统计值。这是后续谓词下推(Predicate Pushdown)优化的基础。

性能优化与高可用设计

仅仅使用 Parquet 格式是不够的,还需要结合架构设计和工程实践,才能将它的威力发挥到极致。

1. 谓词下推(Predicate Pushdown)

这是列式存储查询优化中最核心的技术。当查询带有 `WHERE` 条件时,查询引擎会利用 Parquet 文件元数据中的统计信息(min/max)来跳过(skip)读取整个行组。例如,查询 `SELECT * FROM table WHERE year = 2023`,引擎读取元数据后,发现某个 Row Group 的 `year` 列的 min/max 值都是 2022,那么这个 Row Group 对应的所有 Column Chunk 都可以被直接跳过,连 I/O 都不需要发生。

工程坑点: 谓词下推的效果高度依赖数据的物理布局。如果数据是无序写入的,每个 Row Group 的 min/max 值范围都会很大,导致重叠严重,下推效果会很差。因此,在写入 Parquet 前,对数据按照查询过滤条件中常用的列(尤其是分区列和时间列)进行排序,是一个至关重要的、但常常被忽略的优化步骤。排序让相同值域的数据聚集在一起,使得 Row Group 的 min/max 区间变得紧凑,大大提高了谓词下推的效率。

2. 分区(Partitioning)与数据组织

分区是比谓词下推更宏观、更粗粒度的数据过滤。通过将数据存储在 `key=value` 形式的目录结构中,查询引擎可以直接根据 `WHERE` 子句中的分区键,跳过整个目录的扫描。例如,`WHERE date = ‘2023-12-25’` 会让引擎只去读取 `/path/to/data/date=2023-12-25/` 目录下的文件。

Trade-off 分析:

  • 分区粒度: 按天分区是常见做法。但如果每天的数据量过大(TB 级别),可以考虑按小时分区。如果过小(几 MB),则会产生大量小文件。
  • 分区键选择: 应选择最常用、基数适中的过滤字段。高基数列(如 `user_id`)不适合做分区键,否则会产生海量分区,压垮元数据管理。

3. 小文件问题(Small File Problem)

这是数据湖领域最臭名昭著的问题。大量的小文件(比如小于 128MB)会导致:1) 元数据急剧膨胀,给 NameNode 或 Metastore 带来巨大压力;2) 查询引擎需要处理成千上万个文件,光是打开、读取元数据、关闭文件的开销就足以拖垮性能;3) HDFS/S3 等存储对小文件的处理效率低下。这个问题通常由流式写入或高基数分区导致。

解决方案: 定期运行文件合并(Compaction)作业。例如,可以每天或每小时运行一个 Spark 作业,读取某个分区下的小文件,然后将它们合并成几个较大的、最优大小(如 256MB – 1GB)的 Parquet 文件再写回。

架构演进与落地路径

一个健壮的历史数据平台不是一蹴而就的,它通常遵循一个清晰的演进路径。

第一阶段:初步引入(CSV/JSON -> Parquet)

当团队首次意识到文本文件的查询性能瓶颈时,最直接的改进就是引入 Parquet。可以先从离线批处理开始。建立一个每日的 ETL 作业,将前一天的 JSON/CSV 日志转换为 Parquet 格式,存储在一个按天分区的目录结构下。即使没有复杂的查询引擎,仅这一步,存储成本就能降低 5-10 倍,并且为后续的分析查询打下了基础。

第二阶段:构建数据湖雏形(引入查询引擎和 Metastore)

随着数据量和分析需求的增加,手动管理文件和目录变得不可行。此时需要引入 Hive Metastore 或 AWS Glue,对 Parquet 文件进行统一的元数据管理。同时引入 Presto/Trino 或 Spark SQL,让数据分析师和业务方可以通过标准的 SQL 接口查询数据,而无需关心底层文件细节。这个阶段,数据湖的雏形已经建立。

第三阶段:优化与治理(处理小文件、数据排序)

平台稳定运行后,性能瓶颈会从小文件、数据倾斜等问题上暴露出来。此时架构的重点转向“治理”。建立自动化的 Compaction 任务,解决小文件问题。在 ETL 流程中,增加对数据排序的步骤,以优化谓词下推。同时,开始建立数据质量监控和血缘关系追踪体系。

第四阶段:迈向湖仓一体(Lakehouse)

传统的 Data Lake 难以支持事务(ACID)、数据更新(Update/Delete)和时间旅行(Time Travel)等高级功能。为了解决这些问题,可以引入 Apache Iceberg、Delta Lake 或 Hudi 等开放数据湖表格式。它们在 Parquet 文件之上,构建了一个事务性的元数据层,通过快照、清单文件等机制,提供了数据库级别的管理能力,同时保留了 Parquet 的性能和开放性。这使得数据湖不仅能做分析,还能支持更复杂的、近实时的业务场景,真正实现“湖仓一体”。

总而言之,Parquet 不仅仅是一种文件格式,它是一种基于深刻的系统原理而设计的数据组织哲学。理解并精通其背后的原理、权衡与生态,是每一位致力于处理大规模数据的架构师的必修课。

延伸阅读与相关资源

  • 想系统性规划股票、期货、外汇或数字币等多资产的交易系统建设,可以参考我们的
    交易系统整体解决方案
  • 如果你正在评估撮合引擎、风控系统、清结算、账户体系等模块的落地方式,可以浏览
    产品与服务
    中关于交易系统搭建与定制开发的介绍。
  • 需要针对现有架构做评估、重构或从零规划,可以通过
    联系我们
    和架构顾问沟通细节,获取定制化的技术方案建议。
滚动至顶部