Data Source 原理

核心组件
  • 分片(Split):对一部分 source 数据的包装,如一个文件或者日志分区。分片是 source 进行任务分配和数据并行读取的基本粒度。
  • 源阅读器(SourceReader):会请求分片并进行处理,例如读取分片所表示的文件或日志分区。SourceReaderTaskManagers 上的 SourceOperators 并行运行,并产生并行的事件流 / 记录流
  • 分片枚举器(SplitEnumerator):会生成分片并将它们分配给 SourceReader。该组件在 JobManager 上以单并行度运行,负责对未分配的分片进行维护,并以均衡的方式将其分配给 reader。

以上 3 个核心组件的关系图:

flink无界数据流_数据源

流处理和批处理的统一

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 的处理:
  • SourceEventsSplitEnumeratorSourceReader 之间来回传递的自定义事件,可以利用此机制来执行复杂的协调任务
  • 分片的发现以及分配
  • 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_AVAILABLESourceReader 有可用的记录
  • NOTHING_AVAILABLESourceReader 现在没有可用的记录,但是将来可能会有记录可用
  • END_OF_INPUTSourceReader 已经处理完所有记录,到达数据的尾部,这意味着 SourceReader 可以终止任务了。

pollNext(ReaderOutput) 会使用 ReaderOutput 作为参数,为了提高性能且在必要情况下,SourceReader 可以在一次 pollNext() 调用中返回多条记录。例如外部系统以块为单位,而一个块可以包含多个记录,但是 source 只能在块的边界处设置 checkpoint 时,sourceReader 可以一次将一个块中的所有记录通过 ReaderOutput 发送到下游。

然而,除非有必要,SourceReader 的实现应该避免在一次 pollNext(ReaderOutper) 的调用中发送多个记录。这是因为对 SourceReader 的轮询的任务线程工作在一个事件循环(event-loop)中,且不能阻塞。

在创建 SourceReader 时,相应的 SourceReaderContext 会提供给 Source,而 Source 则会将其传递给 SourceReader 实例。因此,SourceReader 可以通过 SourceReaderContextSourceEvent 传递相应的信息给 SplitEnumeratorSource 的一个典型设计模式是让 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 都会使用阻塞的操作,例如 KafkaConsumerpoll()、分布式系统的阻塞 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 的一些分片进行抓取。

flink无界数据流_数据源_02

一个具有固定数量的分片提取器,并根据分片 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,并同时创建 TimestampAssignerWatermarkGenerator

TimeStampAssignerWatermarkGenerator 作为 ReaderOutput 的一部分运行,因此 Source 实现着不必实现任何时间戳提取和 watermark 生成的代码。

事件时间戳

事件时间戳的分配分为如下两步:

  1. SourceReader 通过调用 SourceOutput.collect(event, timestamp) 将 Source 记录的时间戳添加到事件中。
  2. 由应用程序配置的 TimestampAssigner 分配最终的时间戳。TimeStampAssigner 会查看原始的 Source 记录的时间戳和事件。

通过以上方法,既可以引用 Source 系统中的时间戳,也可以引用事件数据中的时间戳作为事件时间戳。

watermark 生成

watermark 生成器只在流执行模式下会被激活,批执行模式则会停止 watermark 生成器。

数据 Source API 支持每个分片单独运行 watermark 生成器,这使得 Flink 可以分别观察每个分片的事件时间进度,这对于正确处理事件时间偏差和防止空闲分区阻碍整个应用程序的事件时间进度来说是很重要的。

flink无界数据流_数据源_03

使用 SplitReader API 实现源连接器时,将自动进行处理。所有基于 SplitReader API 的实现都具有分片 watermark。

分片级 watermark 对齐

虽然 Flink 在运行中会处理 source 算子的 watermark 对齐,但是在 source 中也需要额外地实现 SourceReader#pauseOrResumeSplitsSplitReader#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