1. 为什么要在 Flink 1.12.2 上折腾 Hudi 0.9.0
先把背景交代清楚。我手头有一套已经跑了很久的 Flink 1.12.2 实时链路,业务侧的数据源是埋点日志和业务库变更流,下游原本直接写 Hive 做离线分析。问题出在"实时入湖"这个环节:Hive 表只能按天分区批量落,延迟高,而且小文件越滚越多,查询侧压力很大。后来团队决定引入 Hudi 做增量入湖,目标很明确——用 Flink 做流式写入,数据以 Hudi 表的形式落在 HDFS 上,同时让 Hive 能直接查,兼顾实时性和离线生态的兼容性。
选 Flink 1.12.2 不是因为它新,恰恰相反,是因为它是当时生产环境里最稳的一个版本,社区资料多、踩坑记录全。Hudi 选 0.9.0 也是同样的逻辑:这个版本对 Flink 的集成已经相对成熟,hudi-flink模块能独立跑,而且和 Hive 的同步机制(HiveSyncTool)已经比较完善。版本组合这件事,我的经验是不要追最新,要追"被验证过的组合",尤其是 Flink + Hudi 这种跨项目集成,版本错配带来的问题往往不是报错,而是数据静默丢失或者元数据不一致,排查起来非常痛苦。
这篇文章面向的是已经在用 Flink、想往湖仓方向走的中高级开发者,也适合刚接触 Hudi、想搞清楚"流式入湖到底怎么落地"的同学。我会把整个集成过程拆开讲:环境怎么搭、依赖怎么排、Hudi 表怎么建、Flink 任务怎么写、Hive 怎么同步、遇到问题怎么查。所有步骤都是我在实际环境里跑通过的,不是照抄官方文档。
提示:Flink 1.12.2 和 Hudi 0.9.0 的集成对 Scala 版本、Hadoop 版本都比较敏感,动手前先把版本矩阵确认清楚,能省掉后面一大半的排查时间。
2. 环境与依赖:版本矩阵是成败的第一道关
2.1 版本组合的确定逻辑
Flink 和 Hudi 的集成,本质上是 Hudi 提供了一个 Flink 的 Connector(hudi-flink-bundle),这个 bundle 里打包了 Hudi 的核心逻辑和 Flink 的适配层。所以第一个要确认的就是:Hudi 0.9.0 的 flink bundle 是针对哪个 Flink 版本编译的。Hudi 0.9.0 官方支持 Flink 1.12.x,这一点是匹配的,但还要注意 Scala 版本——Flink 1.12.2 通常用 Scala 2.11 或 2.12,而 Hudi 的 bundle 默认是 2.11 编译的。如果你集群是 2.12,需要自己重新编译或者找对应版本。
我实际用的组合是这样的:
| 组件 | 版本 | 说明 |
|---|---|---|
| Flink | 1.12.2 | 流处理引擎,Scala 2.11 |
| Hudi | 0.9.0 | 湖存储格式,flink-bundle |
| Hadoop | 2.7.3 | HDFS 存储层 |
| Hive | 2.3.7 | 元数据与查询层 |
| JDK | 1.8 | 全链路统一 |
这里有个容易被忽略的点:Hive 版本决定了 Hudi 同步时用的 HiveSyncTool 走哪套 API。Hive 2.x 和 3.x 的元数据操作接口差异不小,Hudi 0.9.0 对 Hive 2.3.x 的支持是最稳的。如果你用的是 Hive 3.x,同步时可能会遇到MetaStoreClient相关的兼容问题,需要额外处理。
2.2 依赖包的正确放置方式
Flink 集成 Hudi,依赖分两部分:一部分是 Flink 任务运行时需要的 Hudi bundle,另一部分是 Hive 同步需要的 Hive 相关 jar。我的做法是把它们统一放到 Flink 的lib目录下,而不是打进用户 jar 里,原因是:
- Hudi bundle 体积不小,打进用户 jar 会导致每次提交都要传一个大包,浪费带宽;
- Hive 的 jar 涉及元数据客户端,放在
lib下能被 Flink 的类加载器优先加载,避免和用户代码里的 Hive 依赖冲突。
具体需要放的包:
# Hudi Flink bundle,核心依赖 hudi-flink-bundle_2.11-0.9.0.jar # Hive 同步相关,Hudi 的 HiveSyncTool 依赖这些 hive-exec-2.3.7.jar hive-metastore-2.3.7.jar hive-common-2.3.7.jar libfb303-0.9.3.jar libthrift-0.9.3.jar注意:
hive-exec这个包特别大,而且不同小版本之间可能有类冲突。我踩过一次坑,集群里同时存在两个版本的hive-exec,导致同步时ClassNotFoundException,排查了半天才发现是包重复。建议放包前先find一下,确认没有重复。
2.3 一个容易翻车的细节:Hadoop 依赖冲突
Flink 1.12.2 自带了一套 Hadoop 依赖(如果你用的是带 Hadoop 的发行版),而 Hudi bundle 里也打包了部分 Hadoop 类。这两者如果版本不一致,运行时会出现NoSuchMethodError或者ClassCastException。我的处理方式是:在 Flink 的lib目录里只保留一套 Hadoop 依赖,把 Hudi bundle 里可能冲突的 Hadoop 类排除掉,或者干脆用flink-shaded-hadoop的方式统一。
实际操作中,我建议先跑一个最小任务验证环境,不要一上来就跑全量同步。最小任务就是往一张 Hudi 表里写几条数据,能写进去、能查出来,说明基础环境没问题,再往上叠 Hive 同步。
3. Hudi 表的建模:COW 还是 MOR,这是个问题
3.1 两种表类型的本质区别
Hudi 有两种表类型:Copy On Write(COW)和 Merge On Read(MOR)。这两个名字听起来抽象,用生活化的方式解释:
- COW就像你写文档时每改一次就重新保存一个完整版本,读的时候永远读的是最新完整文件,写的时候代价大(要重写整个文件),但读很快;
- MOR就像你写文档时先记在草稿本上(增量日志),读的时候再把草稿和正式版本合并,写很快,但读的时候要做合并,延迟高一点。
在 Flink 流式写入场景下,这个选择直接影响你的写入延迟和查询体验。我的经验是:
| 场景 | 推荐表类型 | 理由 |
|---|---|---|
| 写入 QPS 高、要求低延迟 | MOR | 增量写日志,写入吞吐高 |
| 查询频繁、要求读性能 | COW | 读时无需合并,查询快 |
| 近实时分析、容忍分钟级延迟 | COW | 平衡写入和读取 |
| 海量更新、批量导入 | MOR | 避免频繁重写文件 |
我这次选的是COW,因为下游 Hive 查询比较频繁,而且业务对写入延迟的容忍度在分钟级,COW 的读性能优势更明显。
3.2 建表参数详解
Hudi 表的建表参数决定了它的行为,几个关键参数必须搞清楚:
CREATE TABLE hudi_order ( id BIGINT, user_id BIGINT, amount DECIMAL(10,2), order_time TIMESTAMP(3), dt STRING, PRIMARY KEY (id) NOT ENFORCED ) PARTITIONED BY (dt) WITH ( 'connector' = 'hudi', 'path' = 'hdfs:///warehouse/hudi/hudi_order', 'table.type' = 'COPY_ON_WRITE', 'hoodie.datasource.write.recordkey.field' = 'id', 'hoodie.datasource.write.precombine.field' = 'order_time', 'hoodie.datasource.write.partitionpath.field' = 'dt', 'hoodie.table.name' = 'hudi_order', 'write.tasks' = '4', 'compaction.tasks' = '2', 'hive_sync.enable' = 'true', 'hive_sync.mode' = 'hms', 'hive_sync.db' = 'ods', 'hive_sync.table' = 'hudi_order', 'hive_sync.metastore.uris' = 'thrift://metastore-host:9083' );逐个解释这些参数背后的逻辑:
recordkey.field:主键字段,Hudi 靠它做去重和更新。必须是唯一标识,否则会出现数据覆盖错乱;precombine.field:当同一条记录有多个版本时,用这个字段判断哪条最新。通常用时间戳字段,如果没有,可以用自增 ID;partitionpath.field:分区字段,决定数据落在哪个分区目录下。注意这个字段必须在表结构里存在;write.tasks:写入并行度,和 Flink 的并行度配合使用。设太小写入慢,设太大文件碎片多;compaction.tasks:MOR 表才需要,COW 表可以忽略。
提示:
hive_sync.mode有三个可选值:hms(直接连 Metastore)、jdbc(连 HiveServer2)、hiveql(执行 HiveQL)。生产环境推荐hms,性能最好,但要求 Flink 任务能直连 Metastore。
3.3 分区策略的取舍
分区字段的选择直接影响查询性能和小文件数量。我见过有人用dt(天)分区,结果一天一个分区,数据量大了之后单分区文件巨大,查询时扫描慢;也有人用dt + hour分区,结果小文件爆炸。
我的经验是:分区粒度要匹配查询模式。如果下游查询基本都带天级过滤,就用天分区;如果查询经常按小时过滤,才考虑小时分区。另外,Hudi 的 COW 表在写入时会按分区聚簇,分区太多会导致每个分区文件数增加,元数据压力大。
对于这次的项目,我用的是天分区,配合 Hudi 的clustering功能定期合并小文件。这个功能在 0.9.0 里已经可用,但需要额外配置调度。
4. Flink 流式写入 Hudi 的任务编写
4.1 用 Flink SQL 还是 DataStream API
Hudi 0.9.0 对 Flink SQL 的支持已经比较完整,建表、写入、同步都能用 SQL 搞定。但 DataStream API 更灵活,适合做复杂的转换逻辑。我的建议是:
- 如果数据源是 Kafka,转换逻辑简单,直接用 Flink SQL,开发快、维护成本低;
- 如果需要在写入前做复杂的 ETL(比如多流 join、状态计算),用 DataStream API,然后通过
HoodieFlinkStreamer或者自定义 Sink 写入。
这次我用的是 Flink SQL,因为链路本身不复杂,SQL 足够。
4.2 完整的 SQL 任务示例
-- 源表:Kafka 中的订单数据 CREATE TABLE kafka_order ( id BIGINT, user_id BIGINT, amount DECIMAL(10,2), order_time TIMESTAMP(3), dt STRING, WATERMARK FOR order_time AS order_time - INTERVAL '5' SECOND ) WITH ( 'connector' = 'kafka', 'topic' = 'order_topic', 'properties.bootstrap.servers' = 'kafka-broker:9092', 'properties.group.id' = 'hudi_sync_group', 'format' = 'json', 'scan.startup.mode' = 'latest-offset' ); -- 目标表:Hudi 表 CREATE TABLE hudi_order ( id BIGINT, user_id BIGINT, amount DECIMAL(10,2), order_time TIMESTAMP(3), dt STRING, PRIMARY KEY (id) NOT ENFORCED ) PARTITIONED BY (dt) WITH ( 'connector' = 'hudi', 'path' = 'hdfs:///warehouse/hudi/hudi_order', 'table.type' = 'COPY_ON_WRITE', 'hoodie.datasource.write.recordkey.field' = 'id', 'hoodie.datasource.write.precombine.field' = 'order_time', 'hoodie.datasource.write.partitionpath.field' = 'dt', 'hive_sync.enable' = 'true', 'hive_sync.mode' = 'hms', 'hive_sync.db' = 'ods', 'hive_sync.table' = 'hudi_order', 'hive_sync.metastore.uris' = 'thrift://metastore-host:9083' ); -- 写入 INSERT INTO hudi_order SELECT id, user_id, amount, order_time, dt FROM kafka_order;这个任务跑起来之后,Flink 会持续从 Kafka 消费数据,按id去重,按order_time取最新版本,写入 Hudi 表,同时自动同步到 Hive。
4.3 Checkpoint 配置:写入可靠性的命门
Hudi 的写入依赖 Flink 的 Checkpoint 机制来保证一致性。如果 Checkpoint 没配好,会出现数据重复写或者丢写。关键配置:
# flink-conf.yaml execution.checkpointing.interval: 60000 execution.checkpointing.mode: EXACTLY_ONCE execution.checkpointing.timeout: 300000 state.backend: rocksdb state.backend.incremental: trueinterval:Checkpoint 间隔,我设的是 60 秒。太短会导致频繁触发写入,文件碎片多;太长会导致数据延迟高;mode:必须用EXACTLY_ONCE,否则 Hudi 的原子性保证不了;timeout:Checkpoint 超时时间,Hudi 写入涉及文件操作,超时时间要留够。
注意:Hudi 的写入是"两阶段提交"模式,Checkpoint 触发时才会真正 commit。如果 Checkpoint 一直失败,数据会堆积在内存里,最终导致 OOM。所以监控 Checkpoint 成功率是必须的。
4.4 并行度与资源规划
Flink 任务的并行度要和 Hudi 的write.tasks配合。我的经验公式是:
Flink 并行度 = Kafka 分区数(或略小于) Hudi write.tasks = Flink 并行度 / 2原因是 Hudi 写入时每个 task 会独立生成文件,task 太多会导致小文件问题。我这次 Kafka 是 8 分区,Flink 并行度设 8,write.tasks设 4,实测下来文件数量可控,写入吞吐也够。
资源方面,每个 TaskManager 给 4GB 内存、2 个 slot,总共 4 个 TaskManager。这个配置跑下来 CPU 利用率在 60% 左右,内存稳定。
5. Hive 同步:从 Hudi 表到可查询的 Hive 表
5.1 同步机制的原理
Hudi 的 Hive 同步本质上是把 Hudi 表的元数据(分区信息、schema)注册到 Hive Metastore 里,让 Hive 能识别这个表。同步有两种触发方式:
- 自动同步:在 Flink 任务里配置
hive_sync.enable = true,每次 commit 时自动同步; - 手动同步:用
HiveSyncTool命令行工具,适合补历史数据或者修复元数据。
自动同步方便,但有个坑:如果 Metastore 挂了或者网络抖动,同步失败会导致 Hive 里看不到最新分区。所以生产环境我建议自动同步 + 定期手动校验结合。
5.2 自动同步的配置细节
自动同步的关键配置除了前面提到的hive_sync.mode、hive_sync.db、hive_sync.table,还有几个容易忽略的:
'hive_sync.partition_fields' = 'dt', 'hive_sync.partition_extractor_class' = 'org.apache.hudi.hive.MultiPartKeysValueExtractor', 'hive_sync.support_timestamp' = 'true'partition_fields:告诉 Hudi 哪些字段是分区字段,必须和 Hudi 表的分区字段一致;partition_extractor_class:分区值提取器,单分区用MultiPartKeysValueExtractor就行;support_timestamp:Hive 里 timestamp 类型的兼容处理,Hive 2.x 建议开启。
5.3 手动同步的完整流程
当自动同步出问题,或者需要补历史分区时,手动同步是救火手段。步骤:
# 1. 准备 Hudi 的同步工具 jar # hudi-hive-sync-bundle-0.9.0.jar # 2. 执行同步命令 java -cp hudi-hive-sync-bundle-0.9.0.jar:hive-exec-2.3.7.jar:hive-metastore-2.3.7.jar \ org.apache.hudi.hive.HiveSyncTool \ --database ods \ --table hudi_order \ --base-path hdfs:///warehouse/hudi/hudi_order \ --partitioned-by dt \ --partition-value-extractor org.apache.hudi.hive.MultiPartKeysValueExtractor \ --metastore-uris thrift://metastore-host:9083执行完之后,去 Hive 里show partitions ods.hudi_order,能看到分区就说明同步成功。
5.4 同步后 Hive 查询的注意事项
Hudi 表同步到 Hive 后,Hive 里看到的是一张外部表,底层数据是 Hudi 的 parquet 文件。查询时要注意:
- COW 表:Hive 直接读 parquet 文件,查询性能和普通 Hive 表差不多;
- MOR 表:Hive 读的是 base 文件,增量日志(log 文件)不会被读到,需要配置
hive.input.format为org.apache.hudi.hadoop.HoodieParquetInputFormat才能读全量。
我这次是 COW 表,Hive 查询直接走原生 parquet reader,性能没问题。但要注意 Hudi 的文件命名和普通 Hive 不一样,如果用了自定义的 InputFormat,要确保 Hive 的 auxlib 里有对应的 Hudi jar。
6. 踩坑实录:那些文档里不会写的问题
6.1 类冲突导致的 NoSuchMethodError
这是我最开始遇到的最头疼的问题。任务提交后,报错:
java.lang.NoSuchMethodError: org.apache.hadoop.fs.FileSystem.getGlobalStorageStatistics()这个方法的签名在 Hadoop 2.7 和 2.8 之间变了。原因是 Flink 自带的 Hadoop 版本和 Hudi bundle 里的不一致。解决办法是统一 Hadoop 版本,把 Flinklib下的flink-shaded-hadoop换成和集群一致的版本,或者干脆用HADOOP_CLASSPATH指定。
6.2 Checkpoint 超时引发的写入失败
跑了一段时间后,Checkpoint 开始频繁超时,报错:
Checkpoint expired before completing排查发现是 Hudi 写入时文件操作耗时太长,超过了 Checkpoint 的 timeout。解决办法有两个:一是调大execution.checkpointing.timeout,二是减少write.tasks降低单次写入的文件数。我两个都做了,timeout 调到 300 秒,write.tasks从 8 降到 4,问题解决。
6.3 Hive 同步的分区字段类型不匹配
自动同步时,Hive 里分区字段类型和 Hudi 里的不一致,导致同步失败。比如 Hudi 里dt是 STRING,Hive 里建表时写成了 INT。解决办法是确保 Hive 表的 schema 和 Hudi 表完全一致,尤其是分区字段。可以在同步前先drop掉 Hive 表,让 Hudi 自动重建。
6.4 小文件过多的治理
跑了一周后,HDFS 上小文件数量暴涨,NameNode 压力大。原因是 Flink 每个 Checkpoint 都会生成新文件,并行度高的时候文件更多。治理手段:
- 降低
write.tasks,减少并行写入的文件数; - 开启 Hudi 的
clustering,定期合并小文件; - 调整 Checkpoint 间隔,从 30 秒改成 60 秒,减少文件生成频率。
我最后用的是组合方案:write.tasks设 4,Checkpoint 间隔 60 秒,每天凌晨跑一次 clustering。小文件数量从几十万降到几万,NameNode 压力明显缓解。
6.5 数据倾斜导致的写入热点
某些分区的数据量特别大,导致写入时个别 task 负载过高,拖慢整体进度。这是典型的数据倾斜。解决办法是在写入前对 key 做散列,或者调整分区策略。我这次是在 SQL 里加了一个MOD散列,把热点 key 打散到多个分区。
7. 性能调优与监控:让链路跑得稳
7.1 写入性能的关键参数
除了前面提到的write.tasks和 Checkpoint 配置,还有几个参数影响写入性能:
| 参数 | 默认值 | 建议值 | 作用 |
|---|---|---|---|
write.batch.size | 64MB | 128MB | 单批次写入的数据量 |
write.operation | upsert | upsert | 写入模式,insert 更快但不更新 |
hoodie.parquet.max.file.size | 120MB | 128MB | 单个 parquet 文件大小 |
hoodie.cleaner.commits.retained | 10 | 20 | 保留的历史版本数 |
write.batch.size调大能提升吞吐,但会增加内存压力;write.operation如果是纯追加场景,改成insert能省掉去重开销。
7.2 监控指标怎么看
Flink 任务跑起来后,要盯几个关键指标:
- Checkpoint 成功率和耗时:成功率低于 99% 就要排查,耗时持续增长说明写入压力大;
- Hudi 的 commit 延迟:从数据写入到 commit 完成的时间,反映端到端延迟;
- HDFS 小文件数量:定期统计,超过阈值就触发 clustering;
- TaskManager 内存和 GC:频繁 Full GC 说明内存不够或者有内存泄漏。
我一般会在 Grafana 上配一个看板,把这些指标都放上去,出问题能第一时间发现。
7.3 资源规划的实战经验
资源规划没有标准答案,但有个大原则:写入并行度不要超过 Kafka 分区数,否则会有 task 空跑。内存方面,每个 slot 至少 2GB,Hudi 写入涉及文件缓冲,内存太小会频繁 flush,影响性能。
我这次是 4 个 TaskManager,每个 4GB 内存、2 个 slot,总共 8 个 slot,跑 8 并行度的任务。CPU 用了 16 核,整体负载在 60% 左右,留了余量应对流量高峰。
8. 一些个人体会
这套链路我跑了小半年,整体稳定,但中间踩的坑确实不少。最大的体会是:Flink + Hudi 的集成,难点不在代码,而在环境和版本。代码写起来就是几行 SQL,但环境配不对,能让你排查好几天。
另外一个感受是,Hudi 的 Hive 同步虽然方便,但不能完全依赖自动同步。我现在的做法是自动同步开着,同时每天跑一次手动校验,对比 Hive 分区和 Hudi 实际分区,不一致就告警。这个校验脚本很简单,但能避免很多"数据明明写了 Hive 却查不到"的问题。
最后分享一个小技巧:如果你也在用 Flink SQL 写 Hudi,建议把建表语句和写入语句分开管理,建表语句单独存一个文件,任务启动时先执行建表(用CREATE TABLE IF NOT EXISTS),再执行写入。这样任务重启时不会因为表不存在而失败,也方便做 schema 变更。