引言
同样一份数据,按行存还是按列存,查询性能能差几十倍——这不是玄学,而是"访问模式决定布局"的必然:分析查询通常只读少数几列、扫全表,按列存就只读那几列、还天然同质可压缩。本文从行存/列存的根本差异讲起,深入 Parquet 的三层结构(row group / column chunk / page)、字典与 RLE 编码、谓词下推如何跳过数据,再对比 ORC、讲清 Arrow 内存格式与零拷贝,最后落到小文件、分区与数据湖选型。
前置:数据序列化格式全景:JSON、Protobuf、MessagePack 与 Parquet、数据压缩原理:Huffman、LZ 家族与通用压缩算法。二进制布局见 二进制与编码工具。
目录
- 1. 行存 vs 列存:访问模式决定布局
- 2. Parquet 结构:row group、column chunk 与 page
- 3. 编码:字典、RLE 与位打包
- 4. 压缩:按列选算法
- 5. 谓词下推与统计信息
- 6. ORC 对比与选型
- 7. Arrow 内存格式与零拷贝
- 8. 小文件问题与分区
- 9. 生态与工具链
- 10. 速查表与一句话记忆
- 延伸阅读
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 是"同类竞品":
| 维度 | Parquet | ORC |
|---|---|---|
| 起源 | Twitter/Cloudera | Hive(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/ORC | Arrow | |
|---|---|---|
| 定位 | 磁盘存储 | 内存表示 |
| 布局 | 列式 + 编码压缩 | 列式、定长偏移 |
| 零拷贝 | 否 | 是 |
| 跨语言 | 读时解码 | 共享内存布局 |
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. 生态与工具链
读写的常用工具:
| 工具 | 用途 |
|---|---|
| PyArrow | Python 读写、Arrow 内存 |
| DuckDB | 直接查 Parquet(SELECT * FROM 'f.parquet') |
| Spark | 分布式读写、写入优化 |
| parquet-tools | 命令行查看结构 |
| pandas | read_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 收尾。
延伸阅读
- 数据序列化格式全景:JSON、Protobuf、MessagePack 与 Parquet
- 数据压缩原理:Huffman、LZ 家族与通用压缩算法
- 二进制与编码工具:Base64、Hex、xxd 与字节调试
- 数据工程专题 — 数据湖与 ETL 管道
- 数据库专题 — 存储引擎与查询优化
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。