← 返回首页

Parquet 与 Arrow 实测:文件里到底存了什么

ParquetPyArrow数据分析

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 万):

列字典开字典关结论
region191,6311,424,216字典小 7.43 倍
url3,724,4213,475,834字典反而大 7.1%
id3,017,6652,004,753字典反而大 50.5%
value5,016,8604,003,111字典反而大 25.3%
flag64,52264,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,00038,675269,801字典 6.98 倍
512 / 100,000116,151347,693字典 2.99 倍
32,768 / 100,000378,387608,500字典 1.61 倍
50,000 / 100,000(50.0%)580,532622,368字典 1.07 倍
65,536 / 100,000(65.5%)633,942632,424打平(0.998)
100,000 / 100,000(100%)675,487643,385PLAIN 赢 5.0%

数值列更干脆:10 万行顺序 int64,字典开 603,746 vs 关 401,768(大 50.3%);顺序 int32 是 1.506 倍;随机 float64 是 1.253 倍。所以基数超过行数六成左右就别开字典,数值列除极低基数外基本都别开。

三、别再默认用 gzip:zstd 又小又快

同一张表、同一批参数,只换压缩器(行组 10 万、字典开):

压缩文件大小相对 snappy写入耗时全表读耗时
none35,880,270大 128.6%0.299s0.459s
snappy(默认)15,697,4031.000.595s0.062s
lz416,004,470大 2.0%0.400s0.041s
zstd10,216,336小 34.9%0.455s0.053s
gzip11,657,380小 25.7%15.125s0.065s
brotli9,921,421小 36.8%4.247s0.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,717100.4%0.082s
只 id3,082,27019.6%0.008s
只 value5,081,44632.4%0.005s
只 url3,788,77324.1%0.016s
id + value8,098,18051.6%0.010s

这是脚注元数据存在的意义:seek 到目标列块就行。value 列是随机浮点、几乎压不动,所以单读一列就吃掉 32.4%;反过来也说明把宽表拆成窄表、或者把大列单独放,收益是实打实的字节数。

五、排序换来剪枝,也换走了体积

把同一份数据按 region 排序再写(行组 5 万、10 个行组),然后分别过滤:

场景读盘字节占文件比例命中行
未排序,过滤 region="eu-west-1"12,254,333100.5%62,704
已排序,过滤 region="eu-west-1"2,587,29220.5%62,704
未排序,过滤 id 在 [0,100000)2,524,39920.7%100,000
已排序,过滤 id 在 [0,100000)12,676,244100.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_dictionaryTrue(全列)改成列名列表,只给低基数列;数值列默认关掉
compressionsnappy归档或冷数据换 zstd;只在追读速时用 lz4
compression_levelNone(=zstd level 1)别往上调,level 9 省 0.08% 慢 2.4 倍
row_group_sizeNone(=1,048,576 行)宽表按官方的 512MB–1GB 字节口径重算
write_statisticsTrue保持,这是行组剪枝的唯一依据
sorting_columnsNone只在真的排过序之后才声明

本文的边界

全部数字来自本机 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 真实评测

🔗 精选推荐工具

使用以下链接支持我们持续产出高质量内容(点击可直接前往购买):

☁️ DigitalOcean 云服务器 ⚡ Vultr 高性能 VPS 🤖 QoderWork 中国版(推荐有奖) ☁️ 阿里云爆款 AI 产品 📚 WordPress 实用书单 🔍 WordPress SEO 书单 🌐 虚拟主机书单 🐳 Docker 书单 🐧 Linux 书单 🐍 Python 书单 💰 联盟营销书单 💵 被动收入书单 🖥️ 服务器书单 ☁️ 云计算书单 🚀 DevOps 书单 🤖 小米 MiMo 开放平台
← 返回首页