从行存到列存:基于 Parquet 的海量历史数据存储优化与实践

本文面向处理海量历史数据(如金融交易流水、风控事件、用户行为日志)的工程师与架构师。当数据量从 TB 迈向 PB 级别,传统基于行式存储(如 CSV、JSON 或数据库归档)的方案在查询性能和存储成本上会遭遇瓶颈。我们将从计算机底层原理出发,剖析 Parquet 这类列式存储格式如何通过优化 I/O、提升压缩比和亲和现代 CPU 架构,实现对分析型查询几个数量级的性能提升,并给出从零开始构建一个基于 Parquet 数据湖的架构演进路线。

现象与问题背景

在一个典型的清结算或风控系统中,每天会产生数十亿条交易或事件记录。这些记录通常包含几十到上百个字段,如用户 ID、交易时间、金额、IP 地址、设备指纹、对手方信息等。初期,这些数据可能以 JSON 或 CSV 格式追加写入日志文件,或定期从生产数据库(如 MySQL)中归档出来,存储在 HDFS 或 S3 等廉价存储上。

随着业务发展,数据分析和模型回溯的需求变得愈发重要。这时,我们会面临一系列棘手的问题:

  • 查询性能雪崩: 一个看似简单的分析查询,例如“统计上个季度所有来自特定城市、使用特定设备的用户交易总额”,需要扫描数 TB 甚至 PB 级的原始数据。即使数据存储在 S3 上,使用 Presto 或 Spark SQL 查询,也常常需要数小时才能返回结果。根本原因在于,查询只需要 3-4 个字段,但行式存储迫使计算引擎读取了整行数据(可能包含上百个无关字段),造成了巨大的 I/O 浪费。
  • 存储成本失控: JSON 和 CSV 这样的文本格式非常冗余。虽然可以通过 Gzip 或 ZSTD 进行压缩,但由于一行内数据类型混杂(字符串、数字、时间戳),压缩算法的效果会大打折扣。PB 级的数据量乘以云厂商的存储单价,是一笔不容忽视的开销。
  • 计算资源消耗巨大: 低效的 I/O 意味着 CPU 需要花费大量时间等待数据从远端存储(如 S3)读取到计算节点内存中。同时,反序列化文本格式(尤其是复杂的 JSON)本身也是一个 CPU 密集型操作,这进一步占用了宝贵的计算资源。
  • 不可分割的压缩: 对整个 CSV/JSON 文件进行 Gzip 压缩后,该文件就变成了一个不可分割的处理单元。分布式计算框架(如 Spark)无法将其切分成多个部分交给不同的 Task 并行处理,从而丧失了大规模并行的能力,处理流程退化为单点瓶颈。

这些问题的根源,都指向了一个共同的技术选择:采用了不适合分析型工作负载(OLAP)的行式存储(Row-based Storage)格式。

关键原理拆解

要理解为什么列式存储(Columnar Storage)能解决上述问题,我们需要回到计算机系统的几个基本原理。这并非某种“黑魔法”,而是对现有硬件和软件体系的深刻理解与极致利用。

原理一:存储介质的 I/O 模型与数据局部性

从冯·诺依曼体系结构诞生之日起,CPU 的计算速度与 I/O(内存、磁盘)速度之间的鸿沟就在不断拉大。对于大数据分析场景,系统瓶颈几乎永远在 I/O 上。我们的目标是:读取最少的数据,做最多的计算。

假设我们有如下一张表:


| UserID (long) | Timestamp (long) | Amount (double) | ProductID (string) |
|---------------|------------------|-----------------|--------------------|
| 1001          | 1667884800       | 199.99          | "P001"             |
| 1002          | 1667884801       | 25.50           | "P002"             |
| 1001          | 1667884802       | 50.00           | "P003"             |

行式存储中,数据在磁盘上的布局是:

[1001, 1667884800, 199.99, "P001"], [1002, 1667884801, 25.50, "P002"], [1001, 1667884802, 50.00, "P003"]

而在列式存储中,布局是:

[1001, 1002, 1001], [1667884800, 1667884801, 1667884802], [199.99, 25.50, 50.00], ["P001", "P002", "P003"]

当执行查询 SELECT SUM(Amount) FROM table WHERE UserID = 1001 时:

  • 行式存储: 必须读取每一条完整的记录,即使我们根本不关心 TimestampProductID 字段。如果一行数据 1KB,我们只需要其中 16 字节(一个 long 和一个 double),I/O 浪费率超过 98%。
  • 列式存储: 查询引擎可以直接定位并只读取 UserIDAmount 这两列的数据。I/O 量从读取整个表,锐减到只读取所需列的数据,这被称为投影下推(Projection Pushdown)。这是列式存储最核心、最直观的优势。

