Hudi 与 Flink/Spark 集成:流式写入 Hudi 的 Exactly-Once 与延迟优化
1. Hudi 基础与 Exactly-Once 语义介绍
Apache Hudi(Hadoop Upserts, Deletes, and Incrementals)是一个开源的流式数据湖平台,它为数据湖提供了 ACID 事务、增量处理和并发控制能力。Hudi 的核心价值在于它能够在数据湖中实现类似于数据库的 ACID 事务特性,同时保留数据湖的灵活性和成本优势。
在流式处理场景中,Exactly-Once 语义是保证数据一致性的关键。Hudi 通过以下机制实现 Exactly-Once 语义:
- 原子性写入:使用文件原子重命名机制,确保写入操作要么完全成功,要么完全失败。
- 元数据管理:维护时间线元数据,记录所有操作的日志信息。
- 版本控制:每个文件都有版本号,允许回滚到特定状态。
- 检查点机制:与 Flink/Spark 的检查点机制结合,确保处理状态的恢复。
Hudi 表主要分为两种类型:写时复制(Copy-on-Write,COW)和读时合并(Merge-on-Read,MOR)。COW 表在写入时创建新文件,适合读多写少场景;MOR 表在写入时只追加日志文件,适合写多读少场景。流式写入通常推荐使用 MOR 表,因为它具有更好的写入性能。
2. Flink 集成与 Exactly-Once 实现
将 Flink 与 Hudi 集成是实现流式数据写入数据湖的有效方式。Flink 的检查点机制与 Hudi 的原子性写入完美结合,可以轻松实现 Exactly-Once 语义。
2.1 基本集成步骤
2.2 关键配置参数
// 创建 Hudi 表
StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env);
String createTableSql = "CREATE TABLE hudi_table (" +
"id INT, name STRING, ts TIMESTAMP) " +
"WITH ('connector' = 'hudi', " +
"'path' = 'hdfs://namenode:8020/warehouse/hudi_table', " +
"'table.type' = 'MOR', " +
"'hoodie.upsert.shuffle.parallelism' = '200', " +
"'hoodie.insert.shuffle.parallelism' = '200', " +
"'hoodie.cleaner.commits.retained' = '30', " +
"'hoodie.backup.path' = 'hdfs://namenode:8020/warehouse/hudi_table_backup', " +
"'hoodie.metadata.enabled' = 'true', " +
"'hoodie.parquet.max.file.size' = '120000000', " +
"'hoodie.parquet.compression' = 'zstd', " +
"'write.batch_size' = '800000', " +
"'write.bulk_shuffle_input' = 'true', " +
"'write.bulk_shuffle_sort_by_partition' = 'true', " +
"'write.bulk_shuffle_memory' = '512MB', " +
"'write.bulk_shuffle_shuffle_by_partition' = 'true', " +
"'write.bulk_shuffle_min_file_size' = '268435456', " +
"'write.bulk_shuffle_compression' = 'zstd', " +
"'write.bulk_shuffle_max_memory' = '1024MB', " +
"'write.bulk_shuffle_sort_memory' = '512MB', " +
"'write.bulk_shuffle_sort_input' = 'true', " +
"'write.bulk_shuffle_sort_parallelism' = '200', " +
"'write.bulk_shuffle_sort_shuffle_by_partition' = 'true', " +
"'write.bulk_shuffle_sort_min_file_size' = '268435456', " +
"'write.bulk_shuffle_sort_compression' = 'zstd', " +
"'write.bulk_shuffle_sort_max_memory' = '1024MB', " +
"'write.bulk_shuffle_sort_sort_by_partition' = 'true', " +
"'write.bulk_shuffle_sort_sort_parallelism' = '200', " +
"'write.bulk_shuffle_sort_sort_min_file_size' = '268435456', " +
"'write.bulk_shuffle_sort_sort_compression' = 'zstd', " +
"'write.bulk_shuffle_sort_sort_max_memory' = '1024MB')";
tableEnv.executeSql(createTableSql);
2.3 Exactly-Once 实现
要实现 Exactly-Once 语义,必须正确配置 Flink 的检查点机制:
// 启用检查点
env.enableCheckpointing(60000); // 60秒检查点间隔
env.getCheckpointConfig().setCheckpointingMode(CheckpointingMode.EXACTLY_ONCE);
env.getCheckpointConfig().setMinPauseBetweenCheckpoints(30000); // 最小间隔30秒
env.getCheckpointConfig().setCheckpointTimeout(600000); // 超时时间10分钟
env.getCheckpointConfig().setMaxConcurrentCheckpoints(1);
env.getCheckpointConfig().setExternalizedCheckpointCleanup(ExternalizedCheckpointCleanup.RETAIN_ON_CANCELLATION);
env.getCheckpointConfig().setEnableUnalignedCheckpoints(true);
// 确保使用 Hudi 的写入策略,保证 Exactly-Once
DataStream<Row> inputStream = env.addSource(new FlinkKafkaConsumer<>(…));
DataStream<Row> resultStream = inputStream.process(…);
// 使用 HudiSink
HudiSinkBuilder hudiSinkBuilder = HudiSinkBuilder.forTable(hudiTablePath)
.withKeyGenerator(new SimpleKeyGenerator())
.withWriteConcurrencyMode(OptimisticConcurrencyControl)
.withGlobalIndex(indexClass)
.withLogRetention(logRetention)
.withBulkInsertBulk_shuffle_input(true)
.withBulkInsertSortMemory(512)
.withBulkInsertShuffleMemory(1024)
.withBulkInsertSortParallelism(200)
.withBulkInsertShuffleParallelism(200)
.withBulkInsertMinFileSize(268435456)
.withBulkInsertCompression("zstd")
.withBulkInsertSortShuffleByPartition(true);
resultStream.addSink(hudiSinkBuilder.build());
3. Spark 集成与延迟优化策略
Spark 与 Hudi 的集成主要通过 Spark SQL 和 DataFrame API 实现。相比于 Flink,Spark 在批处理场景下具有优势,适合处理大规模历史数据。
3.1 Spark 集成基本步骤
3.2 延迟优化策略
在流式写入场景中,延迟是一个关键指标。以下是几种有效的延迟优化策略:
// Spark 写入 Hudi 的优化配置
val hudiOptions = Map(
"hoodie.table.name" -> "hudi_table",
"hoodie.table.payload.class" -> "org.apache.hudi.common.model.PartialUpdateAvroPayload",
"hoodie.table.type" -> "MOR",
"hoodie.timeline.location" -> "/timeline",
"hoodie.timeline.server" -> "localhost",
"hoodie.parquet.max.file.size" -> "120000000",
"hoodie.parquet.compression" -> "zstd",
"hoodie.serializer" -> "org.apache.hudi.client.transaction.lock.ZookeeperBasedLockProvider",
"hoodie.lock.zookeeper.url" -> "zk1:2181,zk2:2181,zk3:2181",
"hoodie.lock.zookeeper.lock_key" -> "hudi_table_lock",
"hoodie.upsert.shuffle.parallelism" -> "200",
"hoodie.insert.shuffle.parallelism" -> "200",
"hoodie.cleaner.commits.retained" -> "30",
"hoodie.backup.path" -> "/backup",
"hoodie.metadata.enabled" -> "true",
"hoodie.payload.check.enabled" -> "false",
"hoodie.bulk_insert.sort_by_partition" -> "true",
"hoodie.bulk_insert.sort_memory" -> "512MB",
"hoodie.bulk_insert.shuffle_by_partition" -> "true",
"hoodie.bulk_insert.shuffle_memory" -> "1024MB",
"hoodie.bulk_insert.sort_shuffle_by_partition" -> "true",
"hoodie.bulk_insert.sort_memory" -> "512MB",
"hoodie.bulk_insert.sort_shuffle_memory" -> "1024MB",
"hoodie.bulk_insert.sort_sort_by_partition" -> "true",
"hoodie.bulk_insert.sort_sort_memory" -> "512MB",
"hoodie.bulk_insert.sort_sort_shuffle_memory" -> "1024MB"
)
val df = spark.read.parquet("input_path")
df.write.format("org.apache.hudi")
.options(hudiOptions)
.option("hoodie.upsert.shuffle.parallelism", "200")
.option("hoodie.insert.shuffle.parallelism", "200")
.option("hoodie.bulk_insert.sort_by_partition", "true")
.option("hoodie.bulk_insert.sort_memory", "512MB")
.option("hoodie.bulk_insert.shuffle_by_partition", "true")
.option("hoodie.bulk_insert.shuffle_memory", "1024MB")
.option("hoodie.bulk_insert.sort_shuffle_by_partition", "true")
.option("hoodie.bulk_insert.sort_shuffle_memory", "1024MB")
.option("hoodie.bulk_insert.sort_sort_by_partition", "true")
.option("hoodie.bulk_insert.sort_sort_memory", "512MB")
.option("hoodie.bulk_insert.sort_sort_shuffle_memory", "1024MB")
.mode("append")
.save("output_path")
3.3 Flink 与 Spark 集成对比
| 处理模式 | 真正的流处理 | 微批处理 |
| Exactly-Once | 原生支持,基于检查点 | 支持,但配置更复杂 |
| 延迟 | 通常更低 | 批量处理可能带来更高延迟 |
| 适用场景 | 实时数据管道 | 大规模批处理与历史数据分析 |
| 成熟度 | 相对较新,API 变化较快 | 更加成熟,生态更丰富 |
| 扩展性 | 高,支持事件时间处理 | 有限,主要处理处理时间 |
4. 最佳实践与注意事项
4.1 参数调优建议
4.2 故障恢复策略
4.3 性能监控
Hudi 提供了多种监控指标,可用于评估系统性能:
5. 最小示例代码
5.1 Flink 最小示例
public class HudiFlinkExample {
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
// 启用检查点
env.enableCheckpointing(60000);
env.getCheckpointConfig().setCheckpointingMode(CheckpointingMode.EXACTLY_ONCE);
// 创建 Kafka 数据源
Properties properties = new Properties();
properties.setProperty("bootstrap.servers", "localhost:9092");
properties.setProperty("group.id", "hudi_group");
FlinkKafkaConsumer<String> kafkaSource = new FlinkKafkaConsumer<>(
"hudi_topic",
new SimpleStringSchema(),
properties
);
DataStream<String> stream = env.addSource(kafkaSource);
// 转换为 Row 类型
DataStream<Row> rowStream = stream.map(new MapFunction<String, Row>() {
@Override
public Row map(String value) throws Exception {
// 简单的 JSON 解析
ObjectMapper mapper = new ObjectMapper();
JsonNode jsonNode = mapper.readTree(value);
return Row.of(
jsonNode.get("id").asInt(),
jsonNode.get("name").asText(),
new Timestamp(jsonNode.get("ts").asLong())
);
}
});
// 创建表环境
StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env);
// 创建 Hudi 表
String createTableSql = "CREATE TABLE hudi_table (" +
"id INT, name STRING, ts TIMESTAMP) " +
"WITH ('connector' = 'hudi', " +
"'path' = 'file:///tmp/hudi_table', " +
"'table.type' = 'MOR', " +
"'hoodie.parquet.max.file.size' = '120000000', " +
"'hoodie.parquet.compression' = 'zstd', " +
"'write.bulk_shuffle_input' = 'true', " +
"'write.bulk_shuffle_sort_by_partition' = 'true')";
tableEnv.executeSql(createTableSql);
// 将数据写入 Hudi 表
Table table = tableEnv.fromDataStream(rowStream);
tableEnv.createTemporaryView("input_table", table);
tableEnv.executeSql("INSERT INTO hudi_table SELECT * FROM input_table");
}
}
5.2 Spark 最小示例
import org.apache.spark.sql.{SaveMode, SparkSession}
import org.apache.spark.sql.functions._
object HudiSparkExample {
def main(args: Array[String]): Unit = {
val spark = SparkSession.builder()
.appName("HudiSparkExample")
.config("spark.serializer", "org.apache.spark.serializer.KryoSerializer")
.getOrCreate()
import spark.implicits._
// 创建测试数据
val data = spark.range(1000)
.selectExpr("id", "concat('user_', id) as name", "current_timestamp() as ts")
.repartition(5)
// 写入 Hudi 表
val hudiOptions = Map(
"hoodie.table.name" -> "hudi_table",
"hoodie.table.type" -> "MOR",
"hoodie.parquet.max.file.size" -> "120000000",
"hoodie.parquet.compression" -> "zstd",
"hoodie.upsert.shuffle.parallelism" -> "200",
"hoodie.insert.shuffle.parallelism" -> "200",
"hoodie.bulk_insert.sort_by_partition" -> "true",
"hoodie.bulk_insert.sort_memory" -> "512MB",
"hoodie.bulk_insert.shuffle_by_partition" -> "true",
"hoodie.bulk_insert.shuffle_memory" -> "1024MB"
)
data.write.format("org.apache.hudi")
.options(hudiOptions)
.option("hoodie.upsert.shuffle.parallelism", "200")
.option("hoodie.insert.shuffle.parallelism", "200")
.option("hoodie.bulk_insert.sort_by_partition", "true")
.option("hoodie.bulk_insert.sort_memory", "512MB")
.option("hoodie.bulk_insert.shuffle_by_partition", "true")
.option("hoodie.bulk_insert.shuffle_memory", "1024MB")
.mode(SaveMode.Append)
.save("file:///tmp/hudi_table")
// 读取 Hudi 表
val hudiDF = spark.read.format("org.apache.hudi").load("file:///tmp/hudi_table")
hudiDF.show(10)
spark.stop()
}
}
5.3 注意事项
流程图
flowchart TD
A[数据源] –> B[数据预处理]
B –> C{Flink/Spark处理引擎}
C –> D[检查点机制]
D –> E[Hudi写入操作]
E –> F[创建/更新文件]
F –> G[维护时间线]
G –> H[元数据管理]
H –> I[结果存储]
I –> J[数据查询]
J –> K[数据消费]
D –> L[状态保存]
L –> M[故障恢复]
M –> C
E –> N[批量插入优化]
N –> O[并行写入]
O –> P[压缩优化]
P –> Q[索引更新]
Q –> F



