列式数据格式:Parquet、ORC 与 Arrow 的原理与选型

系统讲解列式数据格式:行存与列存的访问模式差异、Parquet 的 row group/column chunk/page 三层结构、字典与 RLE 编码、按列压缩、谓词下推与统计信息、ORC 对比、Arrow 内存格式与零拷贝、小文件与分区问题,以及数据湖场景的选型决策。

引言

同样一份数据,按行存还是按列存,查询性能能差几十倍——这不是玄学,而是"访问模式决定布局"的必然:分析查询通常只读少数几列、扫全表,按列存就只读那几列、还天然同质可压缩。本文从行存/列存的根本差异讲起,深入 Parquet 的三层结构(row group / column chunk / page)、字典与 RLE 编码、谓词下推如何跳过数据,再对比 ORC、讲清 Arrow 内存格式与零拷贝,最后落到小文件、分区与数据湖选型。

前置:数据序列化格式全景:JSON、Protobuf、MessagePack 与 Parquet、数据压缩原理:Huffman、LZ 家族与通用压缩算法。二进制布局见 二进制与编码工具。


目录


1. 行存 vs 列存:访问模式决定布局

行存:一行的字段连续存放。列存:一列的字段连续存放。

行存(CSV/JSON/MySQL 行):
  [id1, name1, age1, city1][id2, name2, age2, city2]...

列存(Parquet/ORC):
  [id1, id2, id3, ...] [name1, name2, ...] [age1, age2, ...]

两种查询的差异:

查询行存列存
取整行(按主键)快(一次读全)慢(要拼多列)
扫一列聚合(AVG age)慢(读全部列)快(只读一列)
写一行快(追加)慢(写多列)

列存的三大红利:

1. 列裁剪(Column Pruning)—— 只读需要的列,IO 减少
2. 同质压缩 —— 同列类型相同,压缩率高(见第 4 节)
3. 向量化执行 —— 同列连续,SIMD 友好

OLTP vs OLAP:OLTP(按主键读写单行)用行存;OLAP(扫列聚合)用列存。这也是"数据仓库用列存、事务库用行存"的根本原因。

-- OLAP 典型查询:只碰 2 列、扫全表
SELECT city, AVG(age) FROM users GROUP BY city;
-- 列存:只读 city + age 两列 → 比行存少读 80%+

记忆:行存按"整行访问"优化、列存按"整列扫描"优化——OLAP 只读少数列扫全表,所以列存赢在列裁剪 + 同质压缩 + 向量化。


2. Parquet 结构:row group、column chunk 与 page

Parquet 是三层嵌套的列式结构:

File
 ├── Row Group 0
 │    ├── Column Chunk (col A) → [Page, Page, Page...]
 │    ├── Column Chunk (col B) → [Page, Page...]
 │    └── ...
 ├── Row Group 1
 │    └── ...
 └── Footer(元数据 + Schema + 每列统计)
层作用典型大小
Row Group行的水平分块,并行的最小单位128MB(可配)
Column Chunk一个 row group 内某列的所有数据视列宽
Page列内更细的编码/压缩单位1MB(可配)

为什么分三层:row group 提供并行度(一个 group 一个任务),column chunk 提供列裁剪(只读需要的列),page 提供编码与压缩的粒度(page 内数据同质,压缩效果好)。

Footer 是"目录":存在文件末尾(便于写完一次性落),包含 schema、每个 row group 每列的统计信息(min/max/null 计数)——谓词下推靠它。

import pyarrow.parquet as pq

pf = pq.ParquetFile('data.parquet')
print(pf.metadata.num_row_groups)      # row group 数
print(pf.schema_arrow)                 # schema
print(pf.metadata.row_group(0).column(0).statistics)   # 第一列统计

Parquet 是"自描述"的:schema 存在文件里,读时不需要外部定义(对比某些二进制格式)。

记忆:Parquet = row group(并行)→ column chunk(列裁剪)→ page(编码压缩)三层 + 末尾 Footer(schema + 统计)。


3. 编码:字典、RLE 与位打包

列存的高压缩率,一半靠编码、一半靠压缩算法。Parquet 的核心编码:

