从 GB 到 PB:基于 Parquet 的海量历史数据存储优化实践

本文旨在为面临海量历史数据存储与分析挑战的中高级工程师及架构师,提供一套基于 Parquet 列式存储的深度优化方案。我们将从数据存储的物理本质出发,剖析行存与列存的核心差异,深入 Parquet 文件格式的内部结构,并结合真实代码示例与工程“坑点”,最终给出一套从 TB 级到 PB 级的可演进架构落地路径。这不是一篇入门介绍,而是一次深入计算机系统底层的性能优化之旅,目标是帮助你构建真正高效、经济且可扩展的数据基石。

现象与问题背景

在几乎所有高速发展的业务中,无论是金融交易、电商日志、风控事件还是物联网遥测,数据量都呈现出爆炸性增长。一个典型场景是,我们的核心业务系统(如使用 MySQL 或 PostgreSQL)承载着在线交易(OLTP),其性能与稳定性是生命线。然而,业务分析、数据科学、机器学习等分析型(OLAP)需求,需要对数月甚至数年的历史数据进行聚合、扫描和深度分析。将这些负载直接施加于在线数据库,无异于一场灾难。

最初的解决方案通常是将数据从主库定期“T+1”导出为 CSV 或 JSON 文件,存储在 HDFS 或 S3 等廉价对象存储上。这种方式虽然实现了读写分离,但很快就会暴露出一系列新的、更棘手的问题:

  • 查询性能雪崩: 对一个包含 100 个字段、上亿行数据的 CSV 文件,执行一个只涉及其中 3 个字段的聚合查询(如 `SELECT city, SUM(amount) FROM orders WHERE order_date > ‘2023-01-01’ GROUP BY city`),查询引擎不得不读取并解析每一行的全部 100 个字段。I/O 带宽和 CPU 解析开销巨大,查询响应时间从分钟级恶化到小时级。
  • 存储成本失控: 基于文本的 CSV/JSON 格式是冗余的。字段名在每一行重复,数字也被存储为变长的字符串,导致压缩效率极低。TB 级的数据很快会膨胀到 PB 级,存储成本直线上升。
  • 无模式约束: JSON 的灵活性在数据分析场景下变成了“诅咒”。数据类型的漂移(如 user_id 一会儿是数字,一会儿是字符串)和字段的缺失,使得数据清洗和查询的复杂度大增,数据质量难以保证。

这些问题的根源在于,我们采用了为 OLTP 优化的数据组织方式(行式存储)来应对 OLAP 场景的挑战。要从根本上解决问题,我们必须回到计算机科学的基础,审视数据在物理介质上的存储范式。

关键原理拆解:从磁盘 I/O 到 CPU Cache

(教授视角)

要理解 Parquet 的威力,我们必须先从第一性原理出发,理解数据是如何在内存和磁盘上布局的,以及这种布局如何与现代计算机体系结构交互。数据的存储范式主要分为两种:行式存储(Row-Oriented)和列式存储(Column-Oriented)。

假设我们有这样一张简单的订单表:


| OrderID | UserID | Amount | Timestamp           |
|---------|--------|--------|---------------------|
| 1       | 1001   | 99.9   | 2023-10-27 10:00:00 |
| 2       | 1002   | 19.5   | 2023-10-27 10:01:00 |
| 3       | 1001   | 200.0  | 2023-10-27 10:02:00 |

行式存储(如 MySQL InnoDB, PostgreSQL):

数据在磁盘上是按行连续存储的。这非常适合 OLTP 场景,比如“查询 OrderID=2 的所有信息”,因为一次磁盘 I/O 就可以将整行数据加载到内存。其物理布局近似于:

[1, 1001, 99.9, 2023...], [2, 1002, 19.5, 2023...], [3, 1001, 200.0, 2023...]

然而,对于 OLAP 查询 `SUM(Amount)`,系统被迫读取每一行的所有字段(OrderID, UserID, Timestamp),即使这些字段根本用不上。这就是所谓的“读放大”(Read Amplification)。

列式存储(如 Parquet, ORC, ClickHouse):

数据按列连续存储。每一列的数据被组织在一起。其物理布局近似于:

[1, 2, 3, ...], [1001, 1002, 1001, ...], [99.9, 19.5, 200.0, ...], [2023..., 2023..., 2023...]

