背景默认情况下 Flink 每个 subtask 同步按序处理到来的数据,但可能有一些处理场景需要访问外部存储介质,比如 Sql 中使用 Lookup Join,每一次查询请求需要等待结果响应之后才能进行下一次请求,期间浪费了的大量时间在等待结果上,异步 I/O 的方式可以并发地处理多个请求和接收多个结果,等待的时间可以被其他多个请求均摊,大幅度提高流处理的吞吐量。Flink 通过一个专门的 AsyncWaitOperator 算子实现异步 I/O。本章分享主要介绍异步 I/O 的实现原理以及在 Lookup Join 场景下的一些特点。
核心接口
异步 I/O 的实现算子为 AsyncWaitOperator。
ResultFuture:异步 I/O 的核心接口,定义了两个方法:
- complete:标记该数据已完成处理;
- completeExceptionally:在执行过程中发生异常调用该方法,最常见的比如超时了。
- ResultHandler:ResultFuture 最核心的一个实现,桥接了用户逻辑和框架内部的逻辑,当用户异步获取了数据处理的结果后,ResultHandler 负责将该数据标记为完成并尝试下发;
- StreamRecord:流入的真实数据;
StreamElementQueue:在异步执行过程中缓存到来的数据,当数据处理完成后下发其对应的结果,根据下发顺序否有序分为两种实现:OrderedStreamElementQueue、UnorderedStreamElementQueue,核心定义了三个方法:
- tryPut:将上游到来的数据放入队列,即将 StreamRecord 封装为 StreamElementQueueEntry;
- hasCompletedElements:判断是否有数据已完成异步执行;
- emitCompletedElement:尝试下发已被标记完成的数据对应的异步执行结果。
StreamElementQueueEntry:StreamRecord 外包装的一层结构,也是 StreamElementQueue 中存储的元素的接口抽象,提供了以下几个重要接口:
- complete:继承自 ResultFuture,用于标记该数据对应的结果已计算完;
- isDone:判断该次异步调用是否已完成;
- emitResult:下发结果。
调用流程
用户端
DataStream 场景下,对于用户来说需要做以下三件事:实现 AsyncFunction 接口,并在 asyncInvoke 方法中手动实现异步执行数据处理的逻辑(主要为外部数据库交互);获取处理结果并调用 ResultFuture.complete 下发数据结果;将异步 I/O 应用于 DataStream 作为 DataStream 的一次转换操作。
引擎端
引擎在处理时首先对每个输入数据封装为对应的队列元素,如果是有序队列则为 StreamRecordQueueEntry,无序队列则为 SegmentedStreamRecordQueueEntry,如果该输入数据为 watermark,则为 WatermarkQueueEntry,上图以有序队列为例。
当用户的自定义异步逻辑调用完成后,用户回调 ResultHandler.complete 将结果数据透传给引擎端,引擎首先调用其封装的队列元素的 complete 方法将该数据标记为完成,再调用队列的 emitCompletedElement 尝试下发。这里为什么是“尝试下发”而不是下发,因为在有序队列中,我们要保证先来的数据先下发,当数据 A、B 按序到来,通过异步 I/O 框架处理后 B 先被处理完,此时 B 会被标记为完成,并尝试下发 B 的结果。由于数据 A 尚未完成,因此这一刻不会下发数据,而是等待数据 A 也被标记完成后,在下发 A 的结果的轮次时把 B 一起发掉。
超时处理机制
Flink 不可能无限制地等待用户数据异步执行结果,尤其当使用有序队列缓存结果时,任意一个元素如果在异步执行过程中阻塞,那则会导致后续数据都不再下发,因此 Flink 会为每条数据维护一个定时器,当这条数据在一定时间内没有处理完(即用户调用 ResultFuture.complete 方法),则直接丢弃。Flink SQL 可通过 table.exec.async-lookup.timeout 参数配置,默认为 3 分钟。
考虑一个问题,如果某条数据在超时后其对应结果到来了,那 Flink 是怎么忽略的?ResultHandler 中存在一个原子属性 completed 用于标记该请求是否完成:
public void complete(Collection<OUT> results) {
// already completed (exceptionally or with previous complete call from ill-written
// AsyncFunction), so
// ignore additional result
if (!completed.compareAndSet(false, true)) {
return;
}
processInMailbox(results);
}
public void completeExceptionally(Throwable error) {
// already completed, so ignore exception
if (!completed.compareAndSet(false, true)) {
return;
}
// signal failure through task
getContainingTask()
.getEnvironment()
.failExternally(
new Exception(
"Could not complete the stream element: " + inputRecord + '.',
error));
// complete with empty result, so that we remove timer and move ahead processing (to
// leave potentially
// blocking section in #addToWorkQueue or #waitInFlightInputsFinished)
processInMailbox(Collections.emptyList());
}
AsyncWaitOperator 在处理数据时,会注册一个定时器,定时器被触发时调用 ResultHandler.completeExceptionally,而当数据正常执行调用 ResultHandler.complete(...) 或超时后调用 ResultHandler.completeExceptionally(...) 时,第一件事就是先将 completed 置为 true。因此当这个数据处理超时后,原先的数据请求不会被直接停止,但当从外部存储中获取到数据后,发现 completed 已被置为 true,也不会进行后续的处理。
异步队列
Flink 为了实现异步 I/O 机制,必然是先将数据缓存到一个队列中,队列接口名为 StreamElementQueue,实际不管是有序还是无序的实现,它们最终都是利用 ArrayDeque 来存储数据。队列存储的数据量由 capacity 控制,在 SQL 场景下默认为 100,可通过 table.exec.async-lookup.buffer-capacity 参数设置。当正在处理的数据到达这个阈值后,后面到来的数据在插入队列时会被阻塞,直到前面的数据处理完留出了新的空间,这也是为了避免流量过高时同时启的异步线程过多。
有序队列 OrderedStreamelementQueue
每一条数据当处理完毕时不会立即下发,会首先检查在它之前到来的数据是否处理完毕,如果没有,则不会下发,如下图:
等到 record1 也被处理完毕后,会一次性将队列从所有完成的数据按序下发。该方式缺点很明显,假设前面的数据处理发生异常延迟,则会影响整条链路的数据的时效性。
无序队列 UnorderedStreamElementQueue
无序队列将数据流按 watermark 切割成一个个 Segment 进行存储(Deque<Segment<OUT>>),如果数据流按事件时间产生 watermark,则一系列 Segment 的数据组织如下:
每个 Segment 之内的数据可以无序下发,Segment 之间的数据依然有序,如果在无事件时间的情况下,则无序队列里只有一个 Segment,等价于所有数据均无序。
优点:
- 无事件时间场景下,数据都存在一个 segment 里,完全无序,每条数据独立处理下发,在部分场景下能有效提升吞吐量;
- 在有事件时间场景下,任意两个 watermark 间隔内的数据仍无序下发,部分场景依旧能提升作业吞吐量。
缺点:如果用户对数据有顺序要求,需要用户额外在下游提供排序的逻辑,会增加部分用户的开发成本。
状态恢复
如果作业宕机了,有部分正在异步处理的数据还没得到结果,那这部分数据是不是就彻底丢了呢?答案是不会,Flink 利用了状态恢复机制来确保作业宕机恢复时数据依旧能正常处理。
AsyncWaitOperator 使用了 OperatorState,用于存储正在被异步处理的数据,当制作 checkpoint 时,AsyncWaitOperator 会将队列里的所有数据以 ListState 的形式存到状态中。在作业启动时,初始化状态阶段,AsyncWaitOperator 会从状态里恢复这部分数据,存到 recoveredStreamElements,在初始化算子阶段,遍历这些数据重新发起异步处理。因此,这部分数据可能会重复处理下发,但不会因为宕机而丢数据,唯一会丢的情况只有数据处理超时了或处理过程中发生异常。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。