编码适用原理
PLAIN通用原样存
Dictionary低基数列存唯一值字典 + 索引
RLE重复/有序行程编码(值 + 重复次数)
Bit-Packing小整数用最少位存
Delta递增序列存差值
Byte Stream Split浮点按字节位拆开存

字典编码是"低基数"的杀手锏:一列 country 只有 200 个不同值、却有 1 亿行——存 200 个值 + 1 亿个索引(每个 8 位),压缩比可达 100 倍。

原值: [CN, CN, US, CN, US, US, ...]  (1 亿行,每行 2 字节字符串)
字典: [CN, US]
索引: [0, 0, 1, 0, 1, 1, ...]        (1 亿个 1 位整数 → 位打包)

RLE 对"有序/重复"数据极有效:[1,1,1,1,1,2,2,2] → (1,5),(2,3)。

Delta 编码对时间戳/自增 ID 极有效:存差值而非原值,差值小则位打包后极小。

# 用 pandas/pyarrow 观察编码效果
import pandas as pd
df = pd.DataFrame({'country': ['CN'] * 50000 + ['US'] * 50000})
df.to_parquet('lowcard.parquet', compression='zstd')
import os
print(os.path.getsize('lowcard.parquet'))   # 远小于原始 CSV

列基数决定编码选择:低基数 → 字典;有序/递增 → RLE/Delta;高基数随机 → PLAIN + 通用压缩。Parquet 会自动选,但写入时可提示。

记忆:低基数用字典、有序用 RLE/Delta、小整数用位打包——列存的高压缩率一半来自"同列同质"的编码红利。


4. 压缩:按列选算法

编码之后,还会做通用压缩(同 数据压缩原理 那篇的算法):

算法压缩比速度场景
Snappy中很快默认,平衡
LZ4中极快低延迟
Zstd高快推荐(可调级别)
Gzip高慢兼容性
Brotli最高慢归档

Parquet 支持"逐列压缩"——不同列可用不同算法:

import pyarrow as pa, pyarrow.parquet as pq

t = pa.table({'id': range(100000), 'name': ['x'] * 100000})
pq.write_table(
    t, 'per_col.parquet',
    compression={'id': 'zstd', 'name': 'snappy'},   # 逐列指定
)

为什么逐列有用:ID 列(Delta 编码后)用 zstd 极致压缩;大文本列用 snappy 保速度。同一文件内按列特性调优。

Zstd 的级别:1–22 级,级别越高越慢。级别 3(默认)通常是性价比拐点,级别 15+ 压缩比提升有限但 CPU 暴涨。经验:热数据(常读)用 snappy/lz4、温数据用 zstd-3、冷数据/归档用 zstd-19 或 brotli。

记忆:默认 Snappy 平衡、Zstd 是推荐的通用解、归档用 Zstd-19/Brotli——Parquet 支持逐列选压缩,按列特性调优。


5. 谓词下推与统计信息

谓词下推(Predicate Pushdown)是列存性能的关键:查询条件在"读数据前"就用来跳过整个 row group / page。

三级跳过:

1. 分区裁剪(Partition Pruning)—— 按目录分区跳过整个分区
2. Row Group 跳过 —— 靠 footer 的 min/max 统计
3. Page 跳过 —— 靠 page 级统计(可选)

举例:查询 WHERE date = '2026-01-15',某 row group 的 date 统计是 [2026-01-01, 2026-01-10]——完全不重叠,整个 group 跳过,一个字节都不读。

import pyarrow.dataset as ds

dataset = ds.dataset('events/', format='parquet')
# 过滤条件会下推到 row group 级,跳过不相关数据
table = dataset.to_table(filter=ds.field('date') == '2026-01-15')

统计信息的三个字段:min、max、null_count(Parquet 还有 distinct_count 可选)。排序后的数据下推效果最好——因为 min/max 范围窄,容易判断不重叠。

这也是"写入前排序"的价值:按查询常用列排序,row group 的 min/max 范围收窄,下推命中率提升。

统计信息的局限:对高基数随机列,min/max 覆盖整个范围,下推失效;布隆过滤器可补充"等值查询"的跳过能力。