原理二:CPU 缓存行与向量化执行

现代 CPU 并非按字节从内存读取数据,而是以缓存行(Cache Line,通常为 64 字节)为单位。当 CPU 需要某个数据时,会把它所在的整个缓存行都加载到 L1/L2/L3 Cache 中。如果接下来要访问的数据恰好在同一个缓存行里,就能实现极速访问,这叫缓存命中(Cache Hit)。反之则为缓存未命中(Cache Miss),需要重新从内存加载,性能急剧下降。

在列式存储中,同一列的数据是连续存放的。当计算引擎处理 Amount 这一列时,它加载到 CPU 缓存中的 64 字节数据,几乎都是有效的 double 类型数据。CPU 可以在一个紧凑的循环里高效地处理这些数据。更进一步,现代查询引擎会利用 SIMD(Single Instruction, Multiple Data)指令集,实现向量化执行(Vectorized Execution)。CPU 可以用一条指令同时对多个数据(例如,一个缓存行里的 8 个 double)执行相同的操作(如加法、比较),吞吐量瞬间提升数倍。

而在行式存储中,加载一个缓存行,里面可能混杂了 UserID、Timestamp、Amount 等不同类型的数据。CPU 在处理 Amount 时,缓存行里的大部分数据都是“噪音”,导致缓存命中率极低,无法有效利用向量化执行,处理效率大打折扣。

原理三:数据压缩与信息熵

信息论的奠基人香农告诉我们,一个信源的熵越低,其可压缩性就越高。通俗地讲,数据越有规律、重复度越高,就越容易被压缩。

列式存储将相同类型的数据聚集在一起,创造了极佳的压缩条件:

  • 类型同质性: 一整列都是整数或字符串,相比行列混杂的数据,熵显著降低。
  • 低基数(Low Cardinality)列: 像“国家”、“性别”这类取值范围很小的列,非常适合使用字典编码(Dictionary Encoding)。例如,将 “China”, “United States” 等字符串替换为整数 0, 1,并建立一个字典。存储主体就变成了一系列紧凑的整数。
  • 有序或准有序数据: 像时间戳、自增 ID 这类列,数据通常是递增的。可以采用增量编码(Delta Encoding),只存储与前一个值的差值,这些差值通常很小,可以用更少的 bit 来表示。再结合行程编码(Run-Length Encoding, RLE),对于连续出现的相同值(或相同的差值),只需存储该值和它出现的次数即可。

Parquet 格式正是这些原理的集大成者,它会智能地为每一列选择最合适的编码和压缩算法(如 Snappy, Gzip, ZSTD),通常能达到比对行存文件进行通用压缩高得多的压缩比(节省 75% 以上的存储空间是很常见的)。

系统架构总览

一个典型的基于 Parquet 的现代数据湖架构通常由以下几个部分组成,它完美诠释了“存算分离”的思想:

逻辑架构图描述:

数据流从左到右。最左侧是数据源,包括业务数据库(MySQL, Postgres)、应用日志、消息队列(Kafka)。中间是数据注入与转换层,通常由 Spark 或 Flink 集群承担。它负责从数据源拉取数据,进行清洗、转换,然后以 Parquet 格式写入右侧的数据湖存储层。该存储层是像 AWS S3 或 HDFS 这样的分布式对象存储。在存储层之上,有一个关键的元数据目录(如 Hive Metastore, AWS Glue Catalog),它像图书馆的索引卡一样,记录了数据湖中所有“表”的元信息(schema、分区信息、文件路径等)。最上层是查询与分析引擎,如 Presto/Trino、Spark SQL、ClickHouse 等,用户通过这些引擎提交 SQL 查询。引擎首先访问元数据目录找到需要查询的文件,然后直接从数据湖存储中拉取 Parquet 文件进行计算,并将结果返回给用户或 BI 工具。

  • 存储层 (Storage Layer): AWS S3, Google Cloud Storage, Azure Blob Storage 或自建的 HDFS。这是数据的最终归宿,负责数据的持久化、高可用和扩展性。
  • 文件格式 (File Format): Parquet 是核心。所有进入数据湖的结构化、半结构化数据都应被转换为 Parquet 格式。
  • 元数据目录 (Metadata Catalog): Hive Metastore 或其云上托管版本(如 AWS Glue Data Catalog)。它将底层存储上的一系列文件(目录和 Parquet 文件)抽象成逻辑上的数据库和表,是 SQL 查询的入口。
  • 数据处理与 ETL (Processing & ETL): Apache Spark 是事实标准。它强大的分布式计算能力和对 Parquet 的原生支持,使其成为构建、维护和查询数据湖的首选工具。
  • 查询引擎 (Query Engine): Presto/Trino, Spark SQL, AWS Athena。这些引擎善于对存储在 S3/HDFS 上的 Parquet 数据进行高性能的交互式查询。

