流处理
流是数据的自然栖息地。无论是来自Web服务器的事件,来自证券交易所的交易,还是来自工厂车间机器上的传感器读数,数据都将作为流的一部分创建。但是,当您分析数据时,您可以围绕有界 流或无界流来组织处理,并且选择哪种范例会产生深远的影响。
有界和无界流
当您处理有限的数据流时,批处理是工作的范例。在这种操作模式下,您可以选择在产生任何结果之前先摄取整个数据集,这意味着,例如,可以对数据进行排序,计算全局统计数据或产生总结所有输入的最终报告。
另一方面,流处理涉及无限的数据流。至少从概念上讲,输入可能永远不会结束,因此您被迫在数据到达时对其进行连续处理。
在Flink中,应用程序由可以由用户定义的运算符转换的流数据流组成。这些数据流形成有向图,这些图以一个或多个 源开始,并以一个或多个接收器结束。
一个DataStream程序及其数据流。
程序中的转换与数据流中的运算符之间通常存在一一对应的关系。但是,有时,一个转换可能包含多个运算符。
应用程序可能会消耗来自流源(例如消息队列或分布式日志,例如Apache Kafka或Kinesis)的实时数据。但是flink也可以使用来自各种数据源的有限的历史数据。同样,可以将Flink应用程序产生的结果流发送到可以作为接收器连接的各种系统。
用源和接收器刷新应用程序
并行数据流
Flink中的程序本质上是并行的和分布式的。在执行期间,一个 流具有一个或多个流分区,并且每个运算符具有一个或多个运算符子任务。操作员子任务彼此独立,并在不同的线程中执行,并且可能在不同的机器或容器上执行。
操作员子任务的数量是该特定操作员的并行性。同一程序的不同运算符可能具有不同的并行度。
并行数据流
流可以按一对一(或 转发)模式或重新分配模式在两个运算符之间传输数据:
一对一的流(例如 ,上图中的Source和map()运算符之间)保留元素的分区和排序。这意味着map()运算符的subtask [1]将以与Source运算符的subtask [1]产生的相同顺序看到相同的元素。
重新分配流(如上面的map()和keyBy / window之间以及keyBy / window和Sink之间)会更改流的分区。每个操作员子任务都将数据发送到不同的目标子任务,具体取决于所选的转换。实例是keyBy() (其重新分区通过散列键),广播() ,或再平衡() (其重新分区随机地)。在重新分发交换中,元素之间的顺序仅保留在每对发送和接收子任务中(例如,map()的subtask [1]和map()的subtask [2]) keyBy / window)。因此,例如,上面显示的keyBy /窗口和Sink运算符之间的重新分配引入了不确定性,即关于不同键的聚合结果到达Sink的顺序。
及时的流处理
对于大多数流应用程序而言,能够使用用于处理实时数据的相同代码重新处理历史数据,并产生确定性的,一致的结果非常有价值。
注意事件发生的顺序,而不是交付事件进行处理的顺序,并能够推理出一组事件何时(或应该)完成,也很重要。例如,考虑电子商务交易或金融交易中涉及的事件集。
通过使用记录在数据流中的事件时间时间戳,而不是使用处理数据的机器的时钟,可以满足及时流处理的这些要求。
有状态流处理
Flink的操作可以是有状态的。这意味着如何处理一个事件可能取决于该事件之前发生的所有事件的累积效果。状态可以用于简单的事情(例如,每分钟统计要显示在仪表板上的事件),也可以用于更复杂的事情(例如,用于欺诈检测模型的计算功能)。
Flink应用程序在分布式群集上并行运行。给定运算符的各种并行实例将在单独的线程中独立执行,并且通常将在不同的机器上运行。
有状态运算符的并行实例集实际上是分片键值存储。每个并行实例负责处理特定键组的事件,并且这些键的状态保存在本地。
下图显示了作业图中前三个运算符的并行度为2的作业,并终止于并行度为1的接收器。第三个运算符是有状态的,您可以看到第二个运算符和第三个运算符之间正在发生完全连接的网络混洗。这样做是为了通过某些键对流进行分区,以便所有需要一起处理的事件都将被处理。
始终在本地访问状态,这有助于Flink应用程序实现高吞吐量和低延迟。您可以选择将状态保留在JVM堆上,或者如果状态太大,则保留在有效组织的磁盘数据结构中。
通过状态快照的容错
Flink能够通过结合状态快照和流重放来提供容错的,一次精确的语义。这些快照捕获分布式管道的整个状态,将偏移量记录到输入队列中,并将整个作业图中的状态记录到摄取数据的那一刻起。
事件驱动型(Event-Driven)
事件驱动型应用是一类具有状态的应用,它从一个或多个事件流提取数据,并根据到来的事件触发计算、状态更新或其他外部动作。比较典型的就是以Kafka为代表的消息队列几乎都是事件驱动型应用。
流与批的世界观
批处理的特点是有界、持久、大量,非常适合需要访问全套记录才能完成的计算工作,一般用于离线统计。
流处理的特点是无界、实时,无需针对整个数据集执行操作,而是对通过系统传输的每个数据项执行操作,一般用于实时统计。
在Spark的世界观中,一切都是由批次组成的,离线数据是一个大批次,而实时数据是由一个一个无限的小批次组成的。
而在Flink的世界观中,一切都是由流组成的,离线数据是有界限的流,实时数据是一个没有界限的流,这就是所谓的有界流和无界流。
无界数据流:无界数据流有一个开始但是没有结束,它们不会在生成时终止并提供数据,必须连续处理无界流,也就是说必须在获取后立即处理event。对于无界数据流我们无法等待所有数据都到达,因为输入是无界的,并且在任何时间点都不会完成。处理无界数据通常要求以特定顺序(例如事件发生的顺序)获取event,以便能够推断结果完整性。
有界数据流:有界数据流有明确定义的开始和结束,可以在执行任何计算之前通过获取所有数据来处理有界流,处理有界流不需要有序获取,因为可以始终对有界数据集进行排序,有界流的处理也称为批处理。
这种以流为世界观的架构,获得的最大好处就是具有极低的延迟。
分层api
最底层级的抽象仅仅提供了有状态流,它将通过在DataStream API中嵌入Process Function来处理数据。Process Function与DataStream API相集成,使其可以对某些特定的操作进行底层的抽象,它允许用户可以自由地处理来自一个或多个数据流的事件,并使用一致的容错的状态。除此之外,用户可以注册事件时间并处理时间回调,从而使程序可以处理复杂的计算。
实际上,大多数应用并不需要上述的底层抽象,而是针对核心API(Core APIs)进行编程,比如DataStream API(有界或无界流数据)以及DataSet API(有界数据集)。这些API为数据处理提供了通用的构建模块,比如由用户定义的多种形式的转换(transformations),连接(joins),聚合(aggregations),窗口操作(window)等等。DataSet API为有界数据集提供了额外的支持,例如循环与迭代。这些API处理的数据类型以类(classes)的形式由各自的编程语言所表示。
Table API是以表为中心的声明式编程,其中表可能会动态变化(在表达流数据时)。Table API遵循(扩展的)关系模型:表有二维数据结构(schema)(类似于关系数据库中的表),同时API提供与RDBMS相似的操作,例如select、project、join、group-by、aggregate等。Table API程序声明式地定义了什么逻辑操作应该执行,而不是准确地确定这些操作代码看上去如何(过程式编程风格)。尽管Table API可以通过多种类型的用户自定义函数(UDF)进行扩展,其仍不如核心API更具表达能力,但是使用起来却更加简洁(代码量更少)。除此之外,Table API程序在执行之前会经过内置优化器进行优化。
你可以在表与DataStream/DataSet之间无缝切换,以允许程序将Table API与DataStream以及DataSet混合使用。
Flink提供的最高层级的抽象是SQL。这一层抽象在语法与表达能力上与Table API类似,但是是以SQL查询表达式的形式表现程序。SQL抽象与Table API交互密切,同时SQL查询可以直接在Table API定义的表上执行。
Warning
目前Flink作为批处理还不是主流,不如Spark成熟,所以DataSet使用的并不是很多。Flink Table API和Flink SQL也并不完善,大多都由各大厂商自己定制。所以我们主要学习DataStream API的使用。实际上Flink作为最接近Google DataFlow模型的实现,是流批统一的观点,所以基本上使用DataStream就可以了。
Flink的API
Flink为开发流/批处理应用程序提供了不同级别的抽象。
编程级别的抽象
最低级别的抽象仅提供状态和及时的流处理。它通过Process Function嵌入到DataStream API中。它允许用户自由地处理一个或多个流中的事件,并提供一致的容错 状态。此外,用户可以注册事件时间和处理时间回调,从而允许程序实现复杂的计算。
实际上,许多应用程序不需要上述低级抽象,而是可以针对核心API进行编程: DataStream API (有界/无界流)和DataSet API(有界数据集)。这些流利的API为数据处理提供了通用的构建块,例如各种形式的用户指定的转换,联接,聚合,窗口,状态等。这些API中处理的数据类型以各自编程语言中的类表示。
低级Process Function与DataStream API集成在一起,从而可以根据需要使用低级抽象。该数据集API提供的有限数据集的其他原语,如循环/迭代。
该表API是为中心的声明性DSL表,其可被动态地改变的表(表示流时)。该表API遵循(扩展)关系模型:表有一个模式连接(类似于在关系数据库中的表)和API提供可比的操作,如选择,项目,连接,分组依据,聚合等表API程序以声明的方式定义应该进行哪些逻辑运算, 而不是确切地指定运算的代码外观。尽管Table API可以通过各种类型的用户定义函数进行扩展,但与Core API相比,它的表达性较差,使用起来更简洁(无需编写更多代码)。此外,Table API程序还经过优化程序,该优化程序在执行之前应用优化规则。
可以在表和DataStream / DataSet之间无缝转换,从而允许程序将Table API与DataStream和 DataSet API混合使用。
Flink提供的最高级别的抽象是SQL。这种抽象在语义和表达方式上均与Table API相似,但是将程序表示为SQL查询表达式。在SQL抽象与表API SQL查询紧密地相互作用,并且可以在中定义的表执行 表API。
客户端不是运行时和程序执行的一部分,但它用于准备并发送Dataflow(JobGraph)给Master(JobManager),然后,客户端断开连接或者维持连接以等待接收计算结果。
当Flink集群启动后,首先会启动一个JobManger和一个或多个的TaskManager。由Client提交任务给JobManager,JobManager再调度任务到各个TaskManager去执行,然后TaskManager将心跳和统计信息汇报给JobManager。TaskManager之间以流的形式进行数据的传输。上述三者均为独立的JVM进程。
Client为提交Job的客户端,可以运行在任何机器上(与JobManager环境连通即可)。提交Job后,Client可以结束进程(Streaming的任务),也可以不结束并等待结果返回。
JobManager主要负责调度Job并协调Task做Checkpoint。从Client处接收到Job和JAR包等资源后,会生成优化后的执行计划,并以Task为单元调度到各个TaskManager去执行。
TaskManager在启动的时候就设置好了槽位数(Slot),每个Slot能启动一个Task,Task为线程。从JobManager处接收需要部署的Task,部署启动后,与自己的上游建立Netty [ Java异步IO库] 连接,接收数据并处理。
关于执行图
Flink中的执行图可以分成四层:
StreamGraph:是根据用户通过Stream API编写的代码生成的最初的图。用来表示程序的拓扑结构。
JobGraph:StreamGraph经过优化后生成了JobGraph,提交给JobManager的数据结构。主要的优化为,将多个符合条件的节点chain在一起作为一个节点,这样可以减少数据在节点之间流动所需要的序列化/反序列化/传输消耗。
ExecutionGraph:JobManager根据JobGraph生成ExecutionGraph。ExecutionGraph是JobGraph的并行化版本,是调度层最核心的数据结构。
物理执行图:JobManager根据ExecutionGraph对Job进行调度后,在各个TaskManager上部署Task后形成的"图",并不是一个具体的数据结构。
3.2 Worker与Slots
每一个Worker(TaskManager)是一个JVM进程,它可能会在独立的线程上执行一个或多个SubTask。为了控制一个Worker能接收多少个Task,Worker通过Task Slot来进行控制(一个Worker至少有一个Task Slot)。
每个Task Slot表示TaskManager拥有资源的一个固定大小的子集。假如一个TaskManager有三个Slot,那么它会将其管理的内存分成三份给各个Slot。资源Slot化意味着一个SubTask将不需要跟来自其他Job的SubTask竞争被管理的内存,取而代之的是它将拥有一定数量的内存储备。需要注意的是,这里不会涉及到CPU的隔离,Slot目前仅仅用来隔离Task的受管理的内存。
通过调整Task Slot的数量,允许用户定义SubTask之间如何互相隔离。如果一个TaskManager一个Slot,那将意味着每个Task Group运行在独立的JVM中(该JVM可能是通过一个特定的容器启动的),而一个TaskManager多个Slot意味着更多的SubTask可以共享同一个JVM。而在同一个JVM进程中的Task将共享TCP连接(基于IO多路复用)和心跳消息。它们也可能共享数据集和数据结构,因此这减少了每个Task的负载。
Task Slot是静态的概念,是指TaskManager具有的并发执行能力,可以通过参数taskmanager.numberOfTaskSlots进行配置,而并行度parallelism是动态概念,即TaskManager运行程序时实际使用的并发能力,可以通过参数parallelism.default进行配置。
也就是说,假设一共有3个TaskManager,每一个TaskManager中的分配3个Task Slot,也就是每个TaskManager可以接收3个Task,一共9个Task Slot,如果我们设置parallelism.default=1,即运行程序默认的并行度为1,9个TaskSlot只用了1个,有8个空闲,因此,设置合适的并行度才能提高效率。
程序与数据流
所有的Flink程序都是由三部分组成的:Source、Transformation和Sink。
Source负责读取数据源,Transformation利用各种算子进行处理加工,Sink负责输出。
在运行时,Flink上运行的程序会被映射成Streaming Dataflows,它包含了这三部分。每一个Dataflow以一个或多个sources开始以一个或多个sinks结束。dataflow类似于任意的有向无环图(DAG)。在大部分情况下,程序中的transformations跟dataflow中的operator是一一对应的关系,但有时候,一个transformation可能对应多个operator。
所有的Flink程序都是由三部分组成的:Source、Transformation和Sink。
Source负责读取数据源,Transformation利用各种算子进行处理加工,Sink负责输出。
在运行时,Flink上运行的程序会被映射成Streaming Dataflows,它包含了这三部分。每一个Dataflow以一个或多个sources开始以一个或多个sinks结束。dataflow类似于任意的有向无环图(DAG)。在大部分情况下,程序中的transformations跟dataflow中的operator是一一对应的关系,但有时候,一个transformation可能对应多个operator。
并行数据流
Flink程序的执行具有并行、分布式的特性。在执行过程中,一个stream包含一个或多个stream partition,而每一个operator包含一个或多个operator subtask,这些operator subtasks在不同的线程、不同的物理机或不同的容器中彼此互不依赖的执行。
一个特定operator的subtask的个数被称之为其parallelism(并行度)。一个stream的并行度总是等同于其producing operator的并行度。一个程序中,不同的operator可能具有不同的并行度。
Stream在operator之间传输数据的形式可以是one-to-one(forwarding)的模式也可以是redistributing的模式,具体是哪一种形式,取决于operator的种类。
One-to-one:stream(比如在source和map operator之间)维护着分区以及元素的顺序。那意味着map operator的subtask看到的元素的个数以及顺序跟source operator的subtask生产的元素的个数、顺序相同,map、fliter、flatMap等算子都是one-to-one的对应关系。类似于spark中的窄依赖
Redistributing:stream(map()跟keyBy/window之间或者keyBy/window跟sink之间)的分区会发生改变。每一个operator subtask依据所选择的transformation发送数据到不同的目标subtask。例如,keyBy()基于hashCode重分区、broadcast和rebalance会随机重新分区,这些算子都会引起redistributing过程,而redistributing过程就类似于Spark中的shuffle过程。类似于spark中的宽依赖
task与operator chains
相同并行度的one to one操作,Flink这样相连的operator链接在一起形成一个task,原来的operator成为里面的subtask。将operators链接成task是非常有效的优化:它能减少线程之间的切换和基于缓存区的数据交换,在减少时延的同时提升吞吐量。链接的行为可以在编程API中进行指
在Flink的流式处理中,会涉及到时间的不同概念,如下图所示:
•Event Time:是事件创建的时间。它通常由事件中的时间戳描述,例如采集的日志数据中,每一条日志都会记录自己的生成时间,Flink通过时间戳分配器访问事件时间戳。
•Ingestion Time:是数据进入Flink的时间。
•Processing Time:是每一个执行基于时间操作的算子的本地系统时间,与机器相关,默认的时间属性就是Processing Time。
著名的星球大战的例子
例如,一条日志进入Flink的时间为2017-11-12 10:00:00.123,到达Window的系统时间为2017-11-12 10:00:01.234,日志的内容如下:
2017-11-02 18:37:15.624 INFO Fail over to rm2
对于业务来说,要统计1min内的故障日志个数,哪个时间是最有意义的?—— Event Time,因为我们要根据日志的生成时间进行统计。
设置时间:
object AverageSensorReadings {
def main(args: Array[String]) {
val env = StreamExecutionEnvironment.getExecutionEnvironment
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)
val sensorData: DataStream[SensorReading] = env.addSource(...)
}
}
如果想设置为Processing Time,将TimeCharacteristic.EventTime替换为TimeCharacteristic.ProcessingTime即可。
5.2.1 Window概述
Streaming流式计算是一种被设计用于处理无限数据集的数据处理引擎,而无限数据集是指一种不断增长的本质上无限的数据集,而Window是一种切割无限数据为有限块进行处理的手段。
Window是无限数据流处理的核心,Window将一个无限的Stream拆分成有限大小的”Buckets”桶,我们可以在这些桶上做计算操作。
5.2.2 Window类型
Window可以分成两类:
•CountWindow:按照指定的数据条数生成一个Window,与时间无关。
•TimeWindow:按照时间生成Window。
对于TimeWindow,可以根据窗口实现原理的不同分成三类:滚动窗口(Tumbling Window)、滑动窗口(Sliding Window)和会话窗口(Session Window)。
滚动窗口(Tumbling Windows)
将数据依据固定的窗口长度对数据进行切片。
特点:时间对齐,窗口长度固定,没有重叠。
滚动窗口分配器将每个元素分配到一个指定窗口大小的窗口中,滚动窗口有一个固定的大小,并且不会出现重叠。例如:如果你指定了一个5分钟大小的滚动窗口,窗口的创建如下图所示:
适用场景:适合做BI统计等(做每个时间段的聚合计算)。
滑动窗口(Sliding Windows)
滑动窗口是固定窗口的更广义的一种形式,滑动窗口由固定的窗口长度和滑动间隔组成。
特点:时间对齐,窗口长度固定,有重叠。
滑动窗口分配器将元素分配到固定长度的窗口中,与滚动窗口类似,窗口的大小由窗口大小参数来配置,另一个窗口滑动参数控制滑动窗口开始的频率。因此,滑动窗口如果滑动参数小于窗口大小的话,窗口是可以重叠的,在这种情况下元素会被分配到多个窗口中。
例如,你有10分钟的窗口和5分钟的滑动,那么每个窗口中5分钟的窗口里包含着上个10分钟产生的数据,如下图所示:
适用场景:对最近一个时间段内的统计(求某接口最近5min的失败率来决定是否要报警)。
会话窗口(Session Windows)
由一系列事件组合一个指定时间长度的timeout间隙组成,类似于web应用的session,也就是一段时间没有接收到新数据就会生成新的窗口。
特点:时间无对齐。
session窗口分配器通过session活动来对元素进行分组,session窗口跟滚动窗口和滑动窗口相比,不会有重叠和固定的开始时间和结束时间的情况,相反,当它在一个固定的时间周期内不再收到元素,即非活动间隔产生,那个这个窗口就会关闭。一个session窗口通过一个session间隔来配置,这个session间隔定义了非活跃周期的长度,当这个非活跃周期产生,那么当前的session将关闭并且后续的元素将被分配到新的session窗口中去。
5.3 Window API
TimeWindow
TimeWindow是将指定时间范围内的所有数据组成一个Window,一次对一个Window里面的所有数据进行计算。
滚动窗口
Flink默认的时间窗口根据Processing Time进行窗口的划分,将Flink获取到的数据根据进入Flink的时间划分到不同的窗口中。
// 每个传感器每个滚动窗口(15s)的最小温度值
val minTempPerWindow: DataStream[(String, Double)] = sensorData
.map(r => (r.id, r.temperature))
// 按照传感器id分流
.keyBy(_._1)
.timeWindow(Time.seconds(15))
.reduce((r1, r2) => (r1._1, r1._2.min(r2._2))
时间间隔可以通过Time.milliseconds(x),Time.seconds(x),Time.minutes(x)等其中的一个来指定。
滑动窗口
滑动窗口和滚动窗口的函数名是完全一致的,只是在传参数时需要传入两个参数,一个是window_size,一个是sliding_size。
下面代码中的sliding_size设置为了5s,也就是说,窗口每5s就计算一次,每一次计算的window范围是15s内的所有元素。
val minTempPerWindow: DataStream[(String, Double)] = sensorData
.map(r => (r.id, r.temperature))
// 按照传感器id分流
.keyBy(_._1)
.timeWindow(Time.seconds(15), Time.seconds(5))
.reduce((r1, r2) => (r1._1, r1._2.min(r2._2))
时间间隔可以通过Time.milliseconds(x),Time.seconds(x),Time.minutes(x)等其中的一个来指定。
在Flink的流式处理中,绝大部分的业务都会使用Event Time,一般只在Event Time无法使用时,才会被迫使用Processing Time或者Ingestion Time。 如果要使用Event Time,那么需要引入Event Time的时间属性,引入方式如下所示:
val env = StreamExecutionEnvironment.getExecutionEnvironment
// 从调用时刻开始给env创建的每一个stream追加时间特征
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)
6.1.2.1 基本概念
我们知道,流处理从事件产生,到流经source,再到operator,中间是有一个过程和时间的,虽然大部分情况下,流到operator的数据都是按照事件产生的时间顺序来的,但是也不排除由于网络、分布式等原因,导致乱序的产生,所谓乱序,就是指Flink接收到的事件的先后顺序不是严格按照事件的Event Time顺序排列的。
那么此时出现一个问题,一旦出现乱序,如果只根据Event Time决定Window的运行,我们不能明确数据是否全部到位,但又不能无限期的等下去,此时必须要有个机制来保证一个特定的时间后,必须触发Window去进行计算了,这个特别的机制,就是Watermark。
•Watermark是一种衡量Event Time进展的机制,它是数据本身的一个隐藏属性,数据本身携带着对应的Watermark。
•Watermark是用于处理乱序事件的,而正确的处理乱序事件,通常用Watermark机制结合Window来实现。
•数据流中的Watermark用于表示timestamp小于Watermark的数据,都已经到达了,因此,Window的执行也是由Watermark触发的。
•Watermark可以理解成一个延迟触发机制,我们可以设置Watermark的延时时长t,每次系统会校验已经到达的数据中最大的maxEventTime,然后认定Event Time小于maxEventTime - t的所有数据都已经到达,如果有窗口的停止时间等于maxEventTime – t,那么这个窗口被触发执行。
有序流的Watermarker如下图所示:(Watermark的延时时长设置为0)
乱序流的Watermarker如下图所示:(Watermark的延时时长设置为2)
当Flink接收到每一条数据时,都会产生一条Watermark,这条Watermark就等于当前所有到达数据中的maxEventTime - 延迟时长,也就是说,Watermark是由数据携带的,一旦数据携带的Watermark比当前未触发的窗口的停止时间要晚,那么就会触发相应窗口的执行。由于Watermark是由数据携带的,因此,如果运行过程中无法获取新的数据,那么没有被触发的窗口将永远都不被触发。
上图中,我们设置的允许最大延迟到达时间为2s,所以时间戳为7s的事件对应的Watermark是5s,时间戳为12s的事件的Watermark是10s,如果我们的窗口1是1s~5s,窗口2是6s~10s,那么时间戳为7s的事件到达时的Watermarker恰好触发窗口1,时间戳为12s的事件到达时的Watermark恰好触发窗口2。
Watermark就是触发前一窗口的"关窗时间",一旦触发关门那么以当前时刻为准在窗口范围内的所有数据都会收入窗中。
只要没有达到水位那么不管现实中的时间推进了多久都不会触发关窗
流式计算分为无状态和有状态两种情况。无状态的计算观察每个独立事件,并根据最后一个事件输出结果。例如,流处理应用程序从传感器接收温度读数,并在温度超过90度时发出警告。有状态的计算则会基于多个事件输出结果。以下是一些例子。
•所有类型的窗口。例如,计算过去一小时的平均温度,就是有状态的计算。
•所有用于复杂事件处理的状态机。例如,若在一分钟内收到两个相差20度以上的温度读数,则发出警告,这是有状态的计算。
•流与流之间的所有关联操作,以及流与静态表或动态表之间的关联操作,都是有状态的计算。
下图展示了无状态流处理和有状态流处理的主要区别。无状态流处理分别接收每条记录(图中的黑条),然后根据最新输入的记录生成输出记录(白条)。有状态流处理会维护状态(根据每条输入记录进行更新),并基于最新输入的记录和当前的状态值生成输出记录(灰条)。
当在分布式系统中引入状态时,自然也引入了一致性问题。一致性实际上是"正确性级别"的另一种说法,即在成功处理故障并恢复之后得到的结果,与没有发生任何故障时得到的结果相比,前者有多正确? 举例来说,假设要对最近一小时登录的用户计数。在系统经历故障之后,计数结果是多少? 在流处理中,一致性分为3个级别。
•at-most-once: 这其实是没有正确性保障的委婉说法——故障发生之后,计数结果可能丢失。同样的还有udp。
•at-least-once: 这表示计数结果可能大于正确值,但绝不会小于正确值。也就是说,计数程序在发生故障后可能多算,但是绝不会少算。
•exactly-once: 这指的是系统保证在发生故障后得到的计数结果与正确值一致。
曾经,at-least-once非常流行。第一代流处理器(如Storm和Samza)刚问世时只保证at-least-once,原因有二。
(1) 保证exactly-once的系统实现起来更复杂。这在基础架构层(决定什么代表正确,以及exactly-once的范围是什么)和实现层都很有挑战性。 (2) 流处理系统的早期用户愿意接受框架的局限性,并在应用层想办法弥补(例如使应用程序具有幂等性,或者用批量计算层再做一遍计算)。
最先保证exactly-once的系统(Storm Trident和Spark Streaming)在性能和表现力这两个方面付出了很大的代价。为了保证exactly-once,这些系统无法单独地对每条记录运用应用逻辑,而是同时处理多条(一批)记录,保证对每一批的处理要么全部成功,要么全部失败。这就导致在得到结果前,必须等待一批记录处理结束。因此,用户经常不得不使用两个流处理框架(一个用来保证exactly-once,另一个用来对每个元素做低延迟处理),结果使基础设施更加复杂。曾经,用户不得不在保证exactly-once与获得低延迟和效率之间权衡利弊。Flink避免了这种权衡。
Flink的一个重大价值在于,它既保证了exactly-once,也具有低延迟和高吞吐的处理能力。
从根本上说,Flink通过使自身满足所有需求来避免权衡,它是业界的一次意义重大的技术飞跃。尽管这在外行看来很神奇,但是一旦了解,就会恍然大悟。
Flink如何保证exactly-once呢? 它使用一种被称为"检查点"的特性,在出现故障时将系统重置回正确状态。下面通过简单的类比来解释检查点的作用。
假设你和两位朋友正在数项链上有多少颗珠子,如下图所示。你捏住珠子,边数边拨,每拨过一颗珠子就给总数加一。你的朋友也这样数他们手中的珠子。当你分神忘记数到哪里时,怎么办呢? 如果项链上有很多珠子,你显然不想从头再数一遍,尤其是当三人的速度不一样却又试图合作的时候,更是如此(比如想记录前一分钟三人一共数了多少颗珠子,回想一下一分钟滚动窗口)。
于是,你想了一个更好的办法: 在项链上每隔一段就松松地系上一根有色皮筋,将珠子分隔开; 当珠子被拨动的时候,皮筋也可以被拨动; 然后,你安排一个助手,让他在你和朋友拨到皮筋时记录总数。用这种方法,当有人数错时,就不必从头开始数。相反,你向其他人发出错误警示,然后你们都从上一根皮筋处开始重数,助手则会告诉每个人重数时的起始数值,例如在粉色皮筋处的数值是多少。
Flink检查点的作用就类似于皮筋标记。数珠子这个类比的关键点是: 对于指定的皮筋而言,珠子的相对位置是确定的; 这让皮筋成为重新计数的参考点。总状态(珠子的总数)在每颗珠子被拨动之后更新一次,助手则会保存与每根皮筋对应的检查点状态,如当遇到粉色皮筋时一共数了多少珠子,当遇到橙色皮筋时又是多少。当问题出现时,这种方法使得重新计数变得简单。
Flink检查点的核心作用是确保状态正确,即使遇到程序中断,也要正确。记住这一基本点之后,我们用一个例子来看检查点是如何运行的。Flink为用户提供了用来定义状态的工具。例如,以下这个Scala程序按照输入记录的第一个字段(一个字符串)进行分组并维护第二个字段的计数状态。
val stream: DataStream[(String, Int)] = …
val counts: DataStream[(String, Int)] = stream
.keyBy(record => record._1)
.mapWithState((in: (String, Int), count: Option[Int]) =>
count match {
case Some© => ( (in._1, c + in._2), Some(c + in._2) )
case None => ( (in._1, in._2), Some(in._2) )
})
该程序有两个算子: keyBy算子用来将记录按照第一个元素(一个字符串)进行分组,根据该key将数据进行重新分区,然后将记录再发送给下一个算子: 有状态的map算子(mapWithState)。map算子在接收到每个元素后,将输入记录的第二个字段的数据加到现有总数中,再将更新过的元素发射出去。下图表示程序的初始状态: 输入流中的6条记录被检查点屏障(checkpoint barrier)隔开,所有的map算子状态均为0(计数还未开始)。所有key为a的记录将被顶层的map算子处理,所有key为b的记录将被中间层的map算子处理,所有key为c的记录则将被底层的map算子处理。
程序的初始状态。注意,a、b、c三组的初始计数状态都是0,即三个圆柱上的值。ckpt表示检查点屏障。每条记录在处理顺序上严格地遵守在检查点之前或之后的规定,例如[“b”,2]在检查点之前被处理,[“a”,2]则在检查点之后被处理
当该程序处理输入流中的6条记录时,涉及的操作遍布3个并行实例(节点、CPU内核等)。那么,检查点该如何保证exactly-once呢?
检查点屏障和普通记录类似。它们由算子处理,但并不参与计算,而是会触发与检查点相关的行为。当读取输入流的数据源(在本例中与keyBy算子内联)遇到检查点屏障时,它将其在输入流中的位置保存到持久化存储中。如果输入流来自消息传输系统(Kafka),这个位置就是偏移量。Flink的存储机制是插件化的,持久化存储可以是分布式文件系统,如HDFS。下图展示了这个过程。
当Flink数据源(在本例中与keyBy算子内联)遇到检查点屏障时,它会将其在输入流中的位置保存到持久化存储中。这让 Flink可以根据该位置重启输入
检查点屏障像普通记录一样在算子之间流动。当map算子处理完前3条记录并收到检查点屏障时,它们会将状态以异步的方式写入持久化存储,如下图所示。
位于检查点之前的所有记录([“b”,2]、[“b”,3]和[“c”,1])被map算子处理之后的情况。此时,持久化存储已经备份了检查点屏障在输入流中的位置(备份操作发生在检查点屏障被输入算子处理的时候)。map算子接着开始处理检查点屏障,并触发将状态异步备份到稳定存储中这个动作
当map算子的状态备份和检查点屏障的位置备份被确认之后,该检查点操作就可以被标记为完成,如下图所示。我们在无须停止或者阻断计算的条件下,在一个逻辑时间点(对应检查点屏障在输入流中的位置)为计算状态拍了快照。通过确保备份的状态和位置指向同一个逻辑时间点,后文将解释如何基于备份恢复计算,从而保证exactly-once。值得注意的是,当没有出现故障时,Flink检查点的开销极小,检查点操作的速度由持久化存储的可用带宽决定。回顾数珠子的例子: 除了因为数错而需要用到皮筋之外,皮筋会被很快地拨过。
检查点操作完成,状态和位置均已备份到稳定存储中。输入流中的所有记录都已处理完成。值得注意的是,备份的状态值与实际的状态值是不同的。备份反映的是检查点的状态
如果检查点操作失败,Flink会丢弃该检查点并继续正常执行,因为之后的某一个检查点可能会成功。虽然恢复时间可能更长,但是对于状态的保证依旧很有力。只有在一系列连续的检查点操作失败之后,Flink才会抛出错误,因为这通常预示着发生了严重且持久的错误。
现在来看看下图所示的情况: 检查点操作已经完成,但故障紧随其后。
故障紧跟检查点,导致最底部的实例丢失
在这种情况下,Flink会重新拓扑(可能会获取新的执行资源),将输入流倒回到上一个检查点,然后恢复状态值并从该处开始继续计算。在本例中,[“a”,2]、[“a”,2]和[“c”,2]这几条记录将被重播。
下图展示了这一重新处理过程。从上一个检查点开始重新计算,可以保证在剩下的记录被处理之后,得到的map算子的状态值与没有发生故障时的状态值一致。
Flink将输入流倒回到上一个检查点屏障的位置,同时恢复map算子的状态值。然后,Flink从此处开始重新处理。这样做保证了在记录被处理之后,map算子的状态值与没有发生故障时的一致
Flink检查点算法的正式名称是异步屏障快照(asynchronous barrier snapshotting)。该算法大致基于Chandy-Lamport分布式快照算法。
Note
检查点是Flink最有价值的创新之一,因为它使Flink可以保证exactly-once,并且不需要牺牲性能。
Flink内置的很多算子,数据源source,数据存储sink都是有状态的,流中的数据都是buffer records,会保存一定的元素或者元数据。例如: ProcessWindowFunction会缓存输入流的数据,ProcessFunction会保存设置的定时器信息等等。
一种能平衡准确性,延迟程度,处理成本的大规模无边界乱序数据处理实践方法
在日常商业运营中,无边界、乱序、大规模数据集越来越普遍了。(例如,网站日志,手机应用统计,传感器网络)。同时,对这些数据的消费需求也越来越复杂。比如说按事件发生时间序列处理数据,按数据本身的特征进行窗口计算等等。同时人们也越来越苛求立刻得到数据分析结果。然而,实践表明,我们永远无法同时优化数据处理的准确性、延迟程度和处理成本等各个维度。因此,数据工作者面临如何协调这些几乎相互冲突的数据处理技术指标的窘境,设计出来各种纷繁的数据处理系统和实践方法。
我们建议数据处理的方法必须进行根本性的改进。作为数据工作者,我们不能把无边界数据集(数据流)切分成有边界的数据,等待一个批次完整后处理。相反地,我们应该假设我们永远无法知道数据流是否终结,何时数据会变完整。唯一应该确信的是,新的数据会源源不断而来,老的数据可能会被撤销或更新。而能够让数据工作者应对这个挑战的唯一可行的方法是通过一个遵守原则的抽象来平衡折衷取舍数据处理的准确性、延迟程度和处理成本。在这篇论文中,我们提出了Dataflow模型,并详细地阐述了它的语义,设计的核心原则,以及在实践开发过程中对模型的检验。
现代数据处理是一个复杂而又令人兴奋的领域。MapReduce和它的衍生系统(如Hadoop, Pig, Hive, Spark等)解决了处理数据的"量"上的问题。流处理SQL上社区也做了很多的工作(如查询系统,窗口,数据流,时间维度,语义模型)。在低延时处理上Spark Streaming, MillWheel, Storm等做了很多尝试。数据工作者现在拥有了很多强有力的工具把大规模无序的数据加工成结构化的数据,而结构化的数据拥有远大于原始数据的价值。但是我们仍然认为现存的模型和方法在处理一些常见的场景时有心无力。
考虑一个例子:一家流媒体平台提供商通过视频广告,向广告商收费把视频内容进行商业变现。收费标准按广告收看次数、时长来计费。这家流媒体的平台支持在线和离线播放。流媒体平台提供商希望知道每天向广告商收费的金额,希望按视频和广告进行汇总统计。另外,他们想在大量的历史离线数据上进行历史数据分析,进行各种实验。
广告商和内容提供者想知道视频被观看了多少次,观看了多长时间,视频被播放时投放了哪个广告,或者广告播放是投放在哪个视频内容中,观看的人群统计分布是什么。广告商也很想知道需要付多少钱,而内容提供者想知道赚到了多少钱。而他们需要尽快得到这些信息,以便调整预算/调整报价,改变受众,修正促销方案,调整未来方向。所有这些越实时越好,因涉及到金额,准确性是至关重要的。
尽管数据处理系统天生就是复杂的,视频平台还是希望一个简单而灵活的编程模型。最后,由于他们基于互联网的业务遍布全球,他们需要的系统要能够处理分散在全球的数据。
上述场景需要计算的指标包括每个视频观看的时间和时长,观看者、视频内容和广告是如何组合的(即按用户,按视频的观看"会话")。概念上这些指标都非常直观,但是现有的模型和系统并无法完美地满足上述的技术要求。
批处理系统如MapReduce(包括Hadoop的变种,如Pig,Hive),FlumeJava, Spark等无法满足时延的要求,因为批处理系统需要等待收集所有的数据成一个批次后才开始处理。对有些流处理系统来说,目前不了解它们在大规模使用的情况下是否还能保持容错性(如(Aurora, TelegraphCQ, Niagara, Esper),而那些提供了可扩展性和容错性的系统则缺乏准确性或语义的表达性。很多系统缺乏“恰好处理一次”的语义(如Storm, Samza, Pulsar)影响了数据的准确性。或者提供了窗口但语义局限于基于记录数或基于数据处理时间的窗口(Spark Streamming, Sonora, Trident)。而大多数提供了基于事件发生时间窗口的,或者依赖于消息必须有序(SQLStream)或者缺乏按事件发生时间触发窗口计算的语义(Stratosphere/Flink)。CEDR和Trill可能值得一提,它们不仅提供了有用的标记触发语义,而且提供了一种增量模型,这一点上和我们这篇论文一致,但它们的窗口语义无法有效地表达基于会话的窗口。它们基于标记的触发语义也无法有效处理3.3节中的某些场景。MillWheel和Spark Streaming的可扩展性良好,容错性不错,低延时,是一种合理的方案,但是对于会话窗口缺乏一种直观的高层编程模型。我们发现只有Pulsar系统对非对齐窗口(译者注:指只有部分记录进入某一特定窗口,会话窗口就是一种非对齐窗口)提供了高层次语义抽象,但是它缺乏对数据准确性的保证。Lambda架构能够达到上述的大部分要求,但是系统体系太过复杂,必须构建和维护两套系统(译者注:指离线和在线系统)。Summingbird改善了Lambda体系的复杂性,提供了针对批处理和流处理系统的一个统一封装抽象,但是这种抽象限制了能支持的计算的种类,并且仍然需要维护两套系统,运维复杂性仍然存在。
上述的问题并非无药可救,这些系统在活跃的发展中终究会解决这些问题。但是我们认为所有这些模型和系统(除了CEDR和Trill)存在一个比较大的问题。这个问题是他们假设输入数据(不管是无边界或者有边界的)在某个时间点后会变完整。我们认为这种假设是有根本性的问题。我们面临的一方面是庞大无序的数据,另一方面是数据消费者复杂的语义和时间线上的各种需求。对于当下如此多样化和多变的数据使用用例(更别说那些浮现在地平线上的, 译者注:应该是指新的,AI时代的到来带来的对数据使用的新玩法),我们认为任何一种有广泛实用价值的方法必须提供简单,强有力的工具,可以为手上某个具体的使用案例平衡数据的准确性、延迟程度和处理成本(译者注:意指对某些用例可能需要低延迟更多,某些用例需要准确性更多。而一个好的工具需要能够动态根据用户的使用场景、配置进行适应,具体的技术细节由工具本身消化)。最后,我们认为需要摆脱目前一个主流的观点,认为执行引擎负责描述系统的语义。合理设计和构建的批,微批次,流处理系统能够保证同样程度的准确性。而这三种系统在处理无边界数据流时都非常常见。如果我们抽象出一个具有足够普遍性,灵活性的模型,那么执行引擎的选择就只是延迟程度和处理成本之间的选择。
从这个方面来说,这篇论文的概念性贡献在于提出了一个统一的模型能够
•对无边界,无序的数据源,允许按数据本身的特征进行窗口计算,得到基于事件发生时间的有序结果,并能在准确性、延迟程度和处理成本之间调整。
•解构数据处理管道的四个相关维度,使得它们透明地,灵活地进行组合。
–计算什么结果
–按事件发生时间计算
–在流计算处理时间时被真正触发计算
–早期的计算结果如何在后期被修正
•分离数据处理的计算逻辑表示和对逻辑的物理实现,使得对批处理,微批处理,流计算引擎的选择成为简单的对准确性、延迟程度和处理成本之间的选择。
具体来说,上述的贡献包含:
•一个支持非对齐事件发生时间窗口的模型,一组简单的窗口创建和使用的API。(参考2.2)
•一个根据数据处理管道特征来决定计算结果输出次数的触发模型。一组强有力而灵活的描述触发语义的声明式API。
•能把数据的更新和撤回和上述窗口、触发模型集成的增量处理模型。(2.3)
•基于MillWheel流处理引擎和FlumeJava批处理引擎的可扩展实现。为Google Cloud Dataflow重写了外部实现,并提供了一个开源的运行引擎不特定的SDK。(3.1)
•指导模型设计的一组核心设计原则。
•Google在处理大规模无边界乱序数据流的处理经验,这也是驱动我们开发这套模型的原因。
最后,不足为奇地,这个模型没有任何魔术效果。那些现有的强一致性批处理系统,微批处理系统,流处理系统,Lambda系统所无法计算的东西仍然无法解决。CPU, RAM, Disk的内在约束依然存在。我们所提供的是一个能够简单地定义表达并行计算的通用框架。这种表达的方式和底层的执行引擎无关,同时针对任何特定的问题域,提供了根据手上数据和资源的情况来精确地调整延时程度和准确性的能力。从这一点上来说,这个模型的目标是简化大规模数据处理管道的构建。
8.1.1 无边界、有边界与流处理、批处理
(本论文中)当描述无限/有限数据集时,我们更愿意使用有边界/无边界这组词汇,而不是流/批。因为流/批可能意味着使用某种特定的执行引擎。在现实中,无边界数据集可以用批处理系统反复调度来处理,而良好设计的流处理系统也可以完美地处理有边界数据集。从这个模型的角度来看,区分流/批的意义是不大的,因此我们保留这组词汇(流、批)用来专指执行引擎。
窗口操作把一个数据集切分为有限的数据片以便于聚合处理。当面对无边界的数据时,有些操作需要窗口(以定义大多数聚合操作需要的边界:汇总,外链接,以时间区域定义的操作;如最近5分钟xx等)。另一些则不需要(如过滤,映射,内链接等)。对有边界的数据,窗口是可选的,不过很多情况下仍然是一种有效的语义概念(如回填一大批的更新数据到之前读取无边界数据源处理过的数据, 译者注:类似于Lambda架构)。窗口基本上都是基于时间的;不过也有些系统支持基于记录数的窗口。这种窗口可以认为是基于一个逻辑上的时间域,该时间域中的元素包含顺序递增的逻辑时间戳。窗口可以是对齐的,也就是说窗口应用于所有落在窗口时间范围内的数据。也可以是非对齐的,也就是应用于部分特定的数据子集(如按某个键值筛选的数据子集)。图一列出了处理无边界数据时常见的三种窗口。
固定窗口(有时叫翻滚窗口)是按固定窗口大小定义的,比如说小时窗口或天窗口。它们一般是对齐窗口,也就是说,每个窗口都包含了对应时间段范围内的所有数据。有时为了把窗口计算的负荷均匀分摊到整个时间范围内,有时固定窗口会做成把窗口的边界的时间加上一个随机数,这样的固定窗口则变成了不对齐窗口。
滑动窗口按窗口大小和滑动周期大小来定义,比如说小时窗口,每一分钟滑动一次。这个滑动周期一般比窗口大小小,也就是说窗口有相互重合之处。滑动窗口一般也是对齐的;尽管上面的图为了画出滑动的效果窗口没有遮盖到所有的键,但其实五个滑动窗口其实是包含了所有的3个键,而不仅仅是窗口3包含了所有的3个键。固定窗口可以看做是滑动窗口的一个特例,即窗口大小和滑动周期大小相等。
会话是在数据的子集上捕捉一段时间内的活动。一般来说会话按超时时间来定义,任何发生在超时时间以内的事件认为属于同一个会话。会话是非对齐窗口。如上图,窗口2只包含key 1,窗口3则只包含key 2。而窗口1和4都包含了key 3。(译者注:假设key是用户id,那么两次活动之间间隔超过了超时时间,因此系统需要重新定义一个会话窗口。)
当处理包含事件发生时间的数据时,有两个时间域需要考虑。尽管已经有很多文献提到(特别是时间管理,语义模型,窗口,乱序处理,标记,心跳,水位标记,帧),这里仍然重复一下,因为这个概念清晰之后2.3节会更易于理解。这两个时间域是:
•事件发生时间。事件发生时间是指当该事件发生时,该事件所在的系统记录下来的系统时间。
•处理时间。处理时间是指在数据处理管道中处理数据时,一个事件被数据处理系统观察到的时间,是数据处理系统的时间。注意我们这里不假设在分布式系统中时钟是同步的。
一个事件的事件发生时间是永远不变的,但是一个事件的处理时间随着它在数据管道中一步步被处理时持续变化的。这个区别是非常重要的,特别是我们需要根据事件的发生时间进行分析的时候。
在数据处理过程中,由于系统本身的一些现实影响(通信延迟,调度算法,处理时长,管道中间数据序列化等)会导致这两个时间存在差值且动态波动(见图2)。使用记录全局数据处理进度的标记、或水位标记,是一种很好的方式来可视化这个差值。在本论文中,我们采用一种类似MillWheel的水位标记,它是一个时间戳,代表小于这个时间戳的数据已经完全被系统处理了(通常用启发式方法建立)。我们之前曾经说过,数据已经被完全处理的标记经常和数据的准确性是相互冲突的,因此,我们不会太过于依赖于水位标记。不过,它确实是一种有用的手段。系统可以用它猜测所有事件发生时间早于水位标记的数据已经完全被观察到。应用可以用它来可视化处理时间差,也用它来监控系统总体的健康状况和总体处理进展,也可以用它来做一些不影响数据准确性的决策,比如基本垃圾回收策略等。
(译者注:假设事件发生系统和数据处理系统的时钟完全同步)在理想的情况下,两个时间的差值应该永远为零;事件一旦发生,我们就马上处理掉。现实则更像图2那样。从12点开始,由于数据处理管道的延迟,水位标记开始偏离真实时间,12:02时则靠近回来,而12:03的时候延迟变得更大。在分布式数据处理系统里,这种偏差波动非常普遍,在考虑数据处理系统如何提供一个正确的,可重复的结果时,把这种情况纳入考虑很关键。
水位标记的建立
对大多数现实世界中分布式数据集,系统缺乏足够的信息来建立一个100%准确的水位标记。举例来说,在视频观看"会话"的例子中,考虑离线观看。如果有人把他们的移动设备带到野外,系统根本没有办法知道他们何时会回到有网络连接的地带,然后开始上传他们在没有网络连接时观看视频的数据。因此,大多数的水位定义是基于有限的信息启发式地定义。对于带有未处理数据的元数据的结构化输入源,比如说日志文件(译者注:可能应该不是泛指一般的日志文件),水位标记的猜测明显要准确些,因此大多数情况下可以作为一个处理完成的估计。另外,很重要的一点,一旦水位标记建立之后,它可以被传递到数据处理管道的下游(就像标记(Punctuation)那样, 译者注:类似于Flink的checkpoint barrier)。当然下游要明确知道这个水位标记仍然是一个猜测。
Flink CEP简介
什么是复杂事件CEP?
一个或多个由简单事件构成的事件流通过一定的规则匹配,然后输出用户想得到的数据,满足规则的复杂事件。
特征:
•目标:从有序的简单事件流中发现一些高阶特征
•输入:一个或多个由简单事件构成的事件流
•处理:识别简单事件之间的内在联系,多个符合一定规则的简单事件构成复杂事件
•输出:满足规则的复杂事件
CEP用于分析低延迟、频繁产生的不同来源的事件流。CEP可以帮助在复杂的、不相关的事件流中找出有意义的模式和复杂的关系,以接近实时或准实时的获得通知并阻止一些行为。
CEP支持在流上进行模式匹配,根据模式的条件不同,分为连续的条件或不连续的条件;模式的条件允许有时间的限制,当在条件范围内没有达到满足的条件时,会导致模式匹配超时。
看起来很简单,但是它有很多不同的功能:
•输入的流数据,尽快产生结果
•在2个event流上,基于时间进行聚合类的计算
•提供实时/准实时的警告和通知
•在多样的数据源中产生关联并分析模式
•高吞吐、低延迟的处理
市场上有多种CEP的解决方案,例如Spark、Samza、Beam等,但他们都没有提供专门的library支持。但是Flink提供了专门的CEP library。
Flink CEP
Flink为CEP提供了专门的Flink CEP library,它包含如下组件:
•Event Stream
•pattern定义
•pattern检测
•生成Alert
首先,开发人员要在DataStream流上定义出模式条件,之后Flink CEP引擎进行模式检测,必要时生成告警。
FlinkCEP是在Flink之上实现的复杂事件处理(CEP)库。 它允许你在×××的事件流中检测事件模式,让你有机会掌握数据中重要的事项。
本文描述了Flink CEP中可用的API调用。 首先介绍Pattern API,它允许你指定要在流中检测的模式,然后介绍如何检测匹配事件序列并对其进行操作。
然后,我们将介绍CEP库在处理事件时间延迟时所做的假设。
每个复杂模式序列都是由多个简单模式组成,即寻找具有相同属性的单个事件的模式。我们可以先定义一些简单的模式,然后组合成复杂的模式序列。
可以将模式序列视为此类模式的结构图,基于用户指定的条件从一个模式转换到下一个模式,例如, event.getName().equals(“start”)。
匹配是一系列输入事件,通过一系列有效的模式转换访问复杂模式图中的所有模式。
注意每个模式必须具有唯一的名称,以便后续可以使用该名称来标识匹配的事件。
注意模式名称不能包含字符“:”。
pattern.oneOrMore(),用于期望一个或多个事件发生的模式(例如之前提到的b +);和pattern.times(#ofTimes),
用于期望给定类型事件的特定出现次数的模式,例如4个;和patterntimes(#fromTimes,#toTimes),用于期望给定类型事件的最小出现次数和最大出现次数的模式,例如, 2-4。
您可以使用pattern.greedy()方法使循环模式变得贪婪,但是还不能使组模式变得贪婪。您可以使用pattern.optional()方法使得所有模式,循环与否,变为可选。
在每个模式中,从一个模式转到下一个模式,可以指定其他条件。您可以将使用下面这些条件:
传入事件的属性,例如其值应大于5,或大于先前接受的事件的平均值。
匹配事件的连续性,例如检测模式a,b,c,序列中间不能有任何非匹配事件。
可以通过pattern.where(),pattern.or()或pattern.until()方法指定事件属性的条件。 条件可以是IterativeConditions或SimpleConditions。
主机被Netcore/Netis漏洞利用成功随即发起Gafgyt通信行为。
l 前置条件:主机作为dip触发41472 Netcore / Netis 路由器后门告警
l 后续条件:主机作为sip触发41533 Gafgyt僵尸网络通信通信告警
两个条件具备时序关系,且中间的间隔时间不应大于30分钟。
在first pattern中,定义的条件是ruleid=41472。
在second pattern中,定义的条件是ruleid=41533且此条消息的源ip==前置条件的目的ip
within方法,限定了满足条件必须在30分钟内
oneOrMore().greedy,第一个条件满足一次或者多次,越多次越好
忽略策略为AfterMatchSkipStrategy.skipPastLastEvent,在满足模式之后,忽略掉之前的部分满足条件
这样对于输入的告警是多次sip=1.1,dip=1.2的netcore告警,随后一条sip=1.2,dip=1.3的gafgyt告警,拿到的命中数据会是{“first”:n条netcore告警的list,“second”:gafgyt告警}
将Kafka input和pattern组合成为Pattern Stream。然后再在select方法中合并满足first pattern的告警与满足second pattern的告警,把他们合并为一条“目的IP被Netcore/Netis攻击成功并开始进行Gafgypt通信”告警输出。
复杂性:多个流join,窗口聚合,事件序列或patterns检测
低延迟:秒或毫秒级别,比如做信用卡盗刷检测,或攻击检测
高吞吐:每秒上万条消息
用户的登录日志数据会以实时的方式传递给Flink,常用的有Kafka,MQ等消息中间件。
接着使用Flink-CEP进行模式匹配,匹配到了就会发出告警处理。
EP更强大的功能是对结构化后的日志数据进行模式匹配,与复杂的业务规则进行对应,发挥更大的业务价值,后续的CEP系列文章里面会更多从日志数据挖掘的方面去做相关介绍。
首先,缩短算子链会合理的合并算子,节省出资源。
其次缩短算子链也会减少 Task(线程)之间的切换、消息的序列化 / 反序列化以及数据在缓冲区的交换次数,进而提高系统的整体吞吐量。
最后,根据数据特性将不需要或者暂不需要的数据进行过滤,然后根据业务需求将数据分别处理,比如有些数据源需要实时的处理,有些数据是可以延迟的,
最后通过使用 keyBy 关键字,控制 Flink 时间窗口大小,在上游算子处理逻辑中尽量合并更多数据来达到降低下游算子的处理压力
消费者消费的速度低于生产者生产的速度,为了使应用正常,消费者会反馈给生产者来调节生产者生产的速度,以使得消费者需要多少,生产者生产多少。
Flink 背压是 jobmanager 针对每一个 task 每 50ms 触发 100 次 Thread.getStackTrace() 调用,求出阻塞的占比。
阻塞占比在 web 上划分了三个等级:
OK: 0 <= Ratio <= 0.10,表示状态良好;
LOW: 0.10 < Ratio <= 0.5,表示有待观察;
HIGH: 0.5 < Ratio <= 1,表示要处理了。
预测分析和复杂事件处理
保证有状态处理系统上的恰一次语义,
执行状态的全局一致快照
低空间成本的异步状态快照,其仅包含非循环执行拓扑中的运算符状态
在拓扑的选定部分上应用下游备份,将快照状态保持为最小。 我们的技术不会停止流操作,它只会引入很小的运行时开销
异步快照算法,该算法可以实现在非循环执行图上的最小快照
描述并实现了我们的算法的泛化,该算法适用于循环执行图
任务的有向图。 数据元素从外部源获取,并以pipeline方式通过任务图。 任务根据收到的数据不断操纵其内部状态,并产生新的输出
Apache Flink 流API主要是处理无界流数据。 可以从外部源(例如消息队列,套接字流,自定义生成器)或通过对其他DataStream进行操作来创建DataStream
这些是以高阶函数的形式支持的,并且是以每个记录为单位逐步调用并生成新的DataStream。 通过将并行实例放置在相应流的不同分区上运行,可以并行化每个运算符,从而实现流转换的分布式执行。
所有DataStream操作符都编译成执行图,该执行图原则上是有向图G =(T,E),其中顶点T表示任务,边E表示任务之间的数据通道
M表示在并行执行期间由任务传输的所有记录的集合。每个任务 t ∈ T 包含了运算符实例的独立执行
一组输入输出通道:It,Ot⊆E;
操作符状态st
用户定义函数(UDF)ft。
在执行期间,每个任务都消费输入记录,更新其操作符状态并根据其用户定义的函数生成新记录。 更具体地说,对于由任务 t ∈ T 接收的每个记录 r ∈ M,根据其UDF ft:st,r
在执行期间,每个任务都消费输入记录,更新其操作符状态并根据其用户定义的函数生成新记录。 更具体地说,对于由任务 t ∈ T 接收的每个记录 r ∈ M,根据其UDF ft:st,r
当我们有一个数据处理流中的各个处理节点,需要保持自己的状态的时候(比如接受一个消息之后,就根据消息更新自己的状态,比如消息记数),怎么保证node的自动failure recovery 的时候,不会造成各个节点的状态不统一从而影响后边的处理呢
当所有的src不再发出消息,那么最终count-1和count-2的计数必须是一样的,且print是只增的。然而当count-1节点挂掉,然后重启,怎么保证它和count-2一致呢?保证print-1和print-2最终会打出一样的数字呢?且保证print只增呢?
一种方法是所有节点记住所有发出的信息,失败重发,下游记住所有收到的消息和所有每个消息所导致的最新状态,failover之后要求所有上游从失败前的消息开始重发,且,只能重发给失败的节点(也就是src-1和src-2要记住不能给count-2重发),且count-1要记住所有这些重发的消息不能导致print打出老的数字(否则违反只增性),而重发结束就要立刻开始给print发出应有的消息。当系统内节点非常多和复杂的时候,记录整个图的消息流动会非常复杂和导致high cost。
Lightweight Asynchronous Snapshots这篇论文提供的算法,是Flink解决Exactly Once Message处理的核心算法。它的优点是不需要所有计算节点记住所有发出的信息。只需要数据源可以replay就行了,超级轻量级。【对比财大气粗的有完美infrastructure支持的Google DataFlow/MillWheel, 记录了所有Shuffle(因为一般shuffle才需要跨机器节点通信)的record在BigTable或者Spanner里,用一个保证consistent的垃圾回收算法来清理无限增长的record。回头讲MillWheel论文的时候会详细讲】
单向图算法(无环)
所有的通信channel需要是先进先出(FIFO)按顺序的
中心coordinator:需要有一个中心coordinator来不断广播持续增长的stage barrier到所有的src数据流里。(比如先给所有src发1,然后5秒后发2,10秒发3… 如此增长)
数据源src,当数据源收到第n个barrier的时候:
-保存状态,保证当需要replay从任意n开始的消息时,可以replay在自己收到barrier-n之后的所有消息。
-广播barrier给下游。
中间处理节点或最终叶子节点:假设一个中间处理节点或最终叶子节点需要m个input流,当在某个input流收到barrier-n的时候,
-block这个input流保证不再收取和处理。
-当收到所有m个input的barrier-n的时候,
–Pi-LocalSnapshot-n: 保存本地状态(take local snapshot n), 保证可以从这个状态恢复(比如存到云端, 在另外x台机器做replica),假设我们每个logic processor都有一个id为Pi,那么每个logic processor的在收到所有inputs的barrier-n之后所保存的本地状态快照则设为Pi-LocalSnapshot-n
–向自己的下游广播barrier-n (如果是叶子节点没有下游,那么不需要广播)
CompleteGlobalSnapshot-n: 当所有的节点(源,中间处理,叶子节点)都处理完barrier-n且完成取快照(take snapshot)的任务之后,我们说我们有了一个完整的全局快照。这意味着我们的deterministic的进度,进步到了barrier-n
单向图Failover
当需要任意节点挂掉,我们从最近的Complete Global Snapshot-n,来重启整个系统;即,健康的节点rollback自己的状态到“接收到barrier-n时候所取的状态快照。fail掉的节点的逻辑Processor Pi被jobManager之类的东西,用自己在的Pi-LocalSnapshot-n重启设置本地状态之后,才开始接收上游的消息。
为什么这样就consistent了?
可以看到当failover的时候,全部节点的状态都回退到了barrier-n之前的数据源message所导致的全网状态,就好像数据源在barrier-n之后根本没有发过消息一样。不断发出的barrier就好像逻辑时钟一样,然而“时间”流动到不同地方的速度不同,只有当一个时间“点”全部流动到了全网,且全网把这个时间“点”的状态全部取了快照(注意当网络很大,最后一个节点取完快照,初始节点可能已经前进到n+5,n+10了,但是由于最后一个节点才刚取完快照,CompleteGlobalSnapshot-n只到n,n是全局consistent的记录点),
如何可以不用全网rollback?
如果没挂的节点的运算可以自动忽略老的已经处理过的消息(或者说replay导致的消息),那么我们只需要重启所有从源到fail掉的节点的这条线即可(比如下图的黄色节点)。
Streaming System或者High Performance Spark这种书,
分布式系统做consistency的快照的算法,可以应对环形流,且不需要节点知道有环(Flink的算法要求环的交接节点知道哪个input channel是环的回路),但是要求所有通信channel是FIFO的(flink也是, 相比之下Google的MillWheel则不需要)。
任何节点的snapshot由本地状态snapshot和节点的input channel snapshot组成
任何src可以任意时间决定take本地状态snapshot,take完本地snapshot,广播一个marker给所有下游
任意没有take本地snapshot的节点(注意这个算法里src也是可以接受别人的msg的),假设从第x个channel收到第一个marker的时候,take本地状态snapshot(且take接受到第一个marker的input channel-x的channel snapshot为空),然后给所有output channel广播这个marker
从收到第一个marker并take完本地snapshot之后,记录所有input channel的msg到log里,直到从所有的input channel都收到这个marker. 作为这些input channel的channel snapshot。
Flink在系统内有环形通信时的算法
当一个节点是环的msg流动的起点时(或者说这个节点正好同时是环的起点和终点),它必定有一个input channel是来自自己的downstream节点的。
这个节点不能像其他节点一样,等待所有的input channel的barrier到来,才take snapshot且广播barrier,因为它有一个或多个input channel的消息是被自己往“下游”发的消息所引发的。如果它自己不向下游广播barrier,那么这些回环input channel永远也不会有barrier发来,那么算法会永久等待。
所以这个这个节点只需要等待所有非回环input channel的barrier到了,它就知道所有可能的barrier都到齐了,那么它就可以take本地snapshot且往“下游”广播barrier了(从而造成barrier会通过回路再次抵达这个节点)
重点: 此节点take完本地snapshot之后,需要记录所有回环input channel的msg到log里,直到从此回环input channel收到自己发出的barrier,当所有回环input channel都收到barrier-n. 此时在Step3 take的本地snapshot,加上所有回环input channel的msg log一起,成为此节点在barrier-n的本地snapshot
Failover,failover的时候,除了从本地snapshot恢复状态之外,还需要replay所有input channel的msg。
一个分布式系统的snapshot可以理解为时间静止时,系统的各个节点的状态和他们之间的channel的状态(channel里的msg list),就是一个stream集合和table集合的剪影。stream指还在channel里没有融入到table里的msg list,tables可以理解为各个节点的本地状态。
从src开始,当src take一个snapshot之后,任何被src在snapshoting之前发出过的"历史msg"所引起的“蝴蝶效应”都必须被下游记录,才能构成完整的整个系统的consistent global snapshot, 难点在于, 我们无法时间静止,所以src在不知道下游什么状况的情况下,还是要继续往下游发msg,那么对所有下游节点,区分那些是“历史msg”和“历史msg引起的蝴蝶效应msg”,还是“new event”, "new msg引起的蝴蝶效应msg"是难点;任何被src的eventA所因果导致的eventB,C,D,E,都必须记录,对任意系统的msg(或者说event),要么这个event已经被状态吸收,merge在某节点最终的table里(这时候这个节点有可能会因为接受到这个event而发出另外的别的event,也需要保证在下游记录),要么这个event需要记录在这个节点的input stream log里。
对于环来说,所有的被"历史event“所因果导致的"发给本节点的需要记录的"未来状态"还未到来,但是已经有"new event"(比如从src来的新消息)来改本地状态了,所以不能等待回环的消息,而必须先把本地状态take snapshot了才行,作为”历史msg的因果导致的msg“,只能作为”未来event“记录在stream log里了。
当然flink的算法也可以设计为,即使非回环input channel的barrier都到齐了,也不unblock input channel ,而是等待所有的回环input channel的barrier也都到齐了,才take本地snapshot,且一起unblock所有的input channel;这样就不需要维护stream log了
1、状态机制 2、精确一次语义 3、高吞吐量 4、可弹性伸缩的应用 5、容错机制,刚好这几点,flink都完美的实现了,并且支持flink sql高级API,减少了开发成本,可用实现快速迭代,易维护等优点