下推有效性:
  有序/聚簇列  → 高(min/max 范围窄)
  随机高基数列 → 低(范围覆盖全域)
  分区键       → 最高(直接跳分区)

记忆:谓词下推靠 footer 的 min/max 统计——分区裁剪 → row group 跳过 → page 跳过三级;数据按查询列排序能大幅提升命中率。


6. ORC 对比与选型

ORC 是 Hive 生态的列式格式,与 Parquet 是"同类竞品":

维度ParquetORC
起源Twitter/ClouderaHive(Facebook)
生态Spark、Arrow、Python 最广Hive、Presto、Spark
嵌套强(Dremel 模型)支持
索引统计 + 布隆内置索引(更细)
类型完整完整(含 Decimal/时间)
压缩逐列逐列

ORC 的差异化:内置更细的索引(每个 stripe 内的行索引、布隆过滤器),历史上在 Hive 上谓词下推更强;Parquet 的差异化:生态更广(Arrow/Pandas/Spark/几乎所有数据工具)。

选型经验:

新项目、跨生态、Python/Spark 为主 → Parquet(生态压倒性)
纯 Hive 老栈、需要极致谓词下推 → ORC
Delta Lake / Iceberg 表格式    → 底层多用 Parquet

注意:Delta Lake、Apache Iceberg、Hudi 这些"表格式"是在 Parquet 之上加事务/版本——它们不是格式替代,而是格式之上的管理层。Parquet 是数据湖的事实标准文件格式。

记忆:Parquet 赢在生态、ORC 赢在 Hive 内的索引——新项目选 Parquet,表格式(Iceberg/Delta)底层也多用 Parquet。


7. Arrow 内存格式与零拷贝

Parquet/ORC 是磁盘格式,Arrow 是内存格式——两者解决不同问题:

Parquet/ORCArrow
定位磁盘存储内存表示
布局列式 + 编码压缩列式、定长偏移
零拷贝否是
跨语言读时解码共享内存布局

Arrow 的核心价值:跨语言零拷贝。同一块内存,Python(PyArrow)、C++、Rust、Java 都能直接读,不需要序列化/反序列化。

import pyarrow as pa

arr = pa.array([1, 2, 3, 4])
# 底层是连续 buffer + 偏移,可直接给其他语言读(零拷贝)
print(arr.buffers())

Arrow 的列式内存布局:每个列是连续的 validity bitmap(空值)+ 数据 buffer + offset buffer(变长类型)。定长偏移让随机访问 O(1),也便于 SIMD。

Flight / Flight SQL:Arrow 的传输协议,用 Arrow 格式直接传数据——避免"数据库→行→序列化→网络→反序列化"的多重拷贝。

传统: 数据库列存 → 行式序列化 → 网络 → 反序列化 → 应用列存
Arrow: 数据库列存 → Arrow IPC(零拷贝)→ 应用列存

Parquet ↔ Arrow 的配合:Parquet 存盘、Arrow 入内存——读 Parquet 时直接解码成 Arrow 列(pyarrow.parquet.read_table 返回的就是 Arrow Table),后续计算全程列式,这是现代分析栈的默认路径。

记忆:Parquet 是磁盘格式、Arrow 是内存格式——Arrow 的杀手锏是跨语言零拷贝与列式内存布局,Flight 用它做传输。


8. 小文件问题与分区

小文件是数据湖头号性能杀手:每个文件都有 footer、都要开一次 IO——1 亿个小文件会让元数据操作淹没实际计算。

问题:
  100 万个小文件(每个 1KB)→ 100 万次 open/read/footer 解析
  → 任务调度开销 >> 实际计算

根因:分区过细(如按小时 + 用户 ID 分区)、流式写入不合并、Spark 默认并行度高。

解决:

1. 合并小文件 —— 定期 compact(rewrite 成大文件)
2. 控制分区粒度 —— 目标文件 128MB~1GB
3. 减少并行度 —— 写时 coalesce/repartition
4. 用表格式 —— Iceberg/Delta 的 compaction 自动合并

分区策略:

