凌晨的生产告警又响了。我盯着群里那行日志看了很久,核心就一句:Caused by: org.apache.parquet.io.ParquetDecodingException: hdfs://xxx/warehouse/ods/xxx/xxx.parquet is not a Parquet file。Flink任务直接失败重启,Hudi表写入链路中断,上下游数据全都卡住。这个报错看着简单,但如果你没有把Parquet文件结构、Hudi写文件生命周期和Flink作业恢复机制串起来想,第一次碰基本会被牵着鼻子走上一整天。
这篇文章就从我实际处理的一个Flink + Hudi生产排障案例说起。我带你完整过一遍报错现场的日志特征、根因定位路径、参数修复方案,以及后续怎么从作业配置层面防住这类问题。如果你正在维护Flink写入Hudi的实时链路,或者准备把流式入湖的稳定性再往上提一档,这篇内容可以直接当排查手册用。
1. 报错场景复现与初步认知
1.1 报错日志长什么样
任务报错不是一上来就甩Parquet异常,而是先有一堆前置日志铺垫。我当时看到的完整链路大概是这样的:
log复制2025-xx-xx 03:21:45 INFO org.apache.hudi.sink.common.AbstractWriteOperator: Checkpoint 128 committed, instant: 2025xxxxxxxxxx
2025-xx-xx 03:22:01 WARN org.apache.hudi.common.table.log.block.HoodieParquetDataBlock: Failed to read block from file: 2025xxxxxxxxxx_xx-xx-xx-xxx.parquet
2025-xx-xx 03:22:01 ERROR org.apache.flink.runtime.operators.coordination.OperatorEventDispatcher: Operator event failed to deliver
2025-xx-xx 03:22:01 ERROR org.apache.flink.runtime.taskexecutor.TaskExecutor: Exception in task
Caused by: org.apache.parquet.io.ParquetDecodingException: hdfs://.../ods/xxx/xxx.parquet is not a Parquet file
注意几个关键信息:
- 任务不是建表阶段报错,而是跑了很长时间之后,在一次checkpoint / commit 后的异步阶段抛错。
- 报错来源是 HoodieParquetDataBlock,说明涉及Hudi的读路径。
- 异常里明确把某个具体parquet文件路径带出来了,但文件对象本身不是合法Parquet。
1.2 错误信息拆解:Parquet文件校验机制
“is not a Parquet file”这句话不是Hudi自己发明的,它来自Parquet的底层读取逻辑。Parquet文件有一套固定的"摩术数"(magic number)机制:
- 文件开头4个字节必须是
PAR1 - 文件结尾4个字节也必须是
PAR1 - 如果文件长度小于12字节,直接认为非法
当你用ParquetReader或者Hudi的parquet block reader去读文件时,底层会先检查文件头魔数,不一致就直接抛出 is not a Parquet file。
这句话翻译成大白话就是:任务拿到一个以.parquet结尾的文件路径,但这个文件的内容完全不符合Parquet格式规范。
那为什么会出现这种文件?先别急着下结论,继续往下看。
提示:生产排障第一原则,永远不要只盯着最显眼的那一行异常。
is not a Parquet file只是表症,真正的问题往往在它前一步的触发条件里。
1.3 为什么Parquet文件会被识别失败
按照文件系统的角度,一个路径上挂着.parquet结尾的文件,内容却不是Parquet,只有下面几种可能:
- 压缩格式不匹配:底层压缩格式发生了切换,比如之前是
gzip,后来变成snappy,或者Hadoop的io压缩编解码器配置被改掉,导致实际文件流不是Parquet的struct layout。 - 文件写入过程中被截断或残留:任务在写文件时被kill,checkpoint恢复后文件没有正确删除,或者commit之前文件未完整落盘就被读到。
- 多作业并发写入同一个Hudi表:两个作业同时写同一个分区路径,其中一个作业rolling back,把另一个作业尚未commit的文件物理删掉或覆盖,留下半截数据。
- Hudi小文件合并或clean操作和读路径竞争:clean清理旧版本文件时,读线程刚好在扫描文件列表,读到被标记删除但文件系统元数据还没刷新的路径,里面已经是无用数据。
排障阶段要把这些因素都写在白板上,然后一条条验证,而不是盲目去修改压缩参数或者直接删文件。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 根因定位:一场分层的排障过程
2.1 先看元数据,再看文件头
拿到这类问题,我习惯的第一步不是去翻代码,而是先确认这个文件在HDFS上的真实状态。
bash复制# 查看文件是否存在、大小是否正常
hdfs dfs -ls hdfs://.../ods/xxx/xxx.parquet
# 转储文件前16字节,看是否有PAR1魔数
hdfs dfs -cat hdfs://.../ods/xxx/xxx.parquet | head -c 16 | xxd
正常情况下你应该看到前四个字节是 50 41 52 31,也就是ASCII的PAR1。我当时看到的是文件大小只有几KB,前四个字节是1f 8b 08 00——一看就是gzip魔数。也就是说,这个文件根本就是被gzip压缩过的普通数据文件,但文件扩展名还是.parquet。
这里涉及一个重要知识点:Parquet本身就支持指定压缩编码器,压缩是针对parquet内部的column chunk进行的,而不是把整个文件再包一层gzip。Hudi在写parquet文件时,如果错误地开启了外部压缩,或者存储系统对路径后缀强制套用了压缩codec,就会生成这种文件。
2.2 检查Hudi写入参数的时间线
我立刻去对比了这个任务从上线到出问题之间的参数变更记录。排障有个很实用的技巧:如果任务是稳定运行很久后才出的问题,优先怀疑最近变更,而不是怀疑最初的配置。
结果发现,问题出在一次看似无关的优化改动。某个数据开发同学为了提高小文件合并效率,修改了以下参数:
properties复制hoodie.parquet.compression.codec=gzip
改之前是:
properties复制hoodie.parquet.compression.codec=snappy
这里有个隐藏逻辑:当你用Flink写Hudi时,这个配置会传递到底层HoodieParquetWriter。ParquetWriter用指定的压缩codec对column chunk编码,生成的仍然是合法parquet文件,文件头尾始终保留PAR1魔数。但问题是,如果某个环节错误地把这个配置当作"对文件整体做压缩",后果就完全不一样了。
继续排查发现,该任务所在的集群里,核心服务层某次发布后启用了hdfs-site.xml中的io.compression.codecs扩展,并且对.parquet路径误配置了全局compression filter。两层因素叠加在一起,写入端生成的确实是不规范的gzip包。此时下游用ParquetReader去读,自然报is not a Parquet file。
2.3 并发写同一表路径的二次验证
除了压缩配置,我还必须排除并发写入的干扰。当时这个Hudi表是多个Flink作业共用的,其中一个作业专门做补数。补数作业会在旧分区上回写数据,如果两次作业的提交事务没有做逻辑隔离,clean操作可能会将对方刚写入的文件当孤儿文件清掉。
我去查了Hudi的commit/rollback历史:
bash复制# 查看最近的文件操作记录
hdfs dfs -ls hdfs://.../ods/xxx/.hoodie/
vi hdfs://.../ods/xxx/.hoodie/hoodie.properties
通过时间线比对,确认在那个报错parquet文件生成的时间窗口内,另一个作业确实执行了rollback。虽然这个不是核心原因,但它解释了为什么问题有时能自愈——因为下一次数据写入会重新生成完整文件,旧的破损路径被覆盖后读路径自然恢复。
2.4 为什么本地无法复现
拿到现场后,我在本地环境尝试用相同参数跑了一遍Flink写入demo,没有任何问题。这就是流式排障和离线排障最大的区别:本地环境和生产环境的Hadoop配置、文件系统默认行为、并发场景完全不同。
本地能跑通不代表生产没问题,因为本地没有全局压缩过滤器,也没有并发clean的触发条件。遇到这种和文件系统、底层配置强关联的问题,必须把现场日志、配置文件、文件样本三样东西都要到,少一样都可能误判。
3. 实操修复:一步步把任务捞回来
3.1 第一优先级:恢复数据写入链路
生产任务挂太久就会造成数据延迟,所以先把任务捞回来才是第一要务。这里不建议直接把出问题的文件删掉就重启,因为该文件可能已经进入Hudi的文件系统视图(FileSystemView),删掉后clean/compaction可能继续引用它。
我当时做的事情,按顺序整理如下:
- 确认损坏文件范围:根据报错日志里的路径,用
parquet-tools批量检查同目录下所有文件。
bash复制for f in $(hdfs dfs -ls hdfs://.../ods/xxx/xxx/ | awk '{print $8}'); do
echo "checking $f"
hdfs dfs -cat $f 2>/dev/null | head -c 4 | xxd
done
- 把损坏文件备份到隔离目录:
bash复制hdfs dfs -mkdir -p /tmp/bad_parquet_backup/xxx
hdfs dfs -mv hdfs://.../ods/xxx/xxx.parquet /tmp/bad_parquet_backup/xxx/
- 回滚Hudi写入配置:把压缩codec改回snappy。
properties复制hoodie.parquet.compression.codec=snappy
- 重启Flink作业,从最近一次checkpoint恢复。
重启后,Flink会从checkpoint状态恢复,Hudi的写算子会重新尝试写入。由于损坏文件已经隔离,新数据写入通常能恢复正常。
3.2 清理文件系统视图的脏数据
文件移走后,还有一个关键操作:刷新Hudi表在文件系统层面的缓存和元数据。
操作命令:
bash复制# 触发Hudi表refresh
hudi-cli connect --path hdfs://.../ods/xxx/
在hudi-cli里执行:
code复制refresh table
show fsview all
如果发现文件系统视图里还带着被移走的parquet文件,需要手动触发一次clean:
code复制clean --clean-instants 5
clean操作会清理不再被引用、或者已经不在文件系统上的孤儿文件。做完这一步,读路径才有可能彻底不再引用坏文件。
注意:不要在任务写高峰期执行clean,否则可能因为HDFS文件删除和写入并发抢锁产生额外阻塞。我当时选择在业务低峰凌晨4点半执行,效果比较稳定。
3.3 修复已经损坏的历史数据
如果这个表的下游有明确的数据一致性要求,从上游重新拉取对应时间段的全部数据是最稳妥的。但对于实时链路,重跑历史数据往往会打乱顺序,所以我建议:
- 先用SQL查询当前表中受影响分区的记录数;
- 对比上游Kafka消息数,初步判断缺失量级;
- 对缺失消息,通过离线补数作业回填对应分区。
补数作业和实时作业如果写同一张表,建议开启Hudi的并发写冲突检测:
properties复制hoodie.write.concurrency.mode=optimistic_concurrency_control
hoodie.clean.fail_on_conflict=true
这样可以减少两个作业同时操作同一批文件时的互相踩踏。
3.4 最后才是考虑整体配置调优
任务恢复之后,解决完紧急问题,还得继续做系统性加固。我把当时验证有效的几项改动列出来,都是围绕根因做对应的。
核心改动表:
| 配置项 | 原值 | 修改后 | 目的 |
|---|---|---|---|
hoodie.parquet.compression.codec |
gzip | snappy | 避免非规范压缩导致的Parquet魔数异常 |
hoodie.metadata.enable |
false | true | 通过Metadata Table减少文件list时的底层I/O干扰 |
hoodie.clean.autoclean |
true | true(开启) | 保证文件系统视图及时同步 |
hoodie.embedded.timeline.server |
false | true | 缩短timeline的异步加载时间,降低空窗期 |
write.tasks |
4 | 6 | 增加写入并行度,降低单文件写超时概率 |
不夸张地说,把hoodie.metadata.enable打开这一步,后续文件list相关异常大量减少。但如果你要开这个参数,请先确保Hudi版本支持,且表结构可以被重建元数据,否则会遇到更多冷门问题。
3.5 配置修改的落地方式
Flink写Hudi时,配置项通过Flink的Configuration传入。你可以把这些配置加到作业的Hudi Sink连接器中:
java复制org.apache.hudi.common.model.HoodieTableType.COPY_ON_WRITE,
org.apache.hudi.configuration.OptionsResolver.resolveTableType(options)
通常做法是写在Flink SQL的WITH子句里:
sql复制CREATE TABLE hudi_sink (
...
) WITH (
'connector' = 'hudi',
'path' = 'hdfs://.../ods/xxx',
'hoodie.parquet.compression.codec' = 'snappy',
'hoodie.metadata.enable' = 'true',
'hoodie.write.concurrency.mode' = 'optimistic_concurrency_control'
);
改完配置后必须重启任务让参数生效。这里强烈建议先在一个测试表上用同样的数据量跑一遍,确认没有参数兼容性问题再上生产。
4. 从排障到预防:一套能长期防住此类问题的实践
4.1 用元数据表降低文件list压力
hoodie.metadata.enable这个参数值得单独讲一下。它的本质是把文件系统列表信息保存在Hudi自身的Metadata Table里,而不是每次读取都去HDFS上list目录。
为什么要关注这个?因为生产中出现is not a Parquet file这类异常的触发时机,往往发生在文件list完成、开始读文件内容的一瞬间。如果list阶段本身压力过大,物化到block读取阶段,一旦文件处于不完整写入状态,就非常容易报错。
启用元数据表后,文件list的稳定性明显提高,尤其是分区数多、小文件数量大的表。我自己测试过,分区数超过1000、小文件超过5000的场景,开启前后的文件扫描耗时差距能达到3到5倍。
不过要提醒一句:如果Hudi版本较老,Metadata Table在同步失败或某些异常场景下会导致写路径卡顿。新版本建议开启,老版本先认真测一轮再决定。
4.2 控制并发写入和文件清理的冲突
这类问题里,并发写入搞出来的脏文件不在少数。梳理一下常见的冲突场景:
- 流式作业和补数作业写同一张表;
- 两个流式作业从不同topic写同一张表,提交事务没有协调;
- 外部Spark作业也用Hudi写同一张表。
要防住这类问题,介质层有两个关键参数:
properties复制hoodie.write.lock.provider=org.apache.hudi.client.transaction.lock.InProcessLockProvider
hoodie.write.concurrency.mode=optimistic_concurrency_control
单作业内部并发,用InProcessLockProvider就够了;跨作业、跨计算引擎的并发,建议上基于Zookeeper或Hive Metastore的锁实现。
同时,把clean的触发频次做一下收敛,不要让它过于激进地删除旧文件。把下面的参数调大一点:
properties复制hoodie.cleaner.commits.retained=30
hoodie.cleaner.policy=KEEP_LATEST_COMMITS
hoodie.keep.min.commits=40
这样可以让文件过期保留的时间窗口变长,有效降低"读的时候文件刚被clean删掉"的概率。
4.3 Flink Checkpoint与Hudi Commit的协调问题
很多人忽略的一个坑是:Flink做checkpoint成功,不代表Hudi已经commit成功。Hudi的Flink writer是两阶段提交,第一阶段把数据写入文件并标记instant,第二阶段等checkpoint完成后才commit。
如果你在checkpoint完成但commit未完成时,强行重启并从checkpoint恢复,恢复后的writer会重新生成一批文件。这批新生成的文件和上一次写入未清理的文件如果路径冲突,就可能产生孤儿文件。
针对这个,建议:
- 不要用
--allowNonRestoredState跳过未完成的Hudi instant; - 作业重启时,确保上一个作业已经完全退出,最好用
yarn application -kill后再启动; - 如果重启频繁,可以把checkpoint间隔适当调大,比如从1分钟调到3分钟,给Hudi commit留下充足时间窗口。
4.4 监控和告警:在成为事故前拦截
这一步是纯粹的经验补充分享。等异常日志刷到ERROR级别再处理已经晚了,要在早期WARN阶段就捕获苗头。
我当时的做法是:
- 在日志采集平台配置关键字告警:
Failed to read block、ParquetDecodingException、is not a Parquet file; - 对Hudi表的
.hoodie目录大小和instant数量做指标监控; - 每个小时跑一次文件完整性检查任务,扫描最近10分钟新建的parquet文件魔数。
这个完整性检查任务其实很简单,核心逻辑就是扫描最新分区目录下的文件,读前4字节判断是不是PAR1。不占用太多资源,却能把很多问题拦截在告警阶段。
5. 常见问题速查表与避坑记录
5.1 问题快速定位表
| 现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
xxx.parquet is not a Parquet file |
文件整体被外部压缩 | head -c 4查看魔数 |
关闭全局压缩过滤器,统一Hudi内部压缩codec |
| 偶发,重启后恢复 | 并发clean/rollback误删文件 | 查看Hudi timeline | 开启并发冲突检测,保留更多commits |
| 固定文件报错 | 写入过程中文件被截断 | 检查文件大小和对应instant状态 | 隔离坏文件,从上游补数 |
| 任务频繁重启后必现 | Flink checkpoint和Hudi commit冲突 | 比对checkpoint和instant时间线 | 修改checkpoint间隔,确认作业完全退出再重启 |
| 开启元数据表后出现异常 | Metadata Table损坏或版本Bug | 查看metadata目录日志 |
关闭元数据表,清理metadata目录后重新开启 |
5.2 几个值得记在笔记里的避坑经验
第一个经验:拿到Parquet异常时,第一反应应该是看文件魔数,而不是改代码。文件系统层面几秒钟就能确认的事情,花半小时去查Flink算子代码完全没必要。
第二个经验:生产改动任何压缩相关参数前,先扫一遍上下游作业对文件格式的假设。Hudi写出来的parquet文件,下游可能直接用Spark或者Hive读取,它们对压缩codec的兼容性都不一样。Hudi内部用gzip没问题,但下游老版本Hive如果配置不对,照样会误判。
第三个经验:尽量把任务配置和集群默认配置隔离干净。不要依赖集群层面的io.compression.codecs隐含行为,把所有跟写入格式相关的参数都显式配置在作业的WITH子句里。这样即使集群升级或者服务端配置变更,也不会默默影响你的写入链路。
第四个经验:保留现场比修复现场更重要。出了问题,先把日志、文件头信息、元数据目录、配置文件留存一份,再去动手修数据。否则后续复盘时你会发现关键证据已经被自己的修复动作覆盖了。
5.3 一个容易被忽略的小细节
最后分享一个细节。当时报错信息里路径是带滚动文件编号的,类似2025xxxxxxxxxx_0_xx-xx-xx_000.parquet。Hudi对同一批次数据写入多个文件,每个文件对应一个write token。如果你排查时发现只有其中一个固定编号的文件有问题,而其他文件正常,那么大概率不是全局压缩配置的问题,而是单个task在写入过程中被中断导致的脏文件。
这种时候直接看Flink task的生命周期日志,找到那个task挂掉的时间点,再和文件创建时间比对,基本就能实锤。
结语的另一种写法:这次排障留给我的几个判断
现在回头看,这个问题的核心其实不在于"把文件结构修复好",而在于写入链路里不同组件的隐含假设发生了冲突。Hudi默认认为它写出来的文件是标准Parquet,存储层认为它可以对任何文件做整体压缩,Flink checkpoint机制又认为只要checkpoint成功数据就安全。三个系统各自的"正常",叠加起来就成了生产事故。
所以在后续的项目里,我会刻意关注跨组件的"默认值"交接点。Hudi配置表里的每个默认参数不只是一个选项,它背后隐含了底层文件系统行为和计算引擎恢复机制的约定。多想一层为什么,能少踩很多坑。
这篇排障记录里的所有操作和参数我都基于一次真实事故还原过。环境不同、版本不同,具体参数值可能需要微调,但排查思路和预防策略是通用的。希望下次你看到is not a Parquet file时,能少一点慌乱,快一点定位。
