凌晨两点半,监控电话把我从睡梦里拽起来。Flink 作业报错:xxx.parquet is not a Parquet file。第一眼看见这个报错我是有点懵的,因为作业白天还跑得好好的,日志没有任何改动,怎么突然就在读文件阶段崩了?这个错误在 Flink + Hudi 的实时数仓链路里不算罕见,但“不是 Parquet 文件”这几个字的背后,往往不是表面理解的文件格式不对那么简单。这篇文章就用这次生产事故当引子,完整还原从报错、取证、定位到修复的全过程,顺便把 Parquet 文件格式和 Hudi 文件布局的底层细节一次性讲透。内容适合正在用 Flink 读写 Hudi 表、或者经常跟 Parquet 文件打交道的数仓工程师和数据开发同学,看完之后你至少能少走两小时弯路。
1. 问题定性:这条报错背后到底发生了什么
1.1 报错信息与直接触发点
我当时截到的核心日志长这样:
text复制Caused by: java.io.IOException: xxx.parquet is not a Parquet file. expected magic number at tail [70, 97, 82, 49] but found [99, 110, 115, 52]
at org.apache.parquet.hadoop.ParquetFileReader.readFooter(ParquetFileReader.java:342)
at org.apache.parquet.hadoop.ParquetFileReader.initializeFromFile(ParquetFileReader.java:209)
at org.apache.parquet.hadoop.ParquetFileReader.<init>(ParquetFileReader.java:193)
at org.apache.parquet.hadoop.ParquetInputFormat.getRecordReader(ParquetInputFormat.java:273)
看到 expected magic number at tail,懂行的应该已经明白了个七八分。Parquet 文件不是一个普通的二进制文件,它的头部和尾部各有一段固定的 4 字节魔数 PAR1,也就是十六进制的 50 41 52 31。任何读 Parquet 的引擎,包括 Flink 底层的 ParquetInputFormat,拿到文件后第一步就是校验文件头尾的魔法数字。头部不对,直接报 not a Parquet file;头部对但尾部不对,就报 expected magic number at tail。
这个报错的直接触发点非常简单粗暴:目标文件缺失了 Parquet 文件应该有的结构标识。但“缺结构”和“文件坏”之间不能划等号,我们需要找到的答案是,为什么这个文件长成了不是 Parquet 的样子。常见可能性就那么几类:文件写了一半、文件被其他格式顶替、读端扫错了文件。接下来要做的不是改配置,而是取证。
1.2 扩展名不等于真实格式
很多第一次遇到这个报错的同事会陷入一个误区:文件后缀明明是 .parquet,为什么还报不是 Parquet 文件?这里我打个比方,扩展名就像身份证上的户籍信息,它理论上应该和真实身份一致,但架不住有人把信息填错,或者干脆用了假证。在分布式文件系统上,扩展名只是名字的一部分,HDFS 甚至不关心扩展名是什么,它只是按字节存数据。
在 Flink + Hudi 的链路里,这个问题尤其容易发生。Hudi 表底层的数据文件有一套自己的布局和命名规范,读 Hudi 表时,读端会拿到一组文件路径,再挨个打开。假如这个过程中混进来一个真实格式是 Avro 或者 JSON 的文件,而它的名字恰好叫 xxx.parquet,那读端一校验头部,就会报 is not a Parquet file。同理,一个写了一半就宕机的文件,也可能只有头部没有尾部,同样会被判定为“不是合法的 Parquet 文件”。所以看到这个报错,第一反应应该是:这个文件到底是“天生不是 parquet”,还是“本来要是 parquet 但没写完”。
1.3 提前划出三条排查主线
经验告诉我,这个报错逃不出以下三条主线,后续所有动作都是围绕它们展开的。
- 文件没有写完整:Flink 作业在写数据过程中被 kill、节点宕机、HDFS 写入异常,都可能留下一个只有文件头的半截 parquet。这种文件本质上是“半成品”,读端校验失败是必然的。
- 文件被“狸猫换太子”:某个进程或人为操作,把一个非 Parquet 格式的文件(Avro、JSON、CSV 文本,甚至 0 字节空文件)硬生生命名成了
.parquet,然后放进了 Hudi 表目录。读端目录扫描到它,同样报错。 - 文件本身没问题,但读错了路径:文件确实是合法的 parquet,但读端因为分区选择、时间线解析、索引错乱等原因,拿到的路径指向了一个不属于当前表的文件。这种情况也要排查。
先明确这三种可能,再去动手,效率会高很多。如果你在没有任何证据的情况下直接删文件、换参数,很可能把一个小问题放大成一个大事故。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析与实操要点:Parquet 格式识别与 Hudi 文件布局
2.1 Parquet 文件底层结构与文件头尾魔数
要彻底搞懂这个报错,必须把 Parquet 的文件结构看一遍。Parquet 文件的整体布局如下:
text复制+-------------------+
| magic "PAR1" | 4 字节
+-------------------+
| row group 0 |
| row group 1 |
| ... |
+-------------------+
| footer | 文件元数据,包含 schema、块偏移、统计信息
+-------------------+
| footer length | 4 字节小端整数,记录 footer 长度
+-------------------+
| magic "PAR1" | 4 字节
+-------------------+
头部 4 字节是 PAR1,尾部 4 字节也是 PAR1,而且在尾部 PAR1 之前,还有 4 字节专门记录 footer 的长度。这个设计非常巧妙:读文件时,先读尾部 8 字节,解析出 footer 长度,再回退去取 footer 元数据,最后校验头部。如果文件只写了几字节就中断,尾部魔数自然不对;如果文件本身就是空的,头部都校验不过去。
生产实践中,用十六进制工具看文件头尾是最快的。比如在 Linux 上,可以用 xxd 或 od 命令:
bash复制# 查看文件头部 4 字节
hdfs dfs -cat /warehouse/ods_xxx/dt=2025-01-10/xxx.parquet | head -c 4 | xxd
# 查看文件尾部 8 字节
hdfs dfs -tail /warehouse/ods_xxx/dt=2025-01-10/xxx.parquet | tail -c 8 | xxd
正常的 Parquet 文件,头部输出应该是 50 41 52 31,尾部输出应该是 footer 长度加 50 41 52 31。如果头对不上,文件就是“假 parquet”;如果头对、尾对不上,文件就是“半截 parquet”。就这一条命令,就能把问题的范围砍掉一半。
2.2 用 Python 快速鉴别文件真实格式
在线排查时,我不一定总有 parquet-tools 可用。其实用 Python 写一个十几行的脚本就能完成文件格式的初筛,这也是我日常处理这类问题前必做的一步。这里的思路很简单:读头部 4 字节,根据特征判断真实格式。
python复制import sys
MAGIC_MAP = {
b'PAR1': 'parquet',
b'Obj\x01': 'avro',
b'\x89P': 'orc', # ORC 文件以 0x89 开头,后跟 P
b'\x00\x00\x00\x14': 'hudi timeline',
}
def check_file(path):
with open(path, 'rb') as f:
head = f.read(4)
return MAGIC_MAP.get(head, 'unknown/text')
if __name__ == '__main__':
print(check_file(sys.argv[1]))
这个脚本对本地文件有效,对 HDFS 上的文件可以先 hdfs dfs -cat 拉下来再跑。Avro 容器文件的头部是 Obj\x01,JSON 或 CSV 一般可见明文,Parquet 头部则是 PAR1。把这几种区分开,报错的性质就清晰了。顺便说一句,如果你想用 Python 正常读写 Parquet 文件,推荐 pyarrow.parquet 或 fastparquet,这一步的脚本只是为了“验尸”,不是用来做业务读写的。
2.3 Hudi 的文件布局:base 文件、log 文件和文件组
接下来聊聊 Hudi 在 HDFS 上的文件组织,因为几乎所有 Flink-Hudi 问题到最后都会绕回这里。Hudi 表目录下通常有一个隐藏的 .hoodie 目录存放时间线(commit、inflight、archive 等),真正的数据文件在表目录的各个分区目录里。
Hudi 有两种典型的文件格式组合:Copy-on-Write(COW)和 Merge-on-Read(MOR)。COW 表每次写入都会生成新的 base 文件,这个文件就是 Parquet 格式;MOR 表除了 base 文件(Parquet),还会有 log 文件,log 文件真实内容是 Avro 容器格式,后缀通常是 .log。Hudi base 文件的命名规则大致如下:
text复制<fileId>_<writeToken>_<instantTime>.parquet
注意里面包含了 commit 时间戳。如果读端因为某种原因把 log 文件当成 parquet 文件去读,或者把时间线文件 .inflight、.commit 当成数据文件,都会报出各种诡异错误。虽然直接把 .log 文件认成 .parquet 的情况比较少见,但它衍生出来的问题,和这次报错的表现形式非常像。
另外,Hudi 的分区目录有两种风格:Hive 风格分区(dt=2025-01-10)和非 Hive 风格分区(2025/01/10)。读取 Hudi 表时,如果 hoodie.datasource.write.hive_style_partitioning 配置不一致,读端可能会把分区路径解析错误,拿到一些目录下混入的杂物文件。这虽然不会直接导致“not a Parquet file”,但会扩大扫描范围,增加混入异常文件的概率。
2.4 Schema 不一致也会伪装成格式错误
还有一个容易被误判的坑:Schema 不一致。Parquet 文件本身格式没问题,但文件内的字段列表、字段类型和读端期望的 schema 对不上时,某些版本的 parquet-mr 会抛出类似于 “column xxx not found” 或解析错误,有些场景下报错信息会很长,只截取第一行会让人误以为是格式错误。特别是 Hudi 表做过 schema evolution 后,旧文件和新读端之间的兼容性需要靠 hoodie.schema.allow.evolution 这类配置来调节。如果读端扫描到了老版本的文件,而 schema provider 没有正确返回历史 schema,报错就会很离谱。
不过,这次我们的事故没到 schema 这一步,文件格式的校验就先挂了。所以先排查格式,再做 schema 比对,顺序不能乱。
3. 实操过程与核心环节实现:一次完整的生产排障记录
3.1 现场取证:把错误现场完整留下来
接到告警后,我第一个动作不是去重启作业,而是先冻结现场。Flink UI 上任务已经自动重启了几次,每次都在同一个文件上挂掉。如果我再不抓紧时间,文件可能被 checkpoint 清理或者被后续任务改写,到时候想查证据都没了。
我先用 hdfs dfs -ls 确认报错路径下这个文件的基本情况:
bash复制hdfs dfs -ls /warehouse/ods_xxx/dt=2025-01-10/
输出结果让我心头一紧:
text复制-rw-r--r-- 3 hdfs supergroup 132 2025-01-10 03:18 /warehouse/ods_xxx/dt=2025-01-10/xxx.parquet
-rw-r--r-- 3 hdfs supergroup 0 2025-01-10 03:19 /warehouse/ods_xxx/dt=2025-01-10/yyy.parquet
-rw-r--r-- 3 hdfs supergroup 45407906 2025-01-10 03:12 /warehouse/ods_xxx/dt=2025-01-10/zzz.parquet
报错是 xxx.parquet,文件只有 132 字节。这个大小说明它既不是一个正常的 Parquet 文件(正常至少几 KB 起步),也不像是一个完整的业务数据文件。再看 yyy.parquet,0 字节,这更可疑。一个分区目录下出现 0 字节文件,相当于你家的书架上突然多了一本没有内页的“书”,读的时候肯定要出问题。我立刻把这两个文件单独列出来,备份到临时目录,避免后续操作破坏证据。
3.2 验证真实文件内容:头部尾部魔数对比
接下来我用十六进制方式直接查看 xxx.parquet 的头部内容:
bash复制hdfs dfs -cat /warehouse/ods_xxx/dt=2025-01-10/xxx.parquet | head -c 4 | xxd
输出是:
text复制0000000: 4f62 6a01
看到 4f 62 6a 01,我基本能判断这个文件是 Avro 容器文件。4f 62 6a 01 对应的 ASCII 是 Obj 加一个不可打印字符 \x01,这正是 Avro Object Container File 的魔数。也就是说,这个文件真实身份是 Avro 格式,却被人为命名成了 .parquet。再检查 yyy.parquet,头部是空的,xxd 输出没有任何字节,0 字节文件。
再看那个 45MB 的 zzz.parquet,我用同样的命令检查头尾:
bash复制hdfs dfs -cat /warehouse/ods_xxx/dt=2025-01-10/zzz.parquet | head -c 4 | xxd
hdfs dfs -tail /warehouse/ods_xxx/dt=2025-01-10/zzz.parquet | tail -c 4 | xxd
头部和尾部都是 50 41 52 31,合法的 Parquet 文件,排除。问题就集中在那两个异常文件上。
3.3 顺藤摸瓜:从文件时间戳追到写入来源
文件是哪里来的?我先把时间线捋了一遍。Hudi 表目录下有一个 .hoodie 文件夹,里面放着 commit、inflight、archive 等时间线文件,我直接看最近一段时间的 commit 记录:
bash复制hdfs dfs -ls /warehouse/ods_xxx/.hoodie/ | grep ".commit" | tail -20
看到凌晨 3 点 12 分左右确实有一个 commit 成功,但是 3 点 18 到 3 点 19 之间只有 .inflight 文件,没有对应的 .commit 文件。这基本坐实了猜想:有另一个 Flink 作业在凌晨 3 点多开始写入分区 dt=2025-01-10,但写入过程没有正常结束。xxx.parquet 和 yyy.parquet 很可能是这个中断作业留下的残留文件。
再往下深挖,我登录了另一个运行中的 Flink 作业的管理界面,发现它的 Sink 操作竟然配置的是 FileSystem 连接器,目标路径直接指向了 Hudi 表的分区目录。它本意是写一份临时 Parquet 数据用于离线分析,但因为配置写错、且写入失败,残留了文件。更离谱的是,该作业复用了一个旧代码模板,输出格式定义成了 Avro,却把输出文件名硬编码成了 .parquet。这完美解释了为什么一个 Avro 文件叫 xxx.parquet。
至于 yyy.parquet,则是这个作业的 writer 在创建文件后、写出任何数据前,正好赶上 Flink 作业重启,留下了一个 0 字节占位文件。
3.4 修复与恢复:清理异常文件并重启作业
定位到根因后,修复就顺理成章了。我按以下步骤操作,每一步都有明确目的:
- 暂停所有指向该 Hudi 表的写作业。这一步是为了防止在清理过程中有新的文件写入,造成并发不一致。
- 备份异常文件。把
xxx.parquet和yyy.parquet移动到表目录外的一个_backup_20250110文件夹,而不是直接删除。万一后续要审计,还有原始凭证。 - 清理 Hudi 时间线中残留的 inflight 状态。使用 Hudi CLI 或直接删除过期
.inflight文件前,先确认没有活跃 writer。这里我没有直接删.inflight,而是通过完整重启那个异常作业来让它的 Flink 状态恢复,避免误删正常 inflight。 - 重启核心 Flink-Hudi 作业。因为异常文件已经被移走,读端不会再扫描到它们。
- 观察消费延迟和写入状态。作业起来后,我盯了 15 分钟,确认 monitor 面板上的延迟指标没有重新拉高、checkpoint 连续成功、Hudi commit 正常生成,再让同事撤掉临时告警。
这里有一个注意事项:清理 Hudi 表目录下的文件必须非常谨慎。直接跑 hdfs dfs -rm 是省事,但如果文件属于一个未完成但后续会被恢复的 commit,删掉之后可能导致表数据缺失。所以我的原则是“先移走,再确认,后删除”,而不是一上来就物理删除。
3.5 根因复盘:为什么“写坏的文件”会被读端读到
复盘时,团队问我最多的问题是:Flink-Hudi 作业不在写这个分区,为什么读作业会挂?这里的核心机制在于:Hudi 读取时,文件系统的目录扫描是“所见即所得”的。读端拿到的是表目录下所有符合分区条件的文件列表,它并不会去校验这些文件是不是某个 Hudi commit 正式提交的产物。一旦有其他进程绕过 Hudi 直接向表目录写文件,它们就会出现在读端的扫描列表里。哪怕这个文件是一个写了一半就中断的临时文件,读端也会尝试去打开。
很多团队在使用 Hudi 时会忽略一个铁律:Hudi 表的目录是受管的,任何文件写入都必须通过 Hudi 的连接器或 API 完成。直接 put、直接写 FileSystem 连接器,都是在给自己埋雷。这次事故等于用一次生产告警,把这个铁律重新刻进了团队的运维规范里。
4. 常见问题与排查技巧实录
4.1 高频报错对照速查表
我把这次事故和以前遇到的类似问题汇总了一下,做成一个速查表,以后看到报错可以直接对号入座:
| 报错现象 | 可能原因 | 优先处理方式 |
|---|---|---|
xxx.parquet is not a Parquet file |
文件头魔数不是 PAR1,可能是 Avro/JSON/0字节文件 |
查文件头,确认真实格式,移走异常文件 |
expected magic number at tail ... but found ... |
文件写了一半,有头没尾 | 查文件大小和写入任务,确认是中断残留后清理 |
Parquet file corrupted |
HDFS 文件块缺失或磁盘损坏 | 用 hdfs fsck 检查文件健康度,从副本或备份恢复 |
column not found |
Schema 不一致或 Hudi schema evolution 未配置 | 对比表 schema 和文件 footer schema,调整读端 schema provider |
Path is not a file |
读端拿到的路径是目录而非文件 | 检查分区路径格式、Hudi 时间线解析、Hive 风格分区配置 |
| 读取 Hudi MOR 表时报格式错误 | log 文件被误当作 base 文件解析 | 检查读端版本和 hoodie.table.log.file.format 配置 |
看清楚原因再动手,比盲目删文件、调参数靠谱得多。
4.2 排障工具箱:我常用的几个命令
除了前面提到的 Python 脚本和 xxd,我再分享几个生产环境里非常实用的工具组合。
parquet-tools 是验证 Parquet 文件是否完整的最快方式:
bash复制parquet-tools meta hdfs://namenode:8020/warehouse/ods_xxx/dt=2025-01-10/xxx.parquet
parquet-tools cat hdfs://namenode:8020/warehouse/ods_xxx/dt=2025-01-10/xxx.parquet
如果文件本身损坏,meta 会直接报错;如果文件健康,它会打印 schema、row group 数量、每个 column 的统计信息。这个工具在主流发行版里一般都有,没有的话也可以用 parquet-cli 替代。
另外,我习惯在写数据作业的关键路径上放一个简单的“事后巡检”脚本,每个小时跑一次:扫描 Hudi 表分区目录下所有 .parquet 文件,读取头部和尾部魔数,把异常文件清单写到报警平台。这个脚本不复杂,但能避免等到读作业崩溃才发现问题。
bash复制#!/bin/bash
# 巡检 Hudi 表目录下的 Parquet 文件,检查魔数
HDFS_PATH=$1
hdfs dfs -ls $HDFS_PATH | awk '{print $NF}' | grep '\.parquet$' | while read f; do
head_magic=$(hdfs dfs -cat "$f" 2>/dev/null | head -c 4 | xxd -p)
tail_magic=$(hdfs dfs -tail "$f" 2>/dev/null | tail -c 4 | xxd -p)
if [[ "$head_magic" != "50415231" ]]; then
echo "BAD_HEAD $f"
elif [[ "$tail_magic" != "50415231" ]]; then
echo "BAD_TAIL $f"
fi
done
注意这个脚本在文件数量特别多时会有性能开销,建议只用在出现告警的特定分区,不要全表扫描。
4.3 避免踩坑的几点经验
第一,Hudi 表和普通 HDFS 目录不要混用。很多人图省事,把临时分析结果直接写到 Hudi 表所在分区目录,理由是“反正也是 parquet 文件,不影响读”。这种想法非常危险。一旦文件格式、命名、写入时机和 Hudi 的 commit 不一致,轻则报错,重则污染整张表的数据可见性。宁可多写一个旁路目录,也不要破坏受管表目录的纯洁性。
第二,Flink 作业中断后,要主动检查残留文件。生产集群上 Flink 作业因为节点宕机、反压超时、checkpoint 失败而重启,是家常便饭。每次重启都可能留下半截文件。如果不做清理,这些文件就会累积,直到某个读作业踩雷。我现在的习惯是,每次 Flink-Hudi 写作业异常重启后,都会用前面提到的脚本扫描一次它负责的分区目录,发现 0 字节或者头部异常的 parquet 文件就及时移走。
第三,遇到这类报错,不要第一时间怀疑 Hudi 连接器的“稳定性”。大多数时候不是 Hudi bug,而是目录里混入了不该出现的文件。我见过有同事反复升级 Hudi 版本、改各种并发参数,最后发现是一个定时 shell 脚本把数据说明文档写进了表目录。先把现场证据收集起来,再谈优化和升级,这是排障的基本原则。另外,如果你的表启用了 Hudi Metadata Table,并且读端出现一些莫名其妙的文件定位错误,也可以在测试环境临时关闭 hoodie.metadata.enable=false 做对比,用来排除元数据表损坏的可能性。
4.4 从这次事故延伸到 Flink-Hudi 日常运维
这次事故还有一个让我印象深刻的点:xxx.parquet is not a Parquet file 这个报错本身并不可怕,可怕的是读作业每 5 分钟重启一次,每次重启都会重复扫描那个异常文件,导致消费延迟像滚雪球一样越滚越大。如果你在监控上看到 Flink 作业频繁重启、checkpoint 连续失败,一定要把错误日志里的文件路径摘出来,第一时间封住那个“漏洞”,而不是反复重启期待它自己恢复。
日常运维中,我还会给 Flink-Hudi 写作业加两道保险:第一,在 Sink 阶段开启 Hudi 的 hoodie.cleaner.automatic=true,让清理器定期删除未完成的文件切片;第二,在 Flink 端调整 execution.checkpointing.interval,不要设置太短,否则频繁的 checkpoint 会放大写入中断的概率。我一般设置在 60 秒到 120 秒之间,具体看业务延迟要求。你要知道,checkpoint 间隔越小,残留文件出现的概率越高,但延迟越低,这是一对需要平衡的矛盾。
我个人在实际排查中最大的体会是:很多所谓“诡异报错”,底层原理往往很简单,就是文件格式校验没过。但如果你不了解 Parquet 文件头尾魔数、不了解 Hudi 的目录结构,就很容易被报错信息带偏,去调连接器参数、换依赖版本,折腾半天还找不到北。先取证、再判断、后修复,用事实引导动作,才能把生产事故的恢复时间控制在分钟级别。
最后再分享一个小技巧:你可以给监控告警配一条自动化的“文件体检”脚本,专门在看板上面返回文件头尾魔数状态。我后来就是靠这个脚本,在这个报错真正影响到读作业之前就把它拦了下来。生产环境的稳定性,往往不是靠某一次力挽狂澜,而是靠这些不起眼的日常巡检堆积出来的。
