深入SeaTunnel数据集成

791 阅读7分钟

我正在参加「掘金·启航计划」

前面已经介绍过Seatunnel的一些入门知识,在使用一段时间后,本文会进一步的去深入Seatunnel。

ETL到ELT

SeaTunnel is an EL(T) data integration platform. Therefore, in SeaTunnel, Transform can only be used to perform some simple transformations on data, such as converting the data of a column to uppercase or lowercase, changing the column name, or splitting a column into multiple columns.

从官方的说明中,可以知道SeaTunnel是一个EL(T)的数据集成工具。在平时的工作中,有时候会去忽略这些概念,更多的是关注如何去在这些开源框架上去进行二次开发。

ETL和ELT每个步骤的意义都是一样,只不过是看我们的转换需要在哪一步进行。

ELT强调的是需要接入到数仓的数据不经过其他转换,或者只做轻量级的转换,让数据更快的落地到数仓。对于比较重的转换操作可以交给计算引擎来完成,如Spark,Flink。一般在数据量比较大的情况下都会选择使用ELT的模式。

引擎的选择

The default engine use by SeaTunnel is SeaTunnel Engine. If you choose to use the Flink or Spark engine, SeaTunnel will package the Connector into a Flink or Spark program and submit it to Flink or Spark to run.

官方介绍了SeaTunnel可以运行在三种引擎之上,Spark、Flink、和SeaTunnel Engine。SeaTunnel Engine是不依赖Spark和Flink的。可以根据需求去灵活的选择。本文会选用Flink作为其运行引擎。

KafkaSource基于Flink引擎的实现

translation介绍

Seatunnel在升级了V2架构之后,不管底层选用哪种引擎,source和sink都是一份代码。Seatunnel基于转换层(translation)来实现了这一特性。

接下来通过KafkaSource的实现来看看Seatunnel是如何借助translation层将其接入Flink引擎的。

Flink中的source

Program Dataflow

通过官方的图可以看出,在Flink中的stream编程中,添加一个source 就是将其加入到env中。

Flink程序的运行特点是并发和分布式的。这里暂时只关注source,一个source的代码可以理解为一个operator。在真正运行的时候这个operator会根据partition的数量来产生多个运行的实例,也就是operator的subtask。这些subtask会真正的运行在不同的线程或者机器上。

Seatunnel 中translation Flink的实现

有了上面的基础概念,我们可以想到在translation flink中需要实现的功能就是将Seatunnel中的的source或者sink加入到Flink的运行环境变量中。

translation flink的核心就是实现一个**BaseSeaTunnelSourceFunction。这是一个抽象类,**实现了核心的读取写入流程,真正的数据读取会交给具体的SeaTunnelSource实现类。

BaseSeaTunnelSourceFunction中的一个实现类是SeaTunnelParallelSource。它的构造方法中就传入了真正读取数据的SeaTunnelSource实现类。

image.png

SeaTunnelParallelSource 其实就是继承了Flink中的RichSourceFunction并且实现了ParallelSourceFunction接口。其实就是Flink中 RichParallelSourceFunction的作用。源码中的注释如下

