Data Source 原理
核心组件
- 分片(
Split
):对一部分 source 数据的包装,如一个文件或者日志分区。分片是 source 进行任务分配和数据并行读取的基本粒度。 - 源阅读器(
SourceReader
):会请求分片并进行处理,例如读取分片所表示的文件或日志分区。SourceReader
在TaskManagers
上的SourceOperators
并行运行,并产生并行的事件流 / 记录流 - 分片枚举器(
SplitEnumerator
):会生成分片并将它们分配给SourceReader
。该组件在JobManager
上以单并行度运行,负责对未分配的分片进行维护,并以均衡的方式将其分配给 reader。
以上 3 个核心组件的关系图:
流处理和批处理的统一
Data Source API 以对无界流数据和有节批数据的处理是统一的。对于有界批数据,枚举器生成固定数量的分片,并且每个分片都必须是有限的;对于无界流数据,分片大小可以是无限的,或者枚举器将不断生成新的分片。
示例
有界 File Source
Source 将包含待读取目录的 URI / 路径,以及一个定义了如何对文件进行解析的格式(Format),在该情况下:
- 分片是一个文件,或者是文件的一个区域
-
SplitEnumerator
将会列举给定目录下的所有文件,并在收到来自 reader 的请求时对分片进行分配。一旦所有的分片都被分配完毕,则会使用NoMoreSplits
来响应请求 -
SourceReader
则会请求分片,读取所分配的分片,并使用给定的格式进行解析。如果当前请求没有获得下一个分片,而是NoMoreSplits
,则会终止任务
无界 Stream File Source
相较于有界 File Source,SplitEnumerator
不会使用 NoMoreSplits
来响应 SourceReader
的请求,并且还会定期列出给定 URI / 路径下的文件来检查是否有新文件。一旦发现新文件,则生成对应的新分片,并将它们分配给空闲的 SourceReader。
无界 Stream Kafka Source
Source 将具有 Kafka TOPIC(或一系列 Topics,或通过正则表达式匹配的 Topic)以及一个解析器(Deserializer)来解析记录(record)。
- 分片是一个 Kafka Topic Partition
-
SplitEnumerator
会链接到 broker 从而选举出已订阅的 Topics 中的所有 Topic Partitions。枚举器可以重复此操作以检查是否有新的 Topic / Partitions -
SourceReaer
使用KafkaConsumer
读取所分配的分片(Topic Partition),并使用提供的解析器反序列化记录。由于流处理中分片(Topic Partition)的大小是无限的,因此 reader 永远无法读取到数据尾部。
有界 Kafka Source
相较于无界 Stream Kafka Source,每个分片都会有一个预定义的结束偏移量。一旦 SourceReader
读取到分片的结束偏移量,整个分片的读取就会结束。而一旦所有分配的分片读取结束,SourceReader
也就终止任务了。
Data Source API
Source API 是一个工厂模式的接口,用于创建以下组件:
Split Enumerator
Source Reader
Split Serializer
Enumerator Checkpoint Serializer
Source 的实现应该是可序列化的,因为 Source 实例在运行时会被序列化并上传到 Flink 集群。
Split Enumerator
SplitEnumerator
是整个 Source 的 “大脑”,需实现如下:
-
SourceReader
的注册处理 SourceReader
的失败处理
-
SourceReader
在失败时会调用addSplitsBack()
方法,此时SplitEnumerator
应当收回已经被分配,但尚未被改SourceReader
确认(acknowledged)的分片。
SourceEvent
的处理:
-
SourceEvents
是SplitEnumerator
和SourceReader
之间来回传递的自定义事件,可以利用此机制来执行复杂的协调任务
- 分片的发现以及分配
-
SplitEnumerator
可以将分片分配到SourceReader
从而响应各种事件,包括发现新的分片,新SourceReader
的注册以及SourceReader
的失败处理等。
SplitEnumerator
可以通过 SplitEnumeratorContext
实现上述功能。
示例:使用
SplitEnumerator
中在不需要自己维护线程的条件下,实现自动寻找分片并分配给SourceREader
class MySplitEnumerator implements SplitEnumerator<MySplit, MyCheckpoint> {
private final long DISCOVER_INTERVAL = 60_000L;
/**
* 一种发现分片的方法
*/
private List<MySplit> discoverSplits() {...}
@Override
public void start() {
...
enumContext.callAsync(this::discoverSplits, splits -> {
Map<Integer, List<MySplit>> assignments = new HashMap<>();
int parallelism = enumContext.currentParallelism();
for (MySplit split : splits) {
int owner = split.splitId().hashCode() % parallelism;
assignments.computeIfAbsent(owner, new ArrayList<>()).add(split);
}
enumContext.assignSplits(new SplitsAssignment<>(assignments));
}, 0L, DISCOVER_INTERVAL);
...
}
...
}
SourceReader
SourceReader
是一个运行在 Task Manager 上的组件,用于处理来自分片的记录。
SourceReader
提供了一个拉动式(pull-based)处理接口,Flink 任务会在循环中不断调用 pollNext(ReaderOutput)
轮询来自 SourceReader
的记录。pollNext(ReaderOutput)
方法的返回值指示 SourceReader
的状态。
-
MORE_AVAILABLE
:SourceReader
有可用的记录 -
NOTHING_AVAILABLE
:SourceReader
现在没有可用的记录,但是将来可能会有记录可用 -
END_OF_INPUT
:SourceReader
已经处理完所有记录,到达数据的尾部,这意味着SourceReader
可以终止任务了。
pollNext(ReaderOutput)
会使用 ReaderOutput
作为参数,为了提高性能且在必要情况下,SourceReader
可以在一次 pollNext()
调用中返回多条记录。例如外部系统以块为单位,而一个块可以包含多个记录,但是 source 只能在块的边界处设置 checkpoint 时,sourceReader
可以一次将一个块中的所有记录通过 ReaderOutput
发送到下游。
然而,除非有必要,SourceReader
的实现应该避免在一次 pollNext(ReaderOutper)
的调用中发送多个记录。这是因为对 SourceReader
的轮询的任务线程工作在一个事件循环(event-loop)中,且不能阻塞。
在创建 SourceReader
时,相应的 SourceReaderContext
会提供给 Source
,而 Source
则会将其传递给 SourceReader
实例。因此,SourceReader
可以通过 SourceReaderContext
的 SourceEvent
传递相应的信息给 SplitEnumerator
。Source
的一个典型设计模式是让 SourceReader
发送它们的本地信息给 SplitEnumerator
,后者则会全局性地做出决定。
Source 的使用方法
示例:通过
Source
创建DataSTream
的样例
final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
Source mySource = new MySource(...);
DataStream<Integer> stream = env.fromSource(
mySource,
WatermarkStrategy.noWatermarks(),
"MySourceName");
...
SplitReader API
核心的 Source Reader API 是完全异步的,但实际上大多数 Source 都会使用阻塞的操作,例如 KafkaConsumer
的 poll()
、分布式系统的阻塞 I/O 操作等因此,为了使其与异步的 Source API 兼容,这些阻塞操作需要再单独的线程中执行,并在之后将数据提交给 reader 的异步线程。
SplitReader
SplitReader
API 只有以下 3 个方法:
-
fetch()
方法:阻塞式的提取方法,返回值为RecordsWithSplits
-
handleSplitsChanges()
方法:非阻塞式处理分片变动方法 -
wakeUp()
方法:非阻塞式的唤星方法,用于唤醒阻塞中的提取操作。
SourceReaderBase
常见的 SourceReader 实现方法如下:
- 有一个线程池以阻塞的方式从外部系统提取分片
- 解决内部提取线程与其他方法调用之间的同步
- 维护每个分片的 watermark 以保证 watermark 对齐
- 维护每个分片的状态已进行 checkpoint
Flink 提供的 SourceReaderBase
类作为 SourceReader
的基本实现,已经实现了上述需求。要重新编辑新的 SourceReader
,只需要让 SourceReader
继承 SourceReaderBase
即可。
SplitFetcherManager
SourceReaderBase
支持几个开箱即用的线程模型,可以通过 SplitFetcherManager
来进行控制。
SplitFetcherManager
创建和维护一个分片提取器池,同时每个分片提取器使用一个 SplitReader
进行提取,它还决定如何分配分片给分片提取器。
示例:使用
SplitFetcherManager
维护固定数量的线程,每个线程分配给SourceReader
的一些分片进行抓取。一个具有固定数量的分片提取器,并根据分片 ID 的哈希值将分片分配给分片提取器的
SplitFetcherManager
:
public class FixedSizeSplitFetcherManager<E, SplitT extends SourceSplit>
extends SplitFetcherManager<E, SplitT> {
private final int numFetchers;
public FixedSizeSplitFetcherManager(
int numFetchers,
FutureCompletingBlockingQueue<RecordsWithSplitIds<E>> elementsQueue,
Supplier<SplitReader<E, SplitT>> splitReaderSupplier) {
super(elementsQueue, splitReaderSupplier);
this.numFetchers = numFetchers;
// 创建 numFetchers 个分片提取器.
for (int i = 0; i < numFetchers; i++) {
startFetcher(createSplitFetcher());
}
}
@Override
public void addSplits(List<SplitT> splitsToAdd) {
// 根据它们所属的提取器将分片聚集在一起。
Map<Integer, List<SplitT>> splitsByFetcherIndex = new HashMap<>();
splitsToAdd.forEach(split -> {
int ownerFetcherIndex = split.hashCode() % numFetchers;
splitsByFetcherIndex
.computeIfAbsent(ownerFetcherIndex, s -> new ArrayList<>())
.add(split);
});
// 将分片分配给它们所属的提取器。
splitsByFetcherIndex.forEach((fetcherIndex, splitsForFetcher) -> {
fetchers.get(fetcherIndex).addSplits(splitsForFetcher);
});
}
}
使用这种线程模型的
SourceReader
如下:
public class FixedFetcherSizeSourceReader<E, T, SplitT extends SourceSplit, SplitStateT>
extends SourceReaderBase<E, T, SplitT, SplitStateT> {
public FixedFetcherSizeSourceReader(
FutureCompletingBlockingQueue<RecordsWithSplitIds<E>> elementsQueue,
Supplier<SplitReader<E, SplitT>> splitFetcherSupplier,
RecordEmitter<E, T, SplitStateT> recordEmitter,
Configuration config,
SourceReaderContext context) {
super(
elementsQueue,
new FixedSizeSplitFetcherManager<>(
config.getInteger(SourceConfig.NUM_FETCHERS),
elementsQueue,
splitFetcherSupplier),
recordEmitter,
config,
context);
}
@Override
protected void onSplitFinished(Map<String, SplitStateT> finishedSplitIds) {
// 在回调过程中对完成的分片进行处理。
}
@Override
protected SplitStateT initializedState(SplitT split) {
...
}
@Override
protected SplitT toSplitType(String splitId, SplitStateT splitState) {
...
}
}
事件时间和 watermark
Source 的实现需要完成一部分事件时间分配和 watermark 生成的工作,离开 SourceREader
的事件需要具有事件时间戳,并且包含 watermark。
API
在 DataStream API 创建期间,WatermarkStrategy 会被传递给 Source,并同时创建 TimestampAssigner
和 WatermarkGenerator
。
TimeStampAssigner
和 WatermarkGenerator
作为 ReaderOutput
的一部分运行,因此 Source 实现着不必实现任何时间戳提取和 watermark 生成的代码。
事件时间戳
事件时间戳的分配分为如下两步:
-
SourceReader
通过调用SourceOutput.collect(event, timestamp)
将 Source 记录的时间戳添加到事件中。 - 由应用程序配置的
TimestampAssigner
分配最终的时间戳。TimeStampAssigner
会查看原始的 Source 记录的时间戳和事件。
通过以上方法,既可以引用 Source 系统中的时间戳,也可以引用事件数据中的时间戳作为事件时间戳。
watermark 生成
watermark 生成器只在流执行模式下会被激活,批执行模式则会停止 watermark 生成器。
数据 Source API 支持每个分片单独运行 watermark 生成器,这使得 Flink 可以分别观察每个分片的事件时间进度,这对于正确处理事件时间偏差和防止空闲分区阻碍整个应用程序的事件时间进度来说是很重要的。
使用 SplitReader
API 实现源连接器时,将自动进行处理。所有基于 SplitReader
API 的实现都具有分片 watermark。
分片级 watermark 对齐
虽然 Flink 在运行中会处理 source 算子的 watermark 对齐,但是在 source 中也需要额外地实现 SourceReader#pauseOrResumeSplits
和 SplitReader#pauseOrResumeSplits
,用于实现分片(split)级别的 watermark 对齐。
当有多个分片指定给同一个 sourceReader
时,分片级的 watermark 对齐将很有用。
在默认情况下,当多个分片指定了同一个 SourceReader
,且有分片超出了 WatermarkStrategy
规定的 watermark 对齐阈值后,将抛出 UnsupportedOperationException, pipeline.watermark-alignment.allow-unaligned-source-splits is set to false
异常。
SourceReaderBase
包含了 SourceReader#pauseOrResumeSplits
的实现,因此继承后只需要实现 SplitReader#pauseOrResumeSplits
。