上一期给大家讲述了Hudi中,MOR表的目录文件结构;本打算这一期讲一下COW表的目录文件,不过考虑到目前在实时读写入Hudi的场景下,用的最多的还是MOR表,所以暂时跳过COW表的文件分析,转而对hudi-flink
模块进行深入理解;本次分析也仅涉及hudi-flink
中的内容
另外,目前COW表已经支持Flink Streaming Read,有兴趣的可以试试看,我也会在之后的分享中从简单使用&原理分析来讲一讲COW表
欢迎大家指出我文章的不足,让我更进一步
Hudi从Release 0.7版本开始完成了写入层的解耦,添加了Flink客户端,可以使用HoodieFlinkStreamer来消费Kafka中的数据,以写入Hudi的COW表中。
在0.8版本,也就是最新Release版本,Hudi社区又进一步完善了Flink和Hudi的集成:
我们从单独来看写的方面,这是0.7版本写入的Pipeline
虽然说这个版本实现了对Hudi表的写入,但是存在一些性能瓶颈:
以上内容来自RFC-24更多细节可以参考该网站
该RFC由阿里Blink团队玉兆提出,以解决0.7版本中的一些瓶颈,代码在0.8版本正式放出
来看一下最新版本的流程图
流程图是我根据HoodieTableSink.getSinkRuntimeProvider()
画的
可以看出,为了解决上面提出的4个性能瓶颈,玉兆重构了整个Sink端的设计
接下来,让我们按照流程图,将每个算子的功能都给大家讲述一遍
友情提醒,我们在看源码的时候,可以利用单元测试进行Debug,这样既无需我们写代码,又能方便快捷的定位到指定断点处
首先我们看看在算子被加载的时候,做了哪些事情;太细节的地方就不说了,大家自己看吧
@Override
public void open(Configuration parameters) throws Exception {
super.open(parameters);
// 根据parameters中参数,得到输入源对应的Avro Schema
this.avroSchema = StreamerUtil.getSourceSchema(this.config);
// 创建将Flink中Rowdata转换为Hudi中的GenericRecord 的converter
this.converter = RowDataToAvroConverters.createConverter(this.rowType);
// 根据指定的主键生成器类型获取主键生成器
this.keyGenerator = StreamerUtil.createKeyGenerator(FlinkOptions.flatOptions(this.config));
// 帮我们创建hoodie pay load 的工具,至于什么是playload,我们接下来会讲到
this.payloadCreation = PayloadCreation.instance(config);
// 创建限速器,当task消费速率超过阈值时,将会slee
long totalLimit = this.config.getLong(FlinkOptions.WRITE_RATE_LIMIT);
if (totalLimit > 0) {
this.rateLimiter = new RateLimiter(totalLimit / getRuntimeContext().getNumberOfParallelSubtasks());
}
}
在初始化完成之后,每来一条数据都会调用map()
方法
@Override
public O map(I i) throws Exception {
if (rateLimiter != null) {
final O hoodieRecord;
if (rateLimiter.sampling()) {
long startTime = System.currentTimeMillis();
hoodieRecord = (O) toHoodieRecord(i);
long endTime = System.currentTimeMillis();
rateLimiter.processTime(endTime - startTime);
} else {
hoodieRecord = (O) toHoodieRecord(i);
}
rateLimiter.sleepIfNeeded();
return hoodieRecord;
} else {
return (O) toHoodieRecord(i);
}
}
限速器部分就不看了,不在我们主流程之类,我们来看一下toHoodieRecord()
方法中的细节
private HoodieRecord toHoodieRecord(I record) throws Exception {
// 通过转换器将Rowdata转换为Hudi中的GenericRecord
GenericRecord gr = (GenericRecord) this.converter.convert(this.avroSchema, record);
// 获取hoodieKey,也就是表中主键+分区字段
final HoodieKey hoodieKey = keyGenerator.getKey(gr);
// 标记是否是删除数据
final boolean isDelete = record.getRowKind() == RowKind.DELETE;
// 创建payload
HoodieRecordPayload payload = payloadCreation.createPayload(gr, isDelete);
// 通过hoodieKey和payload组装成HoodieRecord
return new HoodieRecord<>(hoodieKey, payload);
}
看一下createPayload()
方法,看看payload到底是个什么
public HoodieRecordPayload<?> createPayload(GenericRecord record, boolean isDelete) throws Exception {
if (shouldCombine) {
ValidationUtils.checkState(preCombineField != null);
Comparable<?> orderingVal = (Comparable<?>) HoodieAvroUtils.getNestedFieldVal(record,
preCombineField, false);
return (HoodieRecordPayload<?>) constructor.newInstance(
isDelete ? null : record, orderingVal);
} else {
return (HoodieRecordPayload<?>) this.constructor.newInstance(Option.of(record));
}
}
如果建表参数指定write.insert.drop.duplicates
为True或write.operation
为UPSERT则shouldCombine为True
在Hudi中,是按批写入数据,每批数据中,同一个Key可能存在多条记录,此时我们需要通过重读调用payload中的preCombine()
方法,相同Key的所有数据合并成一条数据,会通过根据每条数据生成的orderingVal
进行排序,默认取最大值的。当然这是针对于write.insert.drop.duplicates
为True或write.operation
为UPSERT的情况
对于INSERT或BULK_INSERT操作类型涞水,不会执行preCombine()
。因此,当输入数据存在重复数据,则表中也会出现重复数据
回到代码中,所以很明显HoodieRecordPayload
就是帮我们去执行preCombine()
的类,另外HoodieRecordPayload
还存放着Avro格式数据的Byte[]
HoodieRecordPayload
与hoodieKey
一起构成了RowDataToHoodieFunction发给下游的HoodieRecord
下游会通过keyBy
算子进行一次Shuffle,避免同一个Key的数据在同一时刻被不同的线程写入同一个文件中
接下来,通过BucketAssignFunction去给每条数据分配对应的Bucket
老规矩,先看open()
方法里面做了什么
@Override
public void open(Configuration parameters) throws Exception {
super.open(parameters);
// 用我们设置的Flink参数构建Hudi Write 参数
HoodieWriteConfig writeConfig = StreamerUtil.getHoodieClientConfig(this.conf);
// 获取Hadoop配置
this.hadoopConf = StreamerUtil.getHadoopConf();
// 将Hadoop配置和Flink RuntimeContext包装到一个对象中
this.context = new HoodieFlinkEngineContext(
new SerializableConfiguration(this.hadoopConf),
new FlinkTaskContextSupplier(getRuntimeContext()));
// 创建Bucket分配器
this.bucketAssigner = BucketAssigners.create(
getRuntimeContext().getIndexOfThisSubtask(),
getRuntimeContext().getNumberOfParallelSubtasks(),
WriteOperationType.isOverwrite(WriteOperationType.fromValue(conf.getString(FlinkOptions.OPERATION))),
HoodieTableType.valueOf(conf.getString(FlinkOptions.TABLE_TYPE)),
context,
writeConfig);
}
这里最关键的就是创建这个BucketAssigners.create()
,我们来看看它的逻辑
public static BucketAssigner create(
int taskID,
int numTasks,
boolean isOverwrite,
HoodieTableType tableType,
HoodieFlinkEngineContext context,
HoodieWriteConfig config) {
if (isOverwrite) {
return new OverwriteBucketAssigner(taskID, numTasks, context, config);
}
switch (tableType) {
case COPY_ON_WRITE:
return new BucketAssigner(taskID, numTasks, context, config);
case MERGE_ON_READ:
return new DeltaBucketAssigner(taskID, numTasks, context, config);
default:
throw new AssertionError();
}
}
这里比较简单,因为我们是MOR表,所以给我们创建了DeltaBucketAssigner
类得对象,我们进去看看这个类的作用
/**
* BucketAssigner for MERGE_ON_READ table type, this allows auto correction of small parquet files to larger ones
* without the need for an index in the logFile.
*
* Note: assumes the index can always index log files for Flink write.
*/
// 以上大意为,用于MOR表的BucketAssigner,允许自动将小的Parquet文件合并为大的parquet文件,并且不需要在Log File中建立Index;
public class DeltaBucketAssigner extends BucketAssigner {
}
再看一下父类的作用
/**
* Bucket assigner that assigns the data buffer of one checkpoint into buckets.
*
* This assigner assigns the record one by one.
* If the record is an update, checks and reuse existing UPDATE bucket or generates a new one;
* If the record is an insert, checks the record partition for small files first, try to find a small file
* that has space to append new records and reuse the small file's data bucket, if
* there is no small file(or no left space for new records), generates an INSERT bucket.
*
*
Use {partition}_{fileId} as the bucket identifier, so that the bucket is unique
* within and among partitions.
*/
public class BucketAssigner {
}
Bucket分配器,用于将一个Checkpoint期间的缓存的数据分配到对应的桶中
Bucket分配器会将数据一条一条的分配
使用{分区}_{文件Id}作为桶的Id,保证桶在一个分区里面是唯一的
介绍完了两个桶分配器,我们回到BucketAssignFunction类中
因为实现了接口CheckpointedFunction
,所以接下来需要初始化状态
@Override
public void initializeState(FunctionInitializationContext context) {
MapStateDescriptor<HoodieKey, HoodieRecordLocation> indexStateDesc =
new MapStateDescriptor<>(
"indexState",
TypeInformation.of(HoodieKey.class),
TypeInformation.of(HoodieRecordLocation.class));
double ttl = conf.getDouble(FlinkOptions.INDEX_STATE_TTL) * 24 * 60 * 60 * 1000;
if (ttl > 0) {
indexStateDesc.enableTimeToLive(StateTtlConfigUtil.createTtlConfig((long) ttl));
}
indexState = context.getKeyedStateStore().getMapState(indexStateDesc);
if (bootstrapIndex) {
MapStateDescriptor<String, Integer> partitionLoadStateDesc =
new MapStateDescriptor<>("partitionLoadState", Types.STRING, Types.INT);
partitionLoadState = context.getKeyedStateStore().getMapState(partitionLoadStateDesc);
}
}
主要是两个状态的初始化
indexState
index.bootstrap.enabled
配置为True,则会将表中的BloomFilter
加载到状态中partitionLoadState
index.bootstrap.enabled
配置为True才会初始化该状态,用于标记每个分区的BloomFilter
是否已被加载到状态中看完了前置工作,我们接着来看BucketAssignFunction是怎么处理每条进入的数据的
假设我们插入了以下的数据到Hudi表中,我们来看一下具体的过程是怎么样的
{"uuid": "id1", "name": "Danny", "age": 23, "ts": "1970-01-01T00:00:01", "partition": "par1"}
{"uuid": "id2", "name": "Stephen", "age": 33, "ts": "1970-01-01T00:00:02", "partition": "par1"}
{"uuid": "id3", "name": "Julian", "age": 53, "ts": "1970-01-01T00:00:03", "partition": "par2"}
{"uuid": "id4", "name": "Fabian", "age": 31, "ts": "1970-01-01T00:00:04", "partition": "par2"}
{"uuid": "id5", "name": "Sophia", "age": 18, "ts": "1970-01-01T00:00:05", "partition": "par3"}
{"uuid": "id6", "name": "Emma", "age": 20, "ts": "1970-01-01T00:00:06", "partition": "par3"}
{"uuid": "id7", "name": "Bob", "age": 44, "ts": "1970-01-01T00:00:07", "partition": "par4"}
{"uuid": "id8", "name": "Han", "age": 56, "ts": "1970-01-01T00:00:08", "partition": "par4"}
@Override
public void processElement(I value, Context ctx, Collector<O> out) throws Exception {
HoodieRecord<?> record = (HoodieRecord<?>) value;
final HoodieKey hoodieKey = record.getKey();
final BucketInfo bucketInfo;
final HoodieRecordLocation location;
if (bootstrapIndex && !partitionLoadState.contains(hoodieKey.getPartitionPath())) {
loadRecords(hoodieKey.getPartitionPath());
}
if (isChangingRecords && this.indexState.contains(hoodieKey)) {
location = new HoodieRecordLocation("U", this.indexState.get(hoodieKey).getFileId());
this.bucketAssigner.addUpdate(record.getPartitionPath(), location.getFileId());
} else {
bucketInfo = this.bucketAssigner.addInsert(hoodieKey.getPartitionPath());
switch (bucketInfo.getBucketType()) {
case INSERT:
location = new HoodieRecordLocation("I", bucketInfo.getFileIdPrefix());
break;
case UPDATE:
location = new HoodieRecordLocation("U", bucketInfo.getFileIdPrefix());
break;
default:
throw new AssertionError();
}
if (isChangingRecords) {
this.indexState.put(hoodieKey, location);
}
}
record.unseal();
record.setCurrentLocation(location);
record.seal();
out.collect((O) record);
}
首先,会校验是否允许从文件中加载BloomFilter,如果允许则取加载;这部分内容我们稍后再说,我们接着往下看
因为write.operation
配置的为Upsert,所以isChangingRecords
为True,但此时indexState
刚刚初始化完毕,且我们刚进入第一条数据,所以我们来到else
的逻辑中;
我们进入this.bucketAssigner.addInsert(hoodieKey.getPartitionPath());
中,看看做了什么事情
上面我们有说过:如果是Insert数据,先检查数据对应的分区中,是否有小文件
所以进入该方法之后,第一行代码就是List
很明显,就是在寻找这条数据对应的分区中,是否存在小文件;我们继续深入这个方法
if (partitionSmallFilesMap.containsKey(partitionPath)) {
return partitionSmallFilesMap.get(partitionPath);
}
partitionSmallFilesMap
是在当前分桶器被创建的时候初始化的,是一个HashMap,用于存放对应分区中的小文件;很明显,当前肯定不存在,我们继续往下走
List
重点还是在getSmallFiles()
中,我们继续深入,这里要注意,因为我们一开始创建的是DeltaBucketAssigner,所以不要进错了方法
进去之后,先是获取当前表的已提交的Instant;此时因为表是我们第一次写入,所以根本没有已经提交的Instant,所以直接返回;也就是说,我们目前根本不存在小文件,所以我们一路回退至BucketAssigner.addInsert()
中
if (newFileAssignStates.containsKey(partitionPath)) {
NewFileAssignState newFileAssignState = newFileAssignStates.get(partitionPath);
if (newFileAssignState.canAssign()) {
newFileAssignState.assign();
}
final String key = StreamerUtil.generateBucketKey(partitionPath, newFileAssignState.fileId);
return bucketInfoMap.get(key);
}
这段逻辑主要是这样:如果newFileAssignStates
中包含当前分区,则取出对应的newFileAssignState
,如果它还有写数据的空间就分配一下空间,无论有无空间,都会根据分区和它的FileId获取到对应的BucketInfo
也就是当前数据对应的分桶信息;
不过这里我不能理解的是,这个分配空间的作用是什么?后续也没有用到这个东西
因为我们当前还是第一条数据,所以newFileAssignStates
肯定是不包含当前分区的,所以我们接着来看
BucketInfo bucketInfo = new BucketInfo(BucketType.INSERT, FSUtils.createNewFileIdPfx(), partitionPath);
final String key = StreamerUtil.generateBucketKey(partitionPath, bucketInfo.getFileIdPrefix());
bucketInfoMap.put(key, bucketInfo);
newFileAssignStates.put(partitionPath, new NewFileAssignState(bucketInfo.getFileIdPrefix(), insertRecordsPerBucket));
return bucketInfo;
因为没有小文件,并且当前数据对应的Key也没有被分配过桶,所以新建一个BucketInfo对象,指定桶类型为Insert、桶的FileId为随机数、以及分区值
并将对应的NewFileAssignState
和BucketInfo
分别放入Map中
做完这些之后,回到BucketAssignFunction中,接下来再根据BucketInfo
类型,分配不同的HoodieRecordLocation
然后再将HoodieRecordLocation放到数据中,最后将数据发往下游
其实BucketAssignFunction中还有很多的内容,但是为了连贯性,我们先看下游的算子,其余内容我们将在下一篇进行揭秘
这边其实有四个类
我们主要来看一下StreamWriteFunction和StreamWriteOperatorCoordinator这两个类
我们来看看它是怎么处理每条数据的:在每条数据来了之后,直接丢到bufferRecord()
方法中
private void bufferRecord(I value) {
final String bucketID = getBucketID(value);
DataBucket bucket = this.buckets.computeIfAbsent(bucketID,
k -> new DataBucket(this.config.getDouble(FlinkOptions.WRITE_BATCH_SIZE)));
boolean flushBucket = bucket.detector.detect(value);
boolean flushBuffer = this.tracer.trace(bucket.detector.lastRecordSize);
if (flushBucket) {
flushBucket(bucket);
this.tracer.countDown(bucket.detector.totalSize);
bucket.reset();
} else if (flushBuffer) {
// find the max size bucket and flush it out
List<DataBucket> sortedBuckets = this.buckets.values().stream()
.sorted((b1, b2) -> Long.compare(b2.detector.totalSize, b1.detector.totalSize))
.collect(Collectors.toList());
final DataBucket bucketToFlush = sortedBuckets.get(0);
flushBucket(bucketToFlush);
this.tracer.countDown(bucketToFlush.detector.totalSize);
bucketToFlush.reset();
}
bucket.records.add((HoodieRecord<?>) value);
}
先是根据数据中的分区路径、FileId算出桶Id
再根据桶Id去获取DataBucket
检测是否需要刷新桶数据
都不满足则将当前数据缓存
有些细节部分没有说,这里大家可以自己看看,接下来我们再看看StreamWriteOperatorCoordinator中的内容
先看看接收到StreamWriteFunction发送的事件后,如何处理的
@Override
public void handleEventFromOperator(int i, OperatorEvent operatorEvent) {
executor.execute(
() -> {
// no event to handle
ValidationUtils.checkState(operatorEvent instanceof BatchWriteSuccessEvent,
"The coordinator can only handle BatchWriteSuccessEvent");
BatchWriteSuccessEvent event = (BatchWriteSuccessEvent) operatorEvent;
// the write task does not block after checkpointing(and before it receives a checkpoint success event),
// if it it checkpoints succeed then flushes the data buffer again before this coordinator receives a checkpoint
// success event, the data buffer would flush with an older instant time.
ValidationUtils.checkState(
HoodieTimeline.compareTimestamps(instant, HoodieTimeline.GREATER_THAN_OR_EQUALS, event.getInstantTime()),
String.format("Receive an unexpected event for instant %s from task %d",
event.getInstantTime(), event.getTaskID()));
if (this.eventBuffer[event.getTaskID()] != null) {
this.eventBuffer[event.getTaskID()].mergeWith(event);
} else {
this.eventBuffer[event.getTaskID()] = event;
}
if (event.isEndInput() && allEventsReceived()) {
// start to commit the instant.
commitInstant();
// no compaction scheduling for batch mode
}
}, "handle write success event for instant %s", this.instant
);
}
这边的逻辑比较简单,如果事件是来自有界输入流则isEndInput()
返回True,并且如果所有的Event事件都被接收,则提交当前Instant
截止到这里,本篇内容就结束了,剩余的内容比如Checkpoint发生时的处理、Update数据处理将会放在下期来讲
另外,大家在看的时候可能出现代码和Master分支对不上的情况,其实很正常,这几个类的内容都隔着好几天写的,社区迭代的比较快,所以会出现代码不一致的情况,问题不大,只要主流程没变就OK