这种布局对 OLAP 查询带来了三个维度的颠覆性优势:

  1. 最小化 I/O: 对于 `SUM(Amount)` 查询,系统只需读取 Amount 这一列的数据。如果表有 100 列,I/O 开销理论上可以降低 99%。在 I/O 密集型的大数据分析中,这是决定性的性能提升。
  2. 极致的压缩率:
    • 数据同质性: 同一列的数据类型完全相同,其业务含义和数值分布也更具规律性(熵更低)。这为压缩算法创造了绝佳的条件。
    • 专用编码(Encoding): 在通用压缩(如 Snappy, Gzip)之前,列存格式会先使用专用编码。例如:
      • 字典编码 (Dictionary Encoding): 对于基数(Cardinality)较低的列(如“国家”列),可以将所有唯一的字符串(’中国’, ‘美国’)建成一个字典,数据本身只存储字典的索引(0, 1)。这极大地减少了存储空间。
      • 游程编码 (Run-Length Encoding, RLE): 对于排过序或有大量连续重复值的列,可以用 `(value, count)` 的形式存储,例如 `[1, 1, 1, 1, 2, 2]` 可以编码为 `(1, 4), (2, 2)`。
  3. CPU 缓存与 SIMD 友好: 当一列数据被加载到内存中时,它是连续的、同质的。现代 CPU 的流水线和预取机制能高效处理这种数据布局,大大提高缓存命中率。更重要的是,CPU 可以利用 SIMD (Single Instruction, Multiple Data) 指令集(如 SSE, AVX)对数据进行向量化计算。一个指令可以同时对多个数据(例如 4 个或 8 个整数)执行加法运算,吞吐量成倍提升。这是行式存储完全无法比拟的计算加速。

Parquet 文件格式深度剖析

(极客工程师视角)

理论是美好的,但魔鬼在细节中。Parquet 之所以能成为大数据领域的事实标准,在于其精巧的文件结构设计,它将列存的优势发挥得淋漓尽致。一个 Parquet 文件并不是一个庞大而单一的二进制块,它内部有着清晰的层次结构。

一个 Parquet 文件的逻辑结构可以这样理解:

File -> Row Group -> Column Chunk -> Page

  • File MetaData (文件元数据): 文件的“藏宝图”,位于文件末尾。它包含了文件的 Schema 定义、所有 Row Group 的元数据信息(每个 Column Chunk 的起始偏移量、统计信息等)。一个聪明的 Reader 会先读取文件末尾的这部分数据,根据查询条件决定需要读取哪些 Row Group 和 Column Chunk,跳过无关数据。这就是谓词下推 (Predicate Pushdown) 的第一层实现。
  • Row Group (行组): 数据的水平切分,是数据写入内存、进行并行处理的基本单元。通常建议大小为 128MB 到 1GB。一个大文件被切分为多个 Row Group,方便 Spark 等计算引擎进行任务分发。
  • Column Chunk (列块): 在一个 Row Group 内部,每一列的数据被存储在一个 Column Chunk 中。它是列式存储的物理体现。
  • Page (页): Column Chunk 内部进一步被划分为 Page(通常 8KB 到 1MB)。Page 是压缩和编码的基本单元。一个 Column Chunk 可能包含多种 Page,如 Data Page(存储数据)、Dictionary Page(存储该列的字典)。Page 级别也存储了统计信息(min/max),允许查询引擎在读取一个 Column Chunk 后,进一步跳过不包含目标数据的 Page。这是谓词下推的第二层。

举个实际的例子:假设我们有一个 1GB 的订单 Parquet 文件,Row Group 大小为 256MB,共有 4 个 Row Group。我们要执行查询 `SELECT * FROM orders WHERE amount > 5000`。查询引擎的执行路径是:

  1. 读取文件末尾的 File MetaData。
  2. 遍历 4 个 Row Group 的元数据,查看其中 `amount` 列的统计信息(min/max 值)。
  3. 假设发现 Row Group 1 和 3 的 `amount` 最大值都小于 5000。引擎会直接跳过这两个 Row Group 的读取,瞬间排除了 512MB 的 I/O。
  4. 对于 Row Group 2 和 4,引擎需要读取它们的 `amount` 列。在读取每个 Column Chunk 内部时,它会先检查每个 Page 的 min/max 统计信息,再次跳过那些最大值小于 5000 的 Page。

