凌晨两点整,值班群里的报警先响了。Flink On Hudi的实时入湖任务,跑着跑着突然整条链路卡死,翻开TaskManager堆栈,一堆xxx.parquet is not a Parquet file刷屏。窗口函数还挂在那边,下游Kafka消费者水位上涨,再拖二十分钟报表就要迟到。这个是生产环境最典型又最容易被低估的一类坑:不是语法错误、不是网络抖动,而是文件系统上躺着一个“看起来叫parquet、实际已经废掉”的残留文件。
今天把这次排障的完整过程拆开揉碎讲清楚。从报错本身开始,到Parquet格式校验原理,再到Hudi在Flink写路径上为什么会产生坏文件,最后给出一套能直接抄作业的修复方案和生产配置。不管你是刚接手Hudi管道,还是已经被这个报错折磨过两次,这篇文章都能让你少走几趟弯路。
1. 问题现象与项目背景复盘
1.1 报错现场与任务拓扑
先说当时的链路结构:MySQL Binlog -> Flink CDC -> Hudi(Copy On Write表)-> Hive外表查询。Flink任务用Hudi的StreamWriteFunction持续写入HDFS(测试环境是HDFS,生产上是OBS),每5分钟做一次checkpoint,每30分钟触发一次commit。任务本身跑了快一个月,数据量不大,单表每天三四千万条增量,平时很稳。
故障发生那晚没有任何代码变更,也没有扩缩容操作。突然之间,TaskManager开始批量报错,每条错误都长这样:
text复制java.io.IOException: org.apache.hadoop.hive.ql.io.HdfsUtils$HdfsFileStatus
at org.apache.hudi.hadoop.HoodieParquetInputFormat.getSplits(...)
Caused by: java.io.IOException: xxx.parquet is not a Parquet file. expected magic number at tail, but found none
这个xxx.parquet是某个分区下的数据文件,文件名里还带着Hudi生成时特有的instantTime标记。乍一看以为是Hive读取端解析问题,但仔细看堆栈,底层是org.apache.hudi.hadoop包里的输入格式,说明确实走到了Hudi自身文件读取路径。
问题的影响范围不是单个文件,而是整个分区没法读。一旦读取端扫描到坏文件,整个split计算直接失败,下游所有查询这个分区的任务全部白给。实时报表连续报错,离线ETL读取同一张表也是同样的异常。
1.2 为什么这个错误特别棘手
is not a Parquet file这个报错看起来很直白,但实际上很绕。Parquet文件在格式设计上同时具备头尾魔数校验,头部和尾部都有PAR1四个字节。Hive和Spark在读取时,优先校验开头魔数;Hudi的自定义InputFormat走了一次更严格的解析,如果文件长度不够、头尾缺失、或者中间元数据损坏,都会抛出这个异常。
麻烦的地方在于:这个错误是“读取侧”发现的,也就是说文件可能早就坏掉了,只不过等到某个读取任务扫到它才爆出来。它不像写数据时的OOM或者网络超时有明确的触发时机,而是像一颗地雷,埋在那里,等你踩。
排查时最怕的就是这种“迟到的错误”。你查Flink任务日志,发现写端一切正常,checkpoint全部完成,没有反压,没有失败。但文件系统上已经堆了一批非法文件,而Hudi的timeline上这些文件对应的instant还被打上了completed标记。两边状态不一致,就是这类故障的核心症结。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 根因分析:一个不存在的parquet文件是怎么被写出来的
2.1 Parquet文件格式与魔数校验原理
先补充一段必要的格式背景。Parquet文件不是普通的二进制流,它的结构分四层:文件头(Header)、行组(Row Groups)、列元数据(Column MetaData)、文件尾(Footer)。
其中文件头就是PAR1四个字节,固定不变;文件尾也是PAR1,紧跟在Footer后面。读取器在解析时,为了快速判断文件是不是Parquet,会先读文件尾部的4个字节校验;如果文件是流式写完但没同步元数据,尾部缺失;如果文件完全没写进去,头部连PAR1都没有。
is not a Parquet file本质上就是校验失败的信息。而检查文件是否合法,最直接的办法是看文件大小:
bash复制hdfs dfs -ls /user/hudi/db/table_name/partition=2024-06-18/
-rw-r--r-- 3 flink hudi 0 2024-06-18 01:30 /.../xxx-6de2e67b-.parquet
-rw-r--r-- 3 flink hudi 483M 2024-06-18 01:30 /.../xxx-8d0e4f23-.parquet
如果看到0字节或者几KB的极小型parquet文件,基本可以断定是写入过程中途中断、flush没有执行完整导致的残片。FileSystem上这些文件是真实存在的,但对读取端来说,它们和不存在没有区别。
2.2 Hudi写文件机制与Flink Checkpoint耦合
Hudi在Flink上的写入路径是:FlinkWrite -> BucketAssigner -> FileId -> ParquetWriter。核心特点是,文件不是在每条记录写入时直接提交,而是在checkpoint触发时执行flush并生成一个新的数据文件。
这个机制本意是为了容错:checkpoint成功之后,这批数据才能在Hudi的timeline上生成一个instant(commit)标记。但问题就藏在“异步”和“中断”的边界里。
当Flink任务发生重启(JobManager切换、failover、手动cancel后resume),如果上一个checkpoint尚未完成,或者flush只做了前半段——文件刚创建、还没写入任何行组——那么HDFS上就会留下一个0字节或者半截的parquet文件。这个文件不会被Hudi的cleaner自动清理,因为它不在commit对应的文件列表里,也没有对应的文件切片状态。它就像孤儿一样待在分区目录下。
等下一次读取任务启动时,InputFormat扫描目录,发现这个文件,尝试按Parquet格式解析,结果头部魔数、Footer全不对,果断抛出not a Parquet file。
2.3 三种最容易踩中的场景
第一个场景就是我们这次遇到的,Flink任务failover后残留半截文件。触发方式是JobManager发生一次主备切换,或者某个TaskManager被YARN秒杀。文件系统上多出来了孤儿文件,但任务本身恢复了,数据也没丢,坏文件就成了定时炸弹。
第二个场景是并发写同一张Hudi表。实时任务在写,离线批量任务也在用同一个表路径做upsert。两个任务各自生成fileId,但中间如果发生了小文件合并(clustering)或者cleaner操作,一个任务还在写的文件被另一个任务处理掉了,读取时就找不到正确的parquet文件。
第三个场景是对象存储与HDFS的语义差异。生产环境如果用S3或者OBS作为Hudi的存储层,注意对象存储的目录一致性是“最终一致”的。写入端刚flush完一个文件,读取端立刻扫描,可能看到的是一个中间态。我实测遇到过S3上文件大小为0、但过了几秒才正常的情况——这个不是S3的bug,而是list操作的缓存延迟。如果下游查询正好卡在那个窗口期,也会报同样的错误。
3. 一次生产排障的完整实操复盘
3.1 日志定位与路径还原
当时我拿到报警后的第一件事,不是去看Flink checkpoint状态,而是先定位这个xxx.parquet文件到底在哪。之前吃过亏,知道光靠堆栈里的文件名搜日志不靠谱——TaskManager一重启,日志滚动,文件名还在,路径已经找不到上下文。
正确做法是,先把异常堆栈里的完整路径拼出来。
bash复制grep "is not a Parquet file" -r /data/flink/logs/taskmanager/*.log | tail -20
拿到文件的绝对路径之后,在HDFS上查看文件真实状态:
bash复制hdfs dfs -ls /user/hudi/db/table_name/partition=2024-06-18/
hdfs dfs -du -h /user/hudi/db/table_name/partition=2024-06-18/xxx.parquet
关键信息是看文件大小。下面的结果基本宣告了问题性质:
text复制0 /user/hudi/db/table_name/partition=2024-06-18/xxx-6de2e67b-.parquet
0字节。一个合法的parquet文件,最小也有几百字节的footer元数据。0字节文件只可能来自未flush的writer。
再用xxd看一眼前16字节,确认它完全不是Parquet格式:
bash复制hdfs dfs -cat /user/hudi/db/table_name/partition=2024-06-18/xxx.parquet | xxd | head -1
输出为空,文件里面什么都没有。这就是一个纯空壳文件。
3.2 时间线与Hudi元数据核验
有经验的同学都知道,光找到坏文件还不算完,得搞清楚它是哪一次commit里被“标记成功”的。Hudi的每次写入都会在.hoodie目录下生成一个instant文件,里面记录了这次提交涉及的文件列表。
bash复制hdfs dfs -ls /user/hudi/db/table_name/.hoodie/
hdfs dfs -cat /user/hudi/db/table_name/.hoodie/20240618013000.commit | head -50
对比发现,那个坏文件xxx-6de2e67b-.parquet在commit文件里根本不存在。这说明什么?说明它没有被纳入任何一次完整的commit。它是Flink写入过程中创建、但还没来得及提交就被打断的临时文件。
Hudi的cleaner清理策略是“清理已提交instant对应的旧文件版本”,孤儿文件不在它的管辖范围内。所以文件躺在那里多久都不会被自动删除。
3.3 最终定位:孤儿文件如何引发全线故障
排查到这里,根因已经浮出水面。
时间线是这样的:凌晨1点30分左右,Flink JobManager发生一次failover。在此期间,某个BufferedWrite已经生成了一个可用的fileId,并开始在partition=2024-06-18目录下写出xxx-6de2e67b-.parquet。文件刚创建完,还没写入任何数据,JobManager切换导致writer进程被杀。
恢复后,Flink从最近一次成功的checkpoint恢复。由于上一个checkpoint里这个fileId尚未提交,新任务重新分配了写入路径,不再使用这个fileId。于是,原文件保留在文件系统上,成为孤儿。
1点45分,实时报表任务读取这个分区,Hudi InputFormat扫描所有parquet文件,尝试打开这个0字节文件,校验魔数失败,抛出异常。由于报表查询的split计算是整个分区级的,单个坏文件直接拖垮整个查询。
此后的多次重试全部失败在同一个文件上,形成死循环。数据本身没丢,checkpoint里数据都在,但文件系统上的垃圾文件成了路障。
4. 解决方案与可落地的修复动作
4.1 应急恢复:先移除路障再重启
遇到这个报错,最直接有效的第一步是把坏文件从分区目录中移走,而不是删除。我当时是先把它改名为xxx.parquet.bak,放到一个单独的quarantine/目录下。
bash复制hdfs dfs -mkdir -p /user/hudi/db/table_name/quarantine/
hdfs dfs -mv /user/hudi/db/table_name/partition=2024-06-18/xxx-6de2e67b-.parquet /user/hudi/db/table_name/quarantine/xxx-6de2e67b-.parquet
这样做的好处是:如果后续需要排查为什么会产生这个文件,还有现场可查;同时读取端立刻恢复,不用等待文件系统刷新。
移动之后,报表查询立即重试,任务恢复正常。这个操作耗时不到两分钟,属于标准的“先止血再治病”。
对于有多个坏文件的情况,可以写一个简单的Shell脚本批量处理:
bash复制hdfs dfs -ls /user/hudi/db/table_name/ | grep "is not a Parquet file" > /tmp/bad_files.txt
# 根据日志里的文件名,批量mv到quarantine目录
注意,不要直接在任务还在跑的时候执行hdfs dfs -rm。万一那个文件正处于写入中(不是孤儿而是正在写的新文件),删除会造成正在运行的writer异常。先mv到隔离目录是更稳妥的策略。
4.2 参数调优:让Hudi自己清理孤儿文件
手动移走只是治标。真正要解决的是让Hudi在正常运作中就能处理这类残留文件。
这里要说一个被很多人忽略的参数:hoodie.cleaner.commits.retained。默认值是10,意思是cleaner只清理最近10个commit之前的旧文件版本。但孤儿文件的问题在于,它不在任何commit里。单纯调大这个值没有用。
更有效的方案是启用Hudi的metadata table,以及设置hoodie.clean.automatic=true之外,还要设置一个合理的hoodie.cleaner.policy为KEEP_LATEST_COMMITS,并让cleaner能识别“文件不在任何commit中且时间超过阈值”的情况。实际上,Hudi从0.11开始对孤儿文件清理提供了hoodie.cleaner.orphan.file.clean配置项(按版本不同可能略有差异),你可以在Flink写数据时通过hoodie.datasource.write.hive_style_partitioning配合启用。
在我的生产配置里,这几项是必加的:
properties复制hoodie.clean.automatic=true
hoodie.cleaner.policy=KEEP_LATEST_COMMITS
hoodie.cleaner.commits.retained=30
hoodie.cleaner.orphan.file.clean=true
hoodie.cleaner.orphan.file.keep.days=1
orphan.file.clean会定期扫描文件系统,把不属于任何commit且超过保留时间的文件清理掉。这个配置在Hudi 0.12之后的版本里已经比较稳定,建议直接打开。
4.3 读取侧规避:在查询入口加一道防火墙
即使写了端不出孤儿文件,也不能保证别人手滑没关并发写入。读取侧加一道防护是最后一道保险。
如果你是用Hive/Spark读取Hudi表,可以在分区扫描前做一次文件大小校验。对于Hive,写一个UDF或者在HiveSQL里过滤掉0字节文件不是特别现实,更通用的做法是写一个简单的Spark任务来检测坏文件:
scala复制val df = spark.read.format("parquet")
.option("basePath", basePath)
.schema(extractedSchema)
.load(partitionPath)
// 手动遍历文件,过滤大小等于0的parquet文件
但说实话,生产环境中我更推荐用Flink的侧输出流(side output)思路来处理:在写入链路中,Flink的Hudi Connector本身具备write.operation参数,你可以把insert改成upsert,同时打开hoodie.write.auto.commit=false,这样commit动作显式由checkpoint控制,减少半截文件的产生。
4.4 数据补偿:坏文件里到底有没有数据
这部分是很多人忽略的。0字节文件确实是没数据,但还有一种情况:文件里有一部分数据,但footer缺失。这种情况下,读取端同样会报not a Parquet file,但文件里的数据不能直接丢掉。
我原来遇到过同批次写坏的文件里包含几万条已提交的记录。处理方式是用hdfs dfs -cat把文件拉下来,用parquet-tools尝试恢复元数据。不过这个恢复操作限制很多,仅限没有压缩且行组完整的场景。更稳妥的做法是:
- 从Flink checkpoint里找到该fileId对应的数据
- 用离线批任务重新写一份数据到Hudi
- 依赖Hudi的主键upsert去重,把重复数据覆盖掉
如果坏文件较大且含有效数据,务必先保留现场,不要急着删除。多数情况下,Hudi的KeyGenerator能根据主键自动去重,重放数据不会导致重复记录。
5. 防止再次踩坑:生产配置与监控告警
5.1 一套稳妥的Hudi+Flink表参数配置
下面给出一套我在生产上验证过的Hudi表配置,专为Flink实时写入场景优化。按这张表的参数设置,可以显著减少孤儿文件和半截文件出现的概率。
| 配置项 | 推荐值 | 用途说明 |
|---|---|---|
hoodie.clean.automatic |
true |
自动触发cleaner |
hoodie.cleaner.policy |
KEEP_LATEST_COMMITS |
保留最近N次commit,清理旧版本 |
hoodie.cleaner.commits.retained |
30 |
与checkpoint频率匹配,避免清理过快 |
hoodie.cleaner.orphan.file.clean |
true |
清理不属于任何commit的孤儿文件 |
hoodie.cleaner.orphan.file.keep.days |
1 |
保留24小时之后再清理,留排查时间 |
hoodie.archive.auto.archive |
true |
自动归档旧的instant元数据 |
hoodie.archive.commits.retained |
20 |
元数据归档后,保留最近20次commit的信息 |
write.operation |
upsert |
写操作模式,主键存在则更新 |
write.auto.commit |
false |
由Flink checkpoint控制commit时机 |
compaction.async.enable |
false |
MOR表下可开启;COW表保持关闭 |
parquet.page.size |
8388608 |
Parquet页大小,默认即可 |
注意hoodie.cleaner.orphan.file.clean在早期版本里未必有同名参数,如果你的Hudi版本较老(0.11以下),升级版本或者用自定义清理脚本替代。
5.2 监控与告警点位部署
这类故障最坑的不是修文件,而是你不知道文件什么时候坏。所以监控这一步必须前置。
首先是粒度监控:对Flink任务,监控checkpoint的成功/失败次数、commit延迟、以及HoodieWriteClient的flush耗时。Hudi自带一些Metrics,但默认不全部开启。可以在Flink提交命令里加上:
bash复制-Dyarn.tags=hudi_metrics
-Dmetrics.reporter.prometheus.class=org.apache.hudi.metrics.custom.HoodiePrometheusReporter
定期抓flush duration和commit duration,如果某次flush耗时骤降,说明writer可能异常退出。
其次是文件系统层面的巡检:每天凌晨跑一个批任务,扫描Hudi表目录下所有parquet文件,过滤大小小于1KB的文件,输出告警。这个脚本很简单,但能提前暴露孤儿文件。
bash复制hdfs dfs -ls -R /user/hudi/db/table_name | awk '$5 < 1024 {print $8}' | grep ".parquet"
最后是读取侧健康检查:对核心实时报表,把查询改成先做一次“开放式文件预览”,也就是只读取parquet footer不读行组。如果footer解析失败,立即pager到值班人员。这个可以用parquet-tools写成定时任务:
bash复制parquet-tools meta /user/hudi/db/table_name/partition=2024-06-18/xxx.parquet > /tmp/meta.txt 2>&1
如果meta命令报错,说明文件已损坏。
5.3 操作习惯:上线前必做的三项检查
亲身踩过几次坑之后,现在每次新接入一张Hudi表,我都强制自己过一遍三件事。
第一,确认cleaner配置不是默认值。默认配置很多字段是偏向离线批量的,如果用在实时写入上,cleaner可能会清理掉正在被读取的文件版本。第二,确认并发写路径。同一张Hudi表不允许两个Flink任务同时写,除非你开启并发写并且指定不同的写入bucket。第三,确认checkpoint与commit的关联方式。write.auto.commit=false是实时场景的标准配合,不要为了省事改成true。
这些检查和排障没有直接关系,但往往能提前弹出很多潜在问题。生产环境求的不是不报错,而是报错后能在最短时间内止血。上面这套组合,目的就是让“坏文件”这类问题快速暴露、快速定位、快速隔离。
最后分享一个小工具用法。排查Hudi相关问题,我习惯先把.hoodie目录下的timeline导出到本地,用文本对比的方式快速确认文件归属。
bash复制hdfs dfs -copyToLocal /user/hudi/db/table_name/.hoodie /tmp/hudi_timeline_$(date +%s)
grep "xxx-6de2e67b" -r /tmp/hudi_timeline_*/
看一眼这个fileId在哪些instant中出现过、有没有对应的commit记录,比在Flink日志里大海捞针快得多。这个习惯我保留了很久,每次排障都能省下至少半小时。
