Parquet 与 Arrow 实测:文件里到底存了什么
TL;DR:Parquet 不是「存进去就变小」的黑盒。我实际在这台 Windows 机器上用 pyarrow 26.0.0 跑了 50 万行 × 5 列的对照,结果是:默认 use_dictionary=True 会把字典套到整数和浮点列上,让整张表比关掉它大 15.0%(15,697,403 vs 13,650,207 字节);改 compression="zstd" 比 gzip 快 33 倍且文件还小 12.4%;只读一列时磁盘只被读走 19.6% 的字节;而把数据按 region 排序能让该列缩小 80.6 倍、过滤读盘掉到 20.5%,代价是整文件反而大 3.5%,且按 id 过滤时剪枝彻底失效。
本文不含任何联盟推广链接,所有数字都是本机跑出来的。
文件里到底装了什么
Apache Parquet 官方格式文档给出的文件布局是这样的:开头 4 字节 magic PAR1,中间是「列块按行组切分」的数据区(第 1 个行组的 N 个列块,接着第 2 个行组的 N 个列块,一直到第 M 个行组),然后是文件元数据、4 字节的元数据长度(小端),最后又是 4 字节 PAR1。
关键在于官方那句 "File metadata is written after the data to allow for single pass writing"——元数据写在数据之后,所以读一个 Parquet 文件的第一步永远是跳到文件尾部拿元数据,再回头找列块的位置。这也是后面「列裁剪为什么真的省 IO」的物理基础:元数据里有每个列块的起始偏移,读引擎可以直接 seek 过去,不必扫全文件。
实测环境与方法
| 项目 | 取值 |
|---|---|
| 库版本 | pyarrow 26.0.0(写入的 created_by 是 parquet-cpp-arrow version 26.0.0)、pandas 3.0.6、numpy 2.5.4 |
| 数据 | 50 万行 × 5 列,合成数据,随机种子 20261021 |
| 列构成 | id(顺序 int64)、region(8 个取值字符串)、url(约 50 万个互不相同字符串)、value(随机 float64)、flag(bool) |
| 机器 | Windows,本机 SSD,单进程 |
| 官方来源 | parquet.apache.org 文件格式页与配置页、Apache Arrow 26.0.0 write_table 参数文档 |
可复现脚本(去掉了注释,直接可跑):
import numpy as np, pyarrow as pa, pyarrow.parquet as pq
rng = np.random.default_rng(20261021)
N = 500_000
regions = np.array(["us-east-1", "us-west-2", "eu-central-1", "ap-southeast-1",
"ap-northeast-1", "eu-west-1", "sa-east-1", "us-east-2"])
tbl = pa.table({
"id": pa.array(np.arange(N, dtype="int64")),
"region": pa.array(rng.choice(regions, N)),
"url": pa.array([f"/api/v1/items/{i}/detail?q=abc{i%97}" for i in range(N)]),
"value": pa.array(rng.random(N)),
"flag": pa.array(rng.random(N) > 0.5),
})
pq.write_table(tbl, "base.parquet", row_group_size=100_000)
print(pq.ParquetFile("base.parquet").metadata)
一、默认字典编码让整张表大了 15.0%
write_table 的 use_dictionary 默认是 True,而且默认对所有列生效。逐列把开关拨一遍(50 万行、snappy、行组 10 万):
| 列 | 字典开 | 字典关 | 结论 |
|---|---|---|---|
| region | 191,631 | 1,424,216 | 字典小 7.43 倍 |
| url | 3,724,421 | 3,475,834 | 字典反而大 7.1% |
| id | 3,017,665 | 2,004,753 | 字典反而大 50.5% |
| value | 5,016,860 | 4,003,111 | 字典反而大 25.3% |
| flag | 64,522 | 64,522 | 完全一致 |
flag 完全一致不是巧合——pyarrow 的文档写得很清楚:BOOLEAN type doesn't support dictionary encoding,布尔列走的是 RLE,元数据里也只有 RLE 和 PLAIN 两种编码,而其他列都带上了 RLE_DICTIONARY。
合起来,整张表默认写是 15,697,403 字节,use_dictionary=False 是 13,650,207 字节,默认配置白胖了 15.0%。我跑了第三个版本——只给低基数列开字典(use_dictionary=["region"] 这一类写法)——得到 13,430,547 字节,比全开小 14.4%,比全关也还小 1.6%。正确做法不是关掉字典,是把它限定在低基数列上。
二、字典编码的甜点区在 65% 基数附近
字典不是「字符串就一定赢」。我用 10 万行、每行一个 25 字符的定长 token,只改不同取值个数:
| 不同取值 / 总行数 | 字典开 | 字典关 | 谁赢 |
|---|---|---|---|
| 8 / 100,000 | 38,675 | 269,801 | 字典 6.98 倍 |
| 512 / 100,000 | 116,151 | 347,693 | 字典 2.99 倍 |
| 32,768 / 100,000 | 378,387 | 608,500 | 字典 1.61 倍 |
| 50,000 / 100,000(50.0%) | 580,532 | 622,368 | 字典 1.07 倍 |
| 65,536 / 100,000(65.5%) | 633,942 | 632,424 | 打平(0.998) |
| 100,000 / 100,000(100%) | 675,487 | 643,385 | PLAIN 赢 5.0% |
数值列更干脆:10 万行顺序 int64,字典开 603,746 vs 关 401,768(大 50.3%);顺序 int32 是 1.506 倍;随机 float64 是 1.253 倍。所以基数超过行数六成左右就别开字典,数值列除极低基数外基本都别开。
三、别再默认用 gzip:zstd 又小又快
同一张表、同一批参数,只换压缩器(行组 10 万、字典开):
| 压缩 | 文件大小 | 相对 snappy | 写入耗时 | 全表读耗时 |
|---|---|---|---|---|
| none | 35,880,270 | 大 128.6% | 0.299s | 0.459s |
| snappy(默认) | 15,697,403 | 1.00 | 0.595s | 0.062s |
| lz4 | 16,004,470 | 大 2.0% | 0.400s | 0.041s |
| zstd | 10,216,336 | 小 34.9% | 0.455s | 0.053s |
| gzip | 11,657,380 | 小 25.7% | 15.125s | 0.065s |
| brotli | 9,921,421 | 小 36.8% | 4.247s | 0.077s |
两个可以直接抄的结论:一是 zstd 同时打赢 gzip 的体积和速度——小 12.4%,写快 33.2 倍(0.455s vs 15.125s),只有 brotli 更小,但 brotli 写起来慢 9.3 倍;二是 zstd 的默认档就是 level 1(默认输出 10,216,336 字节与显式 compression_level=1 完全一致),调到 level 9 只再省 8,580 字节却慢 2.4 倍,调到 level 17 更是 8.043s 且文件反而比 level 1 大 1,661 字节。
四、列裁剪是真的只读那几个字节
我用一个计数字节的文件包装器读 base.parquet:
| 读取的列 | 实际读盘字节 | 占文件比例 | 耗时 |
|---|---|---|---|
| 全部 5 列 | 15,758,717 | 100.4% | 0.082s |
| 只 id | 3,082,270 | 19.6% | 0.008s |
| 只 value | 5,081,446 | 32.4% | 0.005s |
| 只 url | 3,788,773 | 24.1% | 0.016s |
| id + value | 8,098,180 | 51.6% | 0.010s |
这是脚注元数据存在的意义:seek 到目标列块就行。value 列是随机浮点、几乎压不动,所以单读一列就吃掉 32.4%;反过来也说明把宽表拆成窄表、或者把大列单独放,收益是实打实的字节数。
五、排序换来剪枝,也换走了体积
把同一份数据按 region 排序再写(行组 5 万、10 个行组),然后分别过滤:
| 场景 | 读盘字节 | 占文件比例 | 命中行 |
|---|---|---|---|
| 未排序,过滤 region="eu-west-1" | 12,254,333 | 100.5% | 62,704 |
| 已排序,过滤 region="eu-west-1" | 2,587,292 | 20.5% | 62,704 |
| 未排序,过滤 id 在 [0,100000) | 2,524,399 | 20.7% | 100,000 |
| 已排序,过滤 id 在 [0,100000) | 12,676,244 | 100.5% | 100,000 |
一次只能优化一个过滤列。 未排序时每个行组的 region 统计量都是从 ap-northeast-1 到 us-west-2,没一个能跳;排序后 10 个行组里只有 2 个落在范围内。反过来,按 region 排序把 id 打散了,id 的范围过滤从读 20.7% 退化成读 100.5%。
代价是体积:逐列压缩后字节数显示 region 从 191,587 掉到 2,376(小 80.6 倍),但与之相关的 url 列从 3,974,336 涨到 4,570,381(大 15.0%,因为 url 里嵌着顺序 id,排序把它打乱了),整文件从 12,195,324 变成 12,617,166,大了 3.5%。排序是为了剪枝,不是为了变小。
顺带一提,行组切多细也不是单调的:行组 1,000 时文件 15,783,076 字节(500 个行组、脚注 340,426 字节),行组 10,000 时反而更大(15,979,172),到行组 500,000 才降到 13,275,918(1 个行组、脚注 1,391 字节)。脚注随行组数量线性膨胀,行组太细时省下的那点压缩会被元数据吃掉。
六、官方推荐值和 pyarrow 默认值对不上
Parquet 官方配置页原文:行组 "We recommend large row groups (512MB - 1GB)";数据页 "We recommend 8KB for page sizes"。
而 pyarrow 26.0.0 的本地文档与 Apache Arrow 26.0.0 在线文档是另一套:row_group_size 默认 None,含义是 "the minimum of the Table size (in rows) and 1024 * 1024";data_page_size 默认 None,含义是 "use the default data page size of 1MByte"。我写了一百二十万行的单列表验证,实际切成 [1,048,576, 151,424] 两个行组——确实是 1024×1024,不是 512MB–1GB。
这不是谁错,是推荐值以字节计、默认值以行数计:你的列越宽,1024×1024 行对应的字节数就越偏离官方建议。宽表场景下这是值得手动设的一个参数。
声明排序时没人校验你
sorting_columns 的文档原文写着 "The writer does not sort the data nor does it verify that the data is sorted"。我实测把一份完全乱序的表配上 sorting_columns=[SortingColumn(1)] 写进去,pyarrow 一声不响地接受了,读回来 row_group(0).sorting_columns 是 (SortingColumn(column_index=1, descending=False, nulls_first=False),),但每个行组的 region 统计仍然是从 ap-northeast-1 到 us-west-2。
好消息是 pyarrow 自己的 filters 走的是真实统计量,所以过滤结果仍然正确(12,481 行 = 暴力过滤的 12,481 行)。风险在别处:任何信任这个声明、直接据此跳行组的引擎会得到错误结果,而写文件的人没有任何报错。声明排序不等于排序。
另外两个想查却查不到的字段:statistics.distinct_count 是 None(pyarrow 不写基数统计,所以引擎无法从元数据判断某列该不该走字典),write_page_index 默认 False——文档说开启它 "disables the old method of writing statistics to each data page header",页级剪枝默认是不开的。
踩坑录:六个真实报错
报错一:把非 Parquet 文件当 Parquet 读
ArrowInvalid: Could not open Parquet input source 'notparquet.bin': Parquet magic bytes not found in footer. Either the file is corrupted or this is not a parquet file.
常见于下载中断或对象存储里存了别的东西。文件后缀不可信,先检查尾部 4 字节是不是 PAR1。
报错二:0 字节文件
ArrowInvalid: Parquet file size is 0 bytes
写任务被 kill、磁盘写满、或者上游过滤后没数据却仍然建了空文件就会这样。注意区分:真正用带类型的空表 pq.write_table() 写出来的是 467 字节的正常文件(0 行但 schema 完整),能正常读回;0 字节那个是没写完,读不了。
报错三:一列里混了类型
ArrowInvalid: Could not convert 'three' with type str: tried to convert to double
Python list 里 [1, 2.5, "three"] 交给 pa.array() 就会炸。Parquet 一列一个类型,没有「宽容模式」,清洗要在建表前做。
报错四:往同一个 ParquetWriter 里写不同 schema
ValueError: Table schema does not match schema used to create file:
table:
a: double vs.
file:
a: int64
追加写时第二批的 a 变成了浮点。分批写之前先 cast() 到同一 schema,或者干脆写成 write_to_dataset 的分片文件。
报错五:过滤条件里的列不存在
ArrowInvalid: No match for FieldRef.Name(nope) in id: int64
v: double
列名写错时不会返回空表,而是直接抛异常。列名区分大小写,且嵌套列的引用方式不同。
报错六:同一目录下 schema 不一致
ArrowInvalid: Failed to parse string: 'y' as a scalar of type int64
pq.read_table("目录") 会把目录里所有 parquet 当一个数据集,用第一个文件的 schema 去解析其余文件。混进一个 schema 不同的分片,报错发生在解析阶段而不是合并阶段,容易误判成数据脏。
还有一个边界:Python 整数超过 int64 上界时,pa.array([2**63 + 1]) 抛的是 OverflowError: int too big to convert,不是 Arrow 的错误类型。
六个旋钮该怎么拧
| 旋钮 | 默认 | 本文实测给的建议 |
|---|---|---|
use_dictionary | True(全列) | 改成列名列表,只给低基数列;数值列默认关掉 |
compression | snappy | 归档或冷数据换 zstd;只在追读速时用 lz4 |
compression_level | None(=zstd level 1) | 别往上调,level 9 省 0.08% 慢 2.4 倍 |
row_group_size | None(=1,048,576 行) | 宽表按官方的 512MB–1GB 字节口径重算 |
write_statistics | True | 保持,这是行组剪枝的唯一依据 |
sorting_columns | None | 只在真的排过序之后才声明 |
本文的边界
全部数字来自本机 Windows + pyarrow 26.0.0 单进程,数据是合成的:50 万行、字符串为定长 token、浮点为均匀分布。真实数据里的字符串长度分布、时间局部性、空值比例都会改变结论,尤其是第二节那个 65% 的交叉点——它是「25 字符定长 token + snappy」这一组合下的数字,换压缩器或换字符串形态会漂移。我没有测 Spark / Trino / DuckDB 等其它引擎的读取行为,也没有测对象存储上的远端读(本地 SSD 的随机读代价和 S3 完全不同,列裁剪在远端场景下的收益通常比本文的 19.6% 更夸张)。页级剪枝(write_page_index)我只确认了默认关闭,没有做开关对照。
常见问题
Q:字典编码到底该不该开?
A:看基数。本文数据下不同取值少于总行数约 65% 时开字典是净赚,超过就亏;数值列(整数、浮点)除极低基数外基本都是亏的,默认全开会让这张表大 15.0%。
Q:压缩器选哪个?
A:默认 snappy 是速度的稳妥选择;要体积就选 zstd,本文里它比 gzip 小 12.4% 且快 33.2 倍。只在这台机器上追读速可以试 lz4(0.041s),但它比 snappy 还大 2.0%。
Q:为什么我按时间排了序,按 id 查反而变慢了?
A:因为排序只能服务一个过滤列。本文按 region 排序后,region 过滤从读 100.5% 降到 20.5%,但 id 范围过滤从 20.7% 退化到 100.5%。先确定最高频的过滤条件,再决定按什么排序。
Q:sorting_columns 写了会不会自动排序?
A:不会。官方文档原话是写作器既不排序也不校验。本文实测把乱序数据声明成已排序能直接写进去,没有报错。
Q:文件打不开,怎么快速判断是截断还是压根不是 Parquet?
A:看文件尾 4 字节是不是 PAR1。0 字节会拿到 Parquet file size is 0 bytes,有内容但尾部不对会拿到 Parquet magic bytes not found in footer,两者是不同故障。
相关阅读
如果你关心的是「文件存下来之后怎么查」,可以看本机同环境下的 DuckDB 1.5.6 实测:不建库直查 40MB CSV,它讲的是查询层,本文讲的是存储层。服务端侧的写入膨胀问题则有 PostgreSQL 18.6 复制槽实测,同样是一份「没人读就把磁盘吃光」的排查记录。方法论上本文和 LangGraph 1.2 持久化实测 一致:不引第三方基准,所有数字都在本机跑出来并把脚本原样贴出。
👉 立即参与小米 MiMo 开放平台:国内领先的 AI 大模型开放平台,高性价比推理服务
👉 立即参与阿里云 AI:汇集爆款 AI 产品,热门模型专属权益优惠券助力企业创新加速
📌 本文由 AI 辅助生成并经人工审核发布 | TechPassive — AI 驱动的内容测试站点,专注于效率工具与 SaaS 真实评测
🔗 精选推荐工具
使用以下链接支持我们持续产出高质量内容(点击可直接前往购买):