分区键效果陷阱
日期高收益(时间范围查询)太细→小文件
类别中基数大→目录爆炸
高基数列(用户 ID)差目录爆炸 + 小文件

分区 vs 排序:低基数列做分区、高基数列做排序(Z-Order/聚簇)——分区目录数有限,排序在文件内提升下推命中。

-- 分区示例(Hive/Spark 风格)
-- PARTITIONED BY (dt)          -- 低基数:按天
-- CLUSTERED BY (user_id)       -- 高基数:文件内排序

Iceberg/Delta 的隐藏分区:不靠目录名,而靠元数据记录分区值——避免"目录爆炸",同时支持分区裁剪。

记忆:小文件让元数据开销淹没计算——目标是 128MB~1GB 文件;低基数列分区、高基数列排序,Iceberg/Delta 用元数据做隐藏分区避免目录爆炸。


9. 生态与工具链

读写的常用工具:

工具用途
PyArrowPython 读写、Arrow 内存
DuckDB直接查 Parquet(SELECT * FROM 'f.parquet')
Spark分布式读写、写入优化
parquet-tools命令行查看结构
pandasread_parquet/to_parquet

DuckDB 直查 Parquet 是排障利器:

-- 不用建表,直接查文件(含谓词下推)
SELECT city, COUNT(*) FROM 'events/*.parquet'
WHERE date = '2026-01-15' GROUP BY city;

parquet-tools 看结构:

parquet-tools schema data.parquet      # schema
parquet-tools meta data.parquet        # row group + 统计
parquet-tools head -n 5 data.parquet   # 前 5 行

写 Parquet 的工程建议:

import pyarrow as pa, pyarrow.parquet as pq

pq.write_table(
    table, 'out.parquet',
    compression='zstd',              # 逐列可覆盖
    row_group_size=128 * 1024 * 1024,  # 128MB row group
    use_dictionary=True,             # 低基数列字典编码
    write_statistics=True,           # 写 min/max 统计(下推必需)
    version='2.6',                   # 新版本支持更多编码
)

Schema 演进:Parquet 支持加列(旧读新:新列为 null)、删列(新读旧:忽略)、不支持任意改类型。加字段是安全的,改类型要重写。

记忆:DuckDB 直查 Parquet 最快、parquet-tools 看结构;写时开 write_statistics(下推必需)、row_group_size 128MB;schema 可加列不可随意改类型。


10. 速查表与一句话记忆

全篇速查:

主题结论
行 vs 列OLTP 行存、OLAP 列存
Parquet 结构row group → column chunk → page + footer
编码低基数字典、有序 RLE/Delta、小整数位打包
压缩默认 Snappy、推荐 Zstd、归档 Zstd-19
下推分区裁剪 → row group → page,靠 min/max
排序按查询列排序可提升下推命中
ORC赢在 Hive 内索引,Parquet 赢在生态
Arrow内存格式、跨语言零拷贝、Flight 传输
小文件目标 128MB~1GB,低基数分区、高基数排序
工具DuckDB 直查、PyArrow 读写、parquet-tools 看结构

一句话记忆:行存按整行访问优化、列存按整列扫描优化——OLAP 只读少数列扫全表,所以列存赢在列裁剪 + 同质编码压缩 + 向量化;Parquet 是 row group(并行)→ column chunk(列裁剪)→ page(编码压缩)三层 + 末尾 Footer(schema + min/max 统计);低基数列用字典、有序列用 RLE/Delta、小整数位打包,压缩默认 Snappy 推荐 Zstd;谓词下推靠统计信息做分区裁剪 → row group → page 三级跳过,按查询列排序能大幅提升命中率;ORC 赢在 Hive 内索引、Parquet 赢在生态(Iceberg/Delta 底层也是它);Arrow 是内存格式,杀手锏是跨语言零拷贝;小文件是头号杀手(目标 128MB~1GB),低基数分区、高基数排序,用表格式的 compaction 收尾。


延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「others」更多文章

  1. Git 内部原理:对象、引用与 packfile 的底层机制
  2. 网络诊断工具箱:从 ping 到抓包的分层排障
  3. 状态机设计:从状态转移表到分层状态图