Base class for implementing a parallel data source. Upon execution, the runtime will execute as many parallel instances of this function as configured parallelism of the source.
The data source has access to context information (such as the number of parallel instances of the source, and which parallel instance the current instance is) via getRuntimeContext(). It also provides additional life-cycle methods (open(org.apache.flink.configuration.Configuration) and close()

意思就是提供了并行处理source数据的能力。并且可以通过getRuntimeContext()方法获取上下文信息(例如源的并行实例数,以及当前实例是哪个并行实例)。它还提供了额外的生命周期方法如open()和close()。

在Flink中可以通过setParallelism设置并行度,设置source task的subtask的数量。

sourceStream.setParallelism(parallelism);

到此,已经了解了在translation flink中其实就是去实现了一个Flink中的SourceFunction,具体实现为SeaTunnelParallelSource。这个SourceFunction具备了RichParallelSourceFunction的能力。

在执行source execute方法的时候对SeaTunnelParallelSource进行了初始化,并传入了真正的

SeaTunnelSource。

@Override
public List<DataStream<Row>> execute(List<DataStream<Row>> upstreamDataStreams) {
	StreamExecutionEnvironment executionEnvironment =
			flinkRuntimeEnvironment.getStreamExecutionEnvironment();
	List<DataStream<Row>> sources = new ArrayList<>();
	for (int i = 0; i < plugins.size(); i++) {
		SeaTunnelSource internalSource = plugins.get(i);
		BaseSeaTunnelSourceFunction sourceFunction;
		if (internalSource instanceof SupportCoordinate) {
			sourceFunction = new SeaTunnelCoordinatedSource(internalSource);
		} else {
			sourceFunction = new SeaTunnelParallelSource(internalSource);
		}
		DataStreamSource<Row> sourceStream =
				addSource(
						executionEnvironment,
						sourceFunction,
						"SeaTunnel " + internalSource.getClass().getSimpleName(),
						internalSource.getBoundedness()
								== org.apache.seatunnel.api.source.Boundedness.BOUNDED);
		Config pluginConfig = pluginConfigs.get(i);
		if (pluginConfig.hasPath(CommonOptions.PARALLELISM.key())) {
			int parallelism = pluginConfig.getInt(CommonOptions.PARALLELISM.key());
			sourceStream.setParallelism(parallelism);
		}
		registerResultTable(pluginConfig, sourceStream);
		sources.add(sourceStream);
	}
	return sources;
}

SeaTunnelSource就是根据配置文件获取到的具体的实现。比如KafkaSource。

当基于Flink引擎启动任务之后,代码就会执行到SourceFunction中的生命周期方法open()、run()。SourceFunction可以理解为最终运行的Flink中的Task需要执行的代码。这里其实就是运作SeaTunnelParallelSource中对应的方法。

SeaTunnelParallelSource的open方法会根据具体的source结合上下文信息创建出了一个ParallelSource,后续的操作全都交给ParallelSource来实现

@Override
public void open(Configuration parameters) throws Exception {
	super.open(parameters);
	this.internalSource = createInternalSource();
	this.internalSource.open();
}

@Override
protected BaseSourceFunction<SeaTunnelRow> createInternalSource() {
	return new ParallelSource<>(
			source,
			restoredState,
			getRuntimeContext().getNumberOfParallelSubtasks(),
			getRuntimeContext().getIndexOfThisSubtask());
}

这里需要注意的是ParallelSource传入了运行时获取到的上下文信息:总的subTask数量以及当前subTask数量的index。有了这两个上下文信息,就可以对后面做任务切分。

ParallelSource的实现

ParallelSource中主要初始化了两个东西:

splitEnumerator: 任务切分的实现类(每种source都有自己的切分实现类)。在flink中设置了setParallelism,它就会帮我们的task运行多个subtask实例,但是每个subtask具体读取哪些数据还需要我们自己定义切分。

reader: 真正的reader实现类 (真正读取数据的实现)

部分主要代码如下

this.parallelEnumeratorContext =
			new ParallelEnumeratorContext<>(this, parallelism, subtaskId);
this.readerContext = new ParallelReaderContext(this, source.getBoundedness(), subtaskId);

splitEnumerator = source.createEnumerator(parallelEnumeratorContext);
reader = source.createReader(readerContext);

parallelEnumeratorContext中包含了subtask的总数量和当前subtaskId。

前面说了SeaTunnelParallelSource作为task会执行其生命周期方法open()和run()方法。具体的实现交给它创建的ParallelSource去执行对应的方法。

KafkaSource的实现

ParallelSource中的open方法主要是交给具体的实现类做一些初始化的操作

@Override
public void open() throws Exception {
	executorService =
			ThreadPoolExecutorFactory.createScheduledThreadPoolExecutor(
					1, String.format("parallel-split-enumerator-executor-%s", subtaskId));
	splitEnumerator.open();
	if (restoredSplitState.size() > 0) {
		splitEnumerator.addSplitsBack(restoredSplitState, subtaskId);
	}
	reader.open();
	parallelEnumeratorContext.register();
	splitEnumerator.registerReader(subtaskId);
}

先来看看读取kafka分片的逻辑:

这里kafkaSource对应的splitEnumerator实现类是KafkaSourceSplitEnumerator。因此调用的就是KafkaSourceSplitEnumerator相关的实现方法。

一般对kafka的消费,都是按每个topic的分区来,一个分区只会被一个subTask消费。具体的实现就在open里面的discoverySplits方法中。

private void discoverySplits() throws ExecutionException, InterruptedException {
	fetchPendingPartitionSplit();
	assignSplit();
}

private void fetchPendingPartitionSplit() throws ExecutionException, InterruptedException {
	getTopicInfo()
			.forEach(
					split -> {
						if (!assignedSplit.containsKey(split.getTopicPartition())) {
							if (!pendingSplit.containsKey(split.getTopicPartition())) {
								pendingSplit.put(split.getTopicPartition(), split);
							}
						}
					});
}

首先通过kafka客户端获取当前topic中所有的分片信息,并在map中维护。

private Map<TopicPartition, KafkaSourceSplit> pendingSplit;

public class KafkaSourceSplit implements SourceSplit {

    private TopicPartition topicPartition;
    private long startOffset = -1L;
    private long endOffset = -1L;
//.....
}

然后进行assignSplit,分配当前task所应该处理的topic分区。(比如2个分区,2个task,那么每个task就负责消费一个分区的数据)。

private synchronized void assignSplit() {
	Map<Integer, List<KafkaSourceSplit>> readySplit = new HashMap<>(Common.COLLECTION_SIZE);
	for (int taskID = 0; taskID < context.currentParallelism(); taskID++) {
		readySplit.computeIfAbsent(taskID, id -> new ArrayList<>());
	}

	pendingSplit
			.entrySet()
			.forEach(
					s -> {
						if (!assignedSplit.containsKey(s.getKey())) {
							readySplit
									.get(
											getSplitOwner(
													s.getKey(), context.currentParallelism()))
									.add(s.getValue());
						}
					});

	readySplit.forEach(context::assignSplit);

	assignedSplit.putAll(pendingSplit);
	pendingSplit.clear();
}

private static int getSplitOwner(TopicPartition tp, int numReaders) {
	int startIndex = ((tp.topic().hashCode() * 31) & 0x7FFFFFFF) % numReaders;
	return (startIndex + tp.partition()) % numReaders;
}

这里根据subTask的总量对kafka中topic所有分区进行了分组,放入了readySplit结构中。然后最终调用了ParallelEnumeratorContext的assignSplit方法将给每个subTask分配了属于它自己的split信息(就是topic的分区信息)。

@Override
public void assignSplit(int subtaskId, List<SplitT> splits) {
	if (this.subtaskId == subtaskId) {
		parallelSource.addSplits(splits);
	}
}

分片信息最终是加入到一个队列中,到此kafka的分片逻辑就执行的差不多了。

@Override
public void addSplits(List<KafkaSourceSplit> splits) {
	running = true;
	splits.forEach(
			s -> {
				try {
					pendingPartitionsQueue.put(s);
				} catch (InterruptedException e) {
					throw new KafkaConnectorException(
							KafkaConnectorErrorCode.ADD_SPLIT_CHECKPOINT_FAILED, e);
				}
			});
}

接下来会执行subtask(SeaTunnelParallelSource)的run方法去读取数据了。

@Override
public void run(Collector<T> collector) throws Exception {
	Future<?> future =
			executorService.submit(
					() -> {
						try {
							splitEnumerator.run();
						} catch (Exception e) {
							throw new RuntimeException("SourceSplitEnumerator run failed.", e);
						}
					});

	while (running) {
		if (future.isDone()) {
			future.get();
		}
		reader.pollNext(collector);
		Thread.sleep(SLEEP_TIME_INTERVAL);
	}
	LOG.debug("Parallel source runs complete.");
}

核心就是reader的pollNext方法了,具体的实现就在KafkaSourceReader中

@Override
public void pollNext(Collector<SeaTunnelRow> output) throws Exception {
	if (!running) {
		Thread.sleep(THREAD_WAIT_TIME);
		return;
	}

	while (pendingPartitionsQueue.size() != 0) {
		sourceSplits.add(pendingPartitionsQueue.poll());
	}
	sourceSplits.forEach(
			sourceSplit ->
					consumerThreadMap.computeIfAbsent(
							sourceSplit.getTopicPartition(),
							s -> {
								KafkaConsumerThread thread = new KafkaConsumerThread(metadata);
								executorService.submit(thread);
								return thread;
							}));
	sourceSplits.forEach(
			sourceSplit -> {
				CompletableFuture<Void> completableFuture = new CompletableFuture<>();
				try {
					consumerThreadMap
							.get(sourceSplit.getTopicPartition())
							.getTasks()
							.put(
									consumer -> {
										try {
											Set<TopicPartition> partitions =
													Sets.newHashSet(
															sourceSplit.getTopicPartition());
											StringDeserializer stringDeserializer =
													new StringDeserializer();
											stringDeserializer.configure(
													Maps.fromProperties(
															this.metadata.getProperties()),
													false);
											consumer.assign(partitions);
											if (sourceSplit.getStartOffset() >= 0) {
												consumer.seek(
														sourceSplit.getTopicPartition(),
														sourceSplit.getStartOffset());
											}
											ConsumerRecords<byte[], byte[]> records =
													consumer.poll(
															Duration.ofMillis(POLL_TIMEOUT));
											for (TopicPartition partition : partitions) {
												List<ConsumerRecord<byte[], byte[]>>
														recordList = records.records(partition);
												for (ConsumerRecord<byte[], byte[]> record :
														recordList) {

													deserializationSchema.deserialize(
															record.value(), output);

													if (Boundedness.BOUNDED.equals(
																	context.getBoundedness())
															&& record.offset()
																	>= sourceSplit
																			.getEndOffset()) {
														break;
													}
												}
												long lastOffset = -1;
												if (!recordList.isEmpty()) {
													lastOffset =
															recordList
																	.get(recordList.size() - 1)
																	.offset();
													sourceSplit.setStartOffset(lastOffset + 1);
												}

												if (lastOffset >= sourceSplit.getEndOffset()) {
													sourceSplit.setEndOffset(lastOffset);
												}
											}
										} catch (Exception e) {
											completableFuture.completeExceptionally(e);
										}
										completableFuture.complete(null);
									});
				} catch (InterruptedException e) {
					throw new KafkaConnectorException(
							KafkaConnectorErrorCode.CONSUME_DATA_FAILED, e);
				}
				completableFuture.join();
			});

	if (Boundedness.BOUNDED.equals(context.getBoundedness())) {
		// signal to the source that we have reached the end of the data.
		context.signalNoMoreElement();
	}
}

代码中可以看到会从分片逻辑结果队列中pendingPartitionsQueue获取到具体的分片。然后遍历进行消费。消费相关的代码其实就是kafka客户端相关的API。每个topic的分区会安排一个Thread进行消费。

到此,已经大致Seatunnel中一个source 是如何在Flink引擎中运行起来的。

CheckPoint的实现

最后来看一下checkPoint的实现,也是借助Flink自身的API来实现。

CheckpointConfig checkpointConfig = environment.getCheckpointConfig();
environment.enableCheckpointing(interval);

在初始化flink env的时候开启checkPoint功能。

并且在BaseSeaTunnelSourceFunction中实现了CheckpointedFunction和CheckpointListener两个接口。

public abstract class BaseSeaTunnelSourceFunction extends RichSourceFunction<Row>
        implements CheckpointListener, ResultTypeQueryable<Row>, CheckpointedFunction {

先来看看CheckpointedFunction提供的两个方法

initializeState:算子任务启动前调用,为算子中的状态进行初始化。

snapshotState:系统对数据做持久化时调用,用户可以借助此方法在持久化之前,对状态做一些操控。

在KafkaSourceReader中的具体实现就是去持久化其消费的offset信息到Map中。

@Override
public List<KafkaSourceSplit> snapshotState(long checkpointId) {
	checkpointOffsetMap.put(
			checkpointId,
			sourceSplits.stream()
					.collect(
							Collectors.toMap(
									KafkaSourceSplit::getTopicPartition,
									KafkaSourceSplit::getStartOffset)));
	return sourceSplits.stream().map(KafkaSourceSplit::copy).collect(Collectors.toList());
}

最后看一下CheckpointListener 中的notifyCheckpointComplete方法,在一个checkPoint结束时会将offset信息提交到kafka中。

@Override
public void notifyCheckpointComplete(long checkpointId) {
	if (!checkpointOffsetMap.containsKey(checkpointId)) {
		log.warn("checkpoint {} do not exist or have already been committed.", checkpointId);
	} else {
		checkpointOffsetMap
				.remove(checkpointId)
				.forEach(
						(topicPartition, offset) -> {
							try {
								consumerThreadMap
										.get(topicPartition)
										.getTasks()
										.put(
												consumer -> {
													if (this.metadata.isCommitOnCheckpoint()) {
														Map<TopicPartition, OffsetAndMetadata>
																offsets = new HashMap<>();
														offsets.put(
																topicPartition,
																new OffsetAndMetadata(offset));
														consumer.commitSync(offsets);
													}
												});
							} catch (InterruptedException e) {
								log.error("commit offset to kafka failed", e);
							}
						});
	}
}

总结

至此,已经对Seatunnel有了更深一步的了解,希望在后续使用中可以对其自身的Seatunnel Engine进行更多的了解。