核心模块设计与实现

现在,让我们像一个极客工程师一样,深入 Parquet 文件内部,看看它是如何被组织起来,以及如何在代码中驾驭它。

Parquet 文件内部结构剖析

一个 Parquet 文件并非一个扁平的二进制块,它有精巧的内部层次结构,这正是其高性能的秘密所在:

  • File: 文件的顶层。它由一个或多个行组(Row Group)以及一个文件尾(File Footer)组成。
  • File Footer: 文件尾是 Parquet 的“目录”。它包含了文件的 Schema、所有行组的元数据信息(每个行组里每个列块的起始位置、大小、统计信息等)。一个聪明的读取器会先读取文件尾(通常很小),通过它就能知道需要的数据具体在文件的哪个位置,以及是否可以根据统计信息跳过某些行组,从而避免了全文件扫描。
  • Row Group: 行组是数据的水平切片,通常建议大小在 128MB 到 1GB 之间。它是数据写入和读取的并行单位。一个 Spark 的 Task 通常会负责处理一个或多个行组。
  • Column Chunk: 在一个行组内,每一列的数据被连续存储,形成一个列块。这是 I/O 的基本单位。如果查询只涉及 3 列,读取器就只会从每个行组中拉取这 3 个列块。
  • Page: 列块内部又被划分为更小的页(Page),通常大小在 8KB 到几 MB。页是编码和压缩的基本单位。每个页都有自己的元数据,包含了编码类型、压缩类型、元素数量等。这使得 Parquet 可以非常灵活地对一列数据的不同部分采用不同的编码策略。

此外,Parquet 的元数据中包含了一个至关重要的优化:统计信息(Statistics)。在文件尾、行组、甚至页级别,都存储了该数据块中值的 min/max、null 计数等信息。这使得谓词下推(Predicate Pushdown)成为可能。例如,一个查询 `WHERE age > 40`,如果某个行组的元数据里记录了 `age` 列的 `max=35`,那么整个行组(可能几百 MB)就可以被直接跳过,无需读取任何字节。

使用 Spark 写入优化的 Parquet 文件

理论很丰满,但工程实践是关键。下面是一段使用 Apache Spark (Scala) 将数据写入 Parquet 的典型代码,其中蕴含了几个关键的优化实践。


// 
// 假设我们有一个 DataFrame `eventsDF`,包含事件数据
val eventsDF = spark.read.json("s3a://raw-data/events/2023-10-27/")

// 关键的写入操作
eventsDF
  .repartition(32) // 控制输出文件的数量和大小
  .write
  .option("compression", "snappy") // 选择压缩编码,snappy是速度和效率的均衡选择
  .partitionBy("event_date", "event_type") // !!! 最重要的优化:按日期和事件类型分区
  .mode(SaveMode.Append) // 以追加模式写入
  .parquet("s3a://data-lake/events_parquet/")

让我们来犀利地剖析这段代码里的“坑”与“金”:

  • partitionBy("event_date", "event_type"): 这是性能优化的第一道大闸,也是最有效的一道。它会在 S3 上创建类似 /event_date=2023-10-27/event_type=login/part-xxxx.parquet 这样的目录结构。当查询带有 `WHERE event_date = ‘2023-10-27’` 条件时,查询引擎只会去访问相应日期的目录,其他数千个目录连看都不会看一眼。这叫分区裁剪(Partition Pruning),它在文件被打开之前就排除了绝大部分无关数据。
  • repartition(32): 如果不加控制,Spark 可能会根据上游 stage 的 task 数量产生大量小文件。小文件是数据湖性能的头号杀手,它会给元数据管理带来巨大压力,并且在 S3 这类对象存储上,每次 GET 请求都有固定的延迟开销,读 1000 个 1MB 的文件远比读 1 个 1GB 的文件慢。通过 `repartition` 或 `coalesce` 控制输出文件的数量,目标是让每个 Parquet 文件的大小保持在合理的范围(如 256MB – 1GB)。
  • option("compression", "snappy"): Snappy 是一个性能优秀的压缩算法,它的解压速度非常快,对 CPU 消耗小,是大数据场景下推荐的默认选项。如果极度追求压缩比,可以考虑 Gzip 或 ZSTD,但这会增加 CPU 负担。Parquet 的美妙之处在于,这个选择是透明的,查询引擎会自动处理解压。

性能优化与高可用设计

仅仅把数据存成 Parquet 是不够的,一个生产级的系统还需要精细的调优和对失败的考量。