通过这种层层过滤的机制,Parquet 将实际需要扫描和解压的数据量降到了最低。这就是为什么它比“傻瓜式”的 CSV/JSON 快上几个数量级的原因。

核心模块设计与实现

(极客工程师视角)

空谈误国,实干兴邦。我们来看一下在工程实践中如何生成和使用 Parquet 文件。这里以 Python 的 `pyarrow` 库为例,它是与 Parquet 格式交互的首选工具之一。

1. Schema 定义:一切的基石

写入 Parquet 前,最重要的事是定义一个强类型、精确的 Schema。这不仅是为了数据质量,更是为了极致的性能。错误的类型选择(如用 String 存数字或日期)会让所有列存优势荡然无存。


import pyarrow as pa
import pyarrow.parquet as pq
import pandas as pd
from decimal import Decimal

# 模拟业务数据
data = [
    {'tx_id': 'a-1', 'user_id': 1001, 'amount': Decimal('199.99'), 'product_type': 'electronics', 'ts': '2023-10-27T10:00:00Z'},
    {'tx_id': 'b-2', 'user_id': 2002, 'amount': Decimal('49.50'), 'product_type': 'books', 'ts': '2023-10-27T10:05:00Z'},
    {'tx_id': 'c-3', 'user_id': 1001, 'amount': Decimal('1200.00'), 'product_type': 'electronics', 'ts': '2023-10-27T10:10:00Z'},
]
df = pd.DataFrame(data)

# 定义一个高质量的 Schema
# 注意:为金融数据使用 Decimal,为时间使用带时区的 Timestamp
# 这对于后续的精确计算和过滤至关重要
event_schema = pa.schema([
    pa.field('tx_id', pa.string(), nullable=False),
    pa.field('user_id', pa.int64()),
    pa.field('amount', pa.decimal128(10, 2)), # 精度10,小数位2
    pa.field('product_type', pa.string()),
    pa.field('ts', pa.timestamp('us', tz='UTC'))
])

# 将 Pandas DataFrame 转换为 Arrow Table,并应用我们的 Schema
table = pa.Table.from_pandas(df, schema=event_schema, preserve_index=False)

# 写入 Parquet 文件,并指定关键参数
pq.write_table(
    table,
    'transactions.parquet',
    row_group_size=1024 * 1024 * 128,  # 128 MB row group size
    compression='SNAPPY',
    use_dictionary=['product_type'], # 对低基数列显式启用字典编码
    data_page_size=1024 * 1024 # 1 MB data page size
)

这段代码里的几个参数是关键的工程决策点:`row_group_size`, `compression`, `use_dictionary`。我们将在下一节详细讨论它们的权衡。

2. 分区策略:数据组织的大智慧

单个的 Parquet 文件只是起点。对于海量数据,我们必须对其进行物理分区。最常见的是基于日期的 Hive 风格分区,目录结构如下:


/data/transactions/
  ├── dt=2023-10-26/
  │   ├── part-00001.parquet
  │   └── part-00002.parquet
  └── dt=2023-10-27/
      ├── part-00001.parquet
      └── ...

当查询带有 `WHERE dt=’2023-10-27’` 条件时,像 Presto, Spark SQL, DuckDB 这样的查询引擎会直接定位到对应的目录,完全避免了对其他日期数据的扫描。这是分区剪枝 (Partition Pruning),是比谓词下推更宏观、更有效的第一层过滤。

工程血泪教训: 永远不要用高基数的列做分区键! 比如 `user_id`。如果用 `user_id` 分区,一百万个用户就会产生一百万个目录,每个目录下可能只有一个小文件。这将导致“小文件地狱”,文件系统的元数据操作会成为性能瓶颈,HDFS NameNode 或 S3 的 List 操作会被彻底打垮。

性能优化与高可用设计

(极客工程师视角)

选择了 Parquet 并不意味着一劳永逸,精细的调优是榨干硬件性能的关键。

