做数据湖相关开发的同学,应该都对这类报错不陌生:某天凌晨,业务方突然扔过来一条查询失败的消息,说 SQL 跑不动了,报错是 xxx.parquet is not a Parquet file。第一次见到这个提示,我也愣了一下——Parquet 文件不是 Parquet 文件,这是什么鬼?后来在生产上踩了几次坑,才真正摸清这条报错背后的所有可能原因。
这个报错在 Flink + Hudi 的链路里尤其常见。Hudi 底层数据文件用 Parquet 格式存储,Flink 负责把流式数据写进 Hudi 表,当某个 Parquet 文件的头部或尾部校验信息丢失、损坏时,查询引擎(比如 Spark SQL、Flink SQL、Presto)在读取阶段就会直接抛出这个异常。问题一旦出现,往往会影响一整张表的查询,甚至拖垮下游的数仓任务。
这篇文章我会完整复盘这类问题的排查思路:从报错的第一现场,到文件校验、根因定位、数据修复,再到预防措施。内容偏实操,适合正在维护 Flink + Hudi 生产管线的数据工程师、数据平台开发,以及刚接触数据湖、想提前避坑的同学阅读。
1. 现象描述与报错初判
1.1 报错出现的完整场景
先还原一下我遇到的实际情况。
某天凌晨,一个 T+1 的离线报表任务突然失败,调度平台上的日志显示,SQL 引擎在读取 Hudi 表某个分区时抛出了异常。核心报错信息就一句话:
code复制java.io.IOException: xxx.parquet is not a Parquet file. expected magic number at tail [80, 65, 82, 49], but found [0, 0, 0, 0]
这里要解释一下 Parquet 文件的结构。一个合法的 Parquet 文件,开头 4 个字节是魔数 PAR1,结尾 4 个字节也是 PAR1。读取引擎打开文件时,会先校验文件头部的魔数,再跳到文件尾部校验页脚(Footer)位置的魔数。如果尾部 4 个字节不是 PAR1,就会抛出 “not a Parquet file” 的异常。
我当时这个案例里,报错明确指出 expected magic number at tail [80, 65, 82, 49](对应 ASCII 码就是 PAR1),但实际读到的是 [0, 0, 0, 0]。这说明文件尾部直接是 4 个空字节——文件压根没写完,或者被截断了。
不是所有同类报错都长一个样。我整理了一下,常见变体有这几种:
| 报错类型 | 说明 |
|---|---|
xxx.parquet is not a Parquet file. expected magic number at tail ... |
文件尾部魔数不对,多见于文件未写完、被截断、或写入进程崩溃 |
xxx.parquet is not a Parquet file. expected magic number at head ... |
文件头部魔数不对,多见于文件被覆盖、HDFS 块损坏、或文件路径指向了非 Parquet 内容 |
Not a Parquet file: xxx.parquet |
短报错,通常是文件头校验失败 |
Parquet file is corrupted |
更泛化的损坏提示,可能是页脚解析失败或列块元数据错乱 |
不同变体指向的问题源头不一样,排查方向也会有差异。但第一步永远是定位——到底是哪个文件出了问题,文件落在哪个分区、哪个 FileGroup 下。
1.2 报错信息的正确阅读姿势
很多人拿到这条报错就慌了,第一反应是去重跑任务。但在生产环境里,重跑之前必须先想清楚一个问题:这个文件是当前任务刚写出来的,还是历史分区里已经存在的? 这两个场景的处理方式完全不同。
如果是当前任务写入过程中报错,大概率是写链路的问题;如果是查历史分区报错,那可能是更早的数据就已经损坏了,只是之前一直没有被读到而已。
我当时拿到报错后,先做了三件事:
- 从调度平台找到失败任务的日志,确认报错来自哪个引擎(Spark SQL / Flink SQL / Presto),以及读取的是哪张 Hudi 表的哪个分区。
- 从报错信息里提取出完整的文件路径。注意,生产环境的报错有时候会被截断,日志里只显示了文件名
xxx.parquet,没有完整路径。这种情况下,需要结合任务运行的 SQL 手动拼出完整路径,或者去 YARN 的日志里搜完整堆栈。 - 确认表类型。是 COW(Copy On Write)还是 MOR(Merge On Read)?这个信息很关键,因为 MOR 表的 base 文件和 log 文件读取路径不一样,排查侧重也不同。
做完这三步,才算是把“第一现场”固定住了。接下来才能开始真正的排障。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 排查链路:从 SQL 到 HDFS 文件逐层定位
2.1 第一步:确认表路径与文件清单
我这里的实际案例是 COW 表,Flink 流式写入,分区字段是日期,路径格式类似:
code复制hdfs://nameservice/warehouse/hudi_db/hudi_table/dt=2024-06-17/
先用 HDFS 命令列出目标分区下的所有文件:
bash复制hdfs dfs -ls hdfs://nameservice/warehouse/hudi_db/hudi_table/dt=2024-06-17/
正常情况下,这个目录下应该是一堆 .parquet 文件,外加一个 .hoodie 隐藏目录。Hudi 的元数据(commit 时间线、file group 映射关系)都存在 .hoodie 目录下,排查问题的时候经常要用到,后面会细说。
列出来的文件如果和我预期一致,下一步就是找到报错里那个具体的 xxx.parquet 文件,看它的文件大小、修改时间、副本状态:
bash复制hdfs fsck hdfs://nameservice/warehouse/hudi_db/hudi_table/dt=2024-06-17/xxx.parquet -files -locations -blocks
fsck 能告诉我们文件有没有块缺失、副本数是否正常。有时候文件本身没问题,但 HDFS 底层某个块损坏了,读取时会报类似错误。我遇到过一种情况:文件头尾魔数都正常,但中间某个列块的字典页损坏,报错反而更像 Parquet file is corrupted。所以先跑 fsck 排除 HDFS 层面的问题,能省下后面很多排查时间。
2.2 第二步:文件头字节级校验
如果说 fsck 是宏观检查,那接下来这步就是微观取证了。直接用 xxd 或 od 查看文件头部和尾部各 16 个字节的十六进制内容:
bash复制# 查看头部 16 字节
xxd -l 16 hdfs://nameservice/warehouse/hudi_db/hudi_table/dt=2024-06-17/xxx.parquet
# 查看尾部 16 字节
xxd hdfs://nameservice/warehouse/hudi_db/hudi_table/dt=2024-06-17/xxx.parquet | tail -1
或者用 Python 脚本一次性拉取:
python复制import subprocess
path = "hdfs://nameservice/warehouse/hudi_db/hudi_table/dt=2024-06-17/xxx.parquet"
head = subprocess.check_output(["hdfs", "dfs", "-cat", path]).decode("latin1")
不过生产环境文件可能很大,直接 -cat 拉全量不现实。更高效的做法是用 hdfs dfs -tail 只取尾部若干字节:
bash复制hdfs dfs -tail 16 hdfs://nameservice/warehouse/hudi_db/hudi_table/dt=2024-06-17/xxx.parquet | xxd
如果尾部确实是 [0, 0, 0, 0],那基本坐实了文件没写完。但要注意一个细节:HDFS 命令行工具读取文件时,会按块的粒度读取。如果一个 HDFS 块只有部分副本是完整的,也可能读到一半失败。所以我一般习惯直接确认文件在 HDFS 上的具体块信息,再用 hdfs dfs -fetch -block 把单个块拉到本地做离线校验。
2.3 第三步:回溯任务日志找第一现场
文件层面的取证做完,就要回到任务日志里去找“文件是在什么状态下被写出来的”。
Flink 写 Hudi 的链路里,最常见的现场就是:某个 Flink checkpoint 超时,或者 sink 算子所在的 TaskManager 发生了 OOM、被杀、被滚动重启。这种情况下,Hudi sink 正在写的 parquet 文件可能只写了 80% 就中断了,文件尾部自然不是 PAR1。
我当时回溯的路径是:
- 去 YARN ResourceManager 页面找到对应的 Flink 作业。
- 找到作业的 TaskManager 日志,搜索报错前最后一次写入
xxx.parquet的记录。 - 关注关键词:
checkpoint、Exception、TaskManager exited、Container killed、OutOfMemory。
这里有一个排障中非常容易忽略的点:Flink 作业可能因为反压导致 checkpoint 一直失败,最终由 checkpoint.timeout 参数触发了作业失败。但作业失败后,Hudi 的写入端并没有强制关闭正在写的文件,于是这个半拉子文件就被留在了 HDFS 上。如果这时候恰好有其他查询来读这个文件,就会看到 “not a Parquet file”。
另外还有个隐藏场景:Flink 的 Hudi sink 在做小文件合并(Clustering / FileSizing)时,会把多个小 parquet 文件重写为一个大文件。重写过程中如果任务失败,新文件可能没写完,旧文件却被标记为删除。这种场景下,文件损坏的表象背后还有一层元数据不一致的问题——Hudi 的时间线上已经写入了新的 commit 元数据,但实际数据文件并不完整。
3. 根因深挖:常见诱因与判定方法
排查链路走完,我们往往会发现文件确实坏了。但文件坏了只是结果,真正要解决的是“为什么会坏”。我总结了生产环境最常见的四类诱因。
3.1 诱因一:写任务异常退出导致半写入文件
这是占比最高的一类。任务异常退出的原因五花八门:
- TaskManager 进程被 YARN 杀掉(container 超过内存限制、节点宕机)
- Flink 作业因为 checkpoint 失败次数超过阈值而自动失败
- 用户手动停止作业时没有触发 graceful stop,直接 kill 了 YARN application
- 下游 HDFS 集群短时间内大量写入导致 NameNode 高负载,写入 RPC 超时
无论哪种原因,Flink 的 Hudi sink 在写入新文件时,文件生命周期是这样的:
- sink 往 HDFS 上创建一个临时文件,路径通常是
.hoodie/.temp/下,文件名带有 instant 时间戳和 taskID。 - 文件写满或达到滚动阈值后,commit 阶段才把临时文件 move 到正式分区目录下。
- 如果 commit 没有成功,临时文件可能残留,但更麻烦的是——有些版本里,文件在未 commit 但已 flush 的情况下,会被直接 rename 到正式目录,元数据里却没有对应的 commit instant。
这种“文件已就位但元数据缺失”的情况,最坑的地方在于:文件看起来在正式路径下,实际内容却可能是残缺的。查询引擎扫描 Hudi 表时,通过 Hudi 的元数据索引找到这个文件,一读就炸。
判定方法也比较直接:用 Hudi CLI 查看指定分区下所有文件组和时间线,看这个 xxx.parquet 对应的 commit 是否存在。
bash复制# 以 Spark 为例
spark-shell --packages org.apache.hudi:hudi-spark3-bundle_2.12:0.14.0
然后执行:
scala复制import org.apache.hudi.func.HoodieFunctions
val basePath = "hdfs://nameservice/warehouse/hudi_db/hudi_table"
或者更实际的方式,直接用 Hudi 元数据表查询:
bash复制hdfs dfs -ls hdfs://nameservice/warehouse/hudi_db/hudi_table/.hoodie/
目录下会有 hoodie.properties、timeline/、.commits 等元数据。对比文件最后修改时间和时间线里的 commit 时间,基本能判断出问题。
3.2 诱因二:Hudi 与 Flink 版本兼容性
这个诱因容易被忽略,因为它不是每次都触发,只在特定条件下才出现。
Flink 的 Hudi connector 对不同 Flink 版本(1.12、1.13、1.14、1.15 等)是分别编译的,底层调用的执行逻辑有差异。如果业务侧在升级 Flink 版本后,没有同步升级 Hudi connector,或者同时部署了多个 Hudi 版本但 classpath 冲突,就可能出现:
- 数据写入时使用的 Hudi 内部类序列化方式变化,导致某个文件被错误标记
- 在老的 Flink 版本上运行新 Hudi connector,写入的 parquet 文件页大小、压缩方式不兼容
这类问题在日志里往往能看到 ClassNotFoundException 或 NoSuchMethodError,但也可能只在读取时才表现为 “not a Parquet file”。
判定方法很简单:检查作业的 pom.xml 或 lib/ 目录,确认 Hudi bundle 版本和 Flink 版本匹配关系。另外一个更隐蔽的场景是同一个集群上跑了多个 Flink 作业,各自带了不同版本的 Hudi 依赖,而某些引擎的依赖隔离没做好,出现了类互相污染。
3.3 诱因三:并发写表与文件切换竞态
这个坑和写入方式有关。Hudi 表同时被两个或以上的 Flink 作业写入时,比如一个作业写实时数据,一个作业写离线补数任务,如果两个作业配置了相同的 write pool 或使用了相同的 fileID 分配逻辑,就可能出现:两个写入端同时向同一个 FileGroup 追加数据,一个正在写,另一个已经开始重新分配文件并执行切换,最终落盘的文件被部分覆盖。
这里要理解 Hudi 的一个机制:Hudi 的每个 FileGroup 有唯一的 FileID,写入端拿到 FileID 后向其中追加 base file 或 log file。并发写同一个 FileGroup 时,如果 compaction 或 rollback 逻辑没有正确上锁,就可能导致文件被误删或在写入中途被 rollback。
这类问题在日志里往往能看到多位 taskmanager 同时写同一个 xxx.parquet 的迹象。排查时可以看两个作业是否使用了同一张表,以及 Hudi 表是否开启了多写入并发,Spark 引擎下可以通过检查 Spark SQL 的并发度配置来判断。
判定方式:先看 Hudi 表的 .hoodie/timeline 目录下同一时间段内是否有多个不同 INSTANT 同时在写,再看 Flink 作业配置里的 write.operation 和表 schema 是否一致。
3.4 诱因四:小文件合并(Clustering/Compaction)操作冲突
这条主要针对 MOR 表和开启 Clustering 的 COW 表。
Hudi 的 FileSizing 或 Clustering 会在后台启动任务,把小 parquet 文件合并成大文件。合并过程中,如果同时有 Flink 实时任务在向同一个 partition 写入新数据,新数据和合并任务操作同一个 FileGroup 时可能发生竞争。最典型的结果:
- 合并任务完成,commit 生效,实时任务还在用旧 FileID 继续写,写出来的新文件被下一个 cleanup 当临时文件清掉了,但下一个 commit 还在事件上挂着
- 或者反过来,实时任务已经写了新文件,合并任务基于旧快照做 rewrite,新文件覆盖旧文件的路径,恰好在覆盖瞬间文件是空的
这类问题最难查,因为它不是每次发生,而且报错的文件名可能很新,也可能很旧。
排查时可以重点看文件对应的 commit 时间,再对照同一分区下其他文件的大小分布。如果某个文件明显小于其他同目录文件(比如几十 KB 对几百 MB),基本可以判断它是个合并中断产生的残留。
4. 修复操作与数据抢救
定位到根因之后,修复方案分两步走:先把数据恢复回来,再把任务拉起来。顺序不能反,因为如果任务已经失败,重启后 Flink 会从最近成功的 checkpoint 恢复写入状态,可能再次操作到损坏文件所在的 FileGroup。
4.1 定位损坏文件并隔离
在确认文件真的损坏后,第一步是把损坏文件从读取路径上隔离出去,防止它继续阻塞查询。但要注意,隔离不是直接把文件删掉,而是先确认这个文件的元数据状态。
如果是未 commit 的半成品文件,安全的做法是用 Hudi 的 rollback 机制清理。注意清理前必须用干净的状态目录备份,避免误删有效数据。
先查看当前 inflight 的 commit 列表:
bash复制hdfs dfs -ls hdfs://nameservice/warehouse/hudi_db/hudi_table/.hoodie/timeline/
认准带 .inflight 后缀的 instant 文件。然后使用 Hudi CLI 执行 rollback(这里以 0.13+ 版本为例):
bash复制hudi-cli
在 CLI 中连接表后执行 rollback 指定的 instant(具体命令格式见对应版本文档)。如果文件对应的是已 commit 的 instant,但文件内容损坏,那就不能简单 rollback 了,因为 rollback 会把这个 instant 对应的数据全部回滚,可能影响更多分区,这种情况下要考虑从备份或下游数仓重导数据。
4.2 历史分区数据恢复方案
如果损坏文件是历史分区里的,且 Hudi 表所在集群开启了 HDFS Trash,可以先尝试直接从 Trash 里捞:
bash复制hdfs dfs -ls hdfs://nameservice/user/某用户/.Trash/Current/
如果 Trash 里找不到,就从备份中恢复。不少公司对 Hudi 表做了 nightly snapshot 备份(快照在独立集群或冷存储上),恢复时只需要把对应分区的数据文件放回去,同时检查 .hoodie/timeline 里有没有对应 commit,如果没有,要把 commit 文件也一并恢复。
还有一种情况:损坏文件可以通过 Parquet-tools 修复。之前我在一个案例里遇到一个文件,只有尾部 4 个字节被覆盖成 0,但文件主体完整。我用 parquet-tools 先把文件的 footer 元数据 dump 出来,然后重新拼了一个正确的 footer 写回文件尾部,文件就恢复了。不过这个操作有一定风险,因为 Parquet 的 footer 里包含了每个 row group 的统计信息和 schema,如果拼接时哪一位偏移出错,文件就等于真废了。实操前一定先做好备份,且只在文件主体可读的情况下尝试。
4.3 任务恢复与断点续写
文件隔离完后,正式恢复任务前,先检查 Flink 作业最近一次成功的 checkpoint 文件是否可用。Flink 的 Hudi sink 在恢复时会用 checkpoint 里的写入状态重新拉取应该写入的文件组信息。如果原来写入失败的 fileID 已经不存在了(被我们 rollback 清理掉了),Flink 会通过新的 fileID 重新写入新文件,不会对旧文件再追加内容——这种情况下任务可以正常拉起。
拉起命令一般是:
bash复制flink run -s hdfs://nameservice/flink/checkpoints/<app_id>/chk-<checkpoint_id> ...
注意:如果 rollback 清理的正好包含了 Flink checkpoint 里记录的 fileID,某些 Hudi 版本会报“commit not found”之类的错误。这种时候,我不能简单地把 checkpoint 状态直接删了重跑全量——那会导致数据重复或丢失。更稳妥的方式是:从损坏 instant 之前的最近一个有效 checkpoint 恢复,或者重新用 SQL 做一个增量数据追平。
追平的方式是:从 Hudi 表前一天的快照做一次 bootstrap,之后用 Flink 的增量模式同步过去一天的数据。前提是上游 Kafka 仍然保留对应时间段的日志,否则数据就真的丢了,只能从下游明细表回补。
5. 预防措施与长效监控
修复完一轮,如果不把预防做到位,过几周还得再上一次夜班。下面这套预防措施我在生产环境里验证过,基本能挡住 90% 以上的同类问题。
5.1 写入端参数调优
Flink 写 Hudi 时,有几个参数直接影响文件的最终一致性。
第一个是 checkpoint 的超时和重试。建议把 checkpoint 超时设置成实际写入耗时的 2 倍以上,避免因为系统抖动导致 checkpoint 频繁失败,最终把任务拖死。同时开启 checkpoint 失败后的自动重启能力,让任务在写入中断后尽快从 checkpoint 恢复,减少半写入文件在 HDFS 上的暴露时间。
第二个是写入小文件的滚动大小。hoodie.parquet.small.file.limit 这类参数如果配得太小,会造成大量小文件频繁切换,增加了每次文件切换时的窗口期。但如果配得太大,文件在写满之前,长时间不 commit,一旦任务失败,这个未完成的文件残留概率更高。我一般建议把目标文件大小控制在 256MB~512MB 之间,同时开启写失败后未写完文件的自动清理。
Hudi 自身还有一个参数 hoodie.cleaner.policy 值得注意,它控制历史文件的清理频率。对于生产环境,我建议将清理策略配置为在每天低峰期执行一次。这个策略的右边界需要设置成至少保留最近 12 个 commit,确保运维人员有足够时间在出错时从时间线上恢复已有数据。
5.2 文件健康度巡检
与其等查询报错,不如主动扫描。我这边写了一个简单的巡检脚本,每天凌晨对核心 Hudi 表做一次文件级校验,覆盖三块:
- 文件头部和尾部魔数检查,确认全部为
PAR1。 - 文件大小一致性检查,对比
_hoodie元数据中记录的 commit 明细和实际文件大小。 - 空文件检查,如果某个文件大小小于 100KB 且不属于 log 文件,标记为高风险。
巡检脚本可以用 Spark 或者直接用 HDFS 命令并行跑。核心逻辑是遍历分区目录,对每个 parquet 文件用 parquet-tools 或 Python 的 pyarrow 读取文件元数据:
bash复制parquet-tools meta hdfs://nameservice/warehouse/hudi_db/hudi_table/dt=2024-06-17/xxx.parquet
如果 parquet-tools 能正常输出 schema 和 row count,说明文件结构完好;如果报错信息包含 Not a Parquet file 或者 Truncated,就自动在告警群里推送修复指令。
另外,Hudi 自身的元数据表也能提供一致性信息。开启 Metadata Table 后,可以查询 hoodie.metadata 下的文件列表,和实际 HDFS 路径做对比,及时发现被遗漏或异常删除的文件。
5.3 任务级告警规则
Flink 作业的监控要远比“作业是否失败”这一个指标复杂得多。我建议至少给这几个指标加告警:
| 告警项 | 阈值建议 | 说明 |
|---|---|---|
| Checkpoint 失败次数 | 连续 3 次以上即告警 | checkpoint 是 Hudi sink 写一致性的基础 |
| TaskManager 内存使用率 | 持续 15 分钟超过 85% | OOM 前最强的信号 |
| 写入端 flush 时间 | 超过 90 秒告警 | flush 卡住通常意味着文件写不下去 |
| HDFS 写入失败次数 | 1 次即告警 | Hudi sink 的写入异常多数从这里开始 |
| 当日新生成文件损坏数 | 0 容忍 | 通过巡检脚本的结果回传 |
这几个告警看起来简单,但真正落地后,会发现很多文件损坏问题其实可以在早期就被拦截掉。比如 TaskManager 内存使用率持续偏高,导致 JVM 在序列化 Hudi record 时长时间 STW,这会让一个文件句柄长时间不落盘,最终 checkpoint 超时,任务失败,文件半写。
如果告警能在 checkpoint 第一次失败时就发出,就不用等到凌晨被业务方从睡梦中叫醒。
6. 复盘总结:几个值得刻进脑子里的教训
全文写到这里,核心的排查和修复路径已经讲完了。最后我想说几个实际踩过的坑,希望大家引以为戒。
第一个是关于临时文件的认知。Hudi 写入时生成的临时文件名和最终落盘的正式文件名是不同的,前者带有 .temp 后缀或存放在 .hoodie 目录下,后者直接以 .parquet 结尾。排查时如果用错文件名去 HDFS 上找,很容易扑空。我自己就犯过这个错,一开始在正式目录下搜了好半天没找到报错里的那个文件,后来才发现它还在 .hoodie/.temp 里躺着。
第二个是关于元数据备份。Hudi 的 .hoodie/timeline 目录就是这表的命根子。无论是数据修复还是任务恢复,都不要在没有备份的情况下直接改动时间线文件。我在生产上发生过一次:为了删一个损坏的 instant,直接用 rm 删掉了对应的 commit 文件,结果导致所有包含这个 instant 的查询全部无法解析最新快照。后来学乖了,每次操作前先 hdfs dfs -cp 把 timeline 目录整体备份到另一个路径下。
第三个既是经验也是建议——不要过度依赖手动修复。Hudi 的 rollback、cleaner、FileSizing 等机制本身就是为了处理异常而设计的,遇到问题时优先利用这些机制,而不是去手动删除文件。手动干预是最后的兜底,而不是第一选择。
如果你也正在维护 Flink + Hudi 的生产链路,现在不妨做两件事:一是检查自己的巡检脚本是否覆盖了文件头尾魔数校验,二是梳理一遍 Flink 作业在 checkpoint 失败后是否能自动恢复。这两件事做完,你大概率能少熬几个通宵。