对抗层:关键 Trade-off 分析

  • 分区粒度: 分区是把双刃剑。分区太粗(如只按年分区),分区裁剪效果不佳。分区太细(如按秒分区),会导致海量的小分区和目录,元数据爆炸,查询计划生成变慢。经验法则:选择在 `WHERE` 条件中频繁使用、且基数适中(几十到几千)的列作为分区键。目标是让每个最细粒度的分区内的数据量至少在一个 G 以上。
  • 文件大小: 上文已述,避免小文件。但文件也不能过大(如超过 10GB),这可能会导致单个 Task 处理时间过长,引起任务倾斜,并且在任务失败时重试的代价也更高。理想的大小通常与底层文件系统的块大小(HDFS)或一个 Spark Task 的最佳处理数据量相匹配。
  • Schema 演进: 业务总在变,数据表结构也需要调整。Parquet 对 Schema 演进有良好的支持。你可以在表的末尾安全地添加新的 nullable 字段。读取器(如 Spark)能够自动处理“Schema 合并”,即当一个表的不同分区/文件拥有略微不同的 Schema 时,它能智能地将其统一成一个兼容的 Schema。但要避免破坏性的修改,如删除字段或改变字段类型,这通常需要重写历史数据。

高可用性设计

高可用性主要依赖底层平台的保障:

  • 存储层: AWS S3 提供了 11 个 9 的持久性保证,并且跨多个可用区冗余,基本无需我们担心数据丢失。HDFS 通过多副本机制保证数据安全。
  • 元数据: Hive Metastore 需要配置高可用方案,通常是主备模式,后端数据库(如 MySQL)也需要做高可用部署。云上的 Glue Catalog 则由云厂商负责其高可用。
  • 计算层: Spark/Flink 等计算集群本身就是为容错设计的。Worker 节点故障,Driver 会将任务重新调度到其他节点。需要关注的是 Driver 节点的单点问题,可以启用 YARN 的 ApplicationMaster 重试或 Spark 的 Driver HA 模式。

架构演进与落地路径

对于一个已经存在大量历史数据的系统,不可能一蹴而就地完成迁移。一个务实、分阶段的演进路径至关重要。

第一阶段:离线数据批处理转换

这是迁移的起点。首先选择一个业务价值高、查询痛点最明显的历史数据集(例如,过去三年的交易流水)。开发一个一次性的、或周期性(如每日)的 Spark 批处理作业。该作业负责:

  1. 从现有存储(MySQL 归档库、CSV/JSON 文件)读取数据。
  2. 进行必要的数据清洗和类型转换。
  3. 按照精心设计的分区策略,将数据写入 S3/HDFS 上的 Parquet 格式。
  4. 在 Hive Metastore 中创建外部表,指向这批新生成的 Parquet 数据。

完成此阶段后,分析师和数据科学家就可以立即通过 Presto 或 Spark SQL 在新表上体验到数量级的查询性能提升。这会为后续的投入建立信心。

第二阶段:构建增量更新的准实时链路

历史数据迁移后,需要处理每天新增的数据。可以改造第一阶段的批处理作业,使其每天处理 T-1 的数据。但为了追求更高的数据时效性,可以引入流式处理。使用 Flink 或 Spark Streaming 从 Kafka 等消息队列消费增量数据,以微批(micro-batch)的形式每隔几分钟或一小时就将新数据写入 Parquet 文件。这里会遇到小文件问题。因此,必须配套一个Compaction(合并)作业。该作业会定期(如每小时或每天)扫描新写入的小文件,并将它们合并成更大、更优化的 Parquet 文件。

第三阶段:拥抱 Lakehouse 架构,实现批流一体

手动管理小文件合并和数据一致性是复杂且容易出错的。这个阶段,可以引入开源的 Lakehouse 框架,如 Apache Hudi, Apache Iceberg 或 Delta Lake。这些框架在 Parquet 之上提供了一个事务性管理层,它们:

  • 原子性写入: 保证数据写入的原子性,不会出现只写了一半的脏数据。
  • ACID 事务: 支持对数据湖表的 UPDATE, DELETE, MERGE 操作。
  • 自动文件管理: 内置了高效的 Compaction 和数据清理(Data Skipping)策略,将工程师从小文件地狱中解放出来。
  • 时间旅行: 可以查询表的任意历史版本,这对于模型回溯和问题排查非常有价值。

通过引入 Lakehouse,数据湖不再仅仅是“只写一次、偶尔读取”的归档库,而是演变成一个能够同时支持批处理和流式读写、结构化事务和非结构化分析的统一数据平台,为上层的数据应用提供了坚实、高效且可靠的基石。

延伸阅读与相关资源

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