Trade-off 分析:没有银弹

  • Row Group Size: 这是一个核心的权衡。
    • 大 (256MB – 1GB): 优点是数据更连续,利于发挥磁盘顺序读写的吞吐量优势;压缩比通常也更高,因为压缩算法有更多上下文。缺点是写入时需要更大的内存缓冲区,且如果谓词下推无法跳过整个 Row Group,读取的粒度较大。
    • 小 (64MB – 128MB): 优点是写入内存占用小,谓词下推更精细,能跳过更多不必要的数据。缺点是可能导致更多的 I/O seek,压缩率略低。
    • 经验法则: 通常以 HDFS 的块大小(如 128MB 或 256MB)为基准开始调优。对于 SSD 存储,可以适当调小。
  • Compression Codec:
    • Snappy: 综合性能的王者。解压速度极快,压缩比尚可。绝大多数场景下的默认选择。
    • Gzip: 压缩比高,但 CPU 开销巨大,特别是解压。适合“写一次,读几次”的冷数据归档。
    • ZStandard (ZSTD): Facebook 开源的后起之秀,在许多场景下实现了比 Snappy 更高的压缩比和更快的速度。如果你的计算引擎支持,强烈建议尝试。
    • LZO: 速度快,但压缩比是短板。
  • 小文件问题与合并 (Compaction):

    流式写入(如 Flink/Spark Streaming 每分钟输出一个文件)或高基数分区必然导致大量小文件。必须建立配套的合并机制。通常是一个独立的、周期性执行的 Spark 任务,每天或每小时扫描分区,读取小文件,然后合并重写为少量的大文件(符合最佳 Row Group Size)。Delta Lake、Iceberg、Hudi 等“数据湖”格式原生解决了这个问题,它们在 Parquet 之上提供了一层事务和文件管理的元数据。

架构演进与落地路径

一个成熟的、基于 Parquet 的数据平台不是一日建成的。它应该遵循一个务实的、分阶段的演进路径。

第一阶段:离线批处理数仓 (T+1)

这是最容易落地的第一步。目标是解决 OLTP 数据库的分析压力。

  1. 数据抽取: 每天凌晨通过 Sqoop 或自定义脚本,将生产数据库前一天的数据全量或增量抽取出来。
  2. 转换与存储: 使用一个 Spark 或 MapReduce 批处理任务,将数据(可能来自 binlog 的 JSON 格式)转换为 Parquet 格式,进行清洗、转换,并按日期分区写入 S3 或 HDFS。
  3. 查询与分析: 业务方通过 Presto/Trino 或 Hive/Spark SQL 对这些 Parquet 文件进行 ad-hoc 查询和报表生成。

这个阶段的架构简单可靠,能快速解决 80% 的离线分析需求。

第二阶段:构建结构化数据湖

随着数据源和数据消费方的增多,需要一个统一的元数据中心来管理数据资产。

  1. 引入元数据中心: 部署 Hive Metastore 或使用云服务(如 AWS Glue Data Catalog)。所有的数据生产者和消费者都通过 Metastore 来发现和访问数据,而不是直接操作文件路径。这实现了数据和元数据的解耦。
  2. 建立数据治理流程: 定义统一的数据命名规范、Schema 变更流程、数据质量监控(DQ)规则。
  3. 启动文件合并服务: 实现定期的 Compaction 任务,解决小文件问题,维持数据湖的性能。

这个阶段,你的数据平台从一个“数据沼泽”演变为一个有序、可治理的“数据湖”。

第三阶段:迈向准实时(Lakehouse)

业务对数据时效性的要求越来越高,从 T+1 缩短到分钟级。

  1. 引入流式处理: 使用 Flink 或 Spark Streaming 消费 Kafka 等消息队列中的实时业务数据。
  2. 流式写入 Parquet: 流处理任务以微批(micro-batch)的方式,每隔几分钟就将数据写入 Parquet 文件并提交到数据湖。
  3. 采用数据湖格式: 为了解决流式写入带来的小文件和事务问题,引入 Delta Lake, Apache Iceberg 或 Apache Hudi。它们在 Parquet 之上提供了 ACID 事务、时间旅行(版本回溯)和透明的文件管理能力,是构建现代 Lakehouse 架构的关键。

至此,你便构建了一个能够同时支持批处理和流处理、兼具数据仓库强大查询性能和数据湖灵活性的统一平台。Parquet 作为其底层的存储基石,通过其高效的列式存储、压缩和过滤能力,支撑着整个上层建筑,让海量历史数据的价值得以被真正、高效地挖掘。

延伸阅读与相关资源

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