Flink侧面输出流Side outputs和异步IO流asyncIO
Side Outputs
除了DataStream操作产生的主流之外,还可以生成任意数量的附加端输出结果流。
结果流中的数据类型不必与主流中的数据类型匹配,输出的类型也可能不同。
当要分割一个数据流时,此操作非常有用,通常情况下必须复制该流,然后从每个流中筛选出您不想要的数据。但侧面输出流不用复制。
使用侧面输出时,首先需要定义一个OutputTag,用于标识侧面输出流:
OutputTag<String> outputTag = new OutputTag<String>("side-output") {};
注意OutputTag是如何根据边输出流包含的元素类型定义的。
定义好侧输出标签之后,只有在特定的函数中才能使用侧输出流,如下:
ProcessFunction
KeyedProcessFunction
CoProcessFunction
KeyedCoProcessFunction
ProcessWindowFunction
ProcessAllWindowFunction
可以使用Context参数,将数据发送到OutputTag标识的端输出,示例如下:
DataStream<Integer> input = ...;
final OutputTag<String> outputTag = new OutputTag<String>("side-output"){};
SingleOutputStreamOperator<Integer> mainDataStream = input
.process(new ProcessFunction<Integer, Integer>() {
@Override
public void processElement(
Integer value,
Context ctx,
Collector<Integer> out) throws Exception {
// 数据正常输出
out.collect(value);
// 数据侧输出
ctx.output(outputTag, "sideout-" + String.valueOf(value));
}
});
为了检索侧输出流,可以对DataStream操作的结果使用getSideOutput(OutputTag)。这里提供一个DataStream,该DataStream类型为侧输出流的结果:
final OutputTag<String> outputTag = new OutputTag<String>("side-output"){};
SingleOutputStreamOperator<Integer> mainDataStream = ...;
DataStream<String> sideOutputStream = mainDataStream.getSideOutput(outputTag);
Async I/O
异步I/O操作的需要:
1. 当与外部系统交互时(例如,当使用存储在数据库中的数据丰富流事件时),需要注意与外部系统的通信延迟不会影响流应用程序的总体工作。
2. 天真地访问外部数据库中的数据,例如在MapFunction中,通常意味着同步交互:向数据库发送请求,MapFunctiona等待直到收到响应。在许多情况下,这种等待占据了函数的绝大多数时间。
3. 与数据库的异步交互意味着单个并行函数实例可以同时处理多个请求并同时接收响应。这样,等待时间可以与发送其他请求和接收响应重叠。至少,等待时间是在多个请求上分摊的。在大多数情况下,这会导致更高的流吞吐量。
注意:在某些情况下,通过将MapFunction扩展到非常高的并行度来提高吞吐量也是可能的,但通常会带来非常高的资源成本:
拥有更多的并行MapFunctional实例意味着更多的任务、线程、Flink内部网络连接、数据库的网络连接、缓冲区和一般内部记帐开销。
对数据库(或键值对存储)实现适当的异步I/O需要一个支持异步请求的数据库客户端。许多流行的数据库都提供了这样的客户端。
如果没有这样的客户端,可以通过创建多个客户端并使用线程池处理同步调用,尝试将同步客户端转换为有限的并发客户端。
然而,这种方法通常比合适的异步客户端效率低。
Flink的异步I/O API允许用户对数据流使用异步请求客户端。API处理与数据流的集成,以及处理顺序、事件时间、容错、重试支持等。
假设目标数据库有一个异步客户机,则需要三个部分来实现对数据库的异步I/O流转换:
调度请求的AsyncFunction的实现
获取操作结果并将其传递给ResultFuture的回调
将异步I/O操作作为转换应用于数据流,无论是否重试
以下代码示例说明了基本模式:
class AsyncDatabaseRequest extends RichAsyncFunction<String, Tuple2<String, String>> {
/** The database specific client that can issue concurrent requests with callbacks */
private transient DatabaseClient client;
@Override
public void open(Configuration parameters) throws Exception {
client = new DatabaseClient(host, post, credentials);
}
@Override
public void close() throws Exception {
client.close();
}
@Override
public void asyncInvoke(String key, final ResultFuture<Tuple2<String, String>> resultFuture) throws Exception {
// issue the asynchronous request, receive a future for result
final Future<String> result = client.query(key);
// set the callback to be executed once the request by the client is complete
// the callback simply forwards the result to the result future
CompletableFuture.supplyAsync(new Supplier<String>() {
@Override
public String get() {
try {
return result.get();
} catch (InterruptedException | ExecutionException e) {
// Normally handled explicitly.
return null;
}
}
}).thenAccept( (String dbResult) -> {
resultFuture.complete(Collections.singleton(new Tuple2<>(key, dbResult)));
});
}
}
// create the original stream
DataStream<String> stream = ...;
// apply the async I/O transformation without retry
DataStream<Tuple2<String, String>> resultStream =
AsyncDataStream.unorderedWait(stream, new AsyncDatabaseRequest(), 1000, TimeUnit.MILLISECONDS, 100);
// or apply the async I/O transformation with retry
// create an async retry strategy via utility class or a user defined strategy
AsyncRetryStrategy asyncRetryStrategy =
new AsyncRetryStrategies.FixedDelayRetryStrategyBuilder(3, 100L) // maxAttempts=3, fixedDelay=100ms
.retryIfResult(RetryPredicates.EMPTY_RESULT_PREDICATE)
.retryIfException(RetryPredicates.HAS_EXCEPTION_PREDICATE)
.build();
// apply the async I/O transformation with retry
DataStream<Tuple2<String, String>> resultStream =
AsyncDataStream.unorderedWaitWithRetry(stream, new AsyncDatabaseRequest(), 1000, TimeUnit.MILLISECONDS, 100, asyncRetryStrategy);
重要提示:ResultFuture是通过第一次调用ResultFutere.complete完成的。所有后续的完整调用都将被忽略。
以下三个参数控制异步操作:
超时:超时定义异步操作最终被视为失败所需的时间,如果启用了重试,则可能包括多次重试请求。此参数可防止死机/失败的请求。
容量:此参数定义同时可以处理多少个异步请求。尽管异步I/O方法通常会带来更好的吞吐量,但运营商仍然是流应用程序的瓶颈。限制并发请求的数量可以确保操作员不会积累越来越多的待定请求积压,但一旦容量耗尽,就会引发背压。
异步重试策略:AsyncRetryStrategy定义了触发延迟重试的条件和延迟策略,例如固定延迟、指数退避延迟、自定义实现等。
超时处理
当异步I/O请求超时时,默认情况下会抛出异常并重新启动作业。如果要处理超时,可以重写AsyncFunction#超时方法。
确保调用ResultFuture。complete()或ResultFuture。completeExceptionally(),以便向Flink指示此输入记录的处理已完成。
如果不想在发生超时时发出任何记录,可以调用ResultFuture.complete(Collections.emptyList())。
结果的顺序
AsyncFunction发出的并发请求通常以未定义的顺序完成,这取决于哪个请求先完成。为了控制结果记录的发出顺序,Flink提供了两种模式:
无序:一旦异步请求完成,就会发出结果记录。流中记录的顺序在异步I/O操作符之后与之前不同。当使用处理时间作为基本时间特征时,此模式具有最低的延迟和最低的开销。对此模式使用AsyncDataStream.unorderedWait(…)。
Ordered:在这种情况下,将保留流顺序。结果记录的发出顺序与触发异步请求的顺序相同(操作员输入记录的顺序)。为了实现这一点,操作员缓冲一个结果记录,直到它前面的所有记录都被发出(或超时)。
这通常会在检查点中引入一些额外的延迟和开销,因为与无序模式相比,记录或结果在检查点状态下维护的时间更长。对此模式使用AsyncDataStream.orderedWait(…)
当流应用程序与事件时间一起工作时,异步I/O操作员将正确处理水印。具体而言,这意味着两种订单模式的如下内容:
无序:水印不会超过记录,反之亦然,这意味着水印建立了顺序边界。记录仅在水印之间无序发出。在某个水印之后发生的记录只有在该水印发出之后才会发出。只有在发出水印之前输入的所有结果记录都发出后,才会发出水印。
这意味着在存在水印的情况下,无序模式会引入与有序模式相同的延迟和管理开销。该开销的大小取决于水印频率。
有序:保留水印和记录的顺序,就像保留记录之间的顺序一样。与处理时间相比,开销没有显著变化。
请记住,摄入时间是事件时间的特例,它根据源处理时间自动生成水印。
异步I/O操作符提供完全一次容错保证。它将运行中异步请求的记录存储在检查点中,并在从故障恢复时恢复/重新触发请求。
重试支持为异步运算符引入了一种内置机制,对用户的AsyncFunction透明。
异步重试策略:AsyncRetryStrategy包含重试条件AsyncReptryPredicate的定义,以及根据当前尝试次数确定是否继续重试和重试间隔的接口。请注意,在满足触发器重试条件后,可能会因为当前尝试次数超过预设限制而放弃重试,或者在任务结束时被强制终止重试(在这种情况下,系统将最后一次执行结果或异常作为最终状态)。
AsyncRetryPredicate:可以根据返回结果或执行异常触发重试条件。
对于带有Futures的实现,如果它有一个用于回调的Executor(或Scala中的ExecutionContext),我们建议使用DirectExecuto,因为回调通常只做最少的工作,而且DirectExecuter避免了额外的线程到线程切换开销。回调通常只将结果传递给ResultFuture,后者将其添加到输出缓冲区。从那里开始,包括记录发送和与检查点簿记交互的繁重逻辑无论如何都发生在一个专用线程池中。
可以通过org.apache.flink.util.concurrent.Executors.directExecutor() 或者 com.google.common.util.concurrent.MoreExecutors.directExecutor()来获取执行方法。
注意事项
AsyncFunction不是多线程
我们想在此明确指出的一个常见混淆是,AsyncFunction不是以多线程方式调用的。AsyncFunction只有一个实例,它是为流的各个分区中的每个记录顺序调用的。除非asyncInvoke(…)方法返回速度快并且依赖于回调(由客户端),否则它不会产生正确的异步I/O。
例如,以下模式会导致阻塞asyncInvoke(…)函数,从而使异步行为无效:
使用其查找/查询方法调用阻塞的数据库客户端,直到收到结果为止
阻止/等待异步客户端在asyncInvoke(…)方法内返回的未来类型对象
AsyncFunction(AsyncWaitOperator)可以在作业图中的任何位置使用,但不能链接到SourceFunction/SourceStreamTask。
如果启用重试,可能需要更大的队列容量
新的重试功能可能会导致更大的队列容量需求,最大数量可近似计算如下:
inputRate*retryRate*平均重试持续时间
例如,对于inputRate=100条记录/秒的任务,其中1%的元素将平均触发1次重试,平均重试时间为60秒,额外的队列容量要求为:
100条记录/秒*1%*60s=60
也就是说,在无序输出模式下,向工作队列添加更多60个容量可能不会影响吞吐量,在有序模式下,头元素是关键点,它保持未完成的时间越长,操作员提供的处理延迟越长,重试功能可能会增加头元素的未完成时间,如果实际上使用相同的超时限制获得了更多重试。
当队列容量增加时(缓解背压的常见方法),OOM的风险增加。实际上,对于ListState存储,理论上限是Integer。MAX_VALUE,所以队列容量的限制是相同的,但我们不能在生产中增加过大的队列容量,增加任务并行性可能是一种更